Files
threadcount-community/scripts/e2e-staffapp.sh
T
ThreadCount f72f0626b0
Release / release (push) Has been skipped
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 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-17 05:49:16 +10:00

1009 lines
85 KiB
Bash
Executable File
Raw Permalink 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 detail screen: a back chevron and no tab bar. Drawn there, the bar marked none of the five tabs
# as current, which tells a screen reader the app is on no tab at all.
no " and the shelf draws no tab bar" "$SHELF" 'aria-label="Main"'
# 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'
# Nothing can be handed over before it is picked. (Picking straight to collected is allowed: the
# counter phone picks and hands over in one visit, signed at the window.)
check "collected is refused out of order" "$(mut request.collected "{\"id\":\"$RID\"}")" "can't move"
check "the linen room picks" "$(mut request.pick "{\"id\":\"$RID\"}")" '"status":"picking"'
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'
# Between rounds the screen exists and says so: a kit check that is closed is not a secret, and a
# 404 there taught somebody with a notification in their hand that the app was broken. A refusal is
# still the answer everywhere the ROLE is not theirs — see the four checks near the end of the file.
KCCLOSED=$(curl -s -b "$W" "$B/my/kitcheck")
check "between rounds the screen says there is nothing to answer" "$KCCLOSED" 'No kit check is open'
no " rather than asking them to count anything" "$KCCLOSED" '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"
# The leak half of a refused() check is only worth anything if the screen really does render the
# string. `On the ward` appears nowhere in Ward.tsx, so that half could never fail: the fence could
# have broken and served a colleague's roster and this check would still have passed. `Items held`
# is the band Ward.tsx draws, proved by the manager's own check further down. `Sign for` was the
# same mistake more quietly — Round.tsx only writes it when more than one bag is waiting — so the
# round checks grep the kicker every round screen carries, proved at the desk's own screen above.
refused "no ward view without reports" "$W" "/my/ward" "Items held"
refused "no ward round without the flag" "$W" "/my/round" "Ward round"
# 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" "Ward round"
# And the bar must not offer her the way in: a tab drawn from the desk flag in front of a route
# fenced on the ward is a tab that 404s on every screen in the app.
NHOME=$(curl -s -b "$N" "$B/my")
no " and the bar offers her no ward tab at all" "$NHOME" '/my/team'
check " home says why instead" "$NHOME" 'No ward on your record'
check "the clerk who does have a ward keeps the tab" "$(curl -s -b "$D" "$B/my")" '/my/team'
refused " and the ward-less clerk's team page is a refusal" "$N" "/my/team" "Round"
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 "== messages have a screen of their own"
# The Messages tab used to be a button that pushed /my/orders: a control among links, missing from
# anything that lists a page's navigation, and a tab that lied about where it went. It is a link
# now, and this is the screen behind it.
MSGS=$(curl -s -b "$W" "$B/my/messages")
check "the wearer has a messages screen" "$MSGS" 'Messages'
check " naming who is on the other end" "$MSGS" 'Linen room'
check " and showing the last thing said" "$MSGS" 'We have one put by'
check "the tab bar links to it" "$(curl -s -b "$W" "$B/my")" 'href="/my/messages"'
# Unread is only ever about what the linen room said, and it has to be able to go out again: before
# this build nothing ever wrote readAt, so the first reply marked a thread new for ever.
check "opening the thread marks the reply read" "$(smut request.read "$W" "{\"id\":\"$RID\"}")" '"read":1'
check " and a second look has nothing left to mark" "$(smut request.read "$W" "{\"id\":\"$RID\"}")" '"read":0'
check "somebody else's thread cannot be marked read" "$(smut request.read "$O" "{\"id\":\"$RID\"}")" 'No such request'
echo "== the team tab leads where each reader can actually go"
MTEAM=$(curl -s -b "$M" "$B/my/team"); DTEAM=$(curl -s -b "$D" "$B/my/team")
check "a manager's team tab opens the approvals queue" "$MTEAM" '/my/approvals'
no " and not the ward round they are not on" "$MTEAM" '/my/round'
check "the ward desk's opens the round" "$DTEAM" '/my/round'
no " and not a queue they have none of" "$DTEAM" '/my/approvals'
refused "and somebody who is neither has no team at all" "$W" "/my/team" "Approvals"
# An approver who manages nobody. The linen room can re-address a request to somebody with no
# reports, and gating the tab, the badge and the banner on "is a manager" would take away the only
# door they have — with a colleague waiting behind it.
RA=$(smut request.create "$Y" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
check "a request is re-addressed to somebody who manages nobody" "$(mut request.reassign "{\"id\":\"$RA\",\"managerId\":\"$OID\"}")" '"ok":true'
OAPP=$(curl -s -b "$O" "$B/my/approvals")
check " who can open the queue" "$OAPP" 'Approvals'
check " and sees the ask waiting on it" "$OAPP" 'Rafa Lines'
check " with the banner on home" "$(curl -s -b "$O" "$B/my")" 'waiting on you'
check " and a team tab that opens the queue" "$(curl -s -b "$O" "$B/my/team")" '/my/approvals'
check " and the tab bar carries the count" "$(curl -s -b "$O" "$B/my")" 'Team, 1 need you'
# Approving from the queue refreshes the page the approver is standing on, so the fence on that page
# decides what the last approval of all lands on. Asking only "does anybody report to you" turned
# the tap that emptied his queue into "That page isn't here." — for the one person this queue exists
# to protect, who manages nobody and was handed somebody else's request to decide.
check " he approves the last request in it" "$(smut request.approve "$O" "{\"id\":\"$RA\"}")" '"status":"accepted"'
OEMPTY=$(curl -s -b "$O" "$B/my/approvals")
check " and the queue he is standing on is still a screen" "$OEMPTY" 'Nothing waiting on you'
no " with no empty tab row left above it" "$OEMPTY" 'aria-label="Team"'
# A manager whose queue is empty gets a state, not a dead end: the queue is theirs, it is just empty.
EMPID=$(mut staff.save '{"num":"E1","first":"Esi","last":"Empty","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
EMPRID=$(mut staff.save '{"num":"E2","first":"Femi","last":"Reports","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
check "somebody is given a report" "$(mut staff.patch "{\"id\":\"$EMPRID\",\"managerId\":\"$EMPID\"}")" '"ok":true'
E="$T/tc-sa-empty.txt"; rm -f "$E"
check " and claims an account" "$(claim "$EMPID" "$E" "e$TS@example.com")" '"ok":true'
EMPTYQ=$(curl -s -b "$E" "$B/my/approvals")
check "an empty queue is a state" "$EMPTYQ" 'Nothing waiting on you'
check " saying where those requests will arrive" "$EMPTYQ" 'arrive by notification and email'
echo "== the collection code, full screen"
CC=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
refused "no code while it is still with the manager" "$Z" "/my/orders/$CC/code" "Show at the counter"
check "the manager approves it" "$(smut request.approve "$M" "{\"id\":\"$CC\"}")" '"status":"accepted"'
check "the linen room picks it" "$(mut request.pick "{\"id\":\"$CC\"}")" '"status":"picking"'
check " and holds it at the counter" "$(mut request.hold "{\"id\":\"$CC\",\"holdUntil\":\"Fri 4pm\"}")" '"status":"ready"'
CCODE=$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$CC'][0]['collectCode'])")
CODEPAGE=$(curl -s -b "$Z" "$B/my/orders/$CC/code")
check "the code screen carries the digits" "$CODEPAGE" "$CCODE"
check " and whose bag it is" "$CODEPAGE" 'Ines Trolley'
no " with no tab bar to wander off into" "$CODEPAGE" 'aria-label="Main"'
# The manager may read the order — the code is not theirs to hold up at a counter.
refused "the manager who approved it cannot open the code" "$M" "/my/orders/$CC/code" "Show at the counter"
refused "nor can a stranger" "$O" "/my/orders/$CC/code" "Show at the counter"
# ⛔ And not on the order screen either. Four people can open an order — the wearer, the manager it
# was addressed to, whoever raised it, and the desk holding the bag — and only one of them collects
# it. The code printed there let the approver read the four digits off her own approvals history and
# walk to the counter with them, under a button that then refused her.
MORDER=$(curl -s -b "$M" "$B/my/orders/$CC")
check "the manager can still read the order" "$MORDER" 'Ines Trolley'
no " but the code is not on it" "$MORDER" 'Collection code'
no " nor a button to hold up at the counter" "$MORDER" 'Show at the counter'
ZORDER=$(curl -s -b "$Z" "$B/my/orders/$CC")
check "the wearer's own order carries the code" "$ZORDER" 'Collection code'
check " and the way to show it" "$ZORDER" 'Show at the counter'
check "the bag goes home" "$(mut request.collected "{\"id\":\"$CC\"}")" '"status":"collected"'
refused "and the code screen closes behind it" "$Z" "/my/orders/$CC/code" "Show at the counter"
echo "== notifications: the switches, and this phone"
# The preferences are the server's and they are per person. There is no staffId in the payload, so
# there is no way to reach somebody else's switches through it.
check "a wearer turns one off" "$(smut notify.prefs "$W" '{"ready":false}')" '"ready":false'
check " the others stay on" "$(smut notify.prefs "$W" '{}')" '"approved":true'
check " and it is still off when asked again" "$(smut notify.prefs "$W" '{}')" '"ready":false'
check "a colleague writing a staffId writes only their own" "$(smut notify.prefs "$O" "{\"staffId\":\"$WID\",\"ready\":true}")" '"ready":true'
check " and the wearer's switch did not move" "$(smut notify.prefs "$W" '{}')" '"ready":false'
# A device token is stored, never handed back. With no TC_FCM_KEY_FILE on this server every send is
# a no-op, which is the correct answer and not a failure — registering still has to work, or nobody
# could turn notifications on before the key was installed.
TOK="e2e-device-token-$TS"
REG=$(smut push.register "$W" "{\"token\":\"$TOK\"}")
check "a phone registers with no sender configured" "$REG" '"ok":true'
check " and is told the server cannot send yet" "$REG" '"configured":false'
no " without the token coming back" "$REG" "$TOK"
no " and the account screen never carries it" "$(curl -s -b "$W" "$B/my/account")" "$TOK"
no " nor does home" "$(curl -s -b "$W" "$B/my")" "$TOK"
check "signing out hands the phone back" "$(smut push.forget "$W" "{\"token\":\"$TOK\"}")" '"ok":true'
check "a token nobody registered cannot be forgotten by somebody else" "$(smut push.forget "$O" "{\"token\":\"$TOK\"}")" '"ok":true'
check "an empty token is refused" "$(smut push.register "$W" '{"token":""}')" 'No device token'
echo "== the messages screen, thread by thread"
# The tab and the screen behind it are checked above; this is what a row has to carry. A thread is
# always about one request — there is no inbox in this product — so the row names the order, says
# how far it has got, and goes to the thread rather than to the order.
RIDCODE=$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$RID'][0]['code'])")
MSGS2=$(curl -s -b "$W" "$B/my/messages")
check "the thread row names the order it hangs off" "$MSGS2" "$RIDCODE"
check " and how far that order has got" "$MSGS2" 'Collected'
check " and opens the thread, not the order" "$MSGS2" "/my/orders/$RID/messages"
# An open order nobody has said anything about is offered rather than hidden: that is where the next
# thread comes from, and a screen that listed only existing threads is a dead end for somebody who
# has never sent a message.
check " with an order that could start one under it" "$MSGS2" 'Start one'
# Nothing to show is a state, not a refusal (the per-case table): there is nothing private about
# having said nothing yet. Esi has a queue of her own and has never asked for anything.
EMSGS=$(curl -s -b "$E" "$B/my/messages")
check "somebody who has never asked gets a state" "$EMSGS" 'No messages yet'
check " saying where a thread would start" "$EMSGS" 'Ask about any order and the thread starts here'
no " and not a word of anybody else's" "$EMSGS" 'We have one put by'
echo "== a signed slip reaches the wearer, with the quantity on it"
# ⛔ The one number a wearer is shown that is not their own record. A slip is not the linen room's
# count of anything — it is the hand-over they stood at the counter and put their name to — and
# three trousers under one date have to read as three rather than as the same line three times.
PNG='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
SIG=$(mut photo.put "{\"kind\":\"sig\",\"data\":\"$PNG\"}" | py "print(d['result']['id'])")
check "the counter takes a signature" "$SIG" '.'
SLIP=$(mut issue.create "{\"staffId\":\"$VID\",\"apDeduct\":0,\"sigId\":\"$SIG\",\"slip\":true,\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":3,\"src\":\"stock\"}]}")
check " and files a slip against the hand-over" "$SLIP" '"slipId"'
SLIPID=$(echo "$SLIP" | py "print(d['result']['slipId'])")
KITV=$(curl -s -b "$V" "$B/my/kit")
check "the slip is on the wearer's own kit screen" "$KITV" "$SLIPID"
check " naming the garment" "$KITV" 'Navy tunic'
check " and the size it was signed for" "$KITV" '"size\\":\\"12'
# Grouped and counted, not repeated: one line carrying three.
check " carrying the quantity" "$KITV" 'qty\\":3'
no " rather than the same line three times" "$KITV" 'qty\\":1,'
# ⛔ What the ward paid for them is not the wearer's business, on the slip or anywhere near it.
no " with no money anywhere near it" "$KITV" 'cost\\":3'
no "and it is on nobody else's kit screen" "$(curl -s -b "$Y" "$B/my/kit")" "$SLIPID"
echo "== what a notification would say, and whose phone it would reach"
# A notification leaves the server for Google and never comes back, so this is the one thing curl
# cannot see: the words in the body, and whose phone they were addressed to. scripts/push-probe.ts
# runs the real sender with the transport replaced by a recorder — no key of anybody's, nothing
# sent anywhere, and the token itself never printed.
PN=$(smut request.create "$W" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
check "a request to be told about" "$PN" '.'
check " approved" "$(smut request.approve "$M" "{\"id\":\"$PN\"}")" '"status":"accepted"'
check " picked" "$(mut request.pick "{\"id\":\"$PN\"}")" '"status":"picking"'
check " and held at the counter" "$(mut request.hold "{\"id\":\"$PN\",\"holdUntil\":\"Fri 6pm\"}")" '"status":"ready"'
PNROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$PN'][0]))")
PNCODE=$(echo "$PNROW" | py "print(d['code'])")
PNCOLLECT=$(echo "$PNROW" | py "print(d['collectCode'])")
PTOK="e2e-push-token-$TS"
check "the wearer's phone registers" "$(smut push.register "$W" "{\"token\":\"$PTOK\"}")" '"ok":true'
# Her "Ready to collect" switch was turned off further up, and it is the SENDER that honours it —
# a trigger should say what happened, not work out who wants to hear about it.
check " and her switch is still off" "$(smut notify.prefs "$W" '{}')" '"ready":false'
check "a switch turned off silences the send" "$(npx tsx scripts/push-probe.ts send ready "$PN")" '"count":0'
check " so the audience for it is nobody" "$(npx tsx scripts/push-probe.ts audience "$WID" ready)" '"count":0'
check "turning it back on" "$(smut notify.prefs "$W" '{"ready":true}')" '"ready":true'
SENT=$(npx tsx scripts/push-probe.ts send ready "$PN")
check " reaches the one phone" "$SENT" '"count":1'
check " addressed to its owner" "$SENT" "$WID"
check " saying what happened" "$SENT" 'Ready to collect'
check " under the request's own code" "$SENT" "$PNCODE"
check " naming the garment" "$SENT" 'Navy tunic'
check " and opening that order when tapped" "$SENT" "/my/orders/$PN"
# ⛔ The three rules the body obeys, and the reason for the first: a body is drawn on a ward phone's
# lock screen, and the collection code is what a bag is handed over against. This is the documented
# difference from the approved mockup, which put the code on the card.
no "the body never carries the collection code" "$SENT" "$PNCOLLECT"
no " nor a count of anything" "$SENT" '[0-9] garment'
no " nor money" "$SENT" 'cost\|[$]'
# A token belongs to exactly one staff account. A phone handed on re-points to whoever registered it
# last — so the person who left stops being told about the person who arrived — and the account that
# no longer holds it cannot reach it.
check "another account registers the same phone" "$(smut push.register "$O" "{\"token\":\"$PTOK\"}")" '"ok":true'
check " after which the old account has no phone at all" "$(npx tsx scripts/push-probe.ts audience "$WID" ready)" '"count":0'
check " and the send that just reached her reaches nobody" "$(npx tsx scripts/push-probe.ts send ready "$PN")" '"count":0'
check " while the account that registered it has it" "$(npx tsx scripts/push-probe.ts audience "$OID" ready)" '"count":1'
check "the old account forgetting that token is refused" "$(smut push.forget "$W" "{\"token\":\"$PTOK\"}")" '"ok":true'
check " the phone still belongs to the one that registered it" "$(npx tsx scripts/push-probe.ts audience "$OID" ready)" '"count":1'
check "and its own account can hand it back" "$(smut push.forget "$O" "{\"token\":\"$PTOK\"}")" '"ok":true'
check " after which nothing is written to" "$(npx tsx scripts/push-probe.ts audience "$OID" ready)" '"count":0'
# The one send addressed to an approver rather than to a wearer: it names who is waiting, and opens
# the decision rather than the order.
WTOK="e2e-push-mgr-$TS"
check "the approver's phone registers" "$(smut push.register "$M" "{\"token\":\"$WTOK\"}")" '"ok":true'
WREQ=$(smut request.create "$Y" "{\"lines\":[{\"itemId\":\"$TID\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
WSENT=$(npx tsx scripts/push-probe.ts send waiting "$WREQ")
check "a new request tells whoever it is addressed to" "$WSENT" '"count":1'
check " naming the person waiting on them" "$WSENT" 'Rafa Lines'
check " and opening the decision" "$WSENT" "/my/approvals/$WREQ"
no " with no code in it at all" "$WSENT" 'R-[0-9]'
check "the approver hands the phone back" "$(smut push.forget "$M" "{\"token\":\"$WTOK\"}")" '"ok":true'
echo "== approving from the queue settles the whole ask"
# The commonest decision by far is "yes, all of it", and it is made in the list now rather than
# behind another screen. The chip sends every line explicitly — approving from the queue is
# approving all of them, said out loud rather than by omission.
QR=$(smut request.create "$Y" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1},{\"itemId\":\"$TID\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\"}")
QID=$(echo "$QR" | py "print(d['result']['id'])")
QROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$QID'][0]))")
QL1=$(echo "$QROW" | py "print(d['lines'][0]['id'])")
QL2=$(echo "$QROW" | py "print(d['lines'][1]['id'])")
MQ=$(curl -s -b "$M" "$B/my/approvals")
check "the queue carries the ask" "$MQ" 'Rafa Lines'
check " with a one-tap approval on the row" "$MQ" 'Approve [0-9]'
check " and a way into the garment-by-garment screen" "$MQ" "/my/approvals/$QID"
QAP=$(smut request.approve "$M" "{\"id\":\"$QID\",\"lines\":[{\"id\":\"$QL1\",\"decision\":\"approved\",\"reason\":\"\"},{\"id\":\"$QL2\",\"decision\":\"approved\",\"reason\":\"\"}]}")
check "approving from the queue accepts the request" "$QAP" '"status":"accepted"'
QROW2=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$QID'][0]))")
check " with every garment approved" "$(echo "$QROW2" | py "print(len([l for l in d['lines'] if l['status']=='approved']))")" '^2$'
check " and the whole ask in the bag" "$(echo "$QROW2" | py "print(len(d['bag']))")" '^2$'
no "and the row has left the queue" "$(curl -s -b "$M" "$B/my/approvals")" "/my/approvals/$QID"
echo "== signing for every bag, and stopping where it cannot"
# Sign for all is one call per bag, because a signature is against one hand-over. What matters is
# the half-way refusal: it stops, says how many were signed and which bag it stopped on, and leaves
# the rest exactly as they were rather than carrying on past them.
SA=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
SB=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
SC=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
check "three bags for one ward" "$(printf '%s\n' "$SA" "$SB" "$SC" | grep -c .)" '^3$'
for r in "$SA" "$SB" "$SC"; do
smut request.approve "$M" "{\"id\":\"$r\"}" > /dev/null
mut request.pick "{\"id\":\"$r\"}" > /dev/null
mut request.round "{\"id\":\"$r\"}" > /dev/null
done
check "all three go out on the round" "$(curl -s -b "$C" "$B/api/requests" | py "print(len([r for r in d['requests'] if r['id'] in ('$SA','$SB','$SC') and r['status']=='round']))")" '^3$'
# Another clerk signs the middle one a moment before the bulk press — which is the whole reason the
# run cannot assume its own list is still true.
check "somebody else signs one of them first" "$(smut round.sign "$D" "{\"id\":\"$SB\"}")" '"ok":true'
check "the run signs the first" "$(smut round.sign "$D" "{\"id\":\"$SA\"}")" '"ok":true'
check " and stops on the one already gone" "$(smut round.sign "$D" "{\"id\":\"$SB\"}")" 'No such bag'
check " leaving the bag after it untouched" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$SC'][0]['status'])")" '^round$'
check " and what was signed, signed" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$SA'][0]['status'])")" '^delivered$'
check "the clerk signs the rest by hand" "$(smut round.sign "$D" "{\"id\":\"$SC\"}")" '"ok":true'
check " after which the round has nothing left to sign" "$(curl -s -b "$D" "$B/my/round")" 'Every bag is signed'
echo "== nothing to show, told apart from not yours"
# Two different answers, deliberately. A record that is not yours is a dead end with no explanation
# — a refusal that explains itself confirms the thing exists — and those are checked all through
# this file. These are the other case: the screen is yours, and there is nothing on it today.
QUIET=$(mut staff.save '{"num":"Q9","first":"Dara","last":"Quiet","group":"Admin","dept":"Quiet Ward"}' | py "print(d['result']['id'])")
check "a clerk on a ward with nothing arriving" "$QUIET" '.'
check " is put on the desk" "$(mut staff.patch "{\"id\":\"$QUIET\",\"wardDesk\":true}")" '"ok":true'
G="$T/tc-sa-quiet.txt"; rm -f "$G"
check " and claims an account" "$(claim "$QUIET" "$G" "q$TS@example.com")" '"ok":true'
QROUND=$(curl -s -b "$G" "$B/my/round")
check "their round is a state, not a dead end" "$QROUND" 'Nothing on the round for Quiet Ward right now'
no " and carries no other ward's bags" "$QROUND" 'Ines Trolley'
# A kit check that is open, with nothing on this person's record to answer about.
check "the linen room opens another round" "$(mut kitcheck.open '{"dueBy":"2026-12-02"}')" '"id"'
QKIT=$(curl -s -b "$G" "$B/my/kitcheck")
check "somebody holding nothing is told so" "$QKIT" 'Nothing on your record to check'
no " rather than asked to count nothing" "$QKIT" 'Still have'
CYC2=$(curl -s -b "$C" "$B/api/requests" | py "print(d['cycle']['id'])")
check "the linen room closes it again" "$(mut kitcheck.close "{\"id\":\"$CYC2\"}")" '"ok":true'
# A waiting list for a garment nobody stocks is not a state: there is nothing to be on, and the
# screen that would show a queue position must not be drawn at all.
refused "a waitlist for a garment nobody has is refused" "$W" "/my/waitlist?item=nope&si=0" 'in queue'
refused " and so is a size that does not exist" "$W" "/my/waitlist?item=$IID&si=99" 'in queue'
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 ]