ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 11:16:36 +10:00
commit 057da00fd2
406 changed files with 47822 additions and 0 deletions
+718
View File
@@ -0,0 +1,718 @@
#!/usr/bin/env bash
# The staff app: request → approve → fulfil, and everything that must refuse.
#
# The design rests on three separations, and most of this file exists to prove they hold:
# · the linen room cannot approve — that is the ward's job;
# · a manager can only see and decide requests addressed to them;
# · a wearer can only see their own record, and nothing of the register.
set -u
B=${BASE:-http://127.0.0.1:3111}
# Refuses early, with the fix, when the server under test is in production mode with
# Turnstile refusing every auth route — otherwise the first signup fails and every check
# after it reports a security-check error instead of what it was testing.
. "$(dirname "$0")/e2e-preflight.sh"; e2e_preflight "$B"
T=${TMP:-/tmp}
C="$T/tc-sa-coord.txt" # linen room
W="$T/tc-sa-wearer.txt" # the staff member
M="$T/tc-sa-mgr.txt" # their ward manager
D="$T/tc-sa-desk.txt" # the ward clerk
O="$T/tc-sa-other.txt" # a manager on another ward
X="$T/tc-sa-dir.txt" # the ward manager's own manager, a level up
N="$T/tc-sa-noward.txt" # a desk clerk whose ward was never filled in
# Three more nurses on Jamila's ward, under Jamila's manager. They are here because the server holds
# one person to twelve requests an hour — every request emails a manager, and nobody asks twelve
# times in an hour for a reason anybody would recognise. This file does, because it walks every flow
# end to end, so the sections below are spread across a ward rather than put through one nurse.
V="$T/tc-sa-trials.txt" # the nurse whose asks are all meant to be refused
Y="$T/tc-sa-lines.txt" # the nurse who asks for several garments at once
Z="$T/tc-sa-bags.txt" # the nurse whose bags wait at the counter and go out on the round
K="$T/tc-sa-solo.txt" # somebody recorded as their own manager, with nobody else under them
L="$T/tc-sa-lead.txt" # somebody recorded as their own manager, with a report of her own
rm -f "$C" "$W" "$M" "$D" "$O" "$X" "$N" "$V" "$Y" "$Z" "$K" "$L"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
fail() { FAIL=$((FAIL+1)); echo "$1 :: $2"; }
check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$(echo "$out" | head -c 200)"; fi; }
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 200)"; else ok "$name"; fi; }
mut() { curl -s -b "$C" -c "$C" -X POST "$B/api/mutate" -H 'content-type: application/json' -H "origin: $B" -d "{\"op\":\"$1\",\"payload\":$2}"; }
smut() { curl -s -b "$2" -c "$2" -X POST "$B/api/staff/mutate" -H 'content-type: application/json' -H "origin: $B" -d "{\"op\":\"$1\",\"payload\":$3}"; }
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
# A screen that refuses somebody: the not-found page comes back and none of the screen's own words
# do. Deliberately not a status check -- these routes stream under a loading boundary, so the header
# is sent before the page decides, and 200 says nothing either way. Pass the cookie jar, the path,
# and a word the real screen would have rendered.
refused() { local name=$1 jar=$2 path=$3 leak=$4
local body; body=$(curl -s -b "$jar" "$B$path")
if ! printf '%s' "$body" | grep -q "That page isn"; then fail "$name" "no refusal: $(printf '%s' "$body" | head -c 160)"; return; fi
if printf '%s' "$body" | grep -q "$leak"; then fail "$name" "REFUSED BUT LEAKED $leak"; return; fi
ok "$name"
}
# Claim a staff account: generate a code as the linen room, then activate it into a jar.
claim() { local sid=$1 jar=$2 email=$3
local code; code=$(mut staff.selfCode "{\"id\":\"$sid\"}" | py "print(d['result']['code'])")
curl -s -c "$jar" -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" \
-d "{\"agreed\":true,\"code\":\"$code\",\"email\":\"$email\",\"password\":\"wearerpass1\"}"; }
TS=$(date +%s)
CO="sa$TS@example.com"
echo "== setup"
check "linen room signs up" "$(curl -s -c "$C" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
-H "x-forwarded-for: 10.31.$((RANDOM%250)).$((RANDOM%250))" \
-d "{\"first\":\"Sal\",\"last\":\"Linen\",\"facility\":\"Request Hospital $TS\",\"email\":\"$CO\",\"password\":\"password123\"}")" '"ok":true'
check "the facility names its staff groups" "$(e2e_groups "$B" "$C")" '"ok":true'
WID=$(mut staff.save '{"num":"W1","first":"Jamila","last":"Wearer","group":"Registered Nurse","dept":"Rosewood Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
MID=$(mut staff.save '{"num":"M1","first":"Dele","last":"Manager","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
DID=$(mut staff.save '{"num":"D1","first":"Ade","last":"Clerk","group":"Admin","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
OID=$(mut staff.save '{"num":"O1","first":"Otto","last":"Elsewhere","group":"Security","dept":"Linden Ward"}' | py "print(d['result']['id'])")
CID=$(mut staff.save '{"num":"C1","first":"Chidi","last":"Director","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
# Two people whose ward was never filled in: a desk clerk and somebody the clerk must not reach.
# Sharing a blank ward with somebody used to read as sharing a ward, which was the widest version
# of the door the desk raised through. That door is shut now, and this pair is what proves it.
NID=$(mut staff.save '{"num":"N1","first":"Nia","last":"Nodesk","group":"Admin"}' | py "print(d['result']['id'])")
PID=$(mut staff.save '{"num":"P1","first":"Pat","last":"Noward","group":"Registered Nurse"}' | py "print(d['result']['id'])")
# Counted, not concatenated: an id that came back empty because staff.save refused is exactly what
# makes the refusals further down pass for the wrong reason, since a blank subjectId falls into a
# different branch of request.create entirely.
check "seven people on the register" "$(printf '%s\n' "$WID" "$MID" "$DID" "$OID" "$CID" "$NID" "$PID" | grep -c .)" '^7$'
check "the wearer gets a manager" "$(mut staff.patch "{\"id\":\"$WID\",\"managerId\":\"$MID\"}")" '"ok":true'
check "so does the clerk" "$(mut staff.patch "{\"id\":\"$DID\",\"managerId\":\"$MID\"}")" '"ok":true'
check "and the ward-less nurse" "$(mut staff.patch "{\"id\":\"$PID\",\"managerId\":\"$MID\"}")" '"ok":true'
check "the clerk is put on the desk" "$(mut staff.patch "{\"id\":\"$DID\",\"wardDesk\":true}")" '"ok":true'
check "so is the one with no ward" "$(mut staff.patch "{\"id\":\"$NID\",\"wardDesk\":true}")" '"ok":true'
# Anybody may be their own manager now (the owner's decision, 12 September 2026) — that is proved on
# people of its own further down, so Jamila keeps Dele. What is still refused is a manager who is not
# on the register at all, and refusing it must leave her manager where it was.
check "a manager nobody on the register is refused" "$(mut staff.patch "{\"id\":\"$WID\",\"managerId\":\"nope\"}")" 'Unknown staff member'
check " and the wearer keeps the one she has" "$(curl -s -b "$C" "$B/api/backup" | py "print([s['managerId'] for s in d['staff'] if s['id']=='$WID'][0] == '$MID')")" '^True$'
# The three whose sections come later. On Jamila's ward, so the desk can sign for their bags on the
# round, and under Jamila's manager, so a request of theirs is decided exactly as one of hers is.
VID=$(mut staff.save '{"num":"W2","first":"Bea","last":"Trials","group":"Registered Nurse","dept":"Rosewood Ward","top":"S","pants":"10"}' | py "print(d['result']['id'])")
YID=$(mut staff.save '{"num":"W3","first":"Rafa","last":"Lines","group":"Registered Nurse","dept":"Rosewood Ward","top":"L","pants":"14"}' | py "print(d['result']['id'])")
ZID=$(mut staff.save '{"num":"W4","first":"Ines","last":"Trolley","group":"Registered Nurse","dept":"Rosewood Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
check "three more nurses on the ward" "$(printf '%s\n' "$VID" "$YID" "$ZID" | grep -c .)" '^3$'
check " all three under the same manager" "$(printf '%s\n' \
"$(mut staff.patch "{\"id\":\"$VID\",\"managerId\":\"$MID\"}")" \
"$(mut staff.patch "{\"id\":\"$YID\",\"managerId\":\"$MID\"}")" \
"$(mut staff.patch "{\"id\":\"$ZID\",\"managerId\":\"$MID\"}")" | grep -c '"ok":true')" '^3$'
# 143 rather than a dozen, and deliberately not a number that is also one of the sizes: the shelf
# check below proves the count never reaches the ward, and it can only prove that against a figure
# that has no other reason to be on the page.
ITEM=$(mut catalog.add '{"item":"Navy tunic","type":"Tunic","sizes":["10","12","14"],"cost":30,"opening":[{"si":1,"qty":143},{"si":2,"qty":1}]}')
IID=$(echo "$ITEM" | py "print(d['result']['id'])")
check "a garment is on the shelf" "$ITEM" '"id"'
check "with a par level on the thin size" "$(mut stock.reorder "{\"itemId\":\"$IID\",\"si\":2,\"reorder\":3}")" '"ok":true'
# Two more garments, so one request can carry three of them and a decline can be told apart from
# an approval by which shelf moved.
TID=$(mut catalog.add '{"item":"Navy trousers","type":"Trousers","sizes":["10","12","14"],"cost":22,"opening":[{"si":0,"qty":6}]}' | py "print(d['result']['id'])")
FID=$(mut catalog.add '{"item":"Fleece jacket","type":"Fleece","sizes":["S","M","L"],"cost":48,"opening":[{"si":0,"qty":5}]}' | py "print(d['result']['id'])")
check "and two more beside it" "$(printf '%s\n' "$TID" "$FID" | grep -c .)" '^2$'
check "the wearer claims an account" "$(claim "$WID" "$W" "w$TS@example.com")" '"ok":true'
check "the manager claims one" "$(claim "$MID" "$M" "m$TS@example.com")" '"ok":true'
check "the clerk claims one" "$(claim "$DID" "$D" "d$TS@example.com")" '"ok":true'
check "the other ward claims one" "$(claim "$OID" "$O" "o$TS@example.com")" '"ok":true'
check "the director claims one" "$(claim "$CID" "$X" "c$TS@example.com")" '"ok":true'
check "the ward-less clerk claims one" "$(claim "$NID" "$N" "n$TS@example.com")" '"ok":true'
check "the nurse whose asks are refused claims one" "$(claim "$VID" "$V" "v$TS@example.com")" '"ok":true'
check "the several-garments nurse too" "$(claim "$YID" "$Y" "y$TS@example.com")" '"ok":true'
check "and the one whose bags go out" "$(claim "$ZID" "$Z" "z$TS@example.com")" '"ok":true'
echo "== the two doors into the staff app"
check "/api/staff/mutate refuses an anonymous caller" "$(curl -s -X POST "$B/api/staff/mutate" -H 'content-type: application/json' -H "origin: $B" -d '{}')" 'Not signed in'
# /api/staff/decide is the one route with no session behind it by design — the manager taps the
# button in their mail client, signed out — so refusing an anonymous caller is not a property it
# has. Its gate is the token, and a POST carrying none gets the same answer a spent link does.
check "and /api/staff/decide refuses one with no token" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d '{"action":"approve"}')" 'expired'
echo "== wards see words, never counts"
SHELF=$(curl -s -b "$W" "$B/my/shelf")
check "the shelf check renders" "$SHELF" 'Navy tunic'
check "and says In stock" "$SHELF" 'In stock'
check "and Low for the thin size" "$SHELF" '>Low<'
# A leak would put the figure itself in a size row, in whatever wording the day's code happened to
# use, so the check is the number rather than a sentence somebody would have had to write first.
#
# Read off the words on the page and nothing else. The response also carries the framework's own
# payload — a wall of build ids and hashed chunk names — and three digits turn up somewhere in that
# by luck often enough, which reports the ward leaking stock counts on a day when nothing changed.
# The screen is rendered on the server, so everything a nurse can see is in the text once the
# scripts and the mark-up are taken out, and nothing incidental is.
WORDS=$(echo "$SHELF" | python3 -c "
import re, sys
html = re.sub(r'(?is)<(script|style)[^>]*>.*?</\1>', ' ', sys.stdin.read())
print(re.sub(r'(?s)<[^>]*>', ' ', html))
")
# An empty read would make the line below pass without looking at anything.
check " and the size rows are what we are reading" "$WORDS" 'Navy tunic'
no "and never a bare count of the shelf" "$WORDS" '\b143\b'
echo "== raising a request"
REQ=$(smut request.create "$W" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":2}],\"reason\":\"Worn out\",\"note\":\"Both worn through\"}")
check "the wearer can raise one" "$REQ" '"code"'
RID=$(echo "$REQ" | py "print(d['result']['id'])")
check "and it names the approver" "$REQ" 'Dele Manager'
no " and nothing was escalated" "$REQ" '"escalated":true'
check "an unknown garment is refused" "$(smut request.create "$W" '{"lines":[{"itemId":"nope","si":0,"qty":1}]}')" "isn't available"
check "a size that doesn't exist is refused" "$(smut request.create "$W" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":99,\"qty\":1}]}")" 'Pick a size'
check "somebody with no manager cannot raise" "$(smut request.create "$O" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "manager isn't set"
echo "== what may go on one request"
# Bea's section: six asks in a row, all but the last meant to be refused. A refused ask counts
# against the hourly ceiling exactly as a kept one does — it has to, since a phone stuck in a retry
# loop sends nothing but refusals — so this is somebody's own morning rather than Jamila's.
check "a request with no garments is refused" "$(smut request.create "$V" '{"lines":[]}')" 'at least one garment'
check "so is one with nothing but a reason" "$(smut request.create "$V" '{"reason":"Lost"}')" 'at least one garment'
check "none of a garment is refused" "$(smut request.create "$V" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":0}]}")" 'between 1 and 20'
check "and a wild quantity is too" "$(smut request.create "$V" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":99}]}")" 'between 1 and 20'
# Eleven lines, built rather than typed out, so the cap is read from the refusal and not from here.
ELEVEN=$(python3 -c "import json;print(json.dumps([{'itemId':'$IID','si':i%3,'qty':1} for i in range(11)]))")
check "an eleventh garment is refused" "$(smut request.create "$V" "{\"lines\":$ELEVEN}")" 'up to 10 garments'
# The same garment and size twice is one line with the quantities added, not two rows the manager
# has to decide twice and the linen room has to pick twice.
DUP=$(smut request.create "$V" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1},{\"itemId\":\"$IID\",\"si\":1,\"qty\":2}],\"reason\":\"Extra for shifts\"}")
DUPID=$(echo "$DUP" | py "print(d['result']['id'])")
DUPROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$DUPID'][0]))")
check "a repeated garment becomes one line" "$(echo "$DUPROW" | py "print(d['lineCount'])")" '^1$'
check " carrying the total" "$(echo "$DUPROW" | py "print(d['lines'][0]['qty'])")" '^3$'
check "tidied away again" "$(mut request.withdraw "{\"id\":\"$DUPID\",\"reason\":\"Raised in error\"}")" '"ok":true'
echo "== the linen room cannot approve"
check "no pick before approval" "$(mut request.pick "{\"id\":\"$RID\"}")" "can't move"
LR=$(curl -s -b "$C" "$B/api/requests")
check "it is visible to the linen room" "$LR" "$RID"
check " shown as awaiting" "$LR" '"status":"awaiting"'
echo "== only the addressed manager can decide"
check "another ward's manager cannot approve" "$(smut request.approve "$O" "{\"id\":\"$RID\"}")" 'No such request'
check "the wearer cannot approve their own" "$(smut request.approve "$W" "{\"id\":\"$RID\"}")" 'No such request'
check "a decline with no reason is refused" "$(smut request.decline "$M" "{\"id\":\"$RID\"}")" 'Pick a reason'
check "a made-up reason is refused" "$(smut request.decline "$M" "{\"id\":\"$RID\",\"reason\":\"Because\"}")" 'Pick a reason'
APPROVED=$(smut request.approve "$M" "{\"id\":\"$RID\"}")
check "the manager approves" "$APPROVED" '"status":"accepted"'
# The other half of the self-approval marking further down: a manager deciding somebody else's
# request is never handed back as having approved their own.
check " as an ordinary approval, not a self-approval" "$APPROVED" '"selfApproved":false'
check "and cannot approve twice" "$(smut request.approve "$M" "{\"id\":\"$RID\"}")" 'already been decided'
check "nor decline after approving" "$(smut request.decline "$M" "{\"id\":\"$RID\",\"reason\":\"Over allowance\"}")" 'already been decided'
echo "== fulfilment is the linen room's"
check "the manager cannot pick" "$(smut request.pick "$M" "{\"id\":\"$RID\"}")" 'Unknown action'
check "the linen room picks" "$(mut request.pick "{\"id\":\"$RID\"}")" '"status":"picking"'
check "collected is refused out of order" "$(mut request.collected "{\"id\":\"$RID\"}")" "can't move"
HOLD=$(mut request.hold "{\"id\":\"$RID\",\"holdUntil\":\"Fri 6pm\"}")
check "held at the counter" "$HOLD" '"status":"ready"'
ORDER=$(curl -s -b "$W" "$B/my/orders/$RID")
check "the wearer sees a collection code" "$ORDER" 'Show at the counter'
check "and the hold" "$ORDER" 'Fri 6pm'
check "and the timeline names the approver" "$ORDER" 'Approved by Dele Manager'
check "the linen room marks it collected" "$(mut request.collected "{\"id\":\"$RID\"}")" '"status":"collected"'
echo "== a decline carries its reason to the staff member"
R2=$(smut request.create "$Y" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":2,\"qty\":1}],\"reason\":\"Lost\"}" | py "print(d['result']['id'])")
DEC2=$(smut request.decline "$M" "{\"id\":\"$R2\",\"reason\":\"Over allowance\"}")
check "declined with a reason" "$DEC2" '"status":"declined"'
# One garment, so the summary says Declined outright rather than counting it out as 1 of 1.
check " and summarised without arithmetic" "$DEC2" '"summary":"Declined"'
D2=$(curl -s -b "$Y" "$B/my/orders/$R2")
check "the wearer is told which one" "$D2" 'Over allowance'
check "and who decided" "$D2" 'Dele Manager'
R2ROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$R2'][0]))")
check "the garment under it was refused too" "$(echo "$R2ROW" | py "print(d['lines'][0]['status'])")" '^declined$'
check " and carries the same reason" "$(echo "$R2ROW" | py "print(d['lines'][0]['declineReason'])")" '^Over allowance$'
echo "== one request, three garments, one decision"
# The whole point of lines. A nurse who needs a tunic, trousers and a fleece asks once; the manager
# reads the lot on one screen and answers in one action, but can knock back a single garment. Only
# what survives that is picked, bagged and handed over, and the refusal stays on the record so the
# wearer can see what happened to the fleece.
ML=$(smut request.create "$Y" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":2},{\"itemId\":\"$TID\",\"si\":0,\"qty\":1},{\"itemId\":\"$FID\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\",\"note\":\"Starting on nights\"}")
check "three garments go on one request" "$ML" '"code"'
MLID=$(echo "$ML" | py "print(d['result']['id'])")
ROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$MLID'][0]))")
check " three lines on it" "$(echo "$ROW" | py "print(d['lineCount'])")" '^3$'
check " four garments between them" "$(echo "$ROW" | py "print(d['garments'])")" '^4$'
check " and a one-liner for a collapsed row" "$(echo "$ROW" | py "print(d['summary'])")" '4 garments · Navy tunic, Navy trousers, Fleece jacket'
check " with nothing decided yet" "$(echo "$ROW" | py "print(d['decision'] or 'undecided')")" '^undecided$'
L1=$(echo "$ROW" | py "print(d['lines'][0]['id'])")
L2=$(echo "$ROW" | py "print(d['lines'][1]['id'])")
L3=$(echo "$ROW" | py "print(d['lines'][2]['id'])")
# A half-answered decision would send a bag to the counter with a garment nobody had ruled on.
check "leaving a garment undecided is refused" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"}]}")" 'Decide every garment'
check "an id that is not on the request is refused" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"rl_nonsense\",\"decision\":\"approved\"}]}")" "doesn.t match the request"
# "accepted" is the word for a whole request; a garment is "approved". Conflating the two would let
# a typo through as a decision nobody made.
check "the request-level word is not a line's word" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"accepted\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"approved\"}]}")" 'Approve or decline each garment'
check "a declined garment needs a reason" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"declined\"}]}")" 'Pick a reason for each garment'
check "and it has to be one of the reasons" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"declined\",\"reason\":\"Because\"}]}")" 'Pick a reason for each garment'
check "none of that decided anything" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$MLID'][0]['status'])")" '^awaiting$'
DEC=$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"declined\",\"reason\":\"Over allowance\"}]}")
check "two approved, one declined" "$DEC" '"status":"accepted"'
check " summarised in one line" "$DEC" '"summary":"2 of 3 approved"'
check "and the decision is only made once" "$(smut request.decline "$M" "{\"id\":\"$MLID\",\"reason\":\"Over allowance\"}")" 'already been decided'
ROW2=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$MLID'][0]))")
check "one surviving garment makes the request accepted" "$(echo "$ROW2" | py "print(d['status'])")" '^accepted$'
check " the record still holds all three" "$(echo "$ROW2" | py "print(d['lineCount'])")" '^3$'
check " the bag holds two" "$(echo "$ROW2" | py "print(len(d['bag']))")" '^2$'
check " three garments to pick" "$(echo "$ROW2" | py "print(d['garments'])")" '^3$'
no " and the fleece is not one of them" "$(echo "$ROW2" | py "print(json.dumps(d['bag']))")" 'Fleece jacket'
check "the refused line says what it was" "$(echo "$ROW2" | py "print([l for l in d['lines'] if l['item']=='Fleece jacket'][0]['status'])")" '^declined$'
check " and carries its own reason" "$(echo "$ROW2" | py "print([l for l in d['lines'] if l['item']=='Fleece jacket'][0]['declineReason'])")" '^Over allowance$'
check " and is worded for the wearer" "$(echo "$ROW2" | py "print([l for l in d['lines'] if l['item']=='Fleece jacket'][0]['statusLabel'])")" '^Declined$'
check " while the approved lines carry no reason" "$(echo "$ROW2" | py "print(len([l for l in d['bag'] if l['declineReason']]))")" '^0$'
# One refusal out of three is not a refusal of the request, so no reason is invented for it.
check "the request itself is given no reason" "$(echo "$ROW2" | py "print(d['declineReason'] or 'none')")" '^none$'
MLORDER=$(curl -s -b "$Y" "$B/my/orders/$MLID")
check "the wearer's order names the refused garment" "$MLORDER" 'Fleece jacket'
check " with the reason against it" "$MLORDER" 'Over allowance'
check " and still lists what is coming" "$MLORDER" 'Navy trousers'
echo "== only what was approved leaves the shelf"
TUNIC_BEFORE=$(curl -s -b "$C" "$B/api/backup" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId']=='$IID' and i['sizeIndex']==1))")
check "the linen room picks the bag" "$(mut request.pick "{\"id\":\"$MLID\"}")" '"status":"picking"'
check "and holds it at the counter" "$(mut request.hold "{\"id\":\"$MLID\",\"holdUntil\":\"Tue 2pm\"}")" '"status":"ready"'
MLCODE=$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$MLID'][0]['collectCode'])")
check "one collection code, for the whole request" "$MLCODE" '^[0-9][0-9][0-9][0-9]$'
READY=$(curl -s -b "$Y" "$B/my/orders/$MLID")
check " and the wearer is pointed at the counter" "$READY" 'Show at the counter'
check " under the code the linen room is holding" "$READY" "$MLCODE"
# Three garments, two lines, one bag. The screen says so in words, because somebody who asked for
# three things and is given one code will otherwise assume the rest is coming separately.
# React's server renderer puts an empty comment between a literal and an interpolated value, so
# this sentence reaches the browser as `All <!-- -->3<!-- --> garments...`. Read it with the
# separators taken out, or the check hunts for a string the server has never once sent.
READY_TEXT=$(printf '%s' "$READY" | sed 's/<!-- -->//g')
check " said to cover the whole bag" "$READY_TEXT" 'All 3 garments are in one bag'
check " covering the tunics" "$READY" 'Navy tunic'
check " and the trousers with them" "$READY" 'Navy trousers'
check "the linen room hands the bag over" "$(mut request.collected "{\"id\":\"$MLID\"}")" '"status":"collected"'
BK=$(curl -s -b "$C" "$B/api/backup")
check "both tunics came off the shelf" "$(echo "$BK" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId']=='$IID' and i['sizeIndex']==1) - $TUNIC_BEFORE)")" '^2$'
check "and the trousers with them" "$(echo "$BK" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId']=='$TID'))")" '^1$'
check "the fleece never moved" "$(echo "$BK" | py "print(len([i for i in d['issues'] if i['itemId']=='$FID']))")" '^0$'
check "nor was one ordered to replace it" "$(echo "$BK" | py "print(len([l for o in d['orders'] for l in o['lines'] if l['itemId']=='$FID']))")" '^0$'
check " while the trousers were" "$(echo "$BK" | py "print(sum(l['qty'] for o in d['orders'] for l in o['lines'] if l['itemId']=='$TID'))")" '^1$'
check "the hand-over was one event, under the one code" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$MLID'][0]['events'][-1]['meta'])")" "^Code $MLCODE\$"
echo "== two bags at the counter never share a code"
# The whole transaction at the counter is somebody reading four digits off their phone and the
# coordinator finding the bag with that number on it, so two bags waiting at once under the same
# number is somebody being handed the wrong uniform. Uniqueness is only asked of the bags actually
# out there — a code goes back in the pot once its bag has gone home — which is why both of these
# are held before either is checked.
CA=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
CB=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$TID\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
check "the manager approves the first" "$(smut request.approve "$M" "{\"id\":\"$CA\"}")" '"status":"accepted"'
check " and the second" "$(smut request.approve "$M" "{\"id\":\"$CB\"}")" '"status":"accepted"'
check "the first is picked" "$(mut request.pick "{\"id\":\"$CA\"}")" '"status":"picking"'
check " and held at the counter" "$(mut request.hold "{\"id\":\"$CA\",\"holdUntil\":\"Thu 5pm\"}")" '"status":"ready"'
check "the second is picked" "$(mut request.pick "{\"id\":\"$CB\"}")" '"status":"picking"'
check " and held beside it" "$(mut request.hold "{\"id\":\"$CB\",\"holdUntil\":\"Thu 5pm\"}")" '"status":"ready"'
# Looked up by id, one field each, rather than filtered into a list: a bag that never got as far as
# the counter has to read as missing. Filtering shortened the list instead, and a lone code beside
# an empty second field then compared as two codes that differ, which is the one answer this pair
# of lines exists to rule out.
PAIR=$(curl -s -b "$C" "$B/api/requests" | py "codes={r['id']: (r['collectCode'] or 'none') for r in d['requests']}; print(codes.get('$CA','missing'), codes.get('$CB','missing'))")
check "both bags carry a code" "$PAIR" '^[0-9][0-9][0-9][0-9] [0-9][0-9][0-9][0-9]$'
check " and the two are not the same one" "$(echo "$PAIR" | awk '{ if ($1 !~ /^[0-9][0-9][0-9][0-9]$/ || $2 !~ /^[0-9][0-9][0-9][0-9]$/) print "not two codes: " $0; else if ($1 == $2) print "the same"; else print "different" }')" '^different$'
check "the first goes home" "$(mut request.collected "{\"id\":\"$CA\"}")" '"status":"collected"'
check " and the second after it" "$(mut request.collected "{\"id\":\"$CB\"}")" '"status":"collected"'
echo "== the emailed link renders, the button decides"
# Two garments on this one deliberately. The email has no room for a garment-by-garment answer —
# it is one button — so approving through it has to settle every line on the request. A link that
# moved the request to accepted while its lines sat at awaiting would hand the linen room a pick
# list with nothing on it.
R3=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1},{\"itemId\":\"$TID\",\"si\":0,\"qty\":1}],\"reason\":\"Damaged\"}" | py "print(d['result']['id'])")
TOKEN=$(node scripts/approval-mint.cjs "$R3" "$MID" 2>/dev/null)
check "a token can be minted for the test" "$TOKEN" '.'
PAGE=$(curl -s "$B/my/approve?t=$TOKEN")
check "the link opens a page, signed out" "$PAGE" 'needs your approval'
check " listing every garment on the ask" "$PAGE" 'Navy trousers'
check " which says nothing is decided yet" "$PAGE" 'Nothing has been decided yet'
STILL=$(curl -s -b "$C" "$B/api/requests")
check " and rendering it decided nothing" "$(echo "$STILL" | py "print([r for r in d['requests'] if r['id']=='$R3'][0]['status'])")" '^awaiting$'
check "a garbage token is refused" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d '{"token":"nope.nope","action":"approve"}')" 'expired'
check "the POST decides" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d "{\"token\":\"$TOKEN\",\"action\":\"approve\"}")" '"status":"accepted"'
check "and the same link will not decide twice" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d "{\"token\":\"$TOKEN\",\"action\":\"decline\",\"reason\":\"Over allowance\"}")" 'already been decided'
R3ROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$R3'][0]))")
check "the one button settled both garments" "$(echo "$R3ROW" | py "print(len([l for l in d['lines'] if l['status']=='approved']))")" '^2$'
check " so the whole ask is in the bag" "$(echo "$R3ROW" | py "print(len(d['bag']))")" '^2$'
check " and it reads as approved outright" "$(echo "$R3ROW" | py "print(d['decision'])")" '^All 2 approved$'
echo "== the ward round, and who signed"
# Signing for a bag on the ward is the moment its garments leave the linen room's shelf, exactly as
# collecting at the counter is, so the whole approved ask has to move — not just the first line on
# it. Counted before the round and after, because the shelf is what the linen room reconciles.
ROUND_BEFORE=$(curl -s -b "$C" "$B/api/backup" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId'] in ('$IID','$TID')))")
check "sent on the round" "$(mut request.pick "{\"id\":\"$R3\"}")" '"status":"picking"'
check " routed to the ward" "$(mut request.round "{\"id\":\"$R3\"}")" '"status":"round"'
check "another ward cannot sign for it" "$(smut round.sign "$O" "{\"id\":\"$R3\"}")" 'another ward'
check "anyone on the ward can sign" "$(smut round.sign "$D" "{\"id\":\"$R3\"}")" '"ok":true'
check " and both garments came off the shelf with it" "$(curl -s -b "$C" "$B/api/backup" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId'] in ('$IID','$TID')) - $ROUND_BEFORE)")" '^2$'
check "and not twice" "$(smut round.sign "$D" "{\"id\":\"$R3\"}")" 'No such bag'
SIGNED=$(curl -s -b "$Z" "$B/my/orders/$R3")
check "the requester is told who signed" "$SIGNED" 'Ade Clerk'
# Signing is where the linen room's job ends, not where the bag does: it then sits on the desk
# until somebody says it was picked up, and the desk's unclaimed pile only grows until they do.
# The requester, or the clerk standing next to the pile, may say so — and nobody else.
check "a stranger cannot mark it collected" "$(smut round.claim "$O" "{\"id\":\"$R3\"}")" 'somebody else'
check "the desk marks it collected" "$(smut round.claim "$D" "{\"id\":\"$R3\"}")" '"ok":true'
check " and the requester is told who did" "$(curl -s -b "$Z" "$B/my/orders/$R3")" 'Marked by Ade Clerk'
# The requester tapping "I've got it" after the desk has already marked it must leave one claim
# and one line on the timeline, not a second one under the other name.
check "claiming it again changes nothing" "$(smut round.claim "$Z" "{\"id\":\"$R3\"}")" '"ok":true'
# One row, and still the desk's name on it: an implementation that overwrote the first claim with
# the second caller's name would also leave exactly one row, so counting them proves half of it.
check " and the timeline says it once, in the desk's name" "$(curl -s -b "$C" "$B/api/requests" | py "e=[x for x in [r for r in d['requests'] if r['id']=='$R3'][0]['events'] if x['label']=='Collected from the ward']; print(len(e), e[0]['meta'] if e else '')")" '^1 Marked by Ade Clerk$'
R3CODE=$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$R3'][0]['code'])")
ROUNDPAGE=$(curl -s -b "$D" "$B/my/round")
check "the desk's round screen is still there" "$ROUNDPAGE" 'Ward round'
no " and the collected bag is off it" "$ROUNDPAGE" "$R3CODE"
echo "== the ward desk raises for nobody"
# The desk used to raise for anyone on its own ward, on the grounds that half a ward would never
# install anything. That door is shut: the only person who may put a request in somebody else's
# name is their own manager. Ade is on the desk and on Jamila's ward, and between them those two
# facts now buy her exactly what they buy an ordinary staff member.
DR=$(smut request.create "$D" "{\"subjectId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Extra for shifts\"}")
check "the desk cannot raise for its own ward" "$DR" "Only somebody.s own manager"
no " and nothing is created by it" "$DR" '"code"'
check "nor for another ward" "$(smut request.create "$D" "{\"subjectId\":\"$OID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "Only somebody.s own manager"
check "a non-desk staff member cannot either" "$(smut request.create "$W" "{\"subjectId\":\"$DID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "Only somebody.s own manager"
# The widest version of that door was a blank ward: two people with nothing recorded read as
# members of the same one, which handed a clerk with an empty ward the run of every other
# ward-less person in the facility. Nia is that clerk, and Pat reports to Dele, not to her.
BLANK=$(smut request.create "$N" "{\"subjectId\":\"$PID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")
check "nor a desk clerk with no ward recorded" "$BLANK" "Only somebody.s own manager"
no " and nothing is created by that either" "$BLANK" '"code"'
# The flag itself stays — it is what signs for a bag on the round — but the screen it used to open
# has gone from the product, for the clerk who holds the flag as much as for anybody else.
check "and the desk screen went with the door" "$(curl -s -o /dev/null -w '%{http_code}' -b "$D" "$B/my/desk")" '404'
echo "== a manager raises for their own report, and never approves it"
# New in this round: a manager may raise for the people who report to them. The catch is that the
# manager is also the person who would approve it, so the request goes a level up instead — and
# when there is nobody above, it is created with no approver at all and waits on the linen room.
#
# The screen is scoped to the reporting line, not to a ward, and it exists only for somebody who
# actually has one — an empty version of it would tell a nurse they might have a team.
refused "a wearer with nobody under them has no such screen" "$W" "/my/raise" "Who is it for"
refused "nor the ward clerk, who manages nobody either" "$D" "/my/raise" "Who is it for"
TEAM=$(curl -s -b "$M" "$B/my/raise")
check "the manager has one" "$TEAM" 'Raise for your team'
check " listing the people who report to them" "$TEAM" 'Jamila Wearer'
no " and nobody who doesn.t" "$TEAM" 'Otto Elsewhere'
check "and home points them at it" "$(curl -s -b "$M" "$B/my")" 'Raise for someone you manage'
SELFR=$(smut request.create "$M" "{\"subjectId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "the manager can raise for their report" "$SELFR" '"code"'
check " and is told it was moved on" "$SELFR" '"escalated":true'
check " with nobody above them, it has no approver" "$SELFR" '"manager":""'
SRID=$(echo "$SELFR" | py "print(d['result']['id'])")
check " and the subject sees whose name it is in" "$(curl -s -b "$W" "$B/my/orders/$SRID")" 'Raised for you by Dele Manager'
check "so the raiser cannot approve it" "$(smut request.approve "$M" "{\"id\":\"$SRID\"}")" 'No such request'
check "nor decline it" "$(smut request.decline "$M" "{\"id\":\"$SRID\",\"reason\":\"Over allowance\"}")" 'No such request'
SROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$SRID'][0]))")
check "the linen room sees it waiting" "$(echo "$SROW" | py "print(d['status'])")" '^awaiting$'
check " with nobody's name against it" "$(echo "$SROW" | py "print(d['managerName'] or 'nobody')")" '^nobody$'
check " and the timeline says why" "$(echo "$SROW" | py "print(d['events'][0]['meta'])")" 'the linen room will address it'
check "the linen room addresses it to somebody who can decide" "$(mut request.reassign "{\"id\":\"$SRID\",\"managerId\":\"$CID\"}")" '"manager":"Chidi Director"'
check "and that manager can" "$(smut request.approve "$X" "{\"id\":\"$SRID\"}")" '"status":"accepted"'
check "the manager is given a manager of their own" "$(mut staff.patch "{\"id\":\"$MID\",\"managerId\":\"$CID\"}")" '"ok":true'
UPR=$(smut request.create "$M" "{\"subjectId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "now the same raise goes up a level" "$UPR" '"manager":"Chidi Director"'
check " still marked as moved on" "$UPR" '"escalated":true'
no " and never back to the raiser" "$UPR" 'Dele Manager'
UPID=$(echo "$UPR" | py "print(d['result']['id'])")
check "the raiser still cannot decide it" "$(smut request.approve "$M" "{\"id\":\"$UPID\"}")" 'No such request'
# Not deciding it is not the same as losing sight of it: whoever raised a request can follow it.
check "but can still follow it" "$(curl -s -o /dev/null -w '%{http_code}' -b "$M" "$B/my/orders/$UPID")" '200'
check "the level above declines it" "$(smut request.decline "$X" "{\"id\":\"$UPID\",\"reason\":\"Over allowance\"}")" '"status":"declined"'
check "and the wearer is told who did" "$(curl -s -b "$W" "$B/my/orders/$UPID")" 'Chidi Director'
# Somebody who does not report to you is nobody's to raise for — there is no route left that
# reaches them, so a manager gets the same sentence back as the desk does.
check "a manager still cannot raise for a stranger" "$(smut request.create "$M" "{\"subjectId\":\"$OID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "Only somebody.s own manager"
# A request raised for somebody else sits on nobody's own order list, so without a place of its own
# it would go quiet on the person who typed it in. Read off what was drawn rather than off what was
# sent: the screen is handed all three lists whichever tab is showing, so the subject's name is in
# the bytes of both URLs either way. "for Jamila Wearer" is composed as a row is drawn, so it only
# appears on the list the tab actually opened.
RAISED=$(curl -s -b "$M" "$B/my/orders?tab=raised")
check "what they raised for others has its own list" "$RAISED" 'for Jamila Wearer'
OWN=$(curl -s -b "$M" "$B/my/orders")
check " and their own list opens with nothing in it" "$OWN" 'Nothing open'
no " and it is not folded in with their own orders" "$OWN" 'for Jamila Wearer'
echo "== anybody may be their own manager"
# The owner's decision, 12 September 2026. On a facility where one person is the whole register
# there is nobody else to name, and the staff app refuses every request from somebody with no manager
# set — so anybody may be recorded as their own, and approve their own requests and signed forms.
# None of it passes unseen: every such approval is marked self-approved on the record. This replaced
# a rule that allowed it only to somebody with at least one other person reporting to them, which is
# why Kofi below has nobody under him.
KID=$(mut staff.save '{"num":"K1","first":"Kofi","last":"Solo","group":"Registered Nurse","dept":"Linden Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
check "somebody with nobody under them" "$KID" '.'
check " can be recorded as their own manager" "$(mut staff.patch "{\"id\":\"$KID\",\"managerId\":\"$KID\"}")" '"ok":true'
check " and the register holds them as it" "$(curl -s -b "$C" "$B/api/backup" | py "print([s['managerId'] for s in d['staff'] if s['id']=='$KID'][0] == '$KID')")" '^True$'
check "Kofi claims an account" "$(claim "$KID" "$K" "k$TS@example.com")" '"ok":true'
# The complaint that started this: the staff app kept telling him no manager was set. Otto still has
# none, so the same sentence on his home is what proves this line is looking at the right words.
no "the staff app no longer says he has no manager" "$(curl -s -b "$K" "$B/my")" 'manager isnt recorded yet'
check " while somebody with none is still told" "$(curl -s -b "$O" "$B/my")" 'manager isnt recorded yet'
# Being your own manager is not having a team: the raise-for-others screen lists the people who
# report to you, and he is not one of them.
refused "being his own manager gives him no team to raise for" "$K" "/my/raise" "Who is it for"
KR=$(smut request.create "$K" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "he raises a request for himself" "$KR" '"code"'
check " addressed to himself" "$KR" '"manager":"Kofi Solo"'
check " and is told it is his to approve" "$KR" '"selfApproves":true'
no " and it was not moved on anywhere" "$KR" '"escalated":true'
KRID=$(echo "$KR" | py "print(d['result']['id'])")
KROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$KRID'][0]))")
check "the linen room sees it awaiting his own approval" "$(echo "$KROW" | py "print(d['status'], d['managerId']=='$KID')")" '^awaiting True$'
check "somebody else still cannot decide it" "$(smut request.approve "$O" "{\"id\":\"$KRID\"}")" 'No such request'
# The not-found page's words ride along in every staff-app page's data, so a page that refused him
# is told apart by its status, not by its text: a refusal is a 404, the queue is a 200.
check "his approvals queue opens" "$(curl -s -o /dev/null -w '%{http_code}' -b "$K" "$B/my/approvals")" '^200$'
KQ=$(curl -s -b "$K" "$B/my/approvals")
check " and is the queue" "$KQ" 'Approvals'
KAP=$(smut request.approve "$K" "{\"id\":\"$KRID\"}")
check "he approves it" "$KAP" '"status":"accepted"'
check " and is told it was a self-approval" "$KAP" '"selfApproved":true'
KROW2=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$KRID'][0]))")
check "the request records him as its manager" "$(echo "$KROW2" | py "print(d['managerName'], d['managerId']=='$KID')")" '^Kofi Solo True$'
check " and its timeline reads as his own" "$(echo "$KROW2" | py "print(d['events'][-1]['label'])")" '^Approved by Kofi Solo .* their own request$'
check " and says self-approved in so many words" "$(echo "$KROW2" | py "print(d['events'][-1]['meta'])")" '^Self-approved'
# Read off Jamila's first request, decided by Dele: the marking belongs to self-approvals only, and
# a timeline that said it of every approval would say nothing.
RIDTL=$(curl -s -b "$C" "$B/api/requests" | py "print(' | '.join(e['label']+' / '+(e['meta'] or '') for e in [r for r in d['requests'] if r['id']=='$RID'][0]['events']))")
check "an ordinary approval's timeline" "$RIDTL" 'Approved by Dele Manager'
no " never reads as a self-approval" "$RIDTL" 'their own request\|Self-approved'
# A signed order form for his own kit, with him as the approver. The pair staffId === byStaffId is
# the whole of the marking; a form countersigned by somebody else, beside it, must not carry it.
KA=$(mut approval.add "{\"staffId\":\"$KID\",\"byStaffId\":\"$KID\",\"sets\":\"2\",\"fte\":\"1.0\"}")
check "a signed form approved by himself is recorded" "$KA" '"id"'
check " and handed back as self-approved" "$KA" '"selfApproved":true'
KAID=$(echo "$KA" | py "print(d['result']['id'])")
KB=$(mut approval.add "{\"staffId\":\"$KID\",\"byStaffId\":\"$MID\",\"sets\":\"1\"}")
check "one countersigned by somebody else is recorded" "$KB" '"id"'
check " and is not" "$KB" '"selfApproved":false'
KBID=$(echo "$KB" | py "print(d['result']['id'])")
check "the record holds him as his own approver, and only on his own form" "$(curl -s -b "$C" "$B/api/backup" | py "a={x['id']: x for x in d['approvals']}; s=a.get('$KAID'); o=a.get('$KBID'); print(bool(s) and s['staffId']=='$KID' and s['byStaffId']=='$KID', bool(o) and o['byStaffId']=='$KID')")" '^True False$'
echo "== nobody approves a raise they made for somebody else"
# The one rule that stays. Lena is her own manager and Remy reports to her, so a raise of hers for
# Remy would be addressed to Lena — and goes up a level instead. The level up is Lena again, which is
# nobody above: it lands with no approver, in Needs an approver, as a raise with nobody above does.
LDID=$(mut staff.save '{"num":"L1","first":"Lena","last":"Lead","group":"Registered Nurse","dept":"Linden Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
RMID=$(mut staff.save '{"num":"R1","first":"Remy","last":"Report","group":"Registered Nurse","dept":"Linden Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
check "a lead and her report are on the register" "$(printf '%s\n' "$LDID" "$RMID" | grep -c .)" '^2$'
check " she is her own manager" "$(mut staff.patch "{\"id\":\"$LDID\",\"managerId\":\"$LDID\"}")" '"ok":true'
check " and his" "$(mut staff.patch "{\"id\":\"$RMID\",\"managerId\":\"$LDID\"}")" '"ok":true'
check "Lena claims an account" "$(claim "$LDID" "$L" "l$TS@example.com")" '"ok":true'
LTEAM=$(curl -s -b "$L" "$B/my/raise")
check "she has a team to raise for" "$LTEAM" 'Remy Report'
# Her own request, beside the one she raises for Remy: the difference between the two is the rule.
LOWN=$(smut request.create "$L" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "her own request lands on her" "$LOWN" '"manager":"Lena Lead"'
LOWNID=$(echo "$LOWN" | py "print(d['result']['id'])")
check " and she may approve it" "$(smut request.approve "$L" "{\"id\":\"$LOWNID\"}")" '"selfApproved":true'
LR=$(smut request.create "$L" "{\"subjectId\":\"$RMID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
check "she can raise for Remy" "$LR" '"code"'
check " and it is moved on" "$LR" '"escalated":true'
check " with nobody above her but herself, it has no approver" "$LR" '"manager":""'
no " and it is never hers" "$LR" 'Lena Lead'
LRID=$(echo "$LR" | py "print(d['result']['id'])")
check "so she cannot approve it" "$(smut request.approve "$L" "{\"id\":\"$LRID\"}")" 'No such request'
check "nor decline it" "$(smut request.decline "$L" "{\"id\":\"$LRID\",\"reason\":\"Over allowance\"}")" 'No such request'
LROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$LRID'][0]))")
check "the linen room sees it waiting on nobody" "$(echo "$LROW" | py "print(d['status'], d['managerId'] or 'nobody', d['managerName'] or 'nobody')")" '^awaiting nobody nobody$'
check " and the timeline says why" "$(echo "$LROW" | py "print(d['events'][0]['meta'])")" 'the linen room will address it'
check "the linen room cannot hand it back to her" "$(mut request.reassign "{\"id\":\"$LRID\",\"managerId\":\"$LDID\"}")" 'Lena raised this request'
# Sending it to Remy himself would make him its approver, and he isn't his own manager — his app
# couldn't let him decide it — so that is refused too. Only the rule for self-addressing can refuse
# this: Remy isn't the one who raised it.
check " nor send it to Remy, who isn't his own manager" "$(mut request.reassign "{\"id\":\"$LRID\",\"managerId\":\"$RMID\"}")" 'own manager'
check " but can address it to somebody who can decide" "$(mut request.reassign "{\"id\":\"$LRID\",\"managerId\":\"$CID\"}")" '"manager":"Chidi Director"'
check " and that manager can" "$(smut request.approve "$X" "{\"id\":\"$LRID\"}")" '"status":"accepted"'
# Somebody who manages only themselves strands nobody by leaving the register; somebody who is also
# named by a report still does.
check "somebody who is only their own manager can leave the register" "$(mut staff.patch "{\"id\":\"$KID\",\"inactive\":true}")" '"ok":true'
check " while one with a report still can't" "$(mut staff.patch "{\"id\":\"$LDID\",\"inactive\":true}")" 'still names Lena'
# The CSV import links managers in a second pass. A row naming its own staff number used to be
# skipped with an error; it is kept now, and a number nobody has is still an error beside it.
IMP=$(mut import.rows '{"kind":"staff","rows":[{"num":"Q1","first":"Quinn","last":"Self","group":"Registered Nurse","manager":"Q1"},{"num":"Q2","first":"Quade","last":"Nobody","group":"Registered Nurse","manager":"ZZ9"}]}')
check "an import naming a row as its own manager goes through" "$IMP" '"created":2'
check " a manager number nobody has is still an error" "$IMP" 'no staff member with number ZZ9'
check " and the self-named row is its own manager" "$(curl -s -b "$C" "$B/api/backup" | py "s=[s for s in d['staff'] if s['num']=='Q1']; print(bool(s) and s[0]['managerId']==s[0]['id'])")" '^True$'
echo "== the counter raises one over the desk"
# Somebody walks into the linen room without a phone, and the coordinator raises it for them. It
# takes the same list of garments the app sends, and it still goes to that person's own manager —
# a counter that could raise and approve in one move would make the approval a formality.
CR=$(mut request.raise "{\"staffId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1},{\"itemId\":\"$TID\",\"si\":0,\"qty\":2}],\"reason\":\"Worn out\"}")
check "the linen room raises it" "$CR" '"code"'
check " addressed to the wearer's own manager" "$CR" 'Dele Manager'
CRID=$(echo "$CR" | py "print(d['result']['id'])")
CRROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$CRID'][0]))")
check " carrying both garments" "$(echo "$CRROW" | py "print(d['lineCount'])")" '^2$'
check " three between them" "$(echo "$CRROW" | py "print(d['garments'])")" '^3$'
check " and the timeline says where it came from" "$(echo "$CRROW" | py "print(d['events'][0]['meta'])")" 'Raised at the counter'
check "an unknown garment is refused here too" "$(mut request.raise "{\"staffId\":\"$WID\",\"lines\":[{\"itemId\":\"nope\",\"si\":0,\"qty\":1}]}")" "isn't available"
check "so is a request with nothing on it" "$(mut request.raise "{\"staffId\":\"$WID\",\"lines\":[]}")" 'at least one garment'
check "and one for somebody with no manager" "$(mut request.raise "{\"staffId\":\"$OID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" 'no manager recorded'
check "the manager decides it like any other" "$(smut request.approve "$M" "{\"id\":\"$CRID\"}")" '"status":"accepted"'
check " and the wearer is told who raised it" "$(curl -s -b "$W" "$B/my/orders/$CRID")" 'Raised for you by Sal Linen'
echo "== a bag for somebody with no ward stays at the counter"
# The round delivers to a ward, so a wearer whose ward was never filled in has nowhere for one to
# go. The check that a ward has somebody who can sign counted staff on `dept`, which matched every
# ward-less clerk in the building against every ward-less wearer — Nia against Pat — and let the
# bag out onto a round that would then be signed for by a stranger.
NR=$(mut request.raise "{\"staffId\":\"$PID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")
check "the counter raises one for the ward-less nurse" "$NR" '"code"'
NRID=$(echo "$NR" | py "print(d['result']['id'])")
check " her manager approves it" "$(smut request.approve "$M" "{\"id\":\"$NRID\"}")" '"status":"accepted"'
check " and the linen room picks it" "$(mut request.pick "{\"id\":\"$NRID\"}")" '"status":"picking"'
check "but it cannot be sent on the round" "$(mut request.round "{\"id\":\"$NRID\"}")" 'no ward recorded'
check " and the counter is what is left" "$(mut request.hold "{\"id\":\"$NRID\"}")" '"status":"ready"'
echo "== messages hang off one order"
check "the wearer asks" "$(smut request.message "$W" "{\"id\":\"$RID\",\"body\":\"Any chance of a 14?\"}")" '"id"'
check "the linen room replies" "$(mut request.reply "{\"id\":\"$RID\",\"body\":\"We have one put by.\"}")" '"id"'
THREAD=$(curl -s -b "$W" "$B/my/orders/$RID/messages")
check "both appear in the thread" "$THREAD" 'Any chance of a 14'
check " including the reply" "$THREAD" 'We have one put by'
check "a stranger cannot post to it" "$(smut request.message "$O" "{\"id\":\"$RID\",\"body\":\"hello\"}")" 'No such request'
refused "nor read it" "$O" "/my/orders/$RID/messages" "Send"
echo "== the record query with no order behind it"
check "the wearer raises one" "$(smut dispute.raise "$W" '{"body":"I handed two tunics back in August"}')" '"id"'
QUERIES=$(curl -s -b "$C" "$B/api/requests")
check "the linen room sees it" "$QUERIES" 'handed two tunics back'
DQ=$(echo "$QUERIES" | py "print(d['disputes'][0]['id'])")
check "and can mark it sorted" "$(mut dispute.resolve "{\"id\":\"$DQ\"}")" '"ok":true'
no "after which it is off the queue" "$(curl -s -b "$C" "$B/api/requests")" 'handed two tunics back'
echo "== the waitlist"
check "joining a size that is on the shelf is allowed" "$(smut waitlist.join "$W" "{\"itemId\":\"$IID\",\"si\":0}")" '"id"'
check "but only once" "$(smut waitlist.join "$W" "{\"itemId\":\"$IID\",\"si\":0}")" 'already on the list'
WL=$(curl -s -b "$W" "$B/my/waitlist?item=$IID&si=0")
check "the screen shows a position" "$WL" 'in queue'
check " and says waiting needs no approval" "$WL" "doesnt need approval"
WLID=$(curl -s -b "$C" "$B/api/requests" | py "print(d['waiting'][0]['id'])")
check "the linen room offers it when stock lands" "$(mut waitlist.offer "{\"id\":\"$WLID\"}")" '"ok":true'
ACC=$(smut waitlist.accept "$W" "{\"id\":\"$WLID\"}")
check "accepting raises a request" "$ACC" '"request"'
ARID=$(echo "$ACC" | py "print(d['result']['request']['id'])")
check " which still needs the manager" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$ARID'][0]['status'])")" '^awaiting$'
check "leaving a list you are not on is refused" "$(smut waitlist.leave "$W" '{"id":"nope"}')" 'Not on that list'
echo "== the kit check"
check "answering with no cycle open is refused" "$(smut kit.answer "$W" "{\"itemId\":\"$IID\",\"si\":1,\"onRecord\":2,\"confirmed\":2}")" 'No kit check is open'
refused "the phone screen does not exist between rounds" "$W" "/my/kitcheck" "Still have"
check "the linen room opens a round" "$(mut kitcheck.open '{"dueBy":"2026-12-01"}')" '"id"'
check "and cannot open a second" "$(mut kitcheck.open '{"dueBy":"2026-12-01"}')" 'already running'
# Give them something to count. The fleece is what the answers below are checked against: the tunic
# has been handed to Jamila more than once already in this file — at the counter, and again here —
# so the figure on her record for it is nothing this script chose. The fleece has never been near
# her, so three is three.
# One tunic, not three: counting the request lines a manager has already approved for her, she holds
# four tops, and three more would take her past the six anyone may hold. The tunic only has to be on
# her record for the screen to list it.
check "the wearer is issued garments" "$(mut issue.create "{\"staffId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" '"ok":true'
check " and a garment nobody has handed them before" "$(mut issue.create "{\"staffId\":\"$WID\",\"lines\":[{\"itemId\":\"$FID\",\"si\":0,\"qty\":3}]}")" '"ok":true'
KC=$(curl -s -b "$W" "$B/my/kitcheck")
# The heading moves with the copy, so the checks sit on the things that cannot: the screen names
# the garments the record claims and promises nothing is chargeable. The last one is the rule this
# product keeps getting wrong — a wearer takes their uniform home and launders it themselves, so a
# screen that asks them to go and look in a locker is asking about a locker they do not have, and
# gets answered from imagination or not at all.
check "the screen appears" "$KC" 'Kit check'
check " listing what the record says they hold" "$KC" 'Navy tunic'
check " counted off against the record" "$KC" 'on your record'
check " and says nothing is chargeable" "$KC" 'chargeable'
no " and never sends them to a locker" "$KC" '[Ll]ocker'
# The onRecord in the payload came off the phone and is worth nothing as evidence, so the 99 here
# is a deliberate lie: a shortfall of 2 is only possible if the server threw it away and re-read
# the record. Taking the client's word for it would report a shortfall of 98.
SHORT=$(smut kit.answer "$W" "{\"itemId\":\"$FID\",\"si\":0,\"onRecord\":99,\"confirmed\":1}")
check "a shortfall is recorded" "$SHORT" '"short":2'
check " against the record's figure, not the phone's" "$(curl -s -b "$C" "$B/api/requests" | py "print([s for s in d['shortfalls'] if s['staffId']=='$WID' and s['item']=='Fleece jacket'][0]['onRecord'])")" '^3$'
# Confirming more than the record says is clamped, not carried through as a negative shortfall:
# without the clamp this answer reports -6 and the linen room reconciles against it.
check "confirming more than the record is clamped" "$(smut kit.answer "$W" "{\"itemId\":\"$FID\",\"si\":0,\"onRecord\":3,\"confirmed\":9}")" '"short":0'
CYC=$(curl -s -b "$C" "$B/api/requests" | py "print(d['cycle']['id'])")
check "the linen room closes the round" "$(mut kitcheck.close "{\"id\":\"$CYC\"}")" '"ok":true'
echo "== a wearer still cannot reach the register"
check "no coordinator mutation" "$(curl -s -b "$W" -X POST "$B/api/mutate" -H 'content-type: application/json' -H "origin: $B" -d '{"op":"request.pick","payload":{"id":"x"}}')" 'Not signed in'
check "no backup" "$(curl -s -b "$W" "$B/api/backup")" 'Not signed in'
check "no linen-room request list" "$(curl -s -b "$W" "$B/api/requests")" 'Not signed in'
refused "no approvals queue without reports" "$W" "/my/approvals" "Approve"
refused "no ward view without reports" "$W" "/my/ward" "On the ward"
refused "no ward round without the flag" "$W" "/my/round" "Sign for"
# The flag is not enough on its own: the round is one ward's bags, and a clerk whose ward was never
# recorded has none. Left as a plain match on `dept`, which defaults to an empty string, Nia's
# round would have been every ward-less person's bags in the facility.
refused "nor with the flag but no ward" "$N" "/my/round" "Sign for"
check "the manager does get an approvals queue" "$(curl -s -b "$M" "$B/my/approvals")" 'Approvals'
check "and a ward view" "$(curl -s -b "$M" "$B/my/ward")" 'Items held'
echo "== the website's Log in box opens the staff app too"
# A wearer reaches the product the way anybody else does — the home page, then Log in — and types
# the details they set up in the app. So the one box asks the coordinator table first and the
# register only when that address has no coordinator account.
#
# It matters that this is a lookup and not a second attempt. "Try the coordinator, and if that fails
# try the staff one" would score a failure against every staff sign-in, and those ceilings count
# failures: behind one hospital's NAT address at shift change that is a locked-out ward.
#
# Both markers are read off the record: the name says whose session it is, and the static label says
# the screen actually rendered. Either alone would pass on a page that came back for the wrong
# reason.
P="$T/tc-sa-web.txt"; rm -f "$P"
check "the wearer signs in at the website's Log in box" "$(curl -s -c "$P" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"w$TS@example.com\",\"password\":\"wearerpass1\"}")" '"staff":true'
WEB=$(curl -s -b "$P" "$B/my")
check " and the cookie it set opens her own record" "$WEB" 'Jamila'
check " which is the staff app, not a web page" "$WEB" 'Request an item'
check " a wrong password there is refused" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"w$TS@example.com\",\"password\":\"nope\"}")" 'Email or password doesn'
# The app's own door, which the printed slip and the Play app use. The two share one implementation
# now, and nothing else in this file signs in through it — a refactor that broke it would otherwise
# ship green.
Q="$T/tc-sa-door.txt"; rm -f "$Q"
check "the staff app's own door still signs her in" "$(curl -s -c "$Q" -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"w$TS@example.com\",\"password\":\"wearerpass1\"}")" '"ok":true'
check " and that cookie opens the same record" "$(curl -s -b "$Q" "$B/my")" 'Jamila'
check " a wrong password at that door is refused too" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"w$TS@example.com\",\"password\":\"nope\"}")" 'Email or password doesn'
# A coordinator is still a coordinator at the same box, and is sent to the counter rather than the
# staff app.
R="$T/tc-sa-coordweb.txt"; rm -f "$R"
CW=$(curl -s -c "$R" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$CO\",\"password\":\"password123\"}")
check "the linen room signs in at the same box" "$CW" '"ok":true'
no " and is not sent to the staff app" "$CW" '"staff":true'
# One address, one destination. Where both exist the coordinator account wins — which also means the
# staff password on that address opens nothing, and that person reaches their record from inside the
# app. That is the cost of the rule, so it is written down here rather than discovered later.
BID=$(mut staff.save '{"num":"B1","first":"Bo","last":"Both","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
check "somebody claims a staff account on the linen room's own address" "$(claim "$BID" "$T/tc-sa-both.txt" "$CO")" '"ok":true'
BW=$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$CO\",\"password\":\"password123\"}")
check " the coordinator password still opens the counter" "$BW" '"ok":true'
no " and does not open the staff app" "$BW" '"staff":true'
check " while the staff password on that address opens nothing" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$CO\",\"password\":\"wearerpass1\"}")" 'Email or password doesn'
echo "== unknown ops are refused, not ignored"
check "made-up staff op" "$(smut nonsense.thing "$W" '{}')" 'Unknown action'
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]