Files
threadcount-community/scripts/e2e-staffapp.sh
T
ThreadCount 7d650e4c10 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 d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-15 18:27:45 +10:00

719 lines
62 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 ]