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 440e645 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
/* Mint an approval link token for the e2e suite and print it.
|
||||
*
|
||||
* The real token only ever exists inside an email to a ward manager, which the suite has no way to
|
||||
* read. Rather than skip the emailed-link path — the one route into this product that works
|
||||
* without a session, and therefore the one most worth testing — the suite mints its own using the
|
||||
* same signing code the app uses.
|
||||
*
|
||||
* Refuses to run in production. It needs SESSION_SECRET, so anyone who could use it can already
|
||||
* forge one — but a guard costs nothing and states the intent.
|
||||
*
|
||||
* node scripts/approval-mint.cjs <requestId> <managerStaffId> -> prints the token
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const { createHash, createHmac } = require("crypto");
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.error("refusing to run in production");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const [rid, mid] = process.argv.slice(2);
|
||||
if (!rid || !mid) { console.error("usage: approval-mint.cjs <requestId> <managerStaffId>"); process.exit(2); }
|
||||
if (!process.env.SESSION_SECRET) { console.error("SESSION_SECRET not set"); process.exit(2); }
|
||||
|
||||
// Mirrors lib/approvallink.ts exactly — including the domain separation, which is the point of
|
||||
// the test: a token signed with the plain session secret must NOT be accepted.
|
||||
const key = createHash("sha256").update("threadcount:approval:v1:" + process.env.SESSION_SECRET).digest();
|
||||
const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
|
||||
const ttl = Number(process.argv[4] || 14 * 24 * 60 * 60 * 1000);
|
||||
const payload = b64url(Buffer.from(JSON.stringify({ rid, mid, exp: Date.now() + ttl })));
|
||||
const sig = b64url(createHmac("sha256", key).update(payload).digest());
|
||||
console.log(`${payload}.${sig}`);
|
||||
@@ -0,0 +1,40 @@
|
||||
/* Encoder self-check. EAN-13 and Code 128 are both fully specified, so the right test is against
|
||||
published reference values — a barcode that looks fine and scans wrong is the worst outcome. */
|
||||
import { barcodeKind, barcodeSvg, code128Values, ean13CheckDigit, isEan13 } from "../lib/barcode";
|
||||
|
||||
let fail = 0;
|
||||
const ok = (name: string, cond: boolean, got?: unknown) => {
|
||||
if (cond) console.log(" ok " + name);
|
||||
else { console.log(" FAIL " + name + (got === undefined ? "" : " got: " + String(got))); fail++; }
|
||||
};
|
||||
|
||||
console.log("EAN-13 check digits");
|
||||
ok("9312345678907", ean13CheckDigit("931234567890") === 7, ean13CheckDigit("931234567890"));
|
||||
ok("5901234123457", ean13CheckDigit("590123412345") === 7, ean13CheckDigit("590123412345"));
|
||||
ok("4006381333931", ean13CheckDigit("400638133393") === 1, ean13CheckDigit("400638133393"));
|
||||
ok("9357732548036", ean13CheckDigit("935773254803") === 6, ean13CheckDigit("935773254803"));
|
||||
ok("rejects a bad check digit", !isEan13("5901234123450"));
|
||||
ok("rejects 12 digits", !isEan13("590123412345"));
|
||||
ok("accepts a good one", isEan13("5901234123457"));
|
||||
|
||||
console.log("EAN-13 symbol");
|
||||
const svg = barcodeSvg("5901234123457", { module: 1, quiet: 0, text: false });
|
||||
// 95 modules: 3 guard + 6×7 + 5 centre + 6×7 + 3 guard
|
||||
ok("95 modules wide", /width="95"/.test(svg), svg.slice(0, 90));
|
||||
ok("names EAN-13", barcodeKind("5901234123457") === "EAN-13");
|
||||
|
||||
console.log("Code 128");
|
||||
// "Wikipedia" is the standard worked example: START B + 9 chars + check = 11 symbols × 11 modules,
|
||||
// plus the 13-module stop, and a published check symbol of 88.
|
||||
const c = barcodeSvg("Wikipedia", { module: 1, quiet: 0, text: false });
|
||||
ok("134 modules wide", /width="134"/.test(c), c.slice(0, 90));
|
||||
const v = code128Values("Wikipedia");
|
||||
ok("check symbol is 88", v[v.length - 1] === 88, v[v.length - 1]);
|
||||
ok("starts with START B (104)", v[0] === 104, v[0]);
|
||||
ok("names Code 128 for a short code", barcodeKind("ABC-123") === "Code 128");
|
||||
ok("names Code 128 for 13 digits that fail the check", barcodeKind("5901234123450") === "Code 128");
|
||||
ok("empty code makes no svg", barcodeSvg("") === "");
|
||||
ok("starts and ends with a bar", /<rect x="0" y="0"/.test(c) && c.includes('fill="#201e1d"'));
|
||||
|
||||
console.log(fail ? `\n${fail} FAILED` : "\nall barcode checks passed");
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,76 @@
|
||||
/* Check the TOTP implementation against RFC 6238's published test vectors.
|
||||
*
|
||||
* This is the reason it was worth hand-rolling: the algorithm has an official answer sheet, so the
|
||||
* code can be *proved* right rather than trusted. The seeds and expected codes below are taken
|
||||
* from RFC 6238 Appendix B.
|
||||
*
|
||||
* npx tsx scripts/check-totp.ts
|
||||
*/
|
||||
import { base32Decode, base32Encode, hotp, totp, totpVerify, newTotpSecret, encryptSecret, decryptSecret, newRecoveryCodes, hashRecoveryCode } from "../lib/totp";
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (n: string) => { pass++; console.log(" ✓ " + n); };
|
||||
const bad = (n: string, got: unknown, want: unknown) => { fail++; console.log(` ✗ ${n} :: got ${got}, want ${want}`); };
|
||||
const eq = (n: string, got: unknown, want: unknown) => (String(got) === String(want) ? ok(n) : bad(n, got, want));
|
||||
|
||||
// RFC 6238 Appendix B. The ASCII seeds are the shared secrets; times are seconds since the epoch.
|
||||
const SEED_SHA1 = Buffer.from("12345678901234567890", "ascii");
|
||||
const SEED_SHA256 = Buffer.from("12345678901234567890123456789012", "ascii");
|
||||
const SEED_SHA512 = Buffer.from("1234567890123456789012345678901234567890123456789012345678901234", "ascii");
|
||||
|
||||
const VECTORS: [number, string, string, Buffer, "sha1" | "sha256" | "sha512"][] = [
|
||||
[59, "94287082", "SHA1", SEED_SHA1, "sha1"],
|
||||
[59, "46119246", "SHA256", SEED_SHA256, "sha256"],
|
||||
[59, "90693936", "SHA512", SEED_SHA512, "sha512"],
|
||||
[1111111109, "07081804", "SHA1", SEED_SHA1, "sha1"],
|
||||
[1111111111, "14050471", "SHA1", SEED_SHA1, "sha1"],
|
||||
[1234567890, "89005924", "SHA1", SEED_SHA1, "sha1"],
|
||||
[2000000000, "69279037", "SHA1", SEED_SHA1, "sha1"],
|
||||
[20000000000, "65353130", "SHA1", SEED_SHA1, "sha1"],
|
||||
];
|
||||
|
||||
console.log("== RFC 6238 test vectors (8 digits)");
|
||||
for (const [t, expected, name, seed, algo] of VECTORS) {
|
||||
eq(`t=${t} ${name}`, totp(seed, t * 1000, 8, algo), expected);
|
||||
}
|
||||
|
||||
console.log("== base32 round-trips");
|
||||
for (const s of ["", "A", "hello world", "12345678901234567890"]) {
|
||||
const enc = base32Encode(Buffer.from(s, "utf8"));
|
||||
eq(`"${s}"`, base32Decode(enc).toString("utf8"), s);
|
||||
}
|
||||
|
||||
console.log("== the 8-byte counter survives past 2^32");
|
||||
// t=20000000000 above already exercises this; assert the halves explicitly too.
|
||||
eq("large counter differs from its low 32 bits", hotp(SEED_SHA1, 2 ** 32 + 7) !== hotp(SEED_SHA1, 7), true);
|
||||
|
||||
console.log("== verification window");
|
||||
const secret = newTotpSecret();
|
||||
const now = Date.now();
|
||||
const raw = base32Decode(secret);
|
||||
eq("the current code verifies", totpVerify(secret, totp(raw, now), now), true);
|
||||
eq("the previous step verifies", totpVerify(secret, totp(raw, now - 30_000), now), true);
|
||||
eq("the next step verifies", totpVerify(secret, totp(raw, now + 30_000), now), true);
|
||||
eq("two steps back is refused", totpVerify(secret, totp(raw, now - 90_000), now), false);
|
||||
eq("two steps forward is refused", totpVerify(secret, totp(raw, now + 90_000), now), false);
|
||||
eq("a wrong code is refused", totpVerify(secret, "000000", now) && totp(raw, now) !== "000000", false);
|
||||
eq("a short code is refused", totpVerify(secret, "123", now), false);
|
||||
eq("an empty code is refused", totpVerify(secret, "", now), false);
|
||||
|
||||
console.log("== secret storage");
|
||||
process.env.SESSION_SECRET ||= "test-secret-for-check-totp";
|
||||
const enc = encryptSecret(secret);
|
||||
eq("ciphertext is not the secret", enc.includes(secret), false);
|
||||
eq("round-trips", decryptSecret(enc), secret);
|
||||
eq("a tampered tag is rejected", decryptSecret(enc.slice(0, -4) + "AAAA"), null);
|
||||
eq("rubbish is rejected", decryptSecret("nonsense"), null);
|
||||
|
||||
console.log("== recovery codes");
|
||||
const codes = newRecoveryCodes();
|
||||
eq("ten codes", codes.length, 10);
|
||||
eq("all distinct", new Set(codes).size, 10);
|
||||
eq("hash ignores case and dashes", hashRecoveryCode(codes[0].toLowerCase()), hashRecoveryCode(codes[0]));
|
||||
eq("different codes hash differently", hashRecoveryCode(codes[0]) === hashRecoveryCode(codes[1]), false);
|
||||
|
||||
console.log(`\nPASS=${pass} FAIL=${fail}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env bash
|
||||
# Counter app pack: locations (tree, placement, deletion), counting scoped to a location,
|
||||
# the variance-reason gate, size exchange as one movement, /m auth, and the backup round-trip.
|
||||
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}; J="$T/tc-app-cj.txt"; K="$T/tc-app-cj2.txt"; rm -f "$J" "$K"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; }
|
||||
mut2() { curl -s -b "$K" -c "$K" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 300)"; fi; }
|
||||
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 300)"; else ok "$name"; fi; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
bk() { curl -s -b "$J" "$B/api/backup"; }
|
||||
# On hand for one variant, read the way the product reads it: what the shelf row says, less what is
|
||||
# still out with staff. Takes an item id and a size index.
|
||||
oh() { bk | py "print([x['opening']+x['adj'] for x in d['stock'] if x['itemId']=='$1' and x['sizeIndex']==$2][0] - sum(i['qty'] for i in d['issues'] if i['itemId']=='$1' and i['sizeIndex']==$2 and not i['returnedDate']))"; }
|
||||
res() { py "print(d['result']$1)"; }
|
||||
|
||||
TS=$(date +%s)
|
||||
echo "== setup"
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.7.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"App\",\"last\":\"Admin\",\"facility\":\"App Hospital $TS\",\"email\":\"app$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check "supplier" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
|
||||
check "dept" "$(mut dept.save '{"name":"Willow Ward","cc":"RGH-3010"}')" '"ok":true'
|
||||
check "catalog top" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Scrub Top","sku":"T1","supplier":"Alpha Supply","cost":"30","type":"Scrub top","group":"Registered Nurse","sizes":"S|M|L"}]}')" '"created":1'
|
||||
check "catalog pant" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Scrub Pant","sku":"P1","supplier":"Alpha Supply","cost":"25","type":"Pants","group":"Registered Nurse","sizes":"S|M|L"}]}')" '"created":1'
|
||||
check "opening" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"T1","size":"M","opening":"20"},{"sku":"T1","size":"L","opening":"12"},{"sku":"P1","size":"M","opening":"15"}]}')" '"created":3'
|
||||
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"7","first":"Nina","last":"Nurse","group":"Registered Nurse","dept":"Willow Ward","top":"M","pants":"M"}]}')" '"created":1'
|
||||
BK=$(bk)
|
||||
T1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="T1"][0])')
|
||||
P1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="P1"][0])')
|
||||
NINA=$(echo "$BK" | py 'print(d["staff"][0]["id"])')
|
||||
|
||||
echo "== locations"
|
||||
R=$(mut location.save '{"name":"Linen Room","kind":"Room"}'); check "room created" "$R" '"id"'; ROOM=$(echo "$R" | res "['id']")
|
||||
R=$(mut location.save "{\"name\":\"Shelf B\",\"kind\":\"Shelf\",\"parentId\":\"$ROOM\"}"); check "shelf created" "$R" '"id"'; SHELF=$(echo "$R" | res "['id']")
|
||||
R=$(mut location.save "{\"name\":\"Bay B3\",\"kind\":\"Bay\",\"parentId\":\"$SHELF\"}"); check "bay created" "$R" '"id"'; BAY=$(echo "$R" | res "['id']")
|
||||
R=$(mut location.save '{"name":"Laundry","kind":"Laundry"}'); check "laundry created" "$R" '"id"'; LAUNDRY=$(echo "$R" | res "['id']")
|
||||
check "duplicate name refused" "$(mut location.save '{"name":"Shelf B","kind":"Shelf"}')" 'already a location called'
|
||||
check "unknown parent refused" "$(mut location.save '{"name":"Nowhere","parentId":"nope"}')" 'Unknown parent'
|
||||
check "self-parent refused" "$(mut location.save "{\"id\":\"$SHELF\",\"name\":\"Shelf B\",\"parentId\":\"$SHELF\"}")" "can't sit inside itself"
|
||||
check "loop refused (shelf inside its own bay)" "$(mut location.save "{\"id\":\"$SHELF\",\"name\":\"Shelf B\",\"parentId\":\"$BAY\"}")" "can't sit inside itself"
|
||||
check "bad kind falls back to Shelf" "$(mut location.save '{"name":"Odd One","kind":"Nonsense"}')" '"id"'
|
||||
check "kind is Shelf" "$(bk | py 'print([l["kind"] for l in d["locations"] if l["name"]=="Odd One"][0])')" 'Shelf'
|
||||
|
||||
echo "== placement"
|
||||
check "place top M on the bay" "$(mut location.place "{\"itemId\":\"$T1\",\"si\":1,\"locationId\":\"$BAY\"}")" '"placed":1'
|
||||
check "place top L on the shelf" "$(mut location.place "{\"itemId\":\"$T1\",\"si\":2,\"locationId\":\"$SHELF\"}")" '"placed":1'
|
||||
check "bulk place" "$(mut location.place "{\"lines\":[{\"itemId\":\"$P1\",\"si\":1}],\"locationId\":\"$SHELF\"}")" '"placed":1'
|
||||
check "unknown location refused" "$(mut location.place "{\"itemId\":\"$T1\",\"si\":0,\"locationId\":\"nope\"}")" 'Unknown location'
|
||||
check "nothing to place refused" "$(mut location.place "{\"lines\":[],\"locationId\":\"$SHELF\"}")" 'Nothing to place'
|
||||
check "placement lands in the backup" "$(bk | py "print(len([x for x in d['stock'] if x['locationId']=='$SHELF']))")" '2'
|
||||
|
||||
echo "== counting scoped to a location"
|
||||
# Counting the shelf includes its bay: top M (bay), top L (shelf), pant M (shelf).
|
||||
check "count the shelf" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"locationId\":\"$SHELF\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":20},{\"itemId\":\"$T1\",\"si\":2,\"counted\":12},{\"itemId\":\"$P1\",\"si\":1,\"counted\":15}]}")" '"counted":3'
|
||||
check "the count records its location" "$(bk | py 'print(d["stocktakes"][0]["locationId"] is not None)')" 'True'
|
||||
check "a line off the shelf is refused" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"locationId\":\"$LAUNDRY\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":20}]}")" "isn't on that shelf"
|
||||
check "unknown location refused" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"locationId\":\"nope\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":20}]}")" 'Unknown location'
|
||||
check "an unscoped count still works" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":20}]}")" '"counted":1'
|
||||
|
||||
echo "== the variance-reason gate"
|
||||
check "default threshold is 5" "$(bk | py 'print(d["facility"]["varianceReason"])')" '^5$'
|
||||
check "a gap of 5 with no reason is refused" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":15}]}")" 'needs a reason'
|
||||
check "a gap of 4 goes through" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":16}]}")" '"variances":1'
|
||||
check "a gap of 5 with a reason goes through" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":11,\"reason\":\"At laundry\"}]}")" '"variances":1'
|
||||
check "the reason is filed" "$(bk | py 'print([l["reason"] for t in d["stocktakes"] for l in t["lines"] if l["reason"]][0])')" 'At laundry'
|
||||
check "threshold is configurable" "$(mut settings.update '{"varianceReason":2}')" '"ok":true'
|
||||
check "the new threshold bites" "$(mut stocktake.apply "{\"mode\":\"shelf\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":9}]}")" 'needs a reason'
|
||||
check "threshold floors at 1" "$(mut settings.update '{"varianceReason":0}')" '"ok":true'
|
||||
check "stored as 1" "$(bk | py 'print(d["facility"]["varianceReason"])')" '^1$'
|
||||
# Deliberately not 5. restoreBackup falls back to 5 when the file carries no threshold, so a
|
||||
# facility sitting on 5 reads 5 after a restore whether the field made the round trip or not.
|
||||
check "off the default threshold" "$(mut settings.update '{"varianceReason":3}')" '"ok":true'
|
||||
check "on hand is what was counted" "$(bk | py "print([x['opening']+x['adj'] for x in d['stock'] if x['itemId']=='$T1' and x['sizeIndex']==1][0])")" '^11$'
|
||||
|
||||
echo "== size exchange"
|
||||
check "issue an M top" "$(mut issue.create "{\"staffId\":\"$NINA\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")" '"stock":1'
|
||||
ISSUE=$(bk | py "print([i['id'] for i in d['issues'] if i['itemId']=='$T1' and i['sizeIndex']==1][0])")
|
||||
OH_L_BEFORE=$(oh "$T1" 2)
|
||||
check "same size refused" "$(mut issue.exchange "{\"id\":\"$ISSUE\",\"si\":1}")" 'Pick a different size'
|
||||
check "unknown size refused" "$(mut issue.exchange "{\"id\":\"$ISSUE\",\"si\":9}")" 'Unknown size'
|
||||
check "unknown issue refused" "$(mut issue.exchange '{"id":"nope","si":2}')" 'Unknown issue'
|
||||
check "exchange M for L" "$(mut issue.exchange "{\"id\":\"$ISSUE\",\"si\":2}")" '"size":"L"'
|
||||
check "the old issue came back" "$(bk | py "print([i['returnedCond'] for i in d['issues'] if i['id']=='$ISSUE'][0])")" 'Returned - Good'
|
||||
check "a new issue went out in L" "$(bk | py "print(len([i for i in d['issues'] if i['itemId']=='$T1' and i['sizeIndex']==2 and not i['returnedDate']]))")" '^1$'
|
||||
# An exchange is one movement: the L leaves the shelf and the M returns to it. Counting the issue
|
||||
# rows alone would pass a swap that moved the wrong quantity, or moved the wrong size.
|
||||
check "one L left the shelf" "$(oh "$T1" 2)" "^$((OH_L_BEFORE-1))$"
|
||||
check "the M came back on the shelf" "$(oh "$T1" 1)" '^11$'
|
||||
check "the staff record follows the size" "$(bk | py 'print(d["staff"][0]["top"])')" '^L$'
|
||||
check "pants size untouched" "$(bk | py 'print(d["staff"][0]["pants"])')" '^M$'
|
||||
check "exchanging again is refused" "$(mut issue.exchange "{\"id\":\"$ISSUE\",\"si\":0}")" 'already been returned'
|
||||
check "no stock in S refuses the swap" "$(mut issue.exchange "{\"id\":\"$(bk | py "print([i['id'] for i in d['issues'] if i['itemId']=='$T1' and i['sizeIndex']==2 and not i['returnedDate']][0])")\",\"si\":0}")" 'Not enough size S'
|
||||
|
||||
echo "== deleting a location"
|
||||
check "delete the shelf" "$(mut location.delete "{\"id\":\"$SHELF\"}")" '"ok":true'
|
||||
check "its bay moved up to the room" "$(bk | py "print([l['parentId'] for l in d['locations'] if l['name']=='Bay B3'][0]=='$ROOM')")" 'True'
|
||||
# Both numbers matter: three stock rows still there, two of them now homeless. Counting only the
|
||||
# rows still pointing at the dead shelf reads zero even if the delete took the garments with it.
|
||||
check "what was on it is unplaced" "$(bk | py "print(len(d['stock']), len([x for x in d['stock'] if x['locationId'] is None]))")" '^3 2$'
|
||||
check "the bay's placement survived" "$(bk | py "print(len([x for x in d['stock'] if x['locationId']=='$BAY']))")" '^1$'
|
||||
check "unknown location refused" "$(mut location.delete '{"id":"nope"}')" 'Unknown location'
|
||||
|
||||
echo "== permissions"
|
||||
check "invite an issuer" "$(mut users.add "{\"email\":\"iss$TS@example.com\",\"first\":\"Ivy\",\"last\":\"Issuer\",\"role\":\"ISSUER\",\"password\":\"password123\"}")" '"id"'
|
||||
check "issuer signs in" "$(curl -s -c "$K" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "issuer cannot create a location" "$(mut2 location.save '{"name":"Sneaky Shelf"}')" 'Admin only'
|
||||
check "issuer cannot delete a location" "$(mut2 location.delete "{\"id\":\"$BAY\"}")" 'Admin only'
|
||||
check "issuer CAN place a garment" "$(mut2 location.place "{\"itemId\":\"$P1\",\"si\":1,\"locationId\":\"$BAY\"}")" '"placed":1'
|
||||
check "issuer cannot raise the threshold" "$(mut2 settings.update '{"varianceReason":40}')" 'Admin only'
|
||||
|
||||
echo "== the app's own routes"
|
||||
# /m sends people to the app's own sign-in, not the website's two-pane one.
|
||||
check "/m needs a session" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$B/m")" '307.*/m/login'
|
||||
check "/m/login is public" "$(curl -s -o /dev/null -w '%{http_code}' "$B/m/login")" '200'
|
||||
check "/m/signup is public" "$(curl -s -o /dev/null -w '%{http_code}' "$B/m/signup")" '200'
|
||||
check "/m/signed-in needs a session" "$(curl -s -o /dev/null -w '%{http_code}' "$B/m/signed-in")" '307'
|
||||
check "sign-in names the numbered fields" "$(curl -s "$B/m/login")" 'WORK EMAIL\|Work email'
|
||||
check "sign-in is honest about resets" "$(curl -s "$B/m/login")" 'Forgot password'
|
||||
check "create account asks for the facility" "$(curl -s "$B/m/signup")" 'Hospital or facility'
|
||||
no "no NHS wording survived from the handoff" "$(curl -s "$B/m/login"; curl -s "$B/m/signup")" 'nhs.uk\|Trust or hospital'
|
||||
check "/m/count needs a session" "$(curl -s -o /dev/null -w '%{http_code}' "$B/m/count")" '307'
|
||||
check "/m renders for a signed-in user" "$(curl -s -b "$J" "$B/m")" 'Start a count'
|
||||
check "/m/count renders" "$(curl -s -b "$J" "$B/m/count")" 'Where are you counting'
|
||||
check "/m/stock renders" "$(curl -s -b "$J" "$B/m/stock")" 'On hand / par'
|
||||
check "/m/search renders" "$(curl -s -b "$J" "$B/m/search")" 'Name, staff number, garment or code'
|
||||
check "/m/settings renders" "$(curl -s -b "$J" "$B/m/settings")" 'Reason required at'
|
||||
check "/m/variance renders" "$(curl -s -b "$J" "$B/m/variance")" 'What keeps going missing'
|
||||
check "/m/label renders" "$(curl -s -b "$J" "$B/m/label")" 'Barcode gone'
|
||||
# The sign-in form is handed a sanitised `next`; Next's own route payload echoes the raw URL, so
|
||||
# assert on the prop the form actually uses rather than on the page text.
|
||||
nextprop() { curl -s "$B/auth?next=$1" | grep -o '\\"next\\":\\"[^\\]*' | head -1 | tr -d '\\' | cut -d'"' -f4; }
|
||||
check "auth ?next=/m/count is honoured" "$(nextprop '/m/count')" '^/m/count$'
|
||||
check "auth ?next=/app/stock is honoured" "$(nextprop '/app/stock')" '^/app/stock$'
|
||||
check "auth ?next= off-site falls back to /app" "$(nextprop 'https%3A%2F%2Fevil.example')" '^/app$'
|
||||
check "auth ?next= protocol-relative falls back to /app" "$(nextprop '%2F%2Fevil.example')" '^/app$'
|
||||
check "auth ?next= to an unknown path falls back to /app" "$(nextprop '/mischief')" '^/app$'
|
||||
|
||||
echo "== labels print only real barcodes"
|
||||
check "bind a supplier barcode" "$(mut barcode.bind "{\"itemId\":\"$T1\",\"si\":1,\"code\":\"9312345678907\"}")" '"ok":true'
|
||||
# On a code that is really bound, the only thing left that can turn an anonymous visitor away is
|
||||
# the session check — an unbound code goes to /app/stock whether the page is guarded or not.
|
||||
check "/print/labels needs a session" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$B/print/labels?code=9312345678907")" '307.*/auth'
|
||||
check "label sheet renders the bound code" "$(curl -s -b "$J" "$B/print/labels?code=9312345678907&copies=2")" '9312345678907'
|
||||
check "it is drawn as bars" "$(curl -s -b "$J" "$B/print/labels?code=9312345678907&copies=2")" '<svg'
|
||||
check "an unbound code has no sheet" "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" "$B/print/labels?code=9999999999999")" '307'
|
||||
check "copies are capped at 24" "$(curl -s -b "$J" "$B/print/labels?code=9312345678907&copies=999" | grep -o '<svg' | wc -l | tr -d ' ')" '^24$'
|
||||
check "one copy means one label" "$(curl -s -b "$J" "$B/print/labels?code=9312345678907&copies=1" | grep -o '<svg' | wc -l | tr -d ' ')" '^1$'
|
||||
|
||||
echo "== backup round-trip"
|
||||
SNAP=$(bk)
|
||||
echo "$SNAP" > "$T/tc-app-backup.json"
|
||||
check "backup carries locations" "$(echo "$SNAP" | py 'print(len(d["locations"])>=4)')" 'True'
|
||||
# The live facility is moved off the file's value first, so the restore has to write the threshold
|
||||
# back rather than be credited for a row that already happened to read right.
|
||||
check "threshold moved before the restore" "$(mut settings.update '{"varianceReason":7}')" '"ok":true'
|
||||
check "restore" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$(cat "$T/tc-app-backup.json")}")" '"ok":true'
|
||||
NEW=$(bk)
|
||||
check "locations survived" "$(echo "$NEW" | py 'print(len(d["locations"]))')" "$(echo "$SNAP" | py 'print(len(d["locations"]))')"
|
||||
# Which parent, not merely that it has one: the restore re-mints every location id and wires the
|
||||
# tree up in a second pass, so a mis-mapped parent hangs the bay off Laundry and still looks placed.
|
||||
check "the tree survived" "$(echo "$NEW" | py 'byid={l["id"]:l["name"] for l in d["locations"]}; print(byid.get([l["parentId"] for l in d["locations"] if l["name"]=="Bay B3"][0]))')" '^Linen Room$'
|
||||
# Which shelf, not how many rows have one. Both garments are on Bay B3, so a restore that pointed
|
||||
# them at the wrong shelf keeps the count at two and loses the only thing a placement is for.
|
||||
check "placements survived" "$(echo "$NEW" | py 'byid={l["id"]:l["name"] for l in d["locations"]}; print(", ".join(sorted(byid[x["locationId"]] for x in d["stock"] if x["locationId"])))')" '^Bay B3, Bay B3$'
|
||||
check "the count's location survived" "$(echo "$NEW" | py 'print(len([t for t in d["stocktakes"] if t["locationId"]]))')" "$(echo "$SNAP" | py 'print(len([t for t in d["stocktakes"] if t["locationId"]]))')"
|
||||
check "variance reasons survived" "$(echo "$NEW" | py 'print(len([l for t in d["stocktakes"] for l in t["lines"] if l["reason"]]))')" "$(echo "$SNAP" | py 'print(len([l for t in d["stocktakes"] for l in t["lines"] if l["reason"]]))')"
|
||||
check "the threshold survived" "$(echo "$NEW" | py 'print(d["facility"]["varianceReason"])')" '^3$'
|
||||
|
||||
echo "== start fresh clears locations"
|
||||
check "reset" "$(mut data.reset '{"confirm":"RESET"}')" '"ok":true'
|
||||
check "no locations left" "$(bk | py 'print(len(d["locations"]))')" '^0$'
|
||||
|
||||
rm -f "$T/tc-app-backup.json"
|
||||
echo
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# The audit trail: that it records, that it names the actor, that it refuses non-admins, that it
|
||||
# is scoped to one facility, and — the point of the whole design — that it never stores values.
|
||||
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}; J="$T/tc-aud-cj.txt"; K="$T/tc-aud-cj2.txt"; L="$T/tc-aud-cj3.txt"; rm -f "$J" "$K" "$L"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; }
|
||||
mut2() { curl -s -b "$K" -c "$K" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 250)"; fi; }
|
||||
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 250)"; else ok "$name"; fi; }
|
||||
log() { curl -s -b "$J" "$B/api/activity"; }
|
||||
# Signing in and signing up write the actor's name into the same facility's log, so grepping the
|
||||
# whole response for a person proves nothing about the row under test. These pull one op's row out.
|
||||
who_of() { log | python3 -c "import sys,json; e=[x for x in json.load(sys.stdin)['events'] if x['op']=='$1']; print(e[0]['who'] if e else 'MISSING')"; }
|
||||
target_of() { log | python3 -c "import sys,json; e=[x for x in json.load(sys.stdin)['events'] if x['op']=='$1']; print(e[0]['target'] if e else 'MISSING')"; }
|
||||
|
||||
TS=$(date +%s)
|
||||
echo "== setup"
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.14.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Ada\",\"last\":\"Admin\",\"facility\":\"Audit Hospital $TS\",\"email\":\"aud$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
|
||||
echo "== a change is recorded, with who did it"
|
||||
check "make a change" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
|
||||
check "the log has it" "$(log)" 'supplier.add'
|
||||
check "and names the person" "$(who_of supplier.add)" '^Ada Admin$'
|
||||
|
||||
echo "== values are never stored, only identifiers"
|
||||
check "add a garment with a distinctive name" "$(mut catalog.add '{"item":"Zzyzx Distinctive Gown","sku":"SECRETSKU1","supplier":"Alpha Supply","cost":99,"sizes":["S","M"]}')" '"id"'
|
||||
no "the garment name is not in the log" "$(log)" 'Zzyzx'
|
||||
no "the supplier code is not in the log" "$(log)" 'SECRETSKU1'
|
||||
check "but the operation is" "$(log)" 'catalog.add'
|
||||
|
||||
echo "== a staff record's details never reach the log"
|
||||
check "add a staff member" "$(mut import.rows '{"kind":"staff","rows":[{"num":"991","first":"Wilhelmina","last":"Quixotic","group":"Theatre","dept":"Ward 9Z","top":"M","pants":"M"}]}')" '"created":1'
|
||||
no "no first name in the log" "$(log)" 'Wilhelmina'
|
||||
no "no surname in the log" "$(log)" 'Quixotic'
|
||||
# Both of those are satisfied just as well by an import that was never logged at all — and this is
|
||||
# the bulk path, the op most likely to be quietly added to the skip list in lib/audit.ts one day.
|
||||
check "but the operation is" "$(log)" 'import.rows'
|
||||
|
||||
echo "== identifiers are kept so a change can be traced"
|
||||
ITEM=$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['sku']=='SECRETSKU1'][0])")
|
||||
# Without this the id is the empty string, every grep for it matches everything, and a broken
|
||||
# backup or an expired session would be reported as a change that was successfully traced.
|
||||
[ -n "$ITEM" ] || { fail "found the garment id" "backup returned no id for SECRETSKU1"; echo; echo "PASS=$PASS FAIL=$FAIL"; exit 1; }
|
||||
check "edit the garment" "$(mut catalog.update "{\"id\":\"$ITEM\",\"cost\":101}")" '"ok":true'
|
||||
check "the record id is in the log" "$(target_of catalog.update)" "\"id\":\"$ITEM\""
|
||||
|
||||
echo "== an issuer cannot read the log"
|
||||
check "invite an issuer" "$(mut users.add "{\"email\":\"audiss$TS@example.com\",\"first\":\"Ivan\",\"last\":\"Issuer\",\"role\":\"ISSUER\",\"password\":\"password123\"}")" '"id"'
|
||||
check "issuer signs in" "$(curl -s -c "$K" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"audiss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "issuer is refused the log" "$(curl -s -b "$K" "$B/api/activity")" 'Admin only'
|
||||
# A title the account did not already have, so this is a real change rather than a write-back of the
|
||||
# values users.add gave it — and a word worth grepping for afterwards, since it must not be kept.
|
||||
check "an issuer's own change succeeds" "$(mut2 me.profile '{"title":"Store Issuer Zzq"}')" '"result":{"ok":true}'
|
||||
OPS=$(log | python3 -c "import sys,json; print(' '.join(x['op'] for x in json.load(sys.stdin)['events']))")
|
||||
check "an issuer's own change is still recorded" "$OPS" 'me.profile'
|
||||
check "and attributed to them" "$(who_of me.profile)" '^Ivan Issuer$'
|
||||
no "and the title they typed is not in the log" "$(log)" 'Zzq'
|
||||
|
||||
echo "== the log is scoped to one facility"
|
||||
check "a second facility signs up" "$(curl -s -c "$L" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.15.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Bob\",\"last\":\"Other\",\"facility\":\"Other Hospital $TS\",\"email\":\"oth$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
# An error body, a cookie jar that was never written and an empty page all contain no "Ada Admin"
|
||||
# either, so prove the second admin got a readable log of their own before reading anything into
|
||||
# what is missing from it.
|
||||
OTHER=$(curl -s -b "$L" "$B/api/activity")
|
||||
check "the other facility can read its own log" "$OTHER" 'auth:signup'
|
||||
check "and it names Bob" "$OTHER" 'Bob Other'
|
||||
no "the other facility cannot see our events" "$OTHER" 'Ada Admin'
|
||||
|
||||
echo "== unauthenticated access"
|
||||
check "signed out is refused" "$(curl -s "$B/api/activity")" 'Not signed in'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Inventory multi-select bulk actions (catalog.bulk): discontinue/reinstate/supplier/group/reorder/price/history-safe delete.
|
||||
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}; J="$T/tc-bulk-cj.txt"; rm -f "$J"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 300)"; fi; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
|
||||
TS=$(date +%s)
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.9.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Bulk\",\"last\":\"Admin\",\"facility\":\"Bulk Hospital $TS\",\"email\":\"bulk$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check "supplier" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
|
||||
check "supplier 2" "$(mut supplier.add '{"name":"Beta Supply"}')" '"id"'
|
||||
R=$(mut import.rows '{"kind":"catalog","rows":[{"item":"Used Top","sku":"U1","supplier":"Alpha Supply","cost":"10","group":"Registered Nurse","sizes":"S|M"},{"item":"Unused Top","sku":"U2","supplier":"Alpha Supply","cost":"20","group":"Registered Nurse","sizes":"S|M"},{"item":"Stocked Top","sku":"U3","supplier":"Alpha Supply","cost":"30","group":"Security","sizes":"S|M"}]}')
|
||||
check "catalog import 3" "$R" '"created":3'
|
||||
check "opening on U3" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"U3","size":"S","opening":"5"}]}')" '"created":1'
|
||||
check "dept" "$(mut dept.save '{"name":"Ward 1","cc":"100"}')" '"ok":true'
|
||||
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"A","last":"B","group":"Registered Nurse","dept":"Ward 1","top":"S","pants":"S"}]}')" '"created":1'
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
U1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="U1"][0])'); U2=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="U2"][0])'); U3=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="U3"][0])')
|
||||
ST=$(echo "$BK" | py 'print(d["staff"][0]["id"])')
|
||||
# Two halves of the history the delete guard leans on. An order-in leaves an OrderLine and nothing
|
||||
# else; only a shelf issue writes an Issue row, and until one existed here the leading issue.count
|
||||
# term in catalog.bulk's history sum could have been struck out with this file still green.
|
||||
check "issue U1 order-in (writes an OrderLine)" "$(mut issue.create "{\"staffId\":\"$ST\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$U1\",\"si\":0,\"qty\":1,\"src\":\"order\"}]}")" '"ordered":1'
|
||||
check "opening on U1" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"U1","size":"S","opening":"2"}]}')" '"created":1'
|
||||
check "issue U1 off the shelf" "$(mut issue.create "{\"staffId\":\"$ST\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$U1\",\"si\":0,\"qty\":1,\"src\":\"stock\"}]}")" '"stock":1'
|
||||
check "the shelf issue is on the record" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["issues"]))')" '^1$'
|
||||
|
||||
echo "== bulk actions"
|
||||
check "empty selection rejected" "$(mut catalog.bulk '{"ids":[],"action":"discontinue"}')" 'Select at least'
|
||||
check "unknown action rejected" "$(mut catalog.bulk "{\"ids\":[\"$U1\"],\"action\":\"zap\"}")" 'Unknown bulk action'
|
||||
check "discontinue 2" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\"],\"action\":\"discontinue\"}")" '2 products discontinued'
|
||||
# Which two, not how many: a where clause that lost its id filter and archived the wrong pair of
|
||||
# the three still counts to two.
|
||||
check "archived flag set" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sorted((i["sku"], i["archived"]) for i in d["items"]))')" "\[('U1', True), ('U2', True), ('U3', False)\]"
|
||||
check "reinstate 2" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\"],\"action\":\"reinstate\"}")" '2 products reinstated'
|
||||
# That message is assembled from the selection count, so it comes back whether or not the write
|
||||
# ran. Read the flag back, or the un-archiving half of the pair is never tested end to end.
|
||||
check "reinstate cleared archived" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sum(1 for i in d["items"] if i["archived"]))')" '^0$'
|
||||
check "change supplier" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\",\"$U3\"],\"action\":\"supplier\",\"value\":\"Beta Supply\"}")" 'moved to Beta Supply'
|
||||
check "supplier applied" "$(curl -s -b "$J" "$B/api/backup" | py 'print(all(i["supplier"]=="Beta Supply" for i in d["items"]))')" 'True'
|
||||
check "change group" "$(mut catalog.bulk "{\"ids\":[\"$U3\"],\"action\":\"group\",\"value\":\"Kitchen\"}")" 'moved to group Kitchen'
|
||||
# Group is what scopes a garment on the Issue screen, so a group change that quietly didn't land is
|
||||
# a real thing to lose — and nothing else in this file ever reads a group back.
|
||||
check "group applied to U3 only" "$(curl -s -b "$J" "$B/api/backup" | py 'print([i["group"] for i in d["items"] if i["sku"]=="U3"][0], sorted(i["group"] for i in d["items"]))')" "^Kitchen \['Kitchen', 'Registered Nurse', 'Registered Nurse'\]$"
|
||||
check "set reorder" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U3\"],\"action\":\"reorder\",\"value\":\"7\"}")" 'Reorder level set to 7 on every size of 2 products'
|
||||
# Filtering the stock rows down to the two products that were selected hid the opposite bug: a
|
||||
# reorder that ignored `ids` and wrote 7 across the whole catalogue looked identical. U2 is the one
|
||||
# unselected product, so name every row that exists — U2 has to have none.
|
||||
check "reorder on every size of the selected products only" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sorted((next(i["sku"] for i in d["items"] if i["id"]==s["itemId"]), s["reorder"]) for s in d["stock"]))')" "\[('U1', 7), ('U1', 7), ('U3', 7), ('U3', 7)\]"
|
||||
check "bad price rejected" "$(mut catalog.bulk "{\"ids\":[\"$U1\"],\"action\":\"price\",\"value\":\"abc\"}")" 'Enter a price'
|
||||
check "price +10%" "$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\"],\"action\":\"price\",\"value\":\"+10%\"}")" 'Prices updated on 2 products (+10%)'
|
||||
check "price pct applied 11/22" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sorted(i["cost"] for i in d["items"]))')" '\[11.\?0\?, 22.\?0\?, 30.\?0\?\]'
|
||||
check "price absolute" "$(mut catalog.bulk "{\"ids\":[\"$U3\"],\"action\":\"price\",\"value\":\"\$25.50\"}")" 'Prices updated on 1 product\.'
|
||||
check "abs price applied" "$(curl -s -b "$J" "$B/api/backup" | py 'print([i["cost"] for i in d["items"] if i["sku"]=="U3"][0])')" '^25.5$'
|
||||
R=$(mut catalog.bulk "{\"ids\":[\"$U1\",\"$U2\",\"$U3\"],\"action\":\"delete\"}")
|
||||
check "delete: 1 deleted, 2 discontinued" "$R" '"deleted":1,"discontinued":2'
|
||||
check "delete message" "$R" '1 product deleted · 2 products had history or stock on hand'
|
||||
check "unused item gone, others archived" "$(curl -s -b "$J" "$B/api/backup" | py 'print(sorted((i["sku"], i["archived"]) for i in d["items"]))')" "\[('U1', True), ('U3', True)\]"
|
||||
|
||||
echo "== a garment a ward has asked for"
|
||||
# The kind of history the fixture above never covers, and the one the code warns about by name:
|
||||
# RequestLine cascades from the garment, so bulk-deleting one a ward is waiting on strips the line
|
||||
# out from under a live request and leaves the manager an ask with nothing on it. catalog.delete
|
||||
# counts request lines for exactly that reason; catalog.bulk's `used` sum leaves the term out.
|
||||
check "a fourth product" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Asked-for Top","sku":"U4","supplier":"Alpha Supply","cost":"40","group":"Registered Nurse","sizes":"S|M"}]}')" '"created":1'
|
||||
check "somebody to approve for her" "$(mut import.rows '{"kind":"staff","rows":[{"num":"2","first":"M","last":"G","group":"Registered Nurse","dept":"Ward 1","top":"M","pants":"M"}]}')" '"created":1'
|
||||
BK2=$(curl -s -b "$J" "$B/api/backup")
|
||||
U4=$(echo "$BK2" | py 'print([i["id"] for i in d["items"] if i["sku"]=="U4"][0])'); MG=$(echo "$BK2" | py 'print([s["id"] for s in d["staff"] if s["num"]=="2"][0])')
|
||||
check "she reports to them" "$(mut staff.patch "{\"id\":\"$ST\",\"managerId\":\"$MG\"}")" '"ok":true'
|
||||
check "the counter raises a request for U4" "$(mut request.raise "{\"staffId\":\"$ST\",\"lines\":[{\"itemId\":\"$U4\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\"}")" '"code"'
|
||||
check "delete: a request line counts as history" "$(mut catalog.bulk "{\"ids\":[\"$U4\"],\"action\":\"delete\"}")" '"deleted":0,"discontinued":1'
|
||||
check "the request still has its garment on it" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["requests"][0]["lines"]))')" '^1$'
|
||||
|
||||
echo "== issuer gate"
|
||||
check "add issuer" "$(mut users.add "{\"email\":\"bi$TS@example.com\",\"password\":\"password123\",\"first\":\"I\",\"last\":\"S\",\"role\":\"ISSUER\"}")" '"id"'
|
||||
J2="$T/tc-bulk-cj2.txt"; rm -f "$J2"
|
||||
curl -s -c "$J2" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"bi$TS@example.com\",\"password\":\"password123\"}" >/dev/null
|
||||
check "issuer cannot bulk" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"catalog.bulk\",\"payload\":{\"ids\":[\"$U1\"],\"action\":\"reinstate\"}}")" 'Admin only'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# The catalogue as the phone drives it: create a garment, edit its card, add a size, set par,
|
||||
# bind a barcode, archive and reinstate — plus the guards that stop a non-admin doing any of it
|
||||
# and stop size history being rewritten.
|
||||
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}; J="$T/tc-cat-cj.txt"; K="$T/tc-cat-cj2.txt"; rm -f "$J" "$K"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; }
|
||||
mut2() { curl -s -b "$K" -c "$K" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 300)"; fi; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
bk() { curl -s -b "$J" "$B/api/backup"; }
|
||||
res() { py "print(d['result']$1)"; }
|
||||
|
||||
TS=$(date +%s)
|
||||
echo "== setup"
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.11.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Cat\",\"last\":\"Admin\",\"facility\":\"Cat Hospital $TS\",\"email\":\"cat$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
|
||||
echo "== creating a garment from the phone"
|
||||
check "a name is required" "$(mut catalog.add '{"item":"","sizes":["S"]}')" 'Item name required'
|
||||
check "at least one size is required" "$(mut catalog.add '{"item":"Theatre Gown","sizes":[]}')" 'At least one size'
|
||||
R=$(mut catalog.add '{"item":"Theatre Gown","type":"Gown","group":"Theatre","supplier":"Alpha Supply","sku":"TG1","cost":41.5,"sizes":["S","M","L"]}')
|
||||
check "garment created" "$R" '"id"'
|
||||
ID=$(echo "$R" | res "['id']")
|
||||
# The order of the run is the assertion, not its length: every "si":N payload below addresses a
|
||||
# size by its position, so a run that came back as M,S,L would still be three sizes and would still
|
||||
# let every later check read back the index it just wrote.
|
||||
check "the three sizes are in the order they were typed" "$(bk | py "print([i['sizes'] for i in d['items'] if i['id']=='$ID'][0])")" "^\['S', 'M', 'L'\]"
|
||||
check "the supplier was created with it" "$(bk | py "print(any(s['name']=='Alpha Supply' for s in d['suppliers']))")" 'True'
|
||||
|
||||
echo "== editing the product card"
|
||||
check "rename and reprice" "$(mut catalog.update "{\"id\":\"$ID\",\"item\":\"Theatre Gown (Blue)\",\"cost\":44}")" '"ok":true'
|
||||
check "the name stuck" "$(bk | py "print([i['item'] for i in d['items'] if i['id']=='$ID'][0])")" 'Theatre Gown (Blue)'
|
||||
check "the cost stuck" "$(bk | py "print([i['cost'] for i in d['items'] if i['id']=='$ID'][0])")" '^44'
|
||||
check "a negative cost is refused" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":-1}")" 'Invalid cost'
|
||||
|
||||
echo "== sizes"
|
||||
check "add a size" "$(mut catalog.update "{\"id\":\"$ID\",\"addSize\":\"XL\"}")" '"ok":true'
|
||||
# addSize is the append path, and the item has no history yet, so nothing in the product stops it
|
||||
# prepending or inserting. A count of four would not notice; the wrong run only surfaces later as a
|
||||
# baffling failure in "but appending still works".
|
||||
check "XL is appended to the end" "$(bk | py "print([i['sizes'] for i in d['items'] if i['id']=='$ID'][0])")" "^\['S', 'M', 'L', 'XL'\]"
|
||||
check "a duplicate size is refused" "$(mut catalog.update "{\"id\":\"$ID\",\"addSize\":\"XL\"}")" 'already on the item'
|
||||
|
||||
echo "== par and barcode, the two things you set at a shelf"
|
||||
check "set par on size M" "$(mut stock.reorder "{\"itemId\":\"$ID\",\"si\":1,\"reorder\":6}")" '"ok":true'
|
||||
check "par stored" "$(bk | py "print([x['reorder'] for x in d['stock'] if x['itemId']=='$ID' and x['sizeIndex']==1][0])")" '^6$'
|
||||
check "bind a barcode to size M" "$(mut barcode.bind "{\"code\":\"CAT$TS\",\"itemId\":\"$ID\",\"si\":1}")" '"ok":true'
|
||||
check "barcode points at the size" "$(bk | py "print([b['sizeIndex'] for b in d['barcodes'] if b['code']=='CAT$TS'][0])")" '^1$'
|
||||
check "the same code can't be bound twice" "$(mut barcode.bind "{\"code\":\"CAT$TS\",\"itemId\":\"$ID\",\"si\":2}")" 'is already on'
|
||||
# bind ends in an upsert, so a refusal that never happened moves the label to size L rather than
|
||||
# leaving it where it was. Reading it back is the half that proves the shelf is still right.
|
||||
check "and it stayed on size M" "$(bk | py "print([b['sizeIndex'] for b in d['barcodes'] if b['code']=='CAT$TS'][0])")" '^1$'
|
||||
|
||||
echo "== history protects the size order"
|
||||
# The import matches on sku, then resolves the size by name to a position. "created" is a
|
||||
# processed-row counter — it says 1 whether the row landed on M or on L — so the quantity is read
|
||||
# back off the position instead. (The history the next check leans on is already there: the par
|
||||
# level and the barcode above both count towards it.)
|
||||
check "an opening import finds the size by name" "$(mut import.rows "{\"kind\":\"opening\",\"rows\":[{\"sku\":\"TG1\",\"size\":\"M\",\"opening\":\"5\"}]}")" '"errors":\[\]'
|
||||
check "opening stock lands on size M" "$(bk | py "print([x['opening'] for x in d['stock'] if x['itemId']=='$ID' and x['sizeIndex']==1][0])")" '^5$'
|
||||
check "sizes can't be reordered once there is history" "$(mut catalog.update "{\"id\":\"$ID\",\"sizes\":[\"M\",\"S\",\"L\",\"XL\"]}")" "can't be removed or reordered"
|
||||
check "but appending still works" "$(mut catalog.update "{\"id\":\"$ID\",\"sizes\":[\"S\",\"M\",\"L\",\"XL\",\"2XL\"]}")" '"ok":true'
|
||||
|
||||
echo "== archive and reinstate"
|
||||
check "archive" "$(mut catalog.update "{\"id\":\"$ID\",\"archived\":true}")" '"ok":true'
|
||||
check "it reads as archived" "$(bk | py "print([i['archived'] for i in d['items'] if i['id']=='$ID'][0])")" 'True'
|
||||
check "reinstate" "$(mut catalog.update "{\"id\":\"$ID\",\"archived\":false}")" '"ok":true'
|
||||
# A garment that archives but never comes back is the failure this section exists to catch, and
|
||||
# "ok":true is what catalog.update says to any patch it accepted — including one that ignored this.
|
||||
check "it reads as live again" "$(bk | py "print([i['archived'] for i in d['items'] if i['id']=='$ID'][0])")" '^False$'
|
||||
|
||||
echo "== an issuer can look but not touch"
|
||||
check "invite an issuer" "$(mut users.add "{\"email\":\"catiss$TS@example.com\",\"first\":\"Ines\",\"last\":\"Issuer\",\"role\":\"ISSUER\",\"password\":\"password123\"}")" '"id"'
|
||||
check "issuer signs in" "$(curl -s -c "$K" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"catiss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "issuer cannot create a garment" "$(mut2 catalog.add '{"item":"Sneaky Coat","sizes":["M"]}')" 'Admin only'
|
||||
check "issuer cannot edit the card" "$(mut2 catalog.update "{\"id\":\"$ID\",\"item\":\"Renamed\"}")" 'Admin only'
|
||||
check "issuer cannot archive" "$(mut2 catalog.update "{\"id\":\"$ID\",\"archived\":true}")" 'Admin only'
|
||||
# Every field the issuer's three attempts reached for: the name, and the archive flag — a garment
|
||||
# left discontinued by a broken guard is invisible on the catalogue screen, and the name alone
|
||||
# would never see it.
|
||||
check "the garment is untouched" "$(bk | py "print([[i['item'], i['archived']] for i in d['items'] if i['id']=='$ID'][0])")" "^\['Theatre Gown (Blue)', False\]"
|
||||
check "and nothing was created" "$(bk | py "print(any(i['item']=='Sneaky Coat' for i in d['items']))")" '^False$'
|
||||
|
||||
echo "== the phone routes exist and need a session"
|
||||
check "/m/catalogue needs a session" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$B/m/catalogue")" '307.*/m/login'
|
||||
check "/m/catalogue renders for a signed-in admin" "$(curl -s -b "$J" "$B/m/catalogue")" 'Catalogue'
|
||||
check "the product card renders" "$(curl -s -b "$J" "$B/m/catalogue/$ID")" 'Sizes'
|
||||
check "the new-garment form renders" "$(curl -s -b "$J" "$B/m/catalogue/new")" 'New garment'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# The Community edition: the same server started with EDITION=community.
|
||||
#
|
||||
# EDITION=community npm run dev -- -p 3112 # in another shell
|
||||
# BASE=http://127.0.0.1:3112 bash scripts/e2e-community.sh
|
||||
#
|
||||
# What it proves: the front door is the sign-in, not the website; sign-up works with no Turnstile;
|
||||
# a facility has no staff ceiling and no plan tab (the snapshot says so); the demo is off; and
|
||||
# nothing on a page loads the analytics tracker.
|
||||
set -u
|
||||
B=${BASE:-http://127.0.0.1:3112}
|
||||
T=${TMP:-/tmp}
|
||||
J="$T/tc-community.txt"; rm -f "$J"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $(echo "$2" | head -c 240)"; }
|
||||
check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$out"; fi; }
|
||||
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$out"; else ok "$name"; fi; }
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
|
||||
where(){ curl -s -o /dev/null -w '%{redirect_url}' "$@"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; }
|
||||
rows() { python3 -c "import json,sys; n=int(sys.argv[1]); print(json.dumps({'kind':'staff','rows':[{'num':str(7000+i),'first':'C%d'%i,'last':'Row','group':'Kitchen','dept':'Kitchen'} for i in range(1,n+1)]}))" "$1"; }
|
||||
. "$(dirname "$0")/e2e-preflight.sh"
|
||||
TS=$(date +%s)
|
||||
|
||||
echo "== the front door"
|
||||
check "/ redirects" "$(where "$B/")" '/auth'
|
||||
check "health answers" "$(code "$B/api/health")" '200'
|
||||
R=$(curl -s "$B/api/app-info")
|
||||
check "app-info names the product" "$R" '"product":"threadcount"'
|
||||
check " and the edition" "$R" '"edition":"community"'
|
||||
check "the demo is off" "$(code "$B/api/auth/demo?as=admin")" '404'
|
||||
no "the sign-in page loads no tracker" "$(curl -s "$B/auth")" 'pulse.js'
|
||||
no " and asks nothing about plans" "$(curl -s "$B/auth?mode=signup")" 'Hosted Facility'
|
||||
|
||||
echo "== a facility with no ceiling and no plan"
|
||||
R=$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.4.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Com\",\"last\":\"Munity\",\"facility\":\"Community $TS\",\"email\":\"community-$TS@example.com\",\"password\":\"password123\",\"plan\":\"hosted_small\"}")
|
||||
check "signup with no Turnstile token" "$R" '"ok":true'
|
||||
check " groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check " 61 staff import with no ceiling" "$(mut import.rows "$(rows 61)")" '"created":61'
|
||||
check " a write is never read-only" "$(mut dept.save '{"name":"Willow","cc":"CC-1"}')" '"ok":true'
|
||||
R=$(curl -s -b "$J" "$B/app")
|
||||
check " the app opens" "$(code -b "$J" "$B/app")" '200'
|
||||
no " with no plan tab" "$R" '"live":true'
|
||||
check " and the snapshot calls it Community" "$R" 'Community'
|
||||
no " no tracker on an app screen either" "$R" 'pulse.js'
|
||||
|
||||
echo; echo "community: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cost history: that a price is remembered, that it records what it moved from, and that a change
|
||||
# which isn't a change doesn't produce a row.
|
||||
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}; J="$T/tc-cost-cj.txt"; rm -f "$J"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 250)"; fi; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
bk() { curl -s -b "$J" "$B/api/backup"; }
|
||||
costs(){ curl -s -b "$J" "$B/m/catalogue" > /dev/null; bk; }
|
||||
|
||||
TS=$(date +%s)
|
||||
echo "== setup"
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.16.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Cora\",\"last\":\"Cost\",\"facility\":\"Cost Hospital $TS\",\"email\":\"cost$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
|
||||
R=$(mut catalog.add '{"item":"Priced Gown","sku":"PG1","supplier":"Alpha Supply","cost":30,"sizes":["S","M"]}')
|
||||
check "garment created" "$R" '"id"'
|
||||
ID=$(echo "$R" | py "print(d['result']['id'])")
|
||||
|
||||
# The whole history for this garment, oldest first, each row as "cost<-previous". Counting rows can
|
||||
# only ever say that a row appeared; the figures in it are what finance actually reads back.
|
||||
hist() { bk | py "print(' | '.join('%g<-%s' % (c['cost'], 'none' if c['previous'] is None else '%g' % c['previous']) for c in d.get('costs',[]) if c['itemId']=='$ID'))"; }
|
||||
itemcost() { bk | py "print('%g' % [i['cost'] for i in d['items'] if i['id']=='$ID'][0])"; }
|
||||
|
||||
echo "== the opening price is remembered"
|
||||
check "the price landed on the garment" "$(itemcost)" '^30$'
|
||||
# previous is null rather than a figure, which is what makes this row the start of the history
|
||||
# instead of an edit somebody made on day one.
|
||||
check "one opening row, at the opening price" "$(hist)" '^30<-none$'
|
||||
|
||||
echo "== a change records what it moved from"
|
||||
check "raise the price" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":34.5}")" '"ok":true'
|
||||
N1=$(bk | py "print(len([c for c in d.get('costs',[]) if c['itemId']=='$ID']))")
|
||||
check "two rows now" "$N1" '^2$'
|
||||
check "the latest is the new price" "$(bk | py "print([c['cost'] for c in d.get('costs',[]) if c['itemId']=='$ID'][-1])")" '34.5'
|
||||
check "and remembers the old one" "$(bk | py "print([c['previous'] for c in d.get('costs',[]) if c['itemId']=='$ID'][-1])")" '^30'
|
||||
check "and who changed it" "$(bk | py "print([c['byName'] for c in d.get('costs',[]) if c['itemId']=='$ID'][-1])")" 'Cora Cost'
|
||||
|
||||
echo "== setting the same price again is not a change"
|
||||
check "save the identical price" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":34.5}")" '"ok":true'
|
||||
check "no row for the identical price" "$(hist)" '^30<-none | 34\.5<-30$'
|
||||
|
||||
echo "== editing other fields leaves the history alone"
|
||||
check "rename it" "$(mut catalog.update "{\"id\":\"$ID\",\"item\":\"Priced Gown (Blue)\"}")" '"ok":true'
|
||||
check "no row for a rename" "$(hist)" '^30<-none | 34\.5<-30$'
|
||||
|
||||
echo "== a price drop is recorded as a drop"
|
||||
check "lower the price" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":29}")" '"ok":true'
|
||||
check "a third row, 34.5 down to 29" "$(hist)" '^30<-none | 34\.5<-30 | 29<-34\.5$'
|
||||
|
||||
echo "== a negative price is refused on an edit"
|
||||
check "negative refused" "$(mut catalog.update "{\"id\":\"$ID\",\"cost\":-5}")" 'Invalid cost'
|
||||
# Refused has to mean nothing was written. An error message sent after the update had already gone
|
||||
# through would read exactly the same from out here, with the garment left priced at -5.
|
||||
check "the price is untouched" "$(itemcost)" '^29$'
|
||||
check "and the history is untouched" "$(hist)" '^30<-none | 34\.5<-30 | 29<-34\.5$'
|
||||
|
||||
echo "== a negative price on creation is clamped, not refused"
|
||||
# catalog.add takes Math.max(0, cost) where catalog.update throws, so the two doors disagree about
|
||||
# the same bad figure. Pinned here so that whichever way it is settled, it is settled deliberately.
|
||||
R2=$(mut catalog.add '{"item":"Odd Gown","sku":"OG1","supplier":"Alpha Supply","cost":-5,"sizes":["S"]}')
|
||||
check "creation accepted" "$R2" '"id"'
|
||||
ID=$(echo "$R2" | py "print(d['result']['id'])")
|
||||
check "the price is zero, not negative" "$(itemcost)" '^0$'
|
||||
check "and a zero price opens no history" "$(hist)" '^$'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mobile pack: photo store + attachments (approval / receipt / return), delivery rounds (signature + proof), photo route auth, backup round-trip, rounds page.
|
||||
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}; J="$T/tc-mob-cj.txt"; rm -f "$J"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 300)"; fi; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
bk() { curl -s -b "$J" "$B/api/backup"; }
|
||||
PNG='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
JPG='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA='
|
||||
|
||||
TS=$(date +%s)
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.9.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Mob\",\"last\":\"Admin\",\"facility\":\"Mobile Hospital $TS\",\"email\":\"mob$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check "supplier" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
|
||||
check "dept" "$(mut dept.save '{"name":"Willow Ward","cc":"RGH-3010"}')" '"ok":true'
|
||||
check "catalog" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"RN Scrub Top","sku":"T1","supplier":"Alpha Supply","cost":"30","group":"Registered Nurse","sizes":"S|M"}]}')" '"created":1'
|
||||
check "opening accepted" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"T1","size":"M","opening":"5"}]}')" '"created":1'
|
||||
# The response only counts rows walked — it still says {"created":1} when the quantity parsed as 0
|
||||
# and nothing reached the shelf. Everything below issues from this stock, so check the shelf itself.
|
||||
check "opening 5 on the shelf" "$(bk | py 'print(([s["opening"] for s in d["stock"] if s["sizeIndex"]==1] or [0])[0])')" '^5$'
|
||||
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"Nina","last":"Nurse","phone":"0400 111 222","group":"Registered Nurse","dept":"Willow Ward","top":"M","pants":"M"}]}')" '"created":1'
|
||||
BK=$(bk); T1=$(echo "$BK" | py 'print(d["items"][0]["id"])'); NINA=$(echo "$BK" | py 'print(d["staff"][0]["id"])')
|
||||
|
||||
echo "== photo store"
|
||||
check "bad photo rejected" "$(mut photo.put '{"kind":"approval","data":"data:text/html;base64,PGI+"}')" 'JPEG or PNG'
|
||||
check "svg rejected" "$(mut photo.put '{"kind":"approval","data":"data:image/svg+xml;base64,PHN2Zz4="}')" 'JPEG or PNG'
|
||||
R=$(mut photo.put "{\"kind\":\"approval\",\"data\":\"$JPG\"}"); check "jpeg stored" "$R" '"id"'; PH1=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
R=$(mut photo.put "{\"kind\":\"sig\",\"data\":\"$PNG\"}"); check "png stored" "$R" '"id"'; PH2=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
check "photo route serves jpeg" "$(curl -s -b "$J" -o /dev/null -w '%{http_code} %{content_type}' "$B/api/photo/$PH1")" '200 image/jpeg'
|
||||
check "photo route serves png" "$(curl -s -b "$J" -o /dev/null -w '%{http_code} %{content_type}' "$B/api/photo/$PH2")" '200 image/png'
|
||||
check "photo route needs auth" "$(curl -s -o /dev/null -w '%{http_code}' "$B/api/photo/$PH1")" '401'
|
||||
|
||||
echo "== attachments"
|
||||
R=$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J Manager\",\"sets\":\"2\",\"photoId\":\"$PH1\"}"); check "approval with form photo" "$R" '"id"'
|
||||
check "approval photoId in backup" "$(bk | py 'print(d["approvals"][0]["photoId"]=="'$PH1'")')" 'True'
|
||||
# A real photo belonging to another facility, not an invented id: an id that exists nowhere is
|
||||
# still refused by an ownPhoto with the facility filter torn out, so only a genuine cross-facility
|
||||
# id can show that the ownership rule is still there.
|
||||
J2="$T/tc-mob-cj2.txt"; rm -f "$J2"
|
||||
check "second facility signup" "$(curl -s -c "$J2" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.9.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Other\",\"last\":\"Admin\",\"facility\":\"Other Hospital $TS\",\"email\":\"oth$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
R=$(curl -s -b "$J2" -c "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"photo.put\",\"payload\":{\"kind\":\"approval\",\"data\":\"$JPG\"}}"); check "second facility photo stored" "$R" '"id"'; OTHER_PH=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
check "approval with foreign photo rejected" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J Manager\",\"sets\":\"1\",\"photoId\":\"$OTHER_PH\"}")" 'Photo not found'
|
||||
check "approval with unknown photo rejected" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J Manager\",\"sets\":\"1\",\"photoId\":\"nope123\"}")" 'Photo not found'
|
||||
check "issue order-in" "$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"order\"}]}")" '"ordered":1'
|
||||
ORD=$(bk | py 'print([o["id"] for o in d["orders"] if o["orderFor"]=="Staff Member"][0])'); LID=$(bk | py 'print([o for o in d["orders"] if o["orderFor"]=="Staff Member"][0]["lines"][0]["id"])')
|
||||
R=$(mut photo.put "{\"kind\":\"receipt\",\"data\":\"$JPG\"}"); PH3=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
R=$(mut order.receive "{\"id\":\"$ORD\",\"invoice\":\"INV-1\",\"photoId\":\"$PH3\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":1,\"dest\":\"pickup\"}]}"); check "receive with invoice photo accepted" "$R" '"ok":true'
|
||||
# "ok":true is the envelope every op that doesn't throw returns, so it says nothing about where the
|
||||
# garments went. Only a pending pickup row proves the delivery was routed to the wearer instead of
|
||||
# quietly landing on the shelf — which is what the whole rounds section below then relies on.
|
||||
check "receive with invoice photo → pickup" "$(bk | py 'print(len([p for p in d["pickups"] if p["orderId"]=="'$ORD'" and not p["pickedUp"]]))')" '^1$'
|
||||
check "receipt photoId stored" "$(bk | py 'print([o for o in d["orders"] if o["id"]=="'$ORD'"][0]["receipts"][0]["photoId"]=="'$PH3'")')" 'True'
|
||||
check "issue from stock" "$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")" '"stock":1'
|
||||
ISS=$(bk | py 'print([i["id"] for i in d["issues"] if not i["direct"]][0])')
|
||||
R=$(mut photo.put "{\"kind\":\"return\",\"data\":\"$JPG\"}"); PH4=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
check "return with damage photo" "$(mut issue.return "{\"id\":\"$ISS\",\"cond\":\"Returned - Damaged\",\"photoId\":\"$PH4\"}")" '"ok":true'
|
||||
check "return photoId stored" "$(bk | py 'print([i for i in d["issues"] if i["id"]=="'$ISS'"][0]["returnPhotoId"]=="'$PH4'")')" 'True'
|
||||
|
||||
echo "== delivery rounds"
|
||||
PU=$(bk | py 'print([p["id"] for p in d["pickups"] if not p["pickedUp"]][0])')
|
||||
# The ward name sits in every /app page's HTML whether this list renders or not — the layout
|
||||
# serialises the whole snapshot into the flight payload — so match the per-row button, which only
|
||||
# a rendered pending delivery emits.
|
||||
check "rounds page renders ward row" "$(curl -s -b "$J" "$B/app/rounds")" 'Sign for the delivery to Nina Nurse'
|
||||
check "rounds page tel link" "$(curl -s -b "$J" "$B/app/rounds")" 'tel:0400111222'
|
||||
check "dashboard tel link" "$(curl -s -b "$J" "$B/app")" 'tel:0400111222'
|
||||
R=$(mut photo.put "{\"kind\":\"proof\",\"data\":\"$JPG\"}"); PH5=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
check "deliver with signature + proof" "$(mut pickup.deliver "{\"id\":\"$PU\",\"deliveredTo\":\"J. Barnes, Ward Manager\",\"sigId\":\"$PH2\",\"proofId\":\"$PH5\"}")" '"ok":true'
|
||||
check "pickup marked delivered round" "$(bk | py 'p=[p for p in d["pickups"] if p["id"]=="'$PU'"][0]; print(p["deliveredRound"], p["deliveredTo"], p["sigId"]=="'$PH2'", p["proofId"]=="'$PH5'", bool(p["pickedUp"]))')" 'True J. Barnes, Ward Manager True True True'
|
||||
check "direct issue created + receipt signed" "$(bk | py 'i=[i for i in d["issues"] if i["direct"]][0]; print(i["receipt"], i["orderCode"]!="")')" 'True True'
|
||||
check "deliver twice refused" "$(mut pickup.deliver "{\"id\":\"$PU\",\"deliveredTo\":\"x\"}")" 'Already handed over'
|
||||
check "rounds page now empty" "$(curl -s -b "$J" "$B/app/rounds")" 'Nothing waiting for delivery'
|
||||
|
||||
echo "== backup round-trip keeps photos"
|
||||
BKF=$(bk)
|
||||
# A row count alone passes on a backup full of empty strings, which is exactly what the move to
|
||||
# disk storage broke: a photo whose file can't be read is exported as data:"" and still counted.
|
||||
check "backup has photos" "$(echo "$BKF" | py 'print(len(d["photos"]), sum(1 for p in d["photos"] if str(p.get("data") or "").startswith("data:image/")))')" '^5 5$'
|
||||
check "restore" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$BKF}")" '"ok":true\|restored'
|
||||
check "photos restored" "$(bk | py 'print(len(d["photos"]))')" '^5$'
|
||||
NEWAP=$(bk | py 'print(d["approvals"][0]["photoId"] or "")'); check "approval photo remapped + served" "$(curl -s -b "$J" -o /dev/null -w '%{http_code}' "$B/api/photo/$NEWAP")" '200'
|
||||
check "pickup sig/proof remapped" "$(bk | py 'p=d["pickups"][0]; print(bool(p["sigId"]) and bool(p["proofId"]) and p["deliveredTo"])')" 'J. Barnes, Ward Manager'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Photos on disk: stored as files rather than base64, served only to the owning facility, and
|
||||
# still whole in a backup.
|
||||
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}; J="$T/tc-ph-cj.txt"; K="$T/tc-ph-cj2.txt"; rm -f "$J" "$K"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
|
||||
# A real 1x1 JPEG, so what is written is a genuine image rather than random bytes.
|
||||
JPEG_B64=$(python3 -c "
|
||||
import base64
|
||||
# smallest valid JPEG
|
||||
b = bytes.fromhex('ffd8ffe000104a46494600010100000100010000ffdb004300ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc00011080001000101011100ffc40014000100000000000000000000000000000009ffc40014100100000000000000000000000000000000ffda0008010100003f0054df')
|
||||
print(base64.b64encode(b).decode())
|
||||
")
|
||||
DATAURL="data:image/jpeg;base64,$JPEG_B64"
|
||||
|
||||
TS=$(date +%s)
|
||||
echo "== setup"
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.17.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Pia\",\"last\":\"Photo\",\"facility\":\"Photo Hospital $TS\",\"email\":\"ph$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
|
||||
echo "== storing"
|
||||
R=$(mut photo.put "{\"kind\":\"sig\",\"data\":\"$DATAURL\"}")
|
||||
check "photo accepted" "$R" '"id"'
|
||||
PID=$(echo "$R" | py "print(d['result']['id'])")
|
||||
|
||||
echo "== it is on disk, not in the database"
|
||||
ROW=$(curl -s -b "$J" "$B/api/backup" | py "
|
||||
ph = [x for x in d['photos'] if x['id']=='$PID'][0]
|
||||
print('path=' + (ph.get('path') or '') + ' mime=' + (ph.get('mime') or '') + ' bytes=' + str(ph.get('bytes')))
|
||||
")
|
||||
# Named after the row id we generated, never after anything the request supplied — the trailing
|
||||
# space keeps the match inside the path field, so an empty path cannot borrow the slash in the mime.
|
||||
check "the row records a file path" "$ROW" "path=[A-Za-z0-9_-]\{1,\}/$PID\.jpg "
|
||||
check "and the mime type" "$ROW" 'mime=image/jpeg'
|
||||
# The decoded image is 143 bytes (192 as base64). Pinning the number is what catches bytes ever
|
||||
# recording the encoded length instead of the real size of the file on disk.
|
||||
check "and the size" "$ROW" 'bytes=143$'
|
||||
|
||||
echo "== serving"
|
||||
CT=$(curl -s -b "$J" -o /dev/null -w '%{content_type} %{http_code}' "$B/api/photo/$PID")
|
||||
check "served as a jpeg" "$CT" 'image/jpeg 200'
|
||||
check "signed out is refused" "$(curl -s -o /dev/null -w '%{http_code}' "$B/api/photo/$PID")" '401'
|
||||
|
||||
echo "== another facility cannot read it"
|
||||
check "second facility signs up" "$(curl -s -c "$K" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.18.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Oth\",\"last\":\"Er\",\"facility\":\"Other Photo $TS\",\"email\":\"pho$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "their request 404s" "$(curl -s -b "$K" -o /dev/null -w '%{http_code}' "$B/api/photo/$PID")" '404'
|
||||
|
||||
echo "== the backup is still whole"
|
||||
# Byte-identical to what went in, not merely a data URL with the right prefix: the prefix is built
|
||||
# from the mime column, so a truncated or wrong file on disk still produces one.
|
||||
check "backup embeds the image" "$(curl -s -b "$J" "$B/api/backup" | py "
|
||||
ph = [x for x in d['photos'] if x['id']=='$PID'][0]
|
||||
print('same' if ph.get('data') == '$DATAURL' else 'DIFFERENT')
|
||||
")" 'same'
|
||||
|
||||
echo "== rubbish is refused"
|
||||
check "not an image" "$(mut photo.put '{"kind":"sig","data":"data:text/html;base64,PGgxPmhp"}')" 'JPEG or PNG'
|
||||
check "empty" "$(mut photo.put '{"kind":"sig","data":""}')" 'JPEG or PNG'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared preflight and setup for the e2e runners. Sourced by each of them, never run on its own.
|
||||
#
|
||||
# Every suite in here starts by creating an account or signing one in, and both of those doors are
|
||||
# behind Cloudflare Turnstile. In production mode the check fails *closed*: with no TURNSTILE_SECRET
|
||||
# the server refuses to start at all, and if it is running with one but no reachable Cloudflare, the
|
||||
# auth routes answer "Security check unavailable". Either way a run against `next start` on a box
|
||||
# with no Cloudflare keys fails on its first line, with an error about a security check rather than
|
||||
# about the thing being tested — so it is caught here once, with the way out.
|
||||
#
|
||||
# The way out is TURNSTILE_OPTIONAL=1 on the *server* being tested. It is a local smoke-test switch
|
||||
# only; the hosted deploy refuses a secrets file that carries it.
|
||||
#
|
||||
# `next dev` needs none of this: outside production verifyTurnstile is advisory and skips.
|
||||
|
||||
e2e_preflight() {
|
||||
local base=${1:?base url} body
|
||||
body=$(curl -s --max-time 10 -X POST "$base/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"email":"e2e-preflight@example.com","password":"not-a-password"}' 2>/dev/null || true)
|
||||
|
||||
if [ -z "$body" ]; then
|
||||
echo "FATAL: nothing answering at $base."
|
||||
echo " Start the server first — \`npm run dev -- -p 3111\`, or \`next start\` with TURNSTILE_OPTIONAL=1."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$body" in
|
||||
*"Security check unavailable"*|*"complete the security check"*)
|
||||
echo "FATAL: $base is in production mode and Turnstile is refusing every auth route."
|
||||
echo " Restart that server with TURNSTILE_OPTIONAL=1 (local smoke tests only), or run against \`next dev\`."
|
||||
exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# A facility's staff groups, named the way a coordinator names them on the first day.
|
||||
#
|
||||
# A new facility starts with none, and with no group on the FTE table or the starting kit, because any
|
||||
# list the product shipped would be one employer's job titles handed to every other employer. So a
|
||||
# suite that signs up and then files somebody under "Registered Nurse" is testing a facility nobody
|
||||
# has set up, where everybody is on manager approval. Every suite that signs up calls this straight
|
||||
# afterwards, with its own cookie jar: e2e_groups "$B" "$J".
|
||||
#
|
||||
# The names are generic on purpose — no customer's org chart belongs in a test — and they go in through
|
||||
# settings.update, the op the settings screen saves with, so a change to what that op accepts breaks
|
||||
# the setup out loud instead of being stepped around. The response is printed for the suite's own
|
||||
# check() to judge.
|
||||
e2e_groups() {
|
||||
local base=${1:?base url} jar=${2:?cookie jar}
|
||||
curl -s -b "$jar" -c "$jar" -X POST "$base/api/mutate" -H 'content-type: application/json' \
|
||||
-d '{"op":"settings.update","payload":{"staffGroups":["Registered Nurse","Enrolled Nurse","Support Services","Kitchen","Security"],"nursingGroups":["Registered Nurse","Enrolled Nurse"],"kitGroups":["Support Services"]}}'
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-loved uniforms (Update.V3): pool adjust, hand-ins (good/rag, credit), pre-loved issues (free, no ledger/entitlement/replenish), pool stocktake, backup round-trip.
|
||||
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}; J="$T/tc-pl-cj.txt"; rm -f "$J"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 300)"; fi; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
bk() { curl -s -b "$J" "$B/api/backup"; }
|
||||
pool() { bk | py 'print(next((s["preloved"] for s in d["stock"] if s["itemId"]=="'$1'" and s["sizeIndex"]=='$2'),0))'; }
|
||||
|
||||
TS=$(date +%s)
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.9.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"PL\",\"last\":\"Admin\",\"facility\":\"Preloved Hospital $TS\",\"email\":\"pl$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check "supplier" "$(mut supplier.add '{"name":"Alpha Supply"}')" '"id"'
|
||||
check "dept" "$(mut dept.save '{"name":"Ward 1","cc":"100"}')" '"ok":true'
|
||||
check "catalog" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"RN Scrub Top","sku":"T1","supplier":"Alpha Supply","cost":"30","group":"Registered Nurse","sizes":"S|M"},{"item":"Scrub Pant","sku":"P1","supplier":"Alpha Supply","cost":"25","group":"Registered Nurse","sizes":"S|M"},{"item":"Security Shirt","sku":"S1","supplier":"Alpha Supply","cost":"40","group":"Security","sizes":"M|L"}]}')" '"created":3'
|
||||
check "opening" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"T1","size":"M","opening":"10"},{"sku":"P1","size":"M","opening":"10"},{"sku":"S1","size":"L","opening":"4"}]}')" '"created":3'
|
||||
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"Nina","last":"Nurse","group":"Registered Nurse","dept":"Ward 1","top":"M","pants":"M"},{"num":"2","first":"Sam","last":"Guard","group":"Security","dept":"Ward 1","ent":"3","top":"L","pants":"L"}]}')" '"created":2'
|
||||
BK=$(bk)
|
||||
T1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="T1"][0])'); P1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="P1"][0])'); S1=$(echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="S1"][0])')
|
||||
NINA=$(echo "$BK" | py 'print([s["id"] for s in d["staff"] if s["num"]=="1"][0])'); SAM=$(echo "$BK" | py 'print([s["id"] for s in d["staff"] if s["num"]=="2"][0])')
|
||||
|
||||
echo "== pool adjust"
|
||||
check "pre-loved adjust +3" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":3}]}")" '"ok":true'
|
||||
check "pool T1/M = 3" "$(pool $T1 1)" '^3$'
|
||||
check "pre-loved adjust -5 clamps to 0" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":-5}]}")" '"ok":true'
|
||||
check "pool clamped 0" "$(pool $T1 1)" '^0$'
|
||||
check "pool adjust leaves shelf alone (no moves)" "$(bk | py 'print(len(d["moves"]))')" '^0$'
|
||||
check "pre-loved adjust +2 again" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":2}]}")" '"ok":true'
|
||||
|
||||
echo "== hand-ins"
|
||||
check "hand-in needs lines" "$(mut handin.add "{\"staffId\":\"$SAM\",\"lines\":[]}")" 'at least one'
|
||||
check "issue Sam 2 shirts from stock" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":2,\"src\":\"stock\"}]}")" '"stock":2'
|
||||
check "Sam has 2 shirts on his record" "$(bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$SAM'" and not i["preloved"]))')" '^2$'
|
||||
# The ceiling itself, proved by the refusal it should earn: he holds 2 shirts, and 5 more would be 7
|
||||
# tops against the six anyone may hold. His register figure of 3 is a yearly reporting number now and
|
||||
# refuses nothing on its own. Counting his issue rows never shows that.
|
||||
check "Sam holds 2 tops, 5 more is past six" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":5,\"src\":\"order\"}]}")" 'the most anyone holds is 6 sets'
|
||||
R=$(mut handin.add "{\"staffId\":\"$SAM\",\"credit\":true,\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":1,\"cond\":\"Good\",\"laundered\":true},{\"itemId\":\"$S1\",\"si\":1,\"qty\":1,\"cond\":\"Rag\",\"laundered\":false}]}")
|
||||
check "hand-in recorded" "$R" '"good":1,"rag":1,"credit":true'
|
||||
check "hand-in message" "$R" '1 to the pre-loved pool · 1 to rag disposal · allowance credited'
|
||||
check "pool S1/L = 1 (rag not pooled)" "$(pool $S1 1)" '^1$'
|
||||
# Assert the quantity handed in, not the number of rows it landed on. A hand-in that covers part of
|
||||
# an issue row now splits it, so two lines against one qty-2 row leave two stamped rows rather than
|
||||
# one — the same two garments, recorded with the condition each of them actually went out under.
|
||||
check "handedIn qty stamped on past issues" "$(bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$SAM'" and i["handedIn"]))')" '^2$'
|
||||
check "Sam holds nothing after handing both in" "$(bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$SAM'" and not i["handedIn"] and not i["returnedDate"]))')" '^0$'
|
||||
check "hand-in in backup" "$(bk | py 'print(len(d["handins"]), d["handins"][0]["credit"], len(d["handins"][0]["lines"]))')" '1 True 2'
|
||||
# Both shirts handed in leaves him holding nothing, so two more go straight through: the room comes
|
||||
# from what he gave back.
|
||||
check "handing in freed room (2 more OK)" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":2,\"src\":\"stock\"}]}")" '"stock":2'
|
||||
# Holding 2 again, so 5 more is 7 tops — past the six, and refused without an override.
|
||||
check "past six again is refused" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":0,\"qty\":5,\"src\":\"order\"}]}")" 'the most anyone holds is 6 sets'
|
||||
|
||||
echo "== manager approval credit"
|
||||
check "approval 2 sets" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J Manager\",\"sets\":\"2\"}")" '"id"'
|
||||
check "issue Nina 2 sets from stock" "$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":2,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":2,\"src\":\"stock\"},{\"itemId\":\"$P1\",\"si\":1,\"qty\":2,\"src\":\"stock\"}]}")" '"apDeducted":2'
|
||||
check "approval fully used" "$(bk | py 'print(d["approvals"][0]["used"])')" '^2$'
|
||||
check "hand-in 1 top + 1 pant credited" "$(mut handin.add "{\"staffId\":\"$NINA\",\"credit\":true,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"cond\":\"Good\"},{\"itemId\":\"$P1\",\"si\":1,\"qty\":1,\"cond\":\"Good\"}]}")" '"setsBack":1'
|
||||
check "approval used back to 1" "$(bk | py 'print(d["approvals"][0]["used"])')" '^1$'
|
||||
check "pool T1/M now 3 (2 adjust + 1 hand-in)" "$(pool $T1 1)" '^3$'
|
||||
|
||||
echo "== pre-loved issue"
|
||||
SHELF_BEFORE=$(bk | py 'print(next(s["opening"]+s["adj"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')
|
||||
check "pre-loved over pool rejected" "$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":9,\"src\":\"preloved\"}]}")" 'Not enough pre-loved'
|
||||
# The issue ASKS for two sets it must not be given. An approval is the ward agreeing to pay for new
|
||||
# uniform; a pre-loved garment came back off somebody else, costs the ward nothing and goes out
|
||||
# free, so spending a set on one takes something from the wearer the ward never spent. Nina has one
|
||||
# set left here, so a request for two is refused by the rule rather than by arithmetic — and asking
|
||||
# for none, as this line used to, would pass just as well with the rule deleted.
|
||||
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":2,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":2,\"src\":\"preloved\"}]}")
|
||||
check "pre-loved issue ok" "$R" '"preloved":2'
|
||||
check "no approval deduction, even though two sets were asked for" "$R" '"apDeducted":0'
|
||||
check " and the approval row is untouched" "$(bk | py 'print(d["approvals"][0]["used"])')" '^1$'
|
||||
check "pool T1/M down to 1" "$(pool $T1 1)" '^1$'
|
||||
# Drawing from the pool leaves the shelf where it was: the counted stock doesn't move and no
|
||||
# movement is written, or a free garment quietly comes off the linen room's own figures.
|
||||
check "pre-loved issue leaves the shelf alone" "$(bk | py 'print(next(s["opening"]+s["adj"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')" "^$SHELF_BEFORE\$"
|
||||
check "pre-loved issue writes no stock movement" "$(bk | py 'print(len(d["moves"]))')" '^0$'
|
||||
# Anchored, because a top costs 30 and an unanchored "0 Pre-loved" matches "30 Pre-loved" just as
|
||||
# happily — and costing the ward nothing is the half of this row that matters.
|
||||
check "issue row preloved + cost 0" "$(bk | py 'i=[i for i in d["issues"] if i["preloved"]][0]; print(i["cost"], i["cond"])')" '^0 Pre-loved$'
|
||||
check "no replenishment draft line for pre-loved (T1 M draft qty stays 2)" "$(bk | py 'print(sum(l["qty"] for o in d["orders"] if o["replenish"] for l in o["lines"] if l["itemId"]=="'$T1'" and l["size"]=="M"))')" '^2$'
|
||||
check "issue Sam pre-loved not blocked by entitlement" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":2}]}"; mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$S1\",\"si\":1,\"qty\":2,\"src\":\"preloved\"}]}")" '"preloved":2'
|
||||
|
||||
echo "== pool stocktake"
|
||||
check "pool stocktake sets count" "$(mut stocktake.apply "{\"mode\":\"preloved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":5},{\"itemId\":\"$P1\",\"si\":1,\"counted\":1}]}")" '"counted":2,"variances":1'
|
||||
check "pool T1/M = 5 after pool take" "$(pool $T1 1)" '^5$'
|
||||
check "shelf adj untouched by pool take" "$(bk | py 'print(next(s["adj"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')" '^0$'
|
||||
check "stocktake filed with mode preloved" "$(bk | py 'print([t["mode"] for t in d["stocktakes"]])')" "preloved"
|
||||
check "shelf stocktake still shelf mode" "$(mut stocktake.apply "{\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"counted\":6}]}"; bk | py 'print(sorted(t["mode"] for t in d["stocktakes"]))')" "\['preloved', 'shelf'\]"
|
||||
|
||||
echo "== backup round-trip keeps the pool"
|
||||
BKF=$(bk)
|
||||
check "restore" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$BKF}")" '"ok":true'
|
||||
# Name the garment and the size each figure belongs to, not just the numbers. A restore re-creates
|
||||
# every row with a fresh id, so the pool is only really back if each count came down on the item and
|
||||
# the size it was counted against — five pre-loved tops in M read as "a 5 in the list" whether they
|
||||
# landed on the tops, on the pants, or on the small ones.
|
||||
check "pool survived restore, on the right item and size" "$(bk | py 'it={i["id"]:i for i in d["items"]}; print(" ".join(sorted(it[s["itemId"]]["sku"]+"/"+it[s["itemId"]]["sizes"][s["sizeIndex"]]+"="+str(s["preloved"]) for s in d["stock"] if s["preloved"]>0)))')" '^P1/M=1 S1/L=1 T1/M=5$'
|
||||
# Not the number of hand-ins: the garments that came back on them and the credit those earned.
|
||||
# `credited` is what every wearer's entitlement is worked out from, so a restore that dropped it
|
||||
# would move Sam from 3-used to 4-used with two hand-in rows sitting there looking perfectly right.
|
||||
check "handins survived restore, lines and credit intact" "$(bk | py 'print(sorted((h["credit"], sum(l["qty"] for l in h["lines"]), sum(l["credited"] for l in h["lines"])) for h in d["handins"]))')" '^\[(True, 2, 1), (True, 2, 2)\]$'
|
||||
# Garments, not rows: an issue row carries a quantity, so counting rows would call a restore that
|
||||
# brought back one of Nina's two pre-loved tops a complete one. Free travels with them: a pre-loved
|
||||
# garment that comes back priced at the catalogue cost starts charging a ward for something it was
|
||||
# given, which is the one thing the pool exists not to do.
|
||||
check "preloved issues survived restore, still free" "$(bk | py 'p=[i for i in d["issues"] if i["preloved"]]; print(sum(i["qty"] for i in p), sorted({i["cost"] for i in p}))')" '^4 \[0\]$'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
Executable
+242
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scan-to-add / scan-a-size / start-fresh: catalog.add with barcodes, catalog.variantAdd,
|
||||
# ambiguous-binding guards, /api/lookup gating and data.reset.
|
||||
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}; J="$T/tc-scan-cj.txt"; J2="$T/tc-scan-cj2.txt"; rm -f "$J" "$J2"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; }
|
||||
mut2() { curl -s -b "$J2" -c "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 300)"; fi; }
|
||||
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 300)"; else ok "$name"; fi; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
XFF="x-forwarded-for: 10.7.$((RANDOM%250)).$((RANDOM%250))"
|
||||
|
||||
TS=$(date +%s)
|
||||
echo "== setup"
|
||||
check "signup admin" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "$XFF" -d "{\"first\":\"Scan\",\"last\":\"Admin\",\"facility\":\"Scan Hospital $TS\",\"email\":\"scan$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check "supplier" "$(mut supplier.add '{"name":"Northline Workwear"}')" '"id"'
|
||||
|
||||
echo "== quick add from a scan (catalog.add + barcodes)"
|
||||
R=$(mut catalog.add '{"item":"Scrub Top","gender":"Unisex","group":"All","sku":"ST-1","supplier":"Northline Workwear","cost":24.5,"sizes":["S","M","L"],"barcodes":[{"si":1,"code":"9312345678907"}]}')
|
||||
check "created with a bound barcode" "$R" '"id"'
|
||||
IT=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
check "barcode stored against size index 1" "$(echo "$BK" | py 'print([(b["code"],b["sizeIndex"]) for b in d["barcodes"]])')" "9312345678907', 1"
|
||||
check "barcode source is bound" "$(echo "$BK" | py 'print(d["barcodes"][0]["source"])')" '^bound$'
|
||||
check "size out of range rejected" "$(mut catalog.add '{"item":"Bad","cost":1,"sizes":["S"],"barcodes":[{"si":4,"code":"111"}]}')" 'has to point at one of the sizes'
|
||||
check "no item created by the rejected add" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["items"]))')" '^1$'
|
||||
|
||||
echo "== scan a size onto an existing product (catalog.variantAdd)"
|
||||
check "bind a code to an existing size" "$(mut catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\"S\",\"code\":\"9312345678891\"}")" '"si":0'
|
||||
check "existing size not duplicated" "$(curl -s -b "$J" "$B/api/backup" | py 'print(d["items"][0]["sizes"])')" "\['S', 'M', 'L'\]"
|
||||
R=$(mut catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\"2XL\",\"code\":\"9312345678914\"}")
|
||||
check "new size appended" "$R" '"si":3'
|
||||
check "new size reported as created" "$R" '"created":true'
|
||||
check "sizes now 4" "$(curl -s -b "$J" "$B/api/backup" | py 'print(d["items"][0]["sizes"])')" "\['S', 'M', 'L', '2XL'\]"
|
||||
check "3 barcodes bound" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["barcodes"]))')" '^3$'
|
||||
check "size is case-insensitive (no duplicate)" "$(mut catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\"2xl\",\"code\":\"9312345678921\"}")" '"si":3'
|
||||
check "still 4 sizes" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["items"][0]["sizes"]))')" '^4$'
|
||||
check "blank size rejected" "$(mut catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\" \",\"code\":\"9312345678938\"}")" 'Size required'
|
||||
|
||||
echo "== ambiguous bindings are refused"
|
||||
check "code already on another size" "$(mut barcode.bind "{\"itemId\":\"$IT\",\"si\":2,\"code\":\"9312345678907\"}")" 'is already on Scrub Top · size M'
|
||||
check "same code, same size is a no-op not an error" "$(mut barcode.bind "{\"itemId\":\"$IT\",\"si\":1,\"code\":\"9312345678907\"}")" '"ok":true'
|
||||
check "force moves it" "$(mut barcode.bind "{\"itemId\":\"$IT\",\"si\":2,\"code\":\"9312345678907\",\"force\":true}")" '"ok":true'
|
||||
check "moved to size index 2" "$(curl -s -b "$J" "$B/api/backup" | py 'print([b["sizeIndex"] for b in d["barcodes"] if b["code"]=="9312345678907"][0])')" '^2$'
|
||||
R2=$(mut catalog.add '{"item":"Cargo Pant","cost":30,"sizes":["S","M"]}')
|
||||
IT2=$(echo "$R2" | py 'print(d["result"]["id"])')
|
||||
SORT2=$(curl -s -b "$J" "$B/api/backup" | py "print([i['sort'] for i in d['items'] if i['id']=='$IT2'][0])")
|
||||
GEN=$((930000000 + SORT2 * 100 + 1))
|
||||
check "generated code of another item is refused" "$(mut barcode.bind "{\"itemId\":\"$IT\",\"si\":0,\"code\":\"$GEN\"}")" 'generated code for Cargo Pant'
|
||||
check "generated code onto its own item is fine" "$(mut barcode.bind "{\"itemId\":\"$IT2\",\"si\":1,\"code\":\"$GEN\"}")" '"ok":true'
|
||||
|
||||
echo "== printing our own barcode for a garment that arrived without one"
|
||||
# Whole ranges turn up unlabelled and a garment nobody can scan is invisible to a count. The number
|
||||
# has to be one GS1 will never issue to a manufacturer, so it is a real EAN-13 in the restricted
|
||||
# circulation range (prefix 29) — a genuine check digit, readable by any scanner in the building.
|
||||
RG=$(mut catalog.add '{"item":"Cafe Shirt","cost":34.1,"sizes":["8","10","12"]}')
|
||||
ITG=$(echo "$RG" | py 'print(d["result"]["id"])')
|
||||
check "a garment with no codes at all" "$(curl -s -b "$J" "$B/api/backup" | py "print(len([b for b in d['barcodes'] if b['itemId']=='$ITG']))")" '^0$'
|
||||
# One supplier code already on the middle size: generating must fill the gaps and leave that alone.
|
||||
check "one size already carries a supplier code" "$(mut barcode.bind "{\"itemId\":\"$ITG\",\"si\":1,\"code\":\"9312345678952\"}")" '"ok":true'
|
||||
G=$(mut barcode.generate "{\"itemId\":\"$ITG\"}")
|
||||
check "generates for the sizes that have none" "$G" '"count":2'
|
||||
check " and says which sizes it did" "$(echo "$G" | py 'print(sorted(x["size"] for x in d["result"]["made"]))')" "\['12', '8'\]"
|
||||
check "every generated code is a 13-digit 29 code" "$(echo "$G" | py 'print(all(len(x["code"])==13 and x["code"].isdigit() and x["code"].startswith("29") for x in d["result"]["made"]))')" '^True$'
|
||||
# A wrong check digit is a label that prints and then will not scan, which is worse than no label.
|
||||
check " with a valid check digit" "$(echo "$G" | py '
|
||||
def cd(f):
|
||||
return (10 - sum(int(c)*(1 if i%2==0 else 3) for i,c in enumerate(f[:12])) % 10) % 10
|
||||
print(all(cd(x["code"]) == int(x["code"][12]) for x in d["result"]["made"]))')" '^True$'
|
||||
check " and they are all different" "$(echo "$G" | py 'print(len({x["code"] for x in d["result"]["made"]}))')" '^2$'
|
||||
check "the supplier code was left where it was" "$(curl -s -b "$J" "$B/api/backup" | py "print([b['code'] for b in d['barcodes'] if b['itemId']=='$ITG' and b['sizeIndex']==1][0])")" '^9312345678952$'
|
||||
check "every size is now labelled" "$(curl -s -b "$J" "$B/api/backup" | py "print(len([b for b in d['barcodes'] if b['itemId']=='$ITG']))")" '^3$'
|
||||
# The two refusals differ only in their wording, and the wording is the only sign of which path
|
||||
# ran. A whole-garment answer to a one-size request means the si was ignored — and what follows
|
||||
# that is a whole rack relabelled when one hook was asked about.
|
||||
check "asking again says there is nothing to do" "$(mut barcode.generate "{\"itemId\":\"$ITG\"}")" 'Every size on this garment already has a barcode'
|
||||
check "and a single size that is taken says so too" "$(mut barcode.generate "{\"itemId\":\"$ITG\",\"si\":0}")" 'That size already has a barcode'
|
||||
# The generated code is a real binding, so it resolves on a scan like any other.
|
||||
GC=$(echo "$G" | py 'print([x["code"] for x in d["result"]["made"] if x["size"]=="8"][0])')
|
||||
check "a generated code scans back to its own size" "$(curl -s -b "$J" "$B/api/backup" | py "print([b['sizeIndex'] for b in d['barcodes'] if b['code']=='$GC'][0])")" '^0$'
|
||||
# One size at a time is the other half of the op, and proving it needs a garment with more than
|
||||
# one gap: somebody holding a single unlabelled size gets one label, not a fresh number printed
|
||||
# over every size on the garment.
|
||||
RC=$(mut catalog.add '{"item":"Theatre Cap","cost":6,"sizes":["S","M"]}')
|
||||
ITC=$(echo "$RC" | py 'print(d["result"]["id"])')
|
||||
G2=$(mut barcode.generate "{\"itemId\":\"$ITC\",\"si\":1}")
|
||||
check "one size asked for, one code made" "$G2" '"count":1'
|
||||
check " and it is the size that was asked for" "$(echo "$G2" | py 'print([x["size"] for x in d["result"]["made"]])')" "\['M'\]"
|
||||
check " and the other size is left unlabelled" "$(curl -s -b "$J" "$B/api/backup" | py "print([b['sizeIndex'] for b in d['barcodes'] if b['itemId']=='$ITC'])")" "\[1\]"
|
||||
|
||||
echo "== Set on hand records the difference, not the number"
|
||||
# Stock the hand-in pool first. An empty pool answers 0 whether Set left it alone or ate the lot,
|
||||
# and the seconds sitting in it were never on the shelf the count is correcting. Safe to leave in
|
||||
# place for the rest of the section: the pool sits outside on-hand.
|
||||
mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":3}]}" >/dev/null
|
||||
check "receive 3 onto a fresh size" "$(mut stock.moves "{\"mode\":\"Receive\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":3}]}")" '"ok":true'
|
||||
oh() { curl -s -b "$J" "$B/api/backup" | py "
|
||||
import sys
|
||||
st=[s for s in d['stock'] if s['itemId']=='$IT' and s['sizeIndex']==1]
|
||||
base=(st[0]['opening']+st[0]['adj']) if st else 0
|
||||
mv=sum(m['qty'] for m in d['moves'] if m['itemId']=='$IT' and m['sizeIndex']==1)
|
||||
iss=sum(i['qty'] for i in d['issues'] if i['itemId']=='$IT' and i['sizeIndex']==1 and not i.get('direct'))
|
||||
print(base+mv-iss)"; }
|
||||
check "on hand is 3" "$(oh)" '^3$'
|
||||
check "set it to 2 (the count)" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":2}]}")" '"ok":true'
|
||||
check "on hand is now 2, not 5" "$(oh)" '^2$'
|
||||
check "the difference was recorded as -1" "$(curl -s -b "$J" "$B/api/backup" | py "print([m['qty'] for m in d['moves'] if m['itemId']=='$IT' and m['sizeIndex']==1][-1])")" '^-1$'
|
||||
check "recorded as an adjust with a reason" "$(curl -s -b "$J" "$B/api/backup" | py "m=[m for m in d['moves'] if m['itemId']=='$IT' and m['sizeIndex']==1][-1]; print(m['type'], m['reason'])")" 'adjust Counted correction'
|
||||
check "setting to the same number records nothing" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":2}]}" >/dev/null; curl -s -b "$J" "$B/api/backup" | py "print(len([m for m in d['moves'] if m['itemId']=='$IT' and m['sizeIndex']==1]))")" '^2$'
|
||||
check "set upward to 9 works too" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":9}]}" >/dev/null; oh)" '^9$'
|
||||
check "set back to 0 empties the line" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":0}]}" >/dev/null; oh)" '^0$'
|
||||
check "negative count refused" "$(mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":-2}]}")" "can't be negative"
|
||||
check "bad mode still refused" "$(mut stock.moves "{\"mode\":\"Nonsense\",\"lines\":[{\"itemId\":\"$IT\",\"si\":1,\"qty\":1}]}")" 'Bad mode'
|
||||
check "Set does not touch the pre-loved pool" "$(curl -s -b "$J" "$B/api/backup" | py "print(sum(s['preloved'] for s in d['stock'] if s['itemId']=='$IT'))")" '^3$'
|
||||
# On a size that carries a real opening balance, counted to a different figure, so the answer
|
||||
# can't be mistaken for either number. Size 0 is untouched by the size-1 arithmetic above.
|
||||
mut stock.moves "{\"mode\":\"Opening\",\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":5}]}" >/dev/null
|
||||
mut stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":2}]}" >/dev/null
|
||||
check "Set does not rewrite the opening balance" "$(curl -s -b "$J" "$B/api/backup" | py "print([s['opening'] for s in d['stock'] if s['itemId']=='$IT' and s['sizeIndex']==0][0])")" '^5$'
|
||||
|
||||
echo "== duplicate a garment for another colour"
|
||||
check "reorder level on the source" "$(mut stock.reorder "{\"itemId\":\"$IT\",\"si\":0,\"reorder\":6}")" '"ok":true'
|
||||
# Give the source a real product type before copying it. Type is what files a garment under Tops
|
||||
# or Bottoms, so a duplicate that quietly drops it puts the new colour under the wrong heading —
|
||||
# and a blank type on both sides would agree with itself and prove nothing.
|
||||
check "product type on the source" "$(mut catalog.update "{\"id\":\"$IT\",\"type\":\"Scrub top\"}")" '"ok":true'
|
||||
R=$(mut catalog.duplicate "{\"id\":\"$IT\",\"item\":\"Scrub Top — EN (Navy)\",\"group\":\"Enrolled Nurse\",\"sku\":\"ST-EN\"}")
|
||||
check "duplicate created" "$R" '"id"'
|
||||
check "reports the size count" "$R" '"sizes":4'
|
||||
DUP=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
check "name applied" "$(echo "$BK" | py "print([i['item'] for i in d['items'] if i['id']=='$DUP'][0])")" 'Scrub Top — EN (Navy)'
|
||||
check "group applied" "$(echo "$BK" | py "print([i['group'] for i in d['items'] if i['id']=='$DUP'][0])")" '^Enrolled Nurse$'
|
||||
check "sku applied" "$(echo "$BK" | py "print([i['sku'] for i in d['items'] if i['id']=='$DUP'][0])")" '^ST-EN$'
|
||||
check "sizes copied" "$(echo "$BK" | py "print([i['sizes'] for i in d['items'] if i['id']=='$DUP'][0] == [i['sizes'] for i in d['items'] if i['id']=='$IT'][0])")" '^True$'
|
||||
check "cost/supplier/gender/type copied" "$(echo "$BK" | py "src=[i for i in d['items'] if i['id']=='$IT'][0]; c=[i for i in d['items'] if i['id']=='$DUP'][0]; print(c['cost']==src['cost'] and c['supplier']==src['supplier'] and c['gender']==src['gender'] and c['type']==src['type']=='Scrub top')")" '^True$'
|
||||
check "reorder level carried over" "$(echo "$BK" | py "print([s['reorder'] for s in d['stock'] if s['itemId']=='$DUP' and s['sizeIndex']==0][0])")" '^6$'
|
||||
check "NO barcodes copied" "$(echo "$BK" | py "print(len([b for b in d['barcodes'] if b['itemId']=='$DUP']))")" '^0$'
|
||||
check "NO stock copied" "$(echo "$BK" | py "print(sum(s['opening']+s['adj'] for s in d['stock'] if s['itemId']=='$DUP'))")" '^0$'
|
||||
# Every one of the source's codes by name: a duplicate that dragged three of the four across to
|
||||
# the new colour would leave one behind and satisfy a mere "still has some".
|
||||
check "source untouched" "$(echo "$BK" | py "print(sorted(b['code'] for b in d['barcodes'] if b['itemId']=='$IT'))")" "\['9312345678891', '9312345678907', '9312345678914', '9312345678921'\]"
|
||||
check "same name and group refused" "$(mut catalog.duplicate "{\"id\":\"$IT\",\"item\":\"Scrub Top — EN (Navy)\",\"group\":\"Enrolled Nurse\"}")" 'already exists for Enrolled Nurse'
|
||||
check "same name, different group allowed" "$(mut catalog.duplicate "{\"id\":\"$IT\",\"item\":\"Scrub Top — EN (Navy)\",\"group\":\"Registered Nurse\"}")" '"id"'
|
||||
check "unknown source refused" "$(mut catalog.duplicate '{"id":"nope","item":"X"}')" 'Unknown catalogue item'
|
||||
check "each colour keeps its own barcode" "$(mut barcode.bind "{\"itemId\":\"$DUP\",\"si\":0,\"code\":\"9300222000003\"}")" '"ok":true'
|
||||
check "binding the colour did not move the source's code" "$(curl -s -b "$J" "$B/api/backup" | py "print(len([b for b in d['barcodes'] if b['itemId']=='$IT'])==4 and [(b['code'],b['sizeIndex']) for b in d['barcodes'] if b['itemId']=='$DUP']==[('9300222000003', 0)])")" '^True$'
|
||||
|
||||
echo "== product lookup is off until an admin turns it on"
|
||||
check "lookup default off" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907" | py 'print(d["enabled"])')" '^False$'
|
||||
check "off explains itself" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907")" 'Product lookup is off'
|
||||
check "GTIN still decoded while off" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907" | py 'print(d["gtin"]["kind"], d["gtin"]["valid"], d["gtin"]["origin"])')" 'EAN-13 True Australia'
|
||||
check "bad check digit flagged" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678901" | py 'print(d["gtin"]["valid"])')" '^False$'
|
||||
check "turn lookup on" "$(mut settings.update '{"barcodeLookup":true}')" '"ok":true'
|
||||
check "lookup now enabled" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907" | py 'print(d["enabled"])')" '^True$'
|
||||
check "mis-read code is never sent out" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678901")" "check digit doesn't match"
|
||||
check "non-GTIN code is never sent out" "$(curl -s -b "$J" "$B/api/lookup?code=ABC123")" 'Not a standard retail barcode'
|
||||
check "signed-out lookup refused" "$(curl -s "$B/api/lookup?code=9312345678907")" 'Not signed in'
|
||||
# The public barcode databases belong to somebody else: they go down, they run the day's free
|
||||
# quota out, and the linen room still has to be told something. Either a name came back or a note
|
||||
# says in plain words why not — never a blank answer and never a stack trace.
|
||||
check "lookup answers even when the public product database doesn't" "$(curl -s -b "$J" "$B/api/lookup?code=9312345678907" | py 'print(d["enabled"], bool(d.get("name") or d.get("note")))')" '^True True$'
|
||||
check "turn lookup back off" "$(mut settings.update '{"barcodeLookup":false}')" '"ok":true'
|
||||
|
||||
echo "== issuer can scan but not bind"
|
||||
check "add issuer" "$(mut users.add "{\"first\":\"Iss\",\"last\":\"Uer\",\"email\":\"iss$TS@example.com\",\"password\":\"password123\",\"role\":\"ISSUER\"}")" '"ok":true\|"id"'
|
||||
check "issuer login" "$(curl -s -c "$J2" -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "$XFF" -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "issuer can't bind" "$(mut2 barcode.bind "{\"itemId\":\"$IT\",\"si\":0,\"code\":\"9300000000000\"}")" 'Admin only'
|
||||
check "issuer can't add a variant" "$(mut2 catalog.variantAdd "{\"itemId\":\"$IT\",\"size\":\"4XL\",\"code\":\"9300000000001\"}")" 'Admin only'
|
||||
check "issuer can't duplicate" "$(mut2 catalog.duplicate "{\"id\":\"$IT\",\"item\":\"Sneaky\"}")" 'Admin only'
|
||||
check "issuer can't set a counted quantity" "$(mut2 stock.moves "{\"mode\":\"Set\",\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":1}]}")" 'Admin only'
|
||||
check "issuer can't look up" "$(curl -s -b "$J2" "$B/api/lookup?code=9312345678907")" 'Admin only'
|
||||
check "issuer can't reset the facility" "$(mut2 data.reset '{"confirm":"RESET"}')" 'Admin only'
|
||||
|
||||
echo "== product type + opening stock on create"
|
||||
R=$(mut catalog.add '{"item":"Ward Dress","type":"Dress","cost":40,"sizes":["8","10","12"],"barcodes":[{"si":0,"code":"9300111000005"},{"si":1,"code":"9300111000012"}],"opening":[{"si":0,"qty":4},{"si":2,"qty":7}]}')
|
||||
check "created with type, barcodes and opening" "$R" '"id"'
|
||||
DR=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
check "type stored" "$(echo "$BK" | py "print([i['type'] for i in d['items'] if i['id']=='$DR'][0])")" '^Dress$'
|
||||
check "two barcodes bound on create" "$(echo "$BK" | py "print(sorted((b['code'],b['sizeIndex']) for b in d['barcodes'] if b['itemId']=='$DR'))")" "\[('9300111000005', 0), ('9300111000012', 1)\]"
|
||||
check "opening set on size 0 and 2" "$(echo "$BK" | py "print(sorted((s['sizeIndex'],s['opening']) for s in d['stock'] if s['itemId']=='$DR'))")" "\[(0, 4), (2, 7)\]"
|
||||
check "size with no opening has no level row" "$(echo "$BK" | py "print(len([s for s in d['stock'] if s['itemId']=='$DR']))")" '^2$'
|
||||
check "duplicate barcode across sizes rejected" "$(mut catalog.add '{"item":"Dup","cost":1,"sizes":["S","M"],"barcodes":[{"si":0,"code":"9300111000029"},{"si":1,"code":"9300111000029"}]}')" 'same barcode is on two sizes'
|
||||
check "barcode already on another garment rejected at create" "$(mut catalog.add '{"item":"Steal","cost":1,"sizes":["S"],"barcodes":[{"si":0,"code":"9300111000005"}]}')" 'is already on Ward Dress'
|
||||
check "negative opening rejected" "$(mut catalog.add '{"item":"Neg","cost":1,"sizes":["S"],"opening":[{"si":0,"qty":-3}]}')" "can't be negative"
|
||||
check "opening pointing at a missing size rejected" "$(mut catalog.add '{"item":"Bad","cost":1,"sizes":["S"],"opening":[{"si":5,"qty":1}]}')" 'has to point at one of the sizes'
|
||||
check "nothing created by the rejected adds" "$(curl -s -b "$J" "$B/api/backup" | py "print(len([i for i in d['items'] if i['item'] in ('Dup','Steal','Neg','Bad')]))")" '^0$'
|
||||
|
||||
echo "== product type drives the tops/pants split"
|
||||
check "type can be edited" "$(mut catalog.update "{\"id\":\"$DR\",\"type\":\"Tunic\"}")" '"ok":true'
|
||||
check "type updated" "$(curl -s -b "$J" "$B/api/backup" | py "print([i['type'] for i in d['items'] if i['id']=='$DR'][0])")" '^Tunic$'
|
||||
check "type survives a CSV import column" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Cargo Pant CSV","type":"Cargo pants","cost":"30","sizes":"77|82"}]}')" '"created":1'
|
||||
check "imported type stored" "$(curl -s -b "$J" "$B/api/backup" | py "print([i['type'] for i in d['items'] if i['item']=='Cargo Pant CSV'][0])")" '^Cargo pants$'
|
||||
|
||||
echo "== backup round-trip keeps type"
|
||||
BK2=$(curl -s -b "$J" "$B/api/backup")
|
||||
echo "$BK2" > "$T/tc-scan-backup.json"
|
||||
check "restore" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$(cat "$T/tc-scan-backup.json")}")" '"ok":true'
|
||||
check "type survived restore" "$(curl -s -b "$J" "$B/api/backup" | py "print(sorted(i['type'] for i in d['items'] if i['type']))")" "Cargo pants.*Tunic"
|
||||
# Restore re-creates every row with fresh ids, so re-resolve the item the later checks use.
|
||||
IT=$(curl -s -b "$J" "$B/api/backup" | py 'print([i["id"] for i in d["items"] if i["sku"]=="ST-1"][0])')
|
||||
|
||||
echo "== start fresh (data.reset)"
|
||||
check "dept" "$(mut dept.save '{"name":"Ward 9","cc":"900"}')" '"ok":true'
|
||||
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"77","first":"Sam","last":"Reed","group":"Registered Nurse","dept":"Ward 9","top":"M","pants":"M"}]}')" '"created":1'
|
||||
ST=$(curl -s -b "$J" "$B/api/backup" | py 'print(d["staff"][0]["id"])')
|
||||
check "opening balance" "$(mut stock.moves "{\"mode\":\"Opening\",\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":10}]}")" '"ok":true'
|
||||
check "an issue exists" "$(mut issue.create "{\"staffId\":\"$ST\",\"apDeduct\":0,\"lines\":[{\"itemId\":\"$IT\",\"si\":0,\"qty\":1,\"src\":\"shelf\"}]}")" '"ok":true\|"id"'
|
||||
check "wrong confirmation refused" "$(mut data.reset '{"confirm":"reset"}')" 'Type RESET to confirm'
|
||||
check "data still there" "$(curl -s -b "$J" "$B/api/backup" | py 'print(len(d["items"]), len(d["staff"]), len(d["issues"]))')" '^8 1 1$'
|
||||
check "reset" "$(mut data.reset '{"confirm":"RESET"}')" '"ok":true'
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
check "catalogue empty" "$(echo "$BK" | py 'print(len(d["items"]))')" '^0$'
|
||||
check "barcodes empty" "$(echo "$BK" | py 'print(len(d["barcodes"]))')" '^0$'
|
||||
check "stock levels empty" "$(echo "$BK" | py 'print(len(d["stock"]))')" '^0$'
|
||||
check "staff empty" "$(echo "$BK" | py 'print(len(d["staff"]))')" '^0$'
|
||||
check "departments empty" "$(echo "$BK" | py 'print(len(d["depts"]))')" '^0$'
|
||||
check "suppliers empty" "$(echo "$BK" | py 'print(len(d["suppliers"]))')" '^0$'
|
||||
check "issues empty" "$(echo "$BK" | py 'print(len(d["issues"]))')" '^0$'
|
||||
check "orders empty" "$(echo "$BK" | py 'print(len(d["orders"]))')" '^0$'
|
||||
check "moves empty" "$(echo "$BK" | py 'print(len(d["moves"]))')" '^0$'
|
||||
check "facility name kept" "$(echo "$BK" | py 'print(d["facility"]["name"])')" "Scan Hospital $TS"
|
||||
check "staff groups kept" "$(echo "$BK" | py 'print(len(d["facility"]["staffGroups"])>0)')" '^True$'
|
||||
check "numbering restarted" "$(echo "$BK" | py 'print(d["facility"]["orderSeq"], d["facility"]["catalogSeq"])')" '^0 0$'
|
||||
check "logins kept — admin still signed in" "$(mut dept.save '{"name":"Ward 1","cc":"100"}')" '"ok":true'
|
||||
check "first product after reset is sort 1" "$(mut catalog.add '{"item":"Fresh Top","cost":10,"sizes":["M"]}' >/dev/null; curl -s -b "$J" "$B/api/backup" | py 'print(d["items"][0]["sort"])')" '^1$'
|
||||
check "keepSuppliers option" "$(mut supplier.add '{"name":"Keepme"}' >/dev/null; mut data.reset '{"confirm":"RESET","keepSuppliers":true}')" '"ok":true'
|
||||
check "supplier survived" "$(curl -s -b "$J" "$B/api/backup" | py 'print([x["name"] for x in d["suppliers"]])')" 'Keepme'
|
||||
|
||||
echo
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# Security hardening checks: headers, CSRF/origin gating, session invalidation on password change, rate limits,
|
||||
# demo session guard, proxy bad-cookie recovery, receipt cost validation, admin-only write-offs, locked orders, hand-in/return double-count guards.
|
||||
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}; J="$T/tc-sec-cj.txt"; rm -f "$J"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 300)"; fi; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
bk() { curl -s -b "$J" "$B/api/backup"; }
|
||||
|
||||
echo "== headers"
|
||||
H=$(curl -s -D - -o /dev/null "$B/" | tr A-Z a-z)
|
||||
check "CSP present" "$H" 'content-security-policy: .*frame-ancestors .none.'
|
||||
check "HSTS" "$H" 'strict-transport-security'
|
||||
check "nosniff" "$H" 'x-content-type-options: nosniff'
|
||||
check "no x-powered-by" "$(echo "$H" | grep -ci 'x-powered-by' || true)" '^0$'
|
||||
check "referrer policy" "$H" 'referrer-policy'
|
||||
|
||||
echo "== CSRF / origin gating"
|
||||
TS=$(date +%s); EMAIL="sec$TS@example.com"
|
||||
check "cross-site signup refused" "$(curl -s -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H 'origin: https://evil.example' -d '{}')" 'Cross-site'
|
||||
check "sec-fetch-site cross-site refused" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H 'sec-fetch-site: cross-site' -d '{}')" 'Cross-site'
|
||||
check "text/plain form body refused" "$(curl -s -X POST "$B/api/auth/login" -H 'content-type: text/plain' -d '{"email":"a@b.c","password":"x"}')" 'Expected JSON'
|
||||
check "signup ok" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.9.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Sec\",\"last\":\"Admin\",\"facility\":\"Sec Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check "mutate cross-site refused" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -H 'origin: https://evil.example' -d '{"op":"settings.update","payload":{}}')" 'Cross-site'
|
||||
check "mutate same-origin ok" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -H "origin: $B" -H 'sec-fetch-site: same-origin' -d '{"op":"settings.update","payload":{"coordinator":"X"}}')" '"ok":true'
|
||||
check "logout cross-site refused" "$(curl -s -b "$J" -X POST "$B/api/auth/logout" -H 'origin: https://evil.example')" 'Cross-site'
|
||||
|
||||
echo "== session bound to password"
|
||||
OLD=$(grep tc_session "$J" | awk '{print $7}')
|
||||
check "old token works" "$(curl -s -b "tc_session=$OLD" -o /dev/null -w '%{http_code}' "$B/api/backup")" '200'
|
||||
check "change password" "$(mut me.password '{"current":"password123","next":"password456"}')" '"ok":true'
|
||||
check "old token dead after password change" "$(curl -s -b "tc_session=$OLD" -o /dev/null -w '%{http_code}' "$B/api/backup")" '401'
|
||||
check "login with new password" "$(curl -s -c "$J" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"$EMAIL\",\"password\":\"password456\"}")" '"ok":true'
|
||||
# Two separate promises, so two separate checks: joined by an alternation, a proxy that sent the
|
||||
# visitor somewhere else entirely still passed as long as it dropped the stale cookie.
|
||||
BC=$(curl -s -D - -o /dev/null -b 'tc_session=a.!!!' "$B/app" | tr A-Z a-z)
|
||||
check "bad cookie → redirect to /auth (not 500)" "$BC" 'location: /auth?next=%2fapp'
|
||||
check "bad cookie cleared" "$BC" 'set-cookie: tc_session=;'
|
||||
|
||||
echo "== demo session guard"
|
||||
check "signed-in user can't be swapped into demo" "$(curl -s -D - -o /dev/null -b "$J" "$B/api/auth/demo?as=admin" | tr A-Z a-z)" 'location: /demo?signedin=1'
|
||||
check "cross-site demo link bounces to /demo" "$(curl -s -D - -o /dev/null -H 'sec-fetch-site: cross-site' "$B/api/auth/demo?as=admin" | tr A-Z a-z)" 'location: /demo.\?$'
|
||||
|
||||
echo "== rate limits"
|
||||
# A source address of its own for each run of each spray. Both ceilings are per-IP and held in
|
||||
# memory for the life of the server, so a second run inside the window would start part-way up the
|
||||
# bucket and the exact counts below would be wrong through no fault of the limiter.
|
||||
SIP="10.7.$((RANDOM%250)).$((RANDOM%250))"; LIP="10.8.$((RANDOM%250)).$((RANDOM%250))"
|
||||
N=0; for i in $(seq 1 7); do R=$(curl -s -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: $SIP" -d "{\"first\":\"A\",\"last\":\"B\",\"facility\":\"RL$TS$i\",\"email\":\"rl$TS$i@example.com\",\"password\":\"password123\"}"); echo "$R" | grep -q 'Too many' && N=$((N+1)); done
|
||||
# Sign-up counts every attempt, so seven tries against a ceiling of five must be refused exactly
|
||||
# twice. One refusal would mean the ceiling had crept up to six new facilities an hour per
|
||||
# connection; seven would mean it now turns everybody away.
|
||||
check "signup rate-limited (5/h per IP)" "$N" '^2$'
|
||||
N=0; for i in $(seq 1 45); do R=$(curl -s -X POST "$B/api/auth/login" -H 'content-type: application/json' -H "x-forwarded-for: $LIP" -d "{\"email\":\"spray$i@example.com\",\"password\":\"x\"}"); echo "$R" | grep -q 'Too many' && N=$((N+1)); done
|
||||
# Sign-in counts only the attempts that FAILED, and a refusal is not itself counted (lib/ratelimit.ts),
|
||||
# so 45 sprayed passwords against a 40-failure ceiling must leave exactly the last 5 refused.
|
||||
check "login spray limited per IP" "$N" '^5$'
|
||||
|
||||
echo "== ops hardening"
|
||||
check "supplier" "$(mut supplier.add '{"name":"Alpha"}')" '"id"'
|
||||
check "dept" "$(mut dept.save '{"name":"Ward 1","cc":"100"}')" '"ok":true'
|
||||
# For every group: Sam is in Security, and everything below issues this top to him. Tagged for one
|
||||
# group of nurses it would be refused at the counter as outside his staff group, and the hand-in and
|
||||
# return guards this section is about would never be reached.
|
||||
check "catalog" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Uniform Top","sku":"T1","supplier":"Alpha","cost":"30","group":"All","sizes":"S|M"}]}')" '"created":1'
|
||||
check "opening" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"T1","size":"M","opening":"5"}]}')" '"created":1'
|
||||
check "staff" "$(mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"Sam","last":"Guard","group":"Security","dept":"Ward 1","ent":"3","top":"M","pants":"M"}]}')" '"created":1'
|
||||
BK=$(bk); T1=$(echo "$BK" | py 'print(d["items"][0]["id"])'); SAM=$(echo "$BK" | py 'print(d["staff"][0]["id"])')
|
||||
R=$(mut order.create "{\"orderFor\":\"Stock\",\"supplier\":\"Alpha\",\"lines\":[{\"itemId\":\"$T1\",\"size\":\"M\",\"qty\":4}]}"); ORD=$(echo "$R" | py 'print(d["result"]["id"])')
|
||||
check "mark ordered" "$(mut order.status "{\"id\":\"$ORD\",\"status\":\"Ordered\"}")" '"ok":true'
|
||||
LID=$(bk | py 'print([o for o in d["orders"] if o["id"]=="'$ORD'"][0]["lines"][0]["id"])')
|
||||
check "negative invoiced cost rejected" "$(mut order.receive "{\"id\":\"$ORD\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":1,\"dest\":\"shelf\",\"cost\":-5}]}")" 'between'
|
||||
check "over-delivery rejected" "$(mut order.receive "{\"id\":\"$ORD\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":9,\"dest\":\"shelf\"}]}")" 'outstanding'
|
||||
check "receive ok" "$(mut order.receive "{\"id\":\"$ORD\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":4,\"dest\":\"shelf\"}]}")" '"ok":true'
|
||||
check "received order locked" "$(mut order.update "{\"id\":\"$ORD\",\"ref\":\"hack\"}")" 'locked'
|
||||
check "issue 1 from stock" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")" '"stock":1'
|
||||
ISS=$(bk | py 'print(d["issues"][0]["id"])')
|
||||
# setsBack, not good: `good` is the posted quantity echoed straight back, so it survives the
|
||||
# matching loop being deleted outright. setsBack counts the garments that earned credit, and only a
|
||||
# real, un-returned, non-pre-loved past issue produces one.
|
||||
check "hand-in with credit (matched real issue)" "$(mut handin.add "{\"staffId\":\"$SAM\",\"credit\":true,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"cond\":\"Good\"}]}")" '"setsBack":1'
|
||||
check "credited qty recorded = 1" "$(bk | py 'print(d["handins"][0]["lines"][0]["credited"])')" '^1$'
|
||||
check "return of handed-in issue refused" "$(mut issue.return "{\"id\":\"$ISS\",\"cond\":\"Returned - Good\"}")" 'handed in'
|
||||
check "pre-loved issue" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"preloved\"}]}")" '"preloved":1'
|
||||
# The backup lists hand-ins in no particular order, so "one of the two earned credit" was the most
|
||||
# a comparison of the whole list could say — and which one is the entire point of the check. Pick
|
||||
# this hand-in out by its own id instead.
|
||||
H2=$(mut handin.add "{\"staffId\":\"$SAM\",\"credit\":true,\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"cond\":\"Good\"}]}")
|
||||
check "hand-in of pre-loved earns no credit" "$H2" '"setsBack":0'
|
||||
H2ID=$(echo "$H2" | py 'print(d["result"]["id"])')
|
||||
check "pre-loved hand-in credited 0" "$(bk | py 'print([l["credited"] for h in d["handins"] if h["id"]=="'$H2ID'" for l in h["lines"]])')" '^\[0\]$'
|
||||
# That hand-in took the pre-loved top back, so it cannot also be returned — counting one garment into
|
||||
# the pool twice is what this used to pass on. Put one more in the pool, issue it, and return that.
|
||||
mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1}]}" >/dev/null
|
||||
check "second pre-loved issue" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1,\"src\":\"preloved\"}]}")" '"preloved":1'
|
||||
PLI=$(bk | py 'print([i["id"] for i in d["issues"] if i["preloved"] and not i["handedIn"] and not i["returnedDate"]][0])')
|
||||
POOL0=$(bk | py 'print(next(s["preloved"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')
|
||||
check "returned-good pre-loved goes back to pool" "$(mut issue.return "{\"id\":\"$PLI\",\"cond\":\"Returned - Good\"}" >/dev/null; bk | py 'print(next(s["preloved"] for s in d["stock"] if s["itemId"]=="'$T1'" and s["sizeIndex"]==1))')" "^$((POOL0+1))$"
|
||||
check "add issuer" "$(mut users.add "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\",\"first\":\"I\",\"last\":\"S\",\"role\":\"ISSUER\"}")" '"id"'
|
||||
J2="$T/tc-sec-cj2.txt"; rm -f "$J2"; curl -s -c "$J2" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"iss$TS@example.com\",\"password\":\"password123\"}" >/dev/null
|
||||
check "issuer cannot write off (Adjust)" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"stock.moves\",\"payload\":{\"mode\":\"Adjust\",\"reason\":\"x\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":-3}]}}")" 'Admin only'
|
||||
check "issuer can still receive" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"stock.moves\",\"payload\":{\"mode\":\"Receive\",\"lines\":[{\"itemId\":\"$T1\",\"si\":1,\"qty\":1}]}}")" '"ok":true'
|
||||
# Photos are no longer part of this: a file carrying more images than we will take back has
|
||||
# them trimmed and counted rather than being refused outright, so that a room restoring a
|
||||
# year of records is not left with nothing over a pile of signatures. The RECORD caps still
|
||||
# refuse, and they are the ones standing between a hostile file and a million rows built in
|
||||
# memory — items is checked before the envelope is even validated.
|
||||
check "restore caps enforced" "$(python3 -c 'import json; print(json.dumps({"op":"backup.restore","payload":{"format":"threadcount-backup-v2","items":[{}]*5001}}))' | curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' --data-binary @-)" 'too many items'
|
||||
check "import row cap" "$(python3 -c 'import json; print(json.dumps({"op":"import.rows","payload":{"kind":"depts","rows":[{"name":"x","cc":"1"}]*20001}}))' | curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d @-)" 'at most 20,000'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
Executable
+817
@@ -0,0 +1,817 @@
|
||||
#!/usr/bin/env bash
|
||||
# The uniform ceiling: six sets HELD at any time, for every staff group, nursing included. There is no
|
||||
# financial year in it — room comes back only by handing something in, and past six takes a coordinator
|
||||
# override that is recorded as one. Every refusal below is earned by the ceiling and by nothing else,
|
||||
# and each sits beside the collection one garment short of it, so a check that matched everything would
|
||||
# fail its neighbour.
|
||||
#
|
||||
# The routes up to that ceiling are the facility's own answer. It names its staff groups and puts each
|
||||
# one on the FTE table, the starting kit or manager approval, and nothing reads the route off the
|
||||
# letters in a group's name — the same job is "Housekeeping" in one building and "Support Services" in
|
||||
# the next. The last three sections move one group between routes and read what its wearer is offered
|
||||
# after each move, so a screen that ignored the setting would give the same answer twice and fail one
|
||||
# of the pair.
|
||||
#
|
||||
# The closing sections are the staff groups a garment is tagged with. A garment can be for several
|
||||
# groups, and anybody is offered their own group's garments plus those for every group: the staff app
|
||||
# refuses the rest outright, and the counter hands them over only on the coordinator's override,
|
||||
# recorded apart from an override of the ceiling. Every refusal there sits beside the same ask from
|
||||
# somebody whose group the garment is for. The counter's own request door and every order raised for
|
||||
# a person refuse the same garments, with no override of their own; what the counter did issue on the
|
||||
# override stays marked through a collection, a ward round and a partial hand-in.
|
||||
#
|
||||
# The next sections are what happens around those rules: one refusal for a cart both outside the group
|
||||
# and past six, an approved bag handed over after its wearer changed group (marked, beside one that is
|
||||
# not), a queue place for another group's garment that can't be offered, and a group renamed with the
|
||||
# garments tagged for it.
|
||||
#
|
||||
# Then the cut of uniform somebody is offered — the staff record's Uniform style — asked at every door
|
||||
# the staff group is asked at, because it is the same rule about a different column: Men's sees the
|
||||
# men's cut and the unisex range, Women's the women's and the unisex, Either the lot, and blank, which
|
||||
# is what every record on every register reads until a coordinator says otherwise, is offered
|
||||
# everything exactly as it is today. Every refusal there sits beside the same ask from somebody whose
|
||||
# cut it is, and beside a blank record being handed the very garment the refusal is about.
|
||||
#
|
||||
# Then the two things that happen to a marked row afterwards, in the same order the staff group's
|
||||
# sections take them: an approved bag handed over after its wearer was set to another cut, at the
|
||||
# counter and on the ward round alike (marked, beside one whose wearer was left as she was), and a
|
||||
# garment issued on the override then split by a hand-in, a return and a swap for another size —
|
||||
# every half of every split still marked, the new-size row included, beside a garment of the same
|
||||
# wearer's own cut put through the very same three splits and marked on none of them.
|
||||
#
|
||||
# Last, a backup restore that keeps a garment's groups, both marks on every issue row, and the style
|
||||
# on every staff record.
|
||||
set -u
|
||||
B=${BASE:-http://127.0.0.1:3111}
|
||||
. "$(dirname "$0")/e2e-preflight.sh"; e2e_preflight "$B"
|
||||
T=${TMP:-/tmp}; J="$T/tc-sets-cj.txt"; W="$T/tc-sets-wearer.txt"; rm -f "$J" "$W"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$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 300)"; fi; }
|
||||
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 300)"; else ok "$name"; fi; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
bk() { curl -s -b "$J" "$B/api/backup"; }
|
||||
L() { echo "{\"itemId\":\"$1\",\"si\":0,\"qty\":$2,\"src\":\"$3\"}"; }
|
||||
issue(){ mut issue.create "{\"staffId\":\"$1\",\"lines\":[$2]${3:-}}"; }
|
||||
handin(){ mut handin.add "{\"staffId\":\"$1\",\"credit\":false,\"lines\":[{\"itemId\":\"$2\",\"si\":0,\"qty\":1,\"cond\":\"Good\",\"laundered\":true}]}"; }
|
||||
# What somebody still has of one garment: not returned, not handed in.
|
||||
held() { bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$1'" and i["itemId"]=="'$2'" and not i["handedIn"] and not i["returnedDate"]))'; }
|
||||
overq(){ bk | py 'print(sum(i["qty"] for i in d["issues"] if i["staffId"]=="'$1'" and i["override"]))'; }
|
||||
# The facility's three lists as stored, in their stored order: staff groups | FTE table | starting kit.
|
||||
groups(){ bk | py 'f=d["facility"]; print("|".join(",".join(f[k]) for k in ("staffGroups","nursingGroups","kitGroups")))'; }
|
||||
CAP='the most anyone holds is 6 sets'
|
||||
|
||||
TS=$(date +%s)
|
||||
check "signup" "$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.9.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Sets\",\"last\":\"Admin\",\"facility\":\"Sets Hospital $TS\",\"email\":\"sets$TS@example.com\",\"password\":\"password123\"}")" '"ok":true'
|
||||
# A new facility arrives with no staff groups, and with no group on the FTE table or the starting kit:
|
||||
# any list the product shipped would be one employer's job titles, handed to a hotel. Read before the
|
||||
# coordinator names them and again after, so a signup that went back to writing a list of its own
|
||||
# fails here instead of being quietly overwritten by the setup.
|
||||
check "a brand-new facility has no staff groups, and no group on either route" "$(groups)" '^||$'
|
||||
check "the coordinator names this facility's groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
check " and they are the facility's from then on" "$(groups)" '^Registered Nurse,Enrolled Nurse,Support Services,Kitchen,Security|Registered Nurse,Enrolled Nurse|Support Services$'
|
||||
mut supplier.add '{"name":"Alpha Supply"}' >/dev/null
|
||||
mut dept.save '{"name":"Ward 1","cc":"100"}' >/dev/null
|
||||
# Types are given outright rather than left to the garment's name, so which half of a set each one is
|
||||
# never depends on a word in its title.
|
||||
mut import.rows '{"kind":"catalog","rows":[{"item":"Uniform Top","sku":"UT","type":"Scrub top","supplier":"Alpha Supply","cost":"30","group":"All","sizes":"M"},{"item":"Uniform Pant","sku":"UP","type":"Pants","supplier":"Alpha Supply","cost":"25","group":"All","sizes":"M"},{"item":"Fleece Jacket","sku":"FJ","type":"Fleece","supplier":"Alpha Supply","cost":"50","group":"All","sizes":"M"}]}' >/dev/null
|
||||
mut import.rows '{"kind":"opening","rows":[{"sku":"UT","size":"M","opening":"60"},{"sku":"UP","size":"M","opening":"60"},{"sku":"FJ","size":"M","opening":"20"}]}' >/dev/null
|
||||
# Owen is on the starting kit because this facility put Support Services there, not because of any
|
||||
# word in the name. Ella's register figure of 1 is the old yearly number. It is there to be ignored.
|
||||
mut import.rows '{"kind":"staff","rows":[{"num":"1","first":"Owen","last":"Ops","group":"Support Services","dept":"Ward 1","top":"M","pants":"M"},{"num":"2","first":"Nora","last":"Nurse","group":"Registered Nurse","dept":"Ward 1","fte":"1.0","top":"M","pants":"M"},{"num":"3","first":"Kira","last":"Kitchen","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M"},{"num":"4","first":"Ella","last":"Kitchen","group":"Kitchen","dept":"Ward 1","ent":"1","top":"M","pants":"M"},{"num":"5","first":"Sam","last":"Guard","group":"Security","dept":"Ward 1","top":"M","pants":"M"}]}' >/dev/null
|
||||
BK=$(bk)
|
||||
check "setup: three garments, five people" "$(echo "$BK" | py 'print(len(d["items"]), len(d["staff"]))')" '^3 5$'
|
||||
sku(){ echo "$BK" | py 'print([i["id"] for i in d["items"] if i["sku"]=="'$1'"][0])'; }
|
||||
num(){ echo "$BK" | py 'print([s["id"] for s in d["staff"] if s["num"]=="'$1'"][0])'; }
|
||||
UT=$(sku UT); UP=$(sku UP); FJ=$(sku FJ)
|
||||
OWEN=$(num 1); NORA=$(num 2); KIRA=$(num 3); ELLA=$(num 4); SAM=$(num 5)
|
||||
|
||||
echo "== a new starter's kit is an ordinary issue"
|
||||
check "Owen, holding nothing, takes his three-set kit" "$(issue "$OWEN" "$(L $UT 3 stock),$(L $UP 3 stock)")" '"stock":6'
|
||||
check " and nothing on his record is marked an override" "$(overq "$OWEN")" '^0$'
|
||||
|
||||
echo "== six sets held, and not one more — the yearly figure governs nothing"
|
||||
check "Ella, register figure 1, takes six sets" "$(issue "$ELLA" "$(L $UT 6 stock),$(L $UP 6 stock)")" '"stock":12'
|
||||
R=$(issue "$ELLA" "$(L $UT 1 stock)")
|
||||
check " a seventh top is refused" "$R" "$CAP"
|
||||
check " and the refusal says what would make room" "$R" 'Hand a top in'
|
||||
check " and wrote nothing" "$(held "$ELLA" "$UT")" '^6$'
|
||||
|
||||
echo "== handing one in makes room, credit box unticked"
|
||||
check "Ella hands a top back" "$(handin "$ELLA" "$UT")" '"good":1'
|
||||
check " and the replacement goes through" "$(issue "$ELLA" "$(L $UT 1 stock)")" '"stock":1'
|
||||
check " and she is back at six tops" "$(held "$ELLA" "$UT")" '^6$'
|
||||
|
||||
echo "== the ceiling bites on each half, not on whole sets"
|
||||
check "Kira takes six tops and no trousers" "$(issue "$KIRA" "$(L $UT 6 stock)")" '"stock":6'
|
||||
check " a seventh top is refused though she holds no whole set" "$(issue "$KIRA" "$(L $UT 1 stock)")" "$CAP"
|
||||
check " the other half still has room" "$(issue "$KIRA" "$(L $UP 6 stock)")" '"stock":6'
|
||||
|
||||
echo "== nursing is capped like everybody else"
|
||||
check "Nora, a nurse, is refused a seventh top" "$(issue "$NORA" "$(L $UT 7 order)")" "$CAP"
|
||||
|
||||
echo "== an override is real, and recorded"
|
||||
check "Nora takes seven with the coordinator's override" "$(issue "$NORA" "$(L $UT 7 stock)" ',"override":true')" '"stock":7'
|
||||
check " and all seven are marked an override" "$(overq "$NORA")" '^7$'
|
||||
|
||||
echo "== garments on order count, across visits"
|
||||
check "Sam has six tops ordered in" "$(issue "$SAM" "$(L $UT 6 order)")" '"ordered":6'
|
||||
check " a top off the shelf on a later visit is refused" "$(issue "$SAM" "$(L $UT 1 stock)")" "$CAP"
|
||||
|
||||
echo "== pre-loved counts, and handing pre-loved back frees room"
|
||||
check "six pre-loved trousers into the pool" "$(mut stock.moves "{\"mode\":\"Pre-loved\",\"lines\":[{\"itemId\":\"$UP\",\"si\":0,\"qty\":6}]}")" '"ok":true'
|
||||
check "Sam takes six pre-loved trousers" "$(issue "$SAM" "$(L $UP 6 preloved)")" '"preloved":6'
|
||||
check " a new pair on top of them is refused" "$(issue "$SAM" "$(L $UP 1 stock)")" "$CAP"
|
||||
check "Sam hands a pre-loved pair back" "$(handin "$SAM" "$UP")" '"good":1'
|
||||
check " and a new pair goes through" "$(issue "$SAM" "$(L $UP 1 stock)")" '"stock":1'
|
||||
|
||||
echo "== garments in no set carry their own ceiling"
|
||||
check "Kira is refused seven fleeces" "$(issue "$KIRA" "$(L $FJ 7 stock)")" 'outside a set is the most anyone holds'
|
||||
check " and given six" "$(issue "$KIRA" "$(L $FJ 6 stock)")" '"stock":6'
|
||||
|
||||
echo "== the route is the facility's answer, and the wearer is told it"
|
||||
# Read off Owen's own request screen, which puts the sentence allowance() in lib/sets.ts writes in
|
||||
# front of a wearer before they ask for anything. His group's place on the lists is the only thing
|
||||
# moved between the reads below, so the setting is the only thing that can change what he is told.
|
||||
CODE=$(mut staff.selfCode "{\"id\":\"$OWEN\"}" | py "print(d['result']['code'])")
|
||||
check "Owen claims his staff account" "$(curl -s -c "$W" -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"owen$TS@example.com\",\"password\":\"wearerpass1\"}")" '"ok":true'
|
||||
mine(){ curl -s -b "$W" "$B/my/request"; }
|
||||
# Four rather than the standing three, so the number on his screen can only have come from this
|
||||
# facility's own setting.
|
||||
KIT='4 sets on starting'; SIGNED='each one approved by a manager'; HOURS='hours worked propose the starting number'
|
||||
check "this facility's starting kit is four sets" "$(mut settings.update '{"initialSets":4}')" '"ok":true'
|
||||
R=$(mine)
|
||||
check "Support Services is on the starting kit, so Owen is offered four sets to start" "$R" "$KIT"
|
||||
no " and is not told each set waits on a manager" "$R" "$SIGNED"
|
||||
check "the same group taken off the starting kit" "$(mut settings.update '{"kitGroups":[]}')" '"ok":true'
|
||||
R=$(mine)
|
||||
check " puts Owen on manager approval" "$R" "$SIGNED"
|
||||
no " with no starting kit offered" "$R" 'sets on starting'
|
||||
|
||||
echo "== one route per group"
|
||||
check "Support Services goes back on the starting kit" "$(mut settings.update '{"kitGroups":["Support Services"]}')" '"ok":true'
|
||||
# Spelt the way a roster export spells it, because group names are compared with case and stray spaces
|
||||
# set aside, and a refusal that only caught an exact match would let one team onto both routes under a
|
||||
# second spelling. Sent on its own, so it is settled against the starting-kit list already stored.
|
||||
R=$(mut settings.update '{"nursingGroups":["Registered Nurse","Enrolled Nurse","support services "]}')
|
||||
check "the FTE table cannot take a group already on the starting kit" "$R" 'can.t be on the FTE table and the starting kit at once'
|
||||
check " and the refused save changed neither list" "$(groups)" '|Registered Nurse,Enrolled Nurse|Support Services$'
|
||||
check "moving it across, both lists in one save, is allowed" "$(mut settings.update '{"nursingGroups":["Registered Nurse","Enrolled Nurse","Support Services"],"kitGroups":[]}')" '"ok":true'
|
||||
check " and puts Owen on the FTE table" "$(mine)" "$HOURS"
|
||||
check "and moving it back the same way" "$(mut settings.update '{"nursingGroups":["Registered Nurse","Enrolled Nurse"],"kitGroups":["Support Services"]}')" '"ok":true'
|
||||
check " offers him the starting kit again" "$(mine)" "$KIT"
|
||||
|
||||
echo "== renaming a group keeps it on its route"
|
||||
R=$(mut settings.renameGroup '{"from":"Support Services","to":"Housekeeping"}')
|
||||
check "Support Services is renamed Housekeeping" "$R" '"ok":true'
|
||||
check " moving the one person filed under it" "$R" '"staff":1'
|
||||
check " on the facility's list and the starting kit's alike" "$(groups)" '^Registered Nurse,Enrolled Nurse,Housekeeping,Kitchen,Security|Registered Nurse,Enrolled Nurse|Housekeeping$'
|
||||
check " and on Owen's record" "$(bk | py "print([s['group'] for s in d['staff'] if s['id']=='$OWEN'][0])")" '^Housekeeping$'
|
||||
R=$(mine)
|
||||
check "Owen is still offered the starting kit" "$R" "$KIT"
|
||||
no " and the rename did not drop him onto manager approval" "$R" "$SIGNED"
|
||||
# Renaming onto a name already in use would be two teams under one name, one of them quietly taking
|
||||
# the other's route.
|
||||
check "renaming onto a name already in use is refused" "$(mut settings.renameGroup '{"from":"Kitchen","to":"housekeeping"}')" 'already a staff group here'
|
||||
check " and Kira is still in Kitchen" "$(bk | py "print([s['group'] for s in d['staff'] if s['id']=='$KIRA'][0])")" '^Kitchen$'
|
||||
|
||||
echo "== a group with people in it can't be taken off the list"
|
||||
# Dropping a group that still has people filed under it would move them all to manager approval with
|
||||
# nothing on any screen to say so, so the save is refused — while a group nobody is in goes freely.
|
||||
# The list and Kira's group are read off the facility each time, because the sections above rename.
|
||||
LIST(){ bk | py 'print(json.dumps(d["facility"]["staffGroups"]))'; }
|
||||
KGRP=$(bk | py 'print([s["group"] for s in d["staff"] if s["num"]=="3"][0])')
|
||||
check "an empty group can be added" "$(mut settings.update "{\"staffGroups\":$(LIST | py 'print(json.dumps(d+["Porters"]))')}")" '"ok":true'
|
||||
check " and taken off again, with nobody in it" "$(mut settings.update "{\"staffGroups\":$(LIST | py 'print(json.dumps([g for g in d if g!="Porters"]))')}")" '"ok":true'
|
||||
check "a group with people filed under it is refused" "$(mut settings.update "{\"staffGroups\":$(LIST | KGRP="$KGRP" py 'import os; print(json.dumps([g for g in d if g!=os.environ["KGRP"]]))')}")" 'filed under it'
|
||||
check " and stays on the list" "$(LIST)" "$KGRP"
|
||||
|
||||
echo "== a garment is for the staff groups it is tagged with"
|
||||
# The owner's rule: a garment may be tagged for several staff groups, and everybody is offered their
|
||||
# own group's garments plus those for every group. Everybody below holds nothing when first served and
|
||||
# asks for one garment at a time, so the ceiling never comes into it — the group is the only thing
|
||||
# that can refuse any of this, and each refusal has somebody whose group it is for beside it.
|
||||
check "a garment tagged for two groups and one for a third, off the catalogue CSV" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Ward Tunic","sku":"WT","type":"Tunic","supplier":"Alpha Supply","cost":"32","group":"Registered Nurse|Enrolled Nurse","sizes":"M"},{"item":"Patrol Shirt","sku":"PS","type":"Shirt","supplier":"Alpha Supply","cost":"28","group":"Security","sizes":"M"}]}')" '"created":2'
|
||||
mut import.rows '{"kind":"opening","rows":[{"sku":"WT","size":"M","opening":"20"},{"sku":"PS","size":"M","opening":"20"}]}' >/dev/null
|
||||
# Mara is the manager all three report to, so the staff app has somebody to send their asks to, and
|
||||
# so she can be seen raising for one of them.
|
||||
check "four more people, three of them reporting to the fourth" "$(mut import.rows '{"kind":"staff","rows":[{"num":"6","first":"Enzo","last":"Enrolled","group":"Enrolled Nurse","dept":"Ward 1","fte":"1.0","top":"M","pants":"M","manager":"9"},{"num":"7","first":"Rhea","last":"Registered","group":"Registered Nurse","dept":"Ward 1","fte":"1.0","top":"M","pants":"M","manager":"9"},{"num":"8","first":"Gus","last":"Guard","group":"Security","dept":"Ward 1","top":"M","pants":"M","manager":"9"},{"num":"9","first":"Mara","last":"Manager","group":"Registered Nurse","dept":"Ward 1","fte":"1.0"}]}')" '"created":4'
|
||||
BK=$(bk)
|
||||
WT=$(sku WT); PS=$(sku PS)
|
||||
ENZO=$(num 6); RHEA=$(num 7); GUS=$(num 8); MARA=$(num 9)
|
||||
check " all three report to Mara" "$(echo "$BK" | py "print(len([s for s in d['staff'] if s['managerId']=='$MARA']))")" '^3$'
|
||||
# The list as stored and the CSV's own spelling of it beside it, which is what a backup carries.
|
||||
check "the tunic holds both groups, in the order they were written" "$(echo "$BK" | py "i=[i for i in d['items'] if i['sku']=='WT'][0]; print(','.join(i['groups']), i['group'])")" '^Registered Nurse,Enrolled Nurse Registered Nurse|Enrolled Nurse$'
|
||||
check " and the shirt its one" "$(echo "$BK" | py "print(','.join([i['groups'] for i in d['items'] if i['sku']=='PS'][0]))")" '^Security$'
|
||||
|
||||
EJ="$T/tc-sets-enzo.txt"; RJ="$T/tc-sets-rhea.txt"; GJ="$T/tc-sets-gus.txt"; MJ="$T/tc-sets-mara.txt"; rm -f "$EJ" "$RJ" "$GJ" "$MJ"
|
||||
claim(){ local code; code=$(mut staff.selfCode "{\"id\":\"$1\"}" | py "print(d['result']['code'])")
|
||||
curl -s -c "$2" -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "{\"agreed\":true,\"code\":\"$code\",\"email\":\"$3$TS@example.com\",\"password\":\"wearerpass1\"}"; }
|
||||
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}"; }
|
||||
check "all four claim staff accounts" "$(printf '%s\n' "$(claim "$ENZO" "$EJ" enzo)" "$(claim "$RHEA" "$RJ" rhea)" "$(claim "$GUS" "$GJ" gus)" "$(claim "$MARA" "$MJ" mara)" | grep -c '"ok":true')" '^4$'
|
||||
|
||||
# What each of them is offered to ask for, read off their own request screen. Every "not offered"
|
||||
# sits beside something the same page does offer, so a page that failed to render fails there.
|
||||
shop(){ curl -s -b "$1" "$B/my/request"; }
|
||||
R=$(shop "$EJ")
|
||||
check "Enzo, an enrolled nurse, is offered the tunic tagged for both nursing groups" "$R" 'Ward Tunic'
|
||||
check " and the jacket for every group" "$R" 'Fleece Jacket'
|
||||
no " and not the shirt for Security" "$R" 'Patrol Shirt'
|
||||
R=$(shop "$RJ")
|
||||
check "Rhea, a registered nurse, is offered the same tunic" "$R" 'Ward Tunic'
|
||||
no " and not the shirt for Security either" "$R" 'Patrol Shirt'
|
||||
R=$(shop "$GJ")
|
||||
check "Gus, in Security, is offered his own group's shirt" "$R" 'Patrol Shirt'
|
||||
check " and the jacket for every group" "$R" 'Fleece Jacket'
|
||||
no " and never the nurses' tunic" "$R" 'Ward Tunic'
|
||||
|
||||
echo "== the staff app refuses a garment for another group"
|
||||
ASK(){ echo "{\"lines\":[$1],\"reason\":\"Worn out\"}"; }
|
||||
RL(){ echo "{\"itemId\":\"$1\",\"si\":0,\"qty\":1}"; }
|
||||
R=$(smut request.create "$GJ" "$(ASK "$(RL "$WT")")")
|
||||
check "Gus asking for the nurses' tunic is refused" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only\. You.re in Security'
|
||||
no " and nothing is raised" "$R" '"code"'
|
||||
R=$(smut request.create "$GJ" "$(ASK "$(RL "$PS"),$(RL "$WT")")")
|
||||
check " so is the tunic asked for beside his own shirt" "$R" 'Ward Tunic is for'
|
||||
no " and the refusal names only the garment that isn't his" "$R" 'Patrol Shirt is for'
|
||||
check "his own group's shirt and the jacket for everyone go through" "$(smut request.create "$GJ" "$(ASK "$(RL "$PS"),$(RL "$FJ")")")" '"code"'
|
||||
check "Enzo asking for the tunic tagged for his group goes through" "$(smut request.create "$EJ" "$(ASK "$(RL "$WT")")")" '"code"'
|
||||
# Measured against the person it is for, whoever raises it.
|
||||
check "Mara cannot raise the tunic for Gus" "$(smut request.create "$MJ" "{\"subjectId\":\"$GUS\",\"lines\":[$(RL "$WT")],\"reason\":\"Worn out\"}")" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only\. Gus is in Security\.'
|
||||
check " but can raise it for Enzo" "$(smut request.create "$MJ" "{\"subjectId\":\"$ENZO\",\"lines\":[$(RL "$WT")],\"reason\":\"Worn out\"}")" '"code"'
|
||||
check "Gus cannot queue for the tunic either" "$(smut waitlist.join "$GJ" "{\"itemId\":\"$WT\",\"si\":0}")" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only'
|
||||
check " while queueing for his own shirt is allowed" "$(smut waitlist.join "$GJ" "{\"itemId\":\"$PS\",\"si\":0}")" '"id"'
|
||||
|
||||
echo "== at the counter it takes the override, recorded apart from the ceiling's"
|
||||
# Each issue row as SKU=offGroup/override, for one person, sorted by garment.
|
||||
flags(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s/%s' % (sk[r['itemId']], r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$1')))"; }
|
||||
notes(){ bk | py "print(' | '.join(o['notes'] for o in d['orders'] if o['staffId']=='$1'))"; }
|
||||
R=$(issue "$GUS" "$(L $WT 1 stock)")
|
||||
check "the tunic is refused to Gus without the override" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse .*Gus Guard is in Security'
|
||||
check " and the refusal says what lets it through" "$R" 'Tick the coordinator override'
|
||||
no " and is not the ceiling's refusal" "$R" "$CAP"
|
||||
check " and wrote nothing" "$(held "$GUS" "$WT")" '^0$'
|
||||
check "Enzo is handed the same tunic with no override" "$(issue "$ENZO" "$(L $WT 1 stock)")" '"stock":1'
|
||||
check " and so is Rhea" "$(issue "$RHEA" "$(L $WT 1 stock)")" '"stock":1'
|
||||
R=$(issue "$GUS" "$(L $WT 1 stock),$(L $PS 1 stock)" ',"override":true')
|
||||
check "with the override Gus takes the tunic, and his own shirt beside it" "$R" '"stock":2'
|
||||
check " and the counter is told one garment was outside his group" "$R" '"offGroup":1'
|
||||
# Two garments, well inside six sets: the tunic's row says outside his group, the shirt's says
|
||||
# nothing, and neither says override — that word stays the ceiling's.
|
||||
check " the tunic's row is marked outside his group, and not as an override" "$(flags "$GUS")" '^PS=False/False WT=True/False$'
|
||||
check " while Enzo's tunic is marked neither" "$(flags "$ENZO")" '^WT=False/False$'
|
||||
# Ordered in, there is no issue row to mark yet, so the supplier order carries it.
|
||||
check "the tunic ordered in for Gus on the override" "$(issue "$GUS" "$(L $WT 1 order)" ',"override":true')" '"ordered":1'
|
||||
check " and the supplier order says so, and who allowed it" "$(notes "$GUS")" "Outside Gus.s staff group: Ward Tunic .*override recorded by Sets Admin"
|
||||
check "Enzo's tunic ordered in the same way" "$(issue "$ENZO" "$(L $WT 1 order)")" '"ordered":1'
|
||||
check " carries the ordinary note" "$(notes "$ENZO")" 'Ordered at issue for Enzo Enrolled'
|
||||
no " and nothing about a staff group" "$(notes "$ENZO")" 'Outside'
|
||||
# The same ask refused to him at the top of this section. Holding one now changes nothing: a fresh
|
||||
# request is measured against his group, whatever the counter handed him on its override, because
|
||||
# holding another group's garment is no reason to be handed a second one.
|
||||
R=$(smut request.create "$GJ" "$(ASK "$(RL "$WT")")")
|
||||
check "once Gus holds the tunic, the staff app still refuses him another" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only\. You.re in Security'
|
||||
no " and nothing is raised" "$R" '"code"'
|
||||
# The one exemption, and the neighbour that keeps the refusal honest: the very garment he holds,
|
||||
# reported damaged, is replaced like for like.
|
||||
GWT=$(bk | py "print([i['id'] for i in d['issues'] if i['staffId']=='$GUS' and i['itemId']=='$WT' and not i['handedIn'] and not i['returnedDate']][0])")
|
||||
check " but reporting that tunic torn raises its replacement" "$(smut damage.report "$GJ" "{\"issueId\":\"$GWT\",\"kind\":\"Torn\",\"replace\":true}")" '"replacement":{"id":"[^"]*","code"'
|
||||
|
||||
echo "== the phone's one-group label, saved back as it was shown"
|
||||
# The phone counter's catalogue card sends one group string, and shows a garment with several groups
|
||||
# as one label. Saving its other fields sends that label straight back, and must leave every group
|
||||
# in place; picking one group there still moves the garment to it, so the label is not just ignored.
|
||||
GRPS(){ bk | py "print(','.join([i['groups'] for i in d['items'] if i['id']=='$1'][0]))"; }
|
||||
check "the tunic saved with its label as shown and a new price" "$(mut catalog.update "{\"id\":\"$WT\",\"group\":\"Registered Nurse, Enrolled Nurse\",\"cost\":33}")" '"ok":true'
|
||||
check " took the price" "$(bk | py "print('%g' % [i['cost'] for i in d['items'] if i['id']=='$WT'][0])")" '^33$'
|
||||
check " and kept both its groups" "$(GRPS "$WT")" '^Registered Nurse,Enrolled Nurse$'
|
||||
check "saved with one group picked" "$(mut catalog.update "{\"id\":\"$WT\",\"group\":\"Enrolled Nurse\"}")" '"ok":true'
|
||||
check " it is for that group alone" "$(GRPS "$WT")" '^Enrolled Nurse$'
|
||||
check "the desktop's list puts both back" "$(mut catalog.update "{\"id\":\"$WT\",\"groups\":[\"Registered Nurse\",\"Enrolled Nurse\"]}")" '"ok":true'
|
||||
check " and it holds both again" "$(GRPS "$WT")" '^Registered Nurse,Enrolled Nurse$'
|
||||
|
||||
echo "== the counter's request door asks the same question"
|
||||
# request.raise is the linen room raising for somebody without a phone. The hand-over at the end of a
|
||||
# request trusts that the garments on it are the wearer's group's, so this door has to refuse what the
|
||||
# staff app refuses — or it is the way round it. It has no override of its own.
|
||||
reqs(){ bk | py "print(len([r for r in d['requests'] if r['subjectId']=='$1']))"; }
|
||||
N0=$(reqs "$GUS")
|
||||
R=$(mut request.raise "{\"staffId\":\"$GUS\",\"lines\":[$(RL "$PS"),$(RL "$WT")],\"reason\":\"Worn out\"}")
|
||||
check "the counter cannot raise the nurses' tunic for Gus" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only .*Gus Guard is in Security, and a request can only carry'
|
||||
check " and is told the Issue screen's override is the way" "$R" 'on the Issue screen with the coordinator override ticked'
|
||||
no " and the refusal names only the garment that isn't his" "$R" 'Patrol Shirt is for'
|
||||
check " and raised nothing" "$(reqs "$GUS")" "^$N0\$"
|
||||
check "his own shirt and the jacket for everyone, raised the same way, go through" "$(mut request.raise "{\"staffId\":\"$GUS\",\"lines\":[$(RL "$PS"),$(RL "$FJ")],\"reason\":\"Worn out\"}")" '"code"'
|
||||
check " and are on his record as one request" "$(reqs "$GUS")" "^$((N0+1))\$"
|
||||
|
||||
echo "== an order for somebody carries their own group's garments"
|
||||
# What reaches a person off an order is issued at pickup with nobody deciding anything, so the order
|
||||
# is where the group is asked. A stock order is for nobody, and may carry anything.
|
||||
ords(){ bk | py "print(len([o for o in d['orders'] if o['staffId']=='$1']))"; }
|
||||
OL(){ echo "{\"itemId\":\"$1\",\"size\":\"M\",\"qty\":1}"; }
|
||||
N0=$(ords "$GUS")
|
||||
R=$(mut order.create "{\"orderFor\":\"Staff Member\",\"staffId\":\"$GUS\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$WT")]}")
|
||||
check "an order for Gus carrying the nurses' tunic is refused" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only .*Gus Guard is in Security, and an order for somebody can only carry'
|
||||
check " and is pointed at Order in with the override" "$R" 'use Order in on the Issue screen with the coordinator override ticked'
|
||||
check " and wrote no order" "$(ords "$GUS")" "^$N0\$"
|
||||
R=$(mut order.create "{\"orderFor\":\"Staff Member\",\"staffId\":\"$GUS\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$PS")]}")
|
||||
check "an order for Gus carrying his own shirt is written" "$R" '"code"'
|
||||
GD=$(echo "$R" | py "print(d['result']['id'])")
|
||||
check " and is his" "$(ords "$GUS")" "^$((N0+1))\$"
|
||||
# Growing an order is putting a garment on it, and is asked the same question.
|
||||
check "the tunic can't be added to it afterwards" "$(mut order.lineAdd "{\"id\":\"$GD\",\"itemId\":\"$WT\",\"size\":\"M\",\"qty\":1}")" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only'
|
||||
check " while the jacket for everyone can" "$(mut order.lineAdd "{\"id\":\"$GD\",\"itemId\":\"$FJ\",\"size\":\"M\",\"qty\":1}")" '"ok":true'
|
||||
check " leaving the shirt and the jacket on it, and no tunic" "$(bk | py "sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted(sk[l['itemId']] for l in [o for o in d['orders'] if o['id']=='$GD'][0]['lines'])))")" '^FJ PS$'
|
||||
R=$(mut order.create "{\"orderFor\":\"Stock\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$WT")]}")
|
||||
check "a stock order carries the tunic, being for nobody" "$R" '"code"'
|
||||
SO=$(echo "$R" | py "print(d['result']['id'])")
|
||||
FOR(){ bk | py "print([o['orderFor'] for o in d['orders'] if o['id']=='$1'][0])"; }
|
||||
check " but can't then be put in Gus's name" "$(mut order.update "{\"id\":\"$SO\",\"staffId\":\"$GUS\"}")" 'Take it off this order first'
|
||||
check " and is still a stock order" "$(FOR "$SO")" '^Stock$'
|
||||
check " while Enzo's name, whose group it is for, goes on it" "$(mut order.update "{\"id\":\"$SO\",\"staffId\":\"$ENZO\"}")" '"ok":true'
|
||||
check " making it his" "$(FOR "$SO")" '^Staff Member$'
|
||||
|
||||
echo "== ordered in on the override, the garment is marked when it reaches them"
|
||||
# Gus's tunic and Enzo's were both ordered in at the counter further up, Gus's on the override. The
|
||||
# supplier order carries the note; the issue row the collection writes has to carry the mark, or the
|
||||
# Exceptions report never sees the garment in the month it reached him.
|
||||
openOrd(){ bk | py "o=[o['id'] for o in d['orders'] if o['staffId']=='$1' and o['status']=='Ordered']; print(o[0] if len(o)==1 else 'expected one open order, found %d' % len(o))"; }
|
||||
toCounter(){ mut order.receive "{\"id\":\"$1\",\"lines\":$(bk | py "o=[o for o in d['orders'] if o['id']=='$1'][0]; print(json.dumps([{'lineId': l['id'], 'arrived': l['qty'], 'dest': 'pickup'} for l in o['lines']]))")}"; }
|
||||
puOf(){ bk | py "p=[p['id'] for p in d['pickups'] if p['orderId']=='$1' and not p['pickedUp']]; print(p[0] if p else 'none')"; }
|
||||
# The issue rows one order turned into, for one person, as SKU=offGroup.
|
||||
got(){ bk | py "c=[o['code'] for o in d['orders'] if o['id']=='$2'][0]; sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s' % (sk[r['itemId']], r['offGroup']) for r in d['issues'] if r['staffId']=='$1' and r['direct'] and r['orderCode']==c)))"; }
|
||||
GO=$(openOrd "$GUS"); EO=$(openOrd "$ENZO")
|
||||
check "Gus's tunic arrives and waits at the counter" "$(toCounter "$GO")" '"ok":true'
|
||||
check " and so does Enzo's" "$(toCounter "$EO")" '"ok":true'
|
||||
check "Gus collects his" "$(mut pickup.pickedUp "{\"id\":\"$(puOf "$GO")\"}")" '"ok":true'
|
||||
check " and the issue row it wrote is marked outside his group" "$(got "$GUS" "$GO")" '^WT=True$'
|
||||
check "Enzo collects his" "$(mut pickup.pickedUp "{\"id\":\"$(puOf "$EO")\"}")" '"ok":true'
|
||||
check " and his is marked nothing" "$(got "$ENZO" "$EO")" '^WT=False$'
|
||||
# The ward round hands over the same way, and marks the same way.
|
||||
check "another tunic ordered in for Gus on the override" "$(issue "$GUS" "$(L $WT 1 order)" ',"override":true')" '"ordered":1'
|
||||
check " and one for Rhea, whose group it is for" "$(issue "$RHEA" "$(L $WT 1 order)")" '"ordered":1'
|
||||
GO=$(openOrd "$GUS"); RO=$(openOrd "$RHEA")
|
||||
check "both arrive for the ward round" "$(printf '%s\n' "$(toCounter "$GO")" "$(toCounter "$RO")" | grep -c '"ok":true')" '^2$'
|
||||
check "both are signed for on the round" "$(printf '%s\n' "$(mut pickup.deliver "{\"id\":\"$(puOf "$GO")\",\"deliveredTo\":\"Ward 1 desk\"}")" "$(mut pickup.deliver "{\"id\":\"$(puOf "$RO")\",\"deliveredTo\":\"Ward 1 desk\"}")" | grep -c '"ok":true')" '^2$'
|
||||
check " Gus's row marked outside his group" "$(got "$GUS" "$GO")" '^WT=True$'
|
||||
check " and Rhea's not" "$(got "$RHEA" "$RO")" '^WT=False$'
|
||||
|
||||
echo "== a partial hand-in keeps the mark on both halves"
|
||||
# Two of a garment handed over in one act are one issue row; handing one back splits it, and the
|
||||
# half that stays out and the half that came back were both handed over outside her group. Her own
|
||||
# group's shirt goes through the same split beside it, so a split that marked everything fails there.
|
||||
check "one more guard on the register" "$(mut import.rows '{"kind":"staff","rows":[{"num":"10","first":"Tess","last":"Guard","group":"Security","dept":"Ward 1","top":"M","pants":"M"}]}')" '"created":1'
|
||||
TESS=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="10"][0])')
|
||||
R=$(issue "$TESS" "$(L $WT 2 stock),$(L $PS 2 stock)" ',"override":true')
|
||||
check "Tess takes two nurses' tunics on the override, and two of her own shirts" "$R" '"stock":4'
|
||||
check " one garment of them outside her group" "$R" '"offGroup":1'
|
||||
check "she hands one tunic back" "$(handin "$TESS" "$WT")" '"good":1'
|
||||
check " and one shirt" "$(handin "$TESS" "$PS")" '"good":1'
|
||||
# Each issue row as SKU:qty:offGroup:held-or-in.
|
||||
rows(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s:%d:%s:%s' % (sk[r['itemId']], r['qty'], r['offGroup'], 'in' if r['handedIn'] else 'held') for r in d['issues'] if r['staffId']=='$1')))"; }
|
||||
check " each row split in two, both halves of the tunic marked and neither half of the shirt" "$(rows "$TESS")" '^PS:1:False:held PS:1:False:in WT:1:True:held WT:1:True:in$'
|
||||
|
||||
echo "== a list of groups typed on the phone"
|
||||
# The phone sends one group string. One matching no garment's label is a list somebody typed, and
|
||||
# kept whole it would be a single group called "Kitchen, Security" that nobody is in — refusing the
|
||||
# garment to Kitchen and Security alike. Kira is in Kitchen: before, the shirt is another group's;
|
||||
# after, what stops her is the six tops she already holds, and nothing about her group.
|
||||
R=$(issue "$KIRA" "$(L $PS 1 stock)")
|
||||
check "the shirt is refused to Kira as another group's" "$R" 'Patrol Shirt is for Security .*Kira Kitchen is in Kitchen'
|
||||
check "the shirt saved from the phone as a group string no garment is labelled with" "$(mut catalog.update "{\"id\":\"$PS\",\"group\":\"Kitchen, Security\"}")" '"ok":true'
|
||||
check " is stored as two groups, not one called both" "$(bk | py "print(json.dumps([i['groups'] for i in d['items'] if i['id']=='$PS'][0]))")" '^\["Kitchen", "Security"\]$'
|
||||
R=$(issue "$KIRA" "$(L $PS 1 stock)")
|
||||
no " so Kira is no longer told it is another group's" "$R" 'is for'
|
||||
check " only that she already holds six tops" "$R" "$CAP"
|
||||
|
||||
echo "== outside the group and past six, in one refusal"
|
||||
# One override tick answers both questions, so a cart that is both another group's AND past six sets
|
||||
# has to say both in the one refusal — or ticking the box for the group would wave the ceiling through
|
||||
# with nobody told. Kira holds six tops and the tunic is a top for the nurses; Tess, beside her, holds
|
||||
# two, so the same tunic refused to her is the group's refusal alone.
|
||||
R=$(issue "$KIRA" "$(L $WT 1 stock)")
|
||||
check "the nurses' tunic for Kira, holding six tops, is refused as another group's" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse .*Kira Kitchen is in Kitchen'
|
||||
check " and the same refusal says it would also take her past six" "$R" 'It would also take them past what one person holds'
|
||||
check " naming the ceiling" "$R" "$CAP"
|
||||
check " and wrote nothing" "$(held "$KIRA" "$WT")" '^0$'
|
||||
R=$(issue "$TESS" "$(L $WT 1 stock)")
|
||||
check "the same tunic for Tess, well inside six, is refused as another group's" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse .*Tess Guard is in Security'
|
||||
no " with nothing about the ceiling" "$R" 'It would also take them past'
|
||||
|
||||
echo "== handed over after the wearer changed group, the garment is marked"
|
||||
# Both doors that raise a request refuse another group's garment, and a hand-over is not a second
|
||||
# chance to refuse what a manager approved. So a wearer moved to another group between the ask and the
|
||||
# collection is still handed it, and the issue row is marked outside their group. Enzo and Rhea both
|
||||
# ask for the nurses' tunic while it is theirs, one bag each for the counter and one each for the ward
|
||||
# round; then Enzo is moved to Security and Rhea stays where she is. Each hand-over is read as the one
|
||||
# new issue row it wrote, as SKU=offGroup/override.
|
||||
ids(){ bk | py "print(' '.join(i['id'] for i in d['issues'] if i['staffId']=='$1'))"; }
|
||||
since(){ bk | B4="$2" py "import os; b=set(os.environ['B4'].split()); sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s/%s' % (sk[r['itemId']], r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$1' and r['id'] not in b)))"; }
|
||||
rid(){ echo "$1" | py "print(d['result']['id'])" 2>/dev/null; }
|
||||
Q1=$(smut request.create "$EJ" "$(ASK "$(RL "$WT")")"); Q2=$(smut request.create "$EJ" "$(ASK "$(RL "$WT")")")
|
||||
Q3=$(smut request.create "$RJ" "$(ASK "$(RL "$WT")")"); Q4=$(smut request.create "$RJ" "$(ASK "$(RL "$WT")")")
|
||||
check "Enzo asks for the tunic twice, and so does Rhea" "$(printf '%s\n' "$Q1" "$Q2" "$Q3" "$Q4" | grep -c '"code"')" '^4$'
|
||||
EC=$(rid "$Q1"); ER=$(rid "$Q2"); RC=$(rid "$Q3"); RR=$(rid "$Q4")
|
||||
check " and Mara approves all four" "$(for r in $EC $ER $RC $RR; do smut request.approve "$MJ" "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"accepted"')" '^4$'
|
||||
check "Mara takes the ward-desk flag, so the round has somebody on Ward 1 to sign" "$(mut staff.patch "{\"id\":\"$MARA\",\"wardDesk\":true}")" '"ok":true'
|
||||
check "the linen room picks all four" "$(for r in $EC $ER $RC $RR; do mut request.pick "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"picking"')" '^4$'
|
||||
check " holds one of each at the counter" "$(for r in $EC $RC; do mut request.hold "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"ready"')" '^2$'
|
||||
check " and sends the other two on the round" "$(for r in $ER $RR; do mut request.round "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"round"')" '^2$'
|
||||
# Queued for the same tunic while it is theirs, for the waitlist section below.
|
||||
WQE=$(smut waitlist.join "$EJ" "{\"itemId\":\"$WT\",\"si\":0}"); WQR=$(smut waitlist.join "$RJ" "{\"itemId\":\"$WT\",\"si\":0}")
|
||||
check "both queue for the tunic too, while it is their group's" "$(printf '%s\n' "$WQE" "$WQR" | grep -c '"id"')" '^2$'
|
||||
# staff.patch carries no group; the staff record's own save is what moves somebody to another group.
|
||||
check "Enzo is moved to Security" "$(mut staff.save "{\"id\":\"$ENZO\",\"num\":\"6\",\"first\":\"Enzo\",\"last\":\"Enrolled\",\"group\":\"Security\",\"dept\":\"Ward 1\",\"top\":\"M\",\"pants\":\"M\"}")" '"ok":true'
|
||||
check " and is in it" "$(bk | py "print([s['group'] for s in d['staff'] if s['id']=='$ENZO'][0])")" '^Security$'
|
||||
E0=$(ids "$ENZO"); R0=$(ids "$RHEA")
|
||||
check "Enzo collects his bag at the counter" "$(mut request.collected "{\"id\":\"$EC\"}")" '"status":"collected"'
|
||||
check " and its row is marked outside his group, and not as an override" "$(since "$ENZO" "$E0")" '^WT=True/False$'
|
||||
check "Rhea collects hers" "$(mut request.collected "{\"id\":\"$RC\"}")" '"status":"collected"'
|
||||
check " and hers is marked neither" "$(since "$RHEA" "$R0")" '^WT=False/False$'
|
||||
E0=$(ids "$ENZO"); R0=$(ids "$RHEA")
|
||||
check "Mara signs for Enzo's bag on the round" "$(smut round.sign "$MJ" "{\"id\":\"$ER\"}")" '"ok":true'
|
||||
check " and its row is marked outside his group" "$(since "$ENZO" "$E0")" '^WT=True/False$'
|
||||
check "and for Rhea's" "$(smut round.sign "$MJ" "{\"id\":\"$RR\"}")" '"ok":true'
|
||||
check " and hers is not" "$(since "$RHEA" "$R0")" '^WT=False/False$'
|
||||
|
||||
echo "== a place in the queue for another group's garment isn't offered"
|
||||
# Joining is refused for another group's garment, but a place joined while it was theirs outlives a
|
||||
# move to another group. Offering it would hold the stock for forty-eight hours for somebody whose
|
||||
# acceptance is then refused, so the offer is refused — beside Rhea's place for the same tunic.
|
||||
WE=$(rid "$WQE"); WR=$(rid "$WQR")
|
||||
offered(){ bk | py "print([w['offeredAt'] is not None for w in d['waitlist'] if w['id']=='$1'][0])"; }
|
||||
R=$(mut waitlist.offer "{\"id\":\"$WE\"}")
|
||||
check "the tunic can't be offered to Enzo's place, now he is in Security" "$R" 'Ward Tunic is for Registered Nurse, Enrolled Nurse only .*Enzo Enrolled is in Security, so they can.t take it'
|
||||
check " and the linen room is told to take him off the list" "$R" 'Take them off this waitlist instead'
|
||||
check " and his place is not marked offered" "$(offered "$WE")" '^False$'
|
||||
check "Rhea's place for the same tunic is offered" "$(mut waitlist.offer "{\"id\":\"$WR\"}")" '"heldUntil"'
|
||||
check " and marked offered" "$(offered "$WR")" '^True$'
|
||||
|
||||
echo "== a renamed group takes its garments with it"
|
||||
# Garments tagged for a group are renamed with it. Left under the old name they would be for a group
|
||||
# nobody is in any more, and the person the garment is for would need the override to be handed it.
|
||||
check "a cap for Security alone, off the catalogue CSV" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Guard Cap","sku":"GC","type":"Hat","supplier":"Alpha Supply","cost":"12","group":"Security","sizes":"M"}]}')" '"created":1'
|
||||
mut import.rows '{"kind":"opening","rows":[{"sku":"GC","size":"M","opening":"10"}]}' >/dev/null
|
||||
GC=$(bk | py 'print([i["id"] for i in d["items"] if i["sku"]=="GC"][0])')
|
||||
R=$(mut settings.renameGroup '{"from":"Security","to":"Protective Services"}')
|
||||
check "Security is renamed Protective Services" "$R" '"ok":true'
|
||||
check " moving both garments tagged for it, the cap and the shirt" "$R" '"garments":2'
|
||||
check " the cap, read back off the backup, is for the new name" "$(GRPS "$GC")" '^Protective Services$'
|
||||
check " the shirt keeps Kitchen beside the new name" "$(GRPS "$PS")" '^Kitchen,Protective Services$'
|
||||
check " and the tunic, for neither, is untouched" "$(GRPS "$WT")" '^Registered Nurse,Enrolled Nurse$'
|
||||
check " and Tess is filed under the new name" "$(bk | py "print([s['group'] for s in d['staff'] if s['id']=='$TESS'][0])")" '^Protective Services$'
|
||||
check "Tess, in the renamed group, is handed the cap with no override" "$(issue "$TESS" "$(L $GC 1 stock)")" '"stock":1'
|
||||
check " and its row is marked neither outside her group nor as an override" "$(bk | py "print(' '.join('%s/%s' % (r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$TESS' and r['itemId']=='$GC'))")" '^False/False$'
|
||||
R=$(issue "$RHEA" "$(L $GC 1 stock)")
|
||||
check "while Rhea, a nurse, is refused it under its new name" "$R" 'Guard Cap is for Protective Services .*Rhea Registered is in Registered Nurse'
|
||||
check " and wrote nothing" "$(held "$RHEA" "$GC")" '^0$'
|
||||
|
||||
echo "== the cut of uniform a wearer is offered"
|
||||
# The owner's second rule, and deliberately the same shape as the staff group above: the staff record
|
||||
# carries a Uniform style — Men's, Women's or Either — and each garment carries a cut. Somebody set to
|
||||
# Men's is offered the men's cut and the unisex range, somebody set to Women's the women's and the
|
||||
# unisex, somebody set to Either every cut. Blank is the fourth state and the one every record on
|
||||
# every register reads until a coordinator sets it: nobody has said, and nothing is refused.
|
||||
#
|
||||
# The people below are new to the register and hold nothing, and each asks for one or two garments at
|
||||
# a time against a ceiling of six, so the ceiling never comes into any of this — the cut is the only
|
||||
# thing that can refuse anything here, and each refusal sits beside the same ask from somebody whose
|
||||
# cut it is. Kitchen and the two nursing groups are used throughout because the renames above leave
|
||||
# them under the names they were given at the top of the file.
|
||||
#
|
||||
# The types are given outright: the blouse and both shirts are tops, so the two cuts sit in the same
|
||||
# half of a set and nothing below turns on which bucket a garment fell into.
|
||||
check "a women's cut, a men's cut, and a women's cut for the nurses alone" "$(mut import.rows '{"kind":"catalog","rows":[{"item":"Ward Blouse","sku":"WB","type":"Blouse","gender":"Female","supplier":"Alpha Supply","cost":"36","group":"All","sizes":"M"},{"item":"Field Shirt","sku":"FS","type":"Shirt","gender":"Male","supplier":"Alpha Supply","cost":"34","group":"All","sizes":"M"},{"item":"Theatre Scrub Top","sku":"TT","type":"Scrub top","gender":"Female","supplier":"Alpha Supply","cost":"33","group":"Registered Nurse|Enrolled Nurse","sizes":"M"}]}')" '"created":3'
|
||||
mut import.rows '{"kind":"opening","rows":[{"sku":"WB","size":"M","opening":"20"},{"sku":"FS","size":"M","opening":"20"},{"sku":"TT","size":"M","opening":"20"}]}' >/dev/null
|
||||
# Four more people, their cuts written in the register CSV's own column and in the spellings a file
|
||||
# actually arrives in — "Mens" without the apostrophe, "both" for the one who sees everything — so a
|
||||
# rule that only matched the words a picker offers fails here. Dana's and Robin's cells are empty,
|
||||
# which is every record on every register today.
|
||||
check "four more people, two of them with a cut on the file" "$(mut import.rows '{"kind":"staff","rows":[{"num":"11","first":"Dana","last":"Lee","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","manager":"9"},{"num":"12","first":"Milo","last":"Reed","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","manager":"9","style":"Mens"},{"num":"13","first":"Ash","last":"Quinn","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","manager":"9","style":"both"},{"num":"14","first":"Robin","last":"Vale","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","manager":"9"}]}')" '"created":4'
|
||||
BK=$(bk)
|
||||
WB=$(sku WB); FS=$(sku FS); TT=$(sku TT)
|
||||
DANA=$(num 11); MILO=$(num 12); ASH=$(num 13); ROBIN=$(num 14)
|
||||
# One person's cut as stored, in brackets, so blank — nobody has said — is something a check can name
|
||||
# rather than an empty string that matches anything.
|
||||
styleOf(){ bk | py "print('[' + [s['uniformStyle'] for s in d['staff'] if s['id']=='$1'][0] + ']')"; }
|
||||
# Each issue row as SKU=offStyle/override, for one person, sorted by garment: the flags() above with
|
||||
# the cut's mark in place of the group's, because the two are recorded apart and neither is `override`.
|
||||
sflags(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s/%s' % (sk[r['itemId']], r['offStyle'], r['override']) for r in d['issues'] if r['staffId']=='$1')))"; }
|
||||
check "the blouse is the women's cut, the shirt the men's, the theatre top the women's" "$(echo "$BK" | py "print(' '.join(sorted(i['sku'] + '=' + i['gender'] for i in d['items'] if i['sku'] in ('WB', 'FS', 'TT'))))")" '^FS=Male TT=Female WB=Female$'
|
||||
check "Milo's cut, written Mens in the file, is stored as the style itself" "$(styleOf "$MILO")" "^\[Men.s\]$"
|
||||
check " and Ash's, written both, as the one that sees every cut" "$(styleOf "$ASH")" '^\[Either\]$'
|
||||
check " while Dana and Robin, whose cells were empty, are blank" "$(printf '%s%s' "$(styleOf "$DANA")" "$(styleOf "$ROBIN")")" '^\[\]\[\]$'
|
||||
DJ="$T/tc-sets-dana.txt"; MIJ="$T/tc-sets-milo.txt"; AJ="$T/tc-sets-ash.txt"; rm -f "$DJ" "$MIJ" "$AJ"
|
||||
check "three of them claim staff accounts" "$(printf '%s\n' "$(claim "$DANA" "$DJ" dana)" "$(claim "$MILO" "$MIJ" milo)" "$(claim "$ASH" "$AJ" ash)" | grep -c '"ok":true')" '^3$'
|
||||
|
||||
echo "== blank is what every register reads today, and it is offered everything"
|
||||
# Nothing may change for a record nobody has set. Dana is blank here and is offered, and handed, the
|
||||
# men's cut — the very garment she is refused further down, once a coordinator has said which cut she
|
||||
# wears. Both reads are of the same person and the same garment, so the only thing between them is
|
||||
# the field.
|
||||
R=$(shop "$DJ")
|
||||
check "Dana, blank, is offered the men's shirt on her own request screen" "$R" 'Field Shirt'
|
||||
check " and the women's blouse beside it" "$R" 'Ward Blouse'
|
||||
check "Dana is handed the men's shirt at the counter with no override" "$(issue "$DANA" "$(L $FS 1 stock)")" '"stock":1'
|
||||
check " and its row is marked neither the wrong cut nor an override" "$(sflags "$DANA")" '^FS=False/False$'
|
||||
# Joined while nobody had said which cut she wears, for the offer section below.
|
||||
WSD=$(smut waitlist.join "$DJ" "{\"itemId\":\"$FS\",\"si\":0}")
|
||||
check " and she takes a place in the queue for one" "$WSD" '"id"'
|
||||
WSM=$(smut waitlist.join "$MIJ" "{\"itemId\":\"$FS\",\"si\":0}")
|
||||
check "Milo, set to Men's, queues for the same shirt" "$WSM" '"id"'
|
||||
|
||||
echo "== set to Women's, the counter refuses the men's cut without the override"
|
||||
check "the coordinator sets Dana to Women's" "$(mut staff.patch "{\"id\":\"$DANA\",\"uniformStyle\":\"Women's\"}")" '"ok":true'
|
||||
check " and it is on her record" "$(styleOf "$DANA")" "^\[Women.s\]$"
|
||||
# A style nothing recognises would be stored and then read as blank, quietly offering her everything
|
||||
# while the screen showed a decision somebody thought they had made.
|
||||
check "a word the rule doesn't know is refused rather than stored" "$(mut staff.patch "{\"id\":\"$DANA\",\"uniformStyle\":\"whatever the ward says\"}")" 'Uniform style has to be'
|
||||
check " and she is still set to Women's" "$(styleOf "$DANA")" "^\[Women.s\]$"
|
||||
check "Dana takes the women's blouse with no override" "$(issue "$DANA" "$(L $WB 1 stock)")" '"stock":1'
|
||||
check " and the jacket worn in every cut" "$(issue "$DANA" "$(L $FJ 1 stock)")" '"stock":1'
|
||||
R=$(issue "$DANA" "$(L $FS 1 stock)")
|
||||
check "the men's shirt is refused to her" "$R" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s"
|
||||
check " and the refusal says what lets it through" "$R" 'Tick the coordinator override'
|
||||
no " and is not the ceiling's refusal" "$R" "$CAP"
|
||||
# One: the shirt she was handed before anybody set her cut. A refusal that wrote anyway reads two.
|
||||
check " and wrote nothing" "$(held "$DANA" "$FS")" '^1$'
|
||||
check "Milo, whose cut it is, is handed the same shirt with no override" "$(issue "$MILO" "$(L $FS 1 stock)")" '"stock":1'
|
||||
check " and his row is marked neither" "$(sflags "$MILO")" '^FS=False/False$'
|
||||
|
||||
echo "== at the counter the cut takes the override, recorded apart from the ceiling's"
|
||||
R=$(issue "$DANA" "$(L $FS 1 stock),$(L $WB 1 stock)" ',"override":true')
|
||||
check "with the override Dana takes the men's shirt, and her own blouse beside it" "$R" '"stock":2'
|
||||
check " and the counter is told one garment was not her cut" "$R" '"offStyle":1'
|
||||
# Two garments, well inside six sets: the shirt's row says the wrong cut, the blouse's says nothing,
|
||||
# and neither says override — that word stays the ceiling's.
|
||||
check " the shirt's row alone is marked the wrong cut, and nothing is marked an override" "$(sflags "$DANA")" '^FJ=False/False FS=False/False FS=True/False WB=False/False WB=False/False$'
|
||||
|
||||
echo "== the staff app lists her own cut and the unisex range, and refuses the rest"
|
||||
R=$(shop "$DJ")
|
||||
check "Dana's request screen offers the women's blouse" "$R" 'Ward Blouse'
|
||||
check " and the jacket worn in every cut" "$R" 'Fleece Jacket'
|
||||
no " and not the men's shirt, though she is holding two" "$R" 'Field Shirt'
|
||||
R=$(smut request.create "$DJ" "$(ASK "$(RL "$FS")")")
|
||||
check "asking for the men's shirt is refused" "$R" "Field Shirt is the Men.s cut\. You.re set to Women.s"
|
||||
no " and nothing is raised" "$R" '"code"'
|
||||
check "her own blouse goes through" "$(smut request.create "$DJ" "$(ASK "$(RL "$WB")")")" '"code"'
|
||||
check "Milo asking for the shirt in his own cut goes through" "$(smut request.create "$MIJ" "$(ASK "$(RL "$FS")")")" '"code"'
|
||||
# Measured against the person it is for, whoever raises it.
|
||||
check "Mara cannot raise the men's shirt for Dana" "$(smut request.create "$MJ" "{\"subjectId\":\"$DANA\",\"lines\":[$(RL "$FS")],\"reason\":\"Worn out\"}")" "Field Shirt is the Men.s cut\. Dana is set to Women.s\."
|
||||
check " but can raise the blouse for her" "$(smut request.create "$MJ" "{\"subjectId\":\"$DANA\",\"lines\":[$(RL "$WB")],\"reason\":\"Worn out\"}")" '"code"'
|
||||
check "Dana cannot queue for the men's shirt now" "$(smut waitlist.join "$DJ" "{\"itemId\":\"$FS\",\"si\":0}")" "Field Shirt is the Men.s cut"
|
||||
check " while queueing for the blouse is allowed" "$(smut waitlist.join "$DJ" "{\"itemId\":\"$WB\",\"si\":0}")" '"id"'
|
||||
|
||||
echo "== the counter's request door and every order for her ask the same question"
|
||||
N0=$(reqs "$DANA")
|
||||
R=$(mut request.raise "{\"staffId\":\"$DANA\",\"lines\":[$(RL "$WB"),$(RL "$FS")],\"reason\":\"Worn out\"}")
|
||||
check "the counter cannot raise the men's shirt for Dana" "$R" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s, and a request can only carry their own style"
|
||||
check " and is told the Issue screen's override is the way" "$R" 'on the Issue screen with the coordinator override ticked'
|
||||
no " and the refusal names only the garment that isn't her cut" "$R" 'Ward Blouse is the'
|
||||
check " and raised nothing" "$(reqs "$DANA")" "^$N0\$"
|
||||
check "her blouse and the jacket for every cut, raised the same way, go through" "$(mut request.raise "{\"staffId\":\"$DANA\",\"lines\":[$(RL "$WB"),$(RL "$FJ")],\"reason\":\"Worn out\"}")" '"code"'
|
||||
O0=$(ords "$DANA")
|
||||
R=$(mut order.create "{\"orderFor\":\"Staff Member\",\"staffId\":\"$DANA\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$FS")]}")
|
||||
check "an order for Dana carrying the men's shirt is refused" "$R" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s, and an order for somebody can only carry their own style"
|
||||
check " and is pointed at Order in with the override" "$R" 'use Order in on the Issue screen with the coordinator override ticked'
|
||||
check " and wrote no order" "$(ords "$DANA")" "^$O0\$"
|
||||
R=$(mut order.create "{\"orderFor\":\"Staff Member\",\"staffId\":\"$DANA\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$WB")]}")
|
||||
check "an order for Dana carrying her own blouse is written" "$R" '"code"'
|
||||
DD=$(echo "$R" | py "print(d['result']['id'])")
|
||||
check "the men's shirt can't be added to it afterwards" "$(mut order.lineAdd "{\"id\":\"$DD\",\"itemId\":\"$FS\",\"size\":\"M\",\"qty\":1}")" "Field Shirt is the Men.s cut"
|
||||
check " while the jacket for every cut can" "$(mut order.lineAdd "{\"id\":\"$DD\",\"itemId\":\"$FJ\",\"size\":\"M\",\"qty\":1}")" '"ok":true'
|
||||
R=$(mut order.create "{\"orderFor\":\"Stock\",\"supplier\":\"Alpha Supply\",\"lines\":[$(OL "$FS")]}")
|
||||
check "a stock order carries the men's shirt, being for nobody" "$R" '"code"'
|
||||
SS=$(echo "$R" | py "print(d['result']['id'])")
|
||||
check " but can't then be put in Dana's name" "$(mut order.update "{\"id\":\"$SS\",\"staffId\":\"$DANA\"}")" 'Take it off this order first'
|
||||
check " and is still a stock order" "$(FOR "$SS")" '^Stock$'
|
||||
check " while Milo's name, whose cut it is, goes on it" "$(mut order.update "{\"id\":\"$SS\",\"staffId\":\"$MILO\"}")" '"ok":true'
|
||||
check " making it his" "$(FOR "$SS")" '^Staff Member$'
|
||||
|
||||
echo "== ordered in on the override, the cut is marked when the garment reaches her"
|
||||
# There is no issue row to mark when a garment is ordered in, so the supplier order carries it; the
|
||||
# row the collection writes has to carry the mark, or the Exceptions report never sees the garment in
|
||||
# the month it reached her. Both orders below are the only open ones each of them has.
|
||||
check "the men's shirt ordered in for Dana on the override" "$(issue "$DANA" "$(L $FS 1 order)" ',"override":true')" '"ordered":1'
|
||||
check " and the supplier order says whose cut it isn't, and who allowed it" "$(notes "$DANA")" "Not Dana.s uniform style: Field Shirt .*override recorded by Sets Admin"
|
||||
check "one ordered in for Milo, whose cut it is" "$(issue "$MILO" "$(L $FS 1 order)")" '"ordered":1'
|
||||
check " carries the ordinary note" "$(notes "$MILO")" 'Ordered at issue for Milo Reed'
|
||||
no " and nothing about a uniform style" "$(notes "$MILO")" 'uniform style'
|
||||
# The issue rows one order turned into, for one person, as SKU=offStyle.
|
||||
gotS(){ bk | py "c=[o['code'] for o in d['orders'] if o['id']=='$2'][0]; sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s' % (sk[r['itemId']], r['offStyle']) for r in d['issues'] if r['staffId']=='$1' and r['direct'] and r['orderCode']==c)))"; }
|
||||
DO=$(openOrd "$DANA"); MO=$(openOrd "$MILO")
|
||||
check "Dana's shirt arrives and waits at the counter" "$(toCounter "$DO")" '"ok":true'
|
||||
check " and so does Milo's" "$(toCounter "$MO")" '"ok":true'
|
||||
check "Dana collects hers" "$(mut pickup.pickedUp "{\"id\":\"$(puOf "$DO")\"}")" '"ok":true'
|
||||
check " and the issue row it wrote is marked the wrong cut" "$(gotS "$DANA" "$DO")" '^FS=True$'
|
||||
check "Milo collects his" "$(mut pickup.pickedUp "{\"id\":\"$(puOf "$MO")\"}")" '"ok":true'
|
||||
check " and his is marked nothing" "$(gotS "$MILO" "$MO")" '^FS=False$'
|
||||
|
||||
echo "== a place in the queue for another cut isn't offered"
|
||||
# Joining is refused for another cut, but Dana's place was joined before anybody had said which cut
|
||||
# she wears and outlives the decision. Offering it would hold the stock for forty-eight hours for
|
||||
# somebody whose acceptance her own request door now refuses — beside Milo's place for the same shirt.
|
||||
WD=$(rid "$WSD"); WM=$(rid "$WSM")
|
||||
R=$(mut waitlist.offer "{\"id\":\"$WD\"}")
|
||||
check "the men's shirt can't be offered to Dana's place, now she is set to Women's" "$R" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s, so they can.t take it"
|
||||
check " and the linen room is told to take her off the list" "$R" 'Take them off this waitlist instead'
|
||||
check " and her place is not marked offered" "$(offered "$WD")" '^False$'
|
||||
check "Milo's place for the same shirt is offered" "$(mut waitlist.offer "{\"id\":\"$WM\"}")" '"heldUntil"'
|
||||
check " and marked offered" "$(offered "$WM")" '^True$'
|
||||
|
||||
echo "== Either sees both cuts, and so does a record nobody has set"
|
||||
R=$(issue "$ASH" "$(L $WB 1 stock),$(L $FS 1 stock)")
|
||||
check "Ash, set to Either, takes the women's blouse and the men's shirt in one cart, no override" "$R" '"stock":2'
|
||||
check " and the counter is told neither was the wrong cut" "$R" '"offStyle":0'
|
||||
R=$(shop "$AJ")
|
||||
check " his request screen offers the women's blouse" "$R" 'Ward Blouse'
|
||||
check " and the men's shirt beside it" "$R" 'Field Shirt'
|
||||
check "Robin, whom nobody has set, is handed the women's blouse with no override" "$(issue "$ROBIN" "$(L $WB 1 stock)")" '"stock":1'
|
||||
check " and his record is still blank rather than quietly decided for him" "$(styleOf "$ROBIN")" '^\[\]$'
|
||||
|
||||
echo "== another group's garment and another cut, in one refusal"
|
||||
# One override tick answers both questions, so a cart that is both has to say both in the one refusal
|
||||
# — or ticking the box for the cut would wave the group through with nobody told. The theatre top is
|
||||
# the nurses', in the women's cut; Milo is in Kitchen and set to Men's, so it is wrong on both counts.
|
||||
# Mara, a nurse nobody has set a cut for, is handed the same garment beside him.
|
||||
R=$(issue "$MILO" "$(L $TT 1 stock)")
|
||||
check "the nurses' women's-cut top is refused to Milo as another group's" "$R" 'Theatre Scrub Top is for Registered Nurse, Enrolled Nurse — Milo Reed is in Kitchen'
|
||||
check " and, in the same refusal, as another cut" "$R" "Theatre Scrub Top is the Women.s cut — Milo Reed is set to Men.s"
|
||||
check " asking for the tick once, for the one garment" "$(echo "$R" | grep -o 'Tick the coordinator override to issue it anyway' | wc -l | tr -d ' ')" '^1$'
|
||||
no " with nothing about the ceiling" "$R" 'It would also take them past'
|
||||
check " and wrote nothing" "$(held "$MILO" "$TT")" '^0$'
|
||||
check "Mara, a nurse with no cut set, is handed the same top with no override" "$(issue "$MARA" "$(L $TT 1 stock)")" '"stock":1'
|
||||
|
||||
echo "== the cut survives the register CSV the settings screen imports"
|
||||
# The Settings screen parses the file and posts the rows through import.rows, so the round trip worth
|
||||
# testing is the template's own header row and example line, read out of csv.ts and put through that
|
||||
# same op. A style column added to one and not the other, or an example the importer can't read,
|
||||
# fails here rather than on a coordinator's first real file.
|
||||
CSVH=$(sed -n 's/^[[:space:]]*staff:.*headers: "\([^"]*\)".*$/\1/p' "$(dirname "$0")/../lib/csv.ts")
|
||||
CSVX=$(sed -n 's/^[[:space:]]*staff:.*example: `\([^`]*\)`.*$/\1/p' "$(dirname "$0")/../lib/csv.ts")
|
||||
check "the staff template names a style column" "$(echo ",$CSVH,")" ',style,'
|
||||
check " and its header row and example line are the same width" "$(HDR="$CSVH" EX="$CSVX" python3 -c 'import csv, io, os; print(len(next(csv.reader(io.StringIO(os.environ["HDR"])))), len(next(csv.reader(io.StringIO(os.environ["EX"])))))')" '^16 16$'
|
||||
ROW=$(HDR="$CSVH" EX="$CSVX" python3 -c 'import csv, io, json, os; h=next(csv.reader(io.StringIO(os.environ["HDR"]))); x=next(csv.reader(io.StringIO(os.environ["EX"]))); r=dict(zip(h, x)); r["num"]="15"; r["manager"]="9"; print(json.dumps(r))')
|
||||
check "the template's own example row imports" "$(mut import.rows "{\"kind\":\"staff\",\"rows\":[$ROW]}")" '"created":1'
|
||||
FIF=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="15"][0])')
|
||||
check " carrying the cut the example writes" "$(styleOf "$FIF")" "^\[Women.s\]$"
|
||||
check "the same person re-imported with the style cell empty" "$(mut import.rows '{"kind":"staff","rows":[{"num":"15","first":"Mara","last":"Whitfield","style":""}]}')" '"updated":1'
|
||||
check " keeps the cut that was there, as every blank cell does" "$(styleOf "$FIF")" "^\[Women.s\]$"
|
||||
# A register exported from a payroll or a roster writes this column as a gender, and that is the file
|
||||
# this gets loaded from, so those spellings land on a style instead of being turned away.
|
||||
check "a register exported with a gender column reads as a cut" "$(mut import.rows '{"kind":"staff","rows":[{"num":"16","first":"Noor","last":"Haddad","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","gender":"F"}]}')" '"created":1'
|
||||
check " and she is set to Women's" "$(styleOf "$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="16"][0])')")" "^\[Women.s\]$"
|
||||
R=$(mut import.rows '{"kind":"staff","rows":[{"num":"17","first":"Rae","last":"Okonkwo","group":"Kitchen","dept":"Ward 1","top":"M","pants":"M","style":"whatever the ward says"}]}')
|
||||
check "a cell the rule can't read is reported rather than guessed at" "$R" "isn.t Men.s, Women.s, Either or blank"
|
||||
check " and the row is imported all the same" "$R" '"created":1'
|
||||
RAE=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="17"][0])')
|
||||
check " with the cut left blank" "$(styleOf "$RAE")" '^\[\]$'
|
||||
check " so nothing is refused to her at the counter" "$(issue "$RAE" "$(L $FS 1 stock)")" '"stock":1'
|
||||
|
||||
echo "== handed over after the wearer was set to another cut, the garment is marked"
|
||||
# The same shape as the staff-group section above, and there for the same reason: both doors that
|
||||
# raise a request refuse another cut, and a hand-over is not a second chance to refuse what a manager
|
||||
# approved. So somebody set to a cut between the ask and the collection is still handed the bag, and
|
||||
# the issue row is marked the wrong cut. Vera and Nell each ask for the women's blouse twice while it
|
||||
# is theirs — one bag apiece for the counter and one apiece for the ward round — and then Vera is set
|
||||
# to Men's and Nell is left exactly as she was.
|
||||
#
|
||||
# The blouse is for every staff group and neither of them ends up holding more than two of it, so
|
||||
# neither the group rule nor the six-set ceiling can mark anything here: the cut is the only thing
|
||||
# between the two readings, and every row is read for all three marks rather than the one.
|
||||
check "two more in Kitchen, both set to Women's, reporting to Mara" "$(mut import.rows "{\"kind\":\"staff\",\"rows\":[{\"num\":\"18\",\"first\":\"Vera\",\"last\":\"Pike\",\"group\":\"Kitchen\",\"dept\":\"Ward 1\",\"top\":\"M\",\"pants\":\"M\",\"manager\":\"9\",\"style\":\"Women's\"},{\"num\":\"19\",\"first\":\"Nell\",\"last\":\"Ross\",\"group\":\"Kitchen\",\"dept\":\"Ward 1\",\"top\":\"M\",\"pants\":\"M\",\"manager\":\"9\",\"style\":\"Women's\"}]}")" '"created":2'
|
||||
VERA=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="18"][0])')
|
||||
NELL=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="19"][0])')
|
||||
check " and the register carries the cut for both of them" "$(printf '%s%s' "$(styleOf "$VERA")" "$(styleOf "$NELL")")" "^\[Women.s\]\[Women.s\]$"
|
||||
VJ="$T/tc-sets-vera.txt"; NJ="$T/tc-sets-nell.txt"; rm -f "$VJ" "$NJ"
|
||||
check "both claim staff accounts" "$(printf '%s\n' "$(claim "$VERA" "$VJ" vera)" "$(claim "$NELL" "$NJ" nell)" | grep -c '"ok":true')" '^2$'
|
||||
# Each new issue row as SKU=offStyle/offGroup/override: since() above with the cut's mark first and
|
||||
# the other two beside it, because a hand-over that stamped the staff group's flag or the ceiling's
|
||||
# instead of the cut's would otherwise read as a pass.
|
||||
sinceS(){ bk | B4="$2" py "import os; b=set(os.environ['B4'].split()); sk={i['id']: i['sku'] for i in d['items']}; print(' '.join(sorted('%s=%s/%s/%s' % (sk[r['itemId']], r['offStyle'], r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$1' and r['id'] not in b)))"; }
|
||||
V1=$(smut request.create "$VJ" "$(ASK "$(RL "$WB")")"); V2=$(smut request.create "$VJ" "$(ASK "$(RL "$WB")")")
|
||||
N1=$(smut request.create "$NJ" "$(ASK "$(RL "$WB")")"); N2=$(smut request.create "$NJ" "$(ASK "$(RL "$WB")")")
|
||||
check "Vera asks for the women's blouse twice while it is her cut, and so does Nell" "$(printf '%s\n' "$V1" "$V2" "$N1" "$N2" | grep -c '"code"')" '^4$'
|
||||
VC=$(rid "$V1"); VR=$(rid "$V2"); NC=$(rid "$N1"); NR=$(rid "$N2")
|
||||
check " and Mara approves all four" "$(for r in $VC $VR $NC $NR; do smut request.approve "$MJ" "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"accepted"')" '^4$'
|
||||
check "the linen room picks all four" "$(for r in $VC $VR $NC $NR; do mut request.pick "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"picking"')" '^4$'
|
||||
check " holds one of each at the counter" "$(for r in $VC $NC; do mut request.hold "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"ready"')" '^2$'
|
||||
check " and sends the other two on the round" "$(for r in $VR $NR; do mut request.round "{\"id\":\"$r\"}"; echo; done | grep -c '"status":"round"')" '^2$'
|
||||
# staff.patch is the control the cut has of its own, and the one the sections above set a cut with.
|
||||
check "the coordinator sets Vera to Men's, her bags already picked" "$(mut staff.patch "{\"id\":\"$VERA\",\"uniformStyle\":\"Men's\"}")" '"ok":true'
|
||||
check " and her record reads it" "$(styleOf "$VERA")" "^\[Men.s\]$"
|
||||
check " while Nell is left as she was" "$(styleOf "$NELL")" "^\[Women.s\]$"
|
||||
VB=$(ids "$VERA"); NB=$(ids "$NELL")
|
||||
check "Vera collects her bag at the counter" "$(mut request.collected "{\"id\":\"$VC\"}")" '"status":"collected"'
|
||||
check " and the row it wrote is marked the wrong cut, and neither another group's nor an override" "$(sinceS "$VERA" "$VB")" '^WB=True/False/False$'
|
||||
check "Nell collects hers" "$(mut request.collected "{\"id\":\"$NC\"}")" '"status":"collected"'
|
||||
check " and hers is marked none of the three" "$(sinceS "$NELL" "$NB")" '^WB=False/False/False$'
|
||||
VB=$(ids "$VERA"); NB=$(ids "$NELL")
|
||||
check "Mara signs for Vera's bag on the round" "$(smut round.sign "$MJ" "{\"id\":\"$VR\"}")" '"ok":true'
|
||||
check " and that row is marked the wrong cut as well" "$(sinceS "$VERA" "$VB")" '^WB=True/False/False$'
|
||||
check "and for Nell's" "$(smut round.sign "$MJ" "{\"id\":\"$NR\"}")" '"ok":true'
|
||||
check " and hers is not" "$(sinceS "$NELL" "$NB")" '^WB=False/False/False$'
|
||||
|
||||
echo "== every split of an off-style garment keeps the mark"
|
||||
# What the counter handed over on the override is split by three ordinary acts — a hand-in, a return
|
||||
# over the counter, and a swap for another size — and each of them writes a new issue row off the old
|
||||
# one. A split that dropped the mark would take the garment off the Exceptions report for the month
|
||||
# it was queried, and leave the half still out with the wearer reading as an ordinary issue. Both
|
||||
# halves of every split are read, the new-size row a swap writes included, beside a garment of Rory's
|
||||
# own cut put through the very same three splits: a split that stamped every row it wrote would pass
|
||||
# the blouse and fail the trouser.
|
||||
#
|
||||
# Two sizes on each, because a swap needs another size to swap into. A blouse and a trouser because
|
||||
# they are the two halves of a set and carry a ceiling each, so four of each is four tops and four
|
||||
# pairs — well inside six of either, and nothing below is an override of the ceiling. Both are for
|
||||
# every staff group, so the group rule marks nothing here either.
|
||||
# These two go on the catalogue through the counter's own add rather than the importer, and the
|
||||
# reason is a ceiling rather than a preference: every import and every restore counts against one
|
||||
# bucket of twenty per coordinator per ten minutes (app/api/mutate/route.ts). This suite is a single
|
||||
# coordinator that already spends most of them, so adding a catalogue import and an opening-stock
|
||||
# import here pushed it to twenty-one — and the call refused was the restore at the very end, which
|
||||
# is where the marks are read back. The add carries the sizes and the opening stock in the one call,
|
||||
# so the section still builds its own fixtures and stands alone. That leaves nineteen, one spare:
|
||||
# anything added here later should come in the same way rather than through the importer.
|
||||
# No group is sent, and no group means every group, so the group rule marks nothing in this section.
|
||||
check "a women's-cut blouse, two sizes, with stock on the shelf" "$(mut catalog.add '{"item":"Bistro Blouse","sku":"BB","type":"Blouse","gender":"Female","supplier":"Alpha Supply","cost":31,"sizes":["S","M"],"opening":[{"si":0,"qty":20},{"si":1,"qty":20}]}')" '"id":"'
|
||||
check " and a men's-cut trouser beside it" "$(mut catalog.add '{"item":"Bistro Trouser","sku":"BT","type":"Trousers","gender":"Male","supplier":"Alpha Supply","cost":29,"sizes":["S","M"],"opening":[{"si":0,"qty":20},{"si":1,"qty":20}]}')" '"id":"'
|
||||
BB=$(bk | py 'print([i["id"] for i in d["items"] if i["sku"]=="BB"][0])')
|
||||
BT=$(bk | py 'print([i["id"] for i in d["items"] if i["sku"]=="BT"][0])')
|
||||
check "one more in Kitchen, set to Men's and wearing S" "$(mut import.rows "{\"kind\":\"staff\",\"rows\":[{\"num\":\"20\",\"first\":\"Rory\",\"last\":\"Blake\",\"group\":\"Kitchen\",\"dept\":\"Ward 1\",\"top\":\"S\",\"pants\":\"S\",\"style\":\"Men's\"}]}")" '"created":1'
|
||||
RORY=$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="20"][0])')
|
||||
# Every issue row he has, as SKU:size:qty:state:offStyle:offGroup:override. The size, because a swap
|
||||
# writes its new row in another one; the state, because these three splits leave rows handed in,
|
||||
# returned and still out beside each other, and each has to be named for what it is. All three flags
|
||||
# are read, so a split that carried the mark onto the wrong one fails here rather than reading alike.
|
||||
srows(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; sz={i['id']: i['sizes'] for i in d['items']}; st=lambda r: 'in' if r['handedIn'] else (('ret-' + (r['returnedCond'] or '').split(' - ')[-1].lower().replace(' ', '-')) if r['returnedDate'] else 'held'); print(' '.join(sorted('%s:%s:%d:%s:%s:%s:%s' % (sk[r['itemId']], sz[r['itemId']][r['sizeIndex']], r['qty'], st(r), r['offStyle'], r['offGroup'], r['override']) for r in d['issues'] if r['staffId']=='$1')))"; }
|
||||
R=$(issue "$RORY" "$(L $BB 4 stock),$(L $BT 4 stock)" ',"override":true')
|
||||
check "Rory takes four women's-cut blouses on the override, and four trousers of his own cut" "$R" '"stock":8'
|
||||
check " and the counter is told one garment was not his cut" "$R" '"offStyle":1'
|
||||
check " the blouses' row marked the wrong cut, the trousers' not, and neither a group nor an override" "$(srows "$RORY")" '^BB:S:4:held:True:False:False BT:S:4:held:False:False:False$'
|
||||
check "Rory hands one blouse in, and one trouser beside it" "$(printf '%s\n' "$(handin "$RORY" "$BB")" "$(handin "$RORY" "$BT")" | grep -c '"good":1')" '^2$'
|
||||
check " each row split in two, both halves of the blouse marked and neither half of the trouser" "$(srows "$RORY")" '^BB:S:1:in:True:False:False BB:S:3:held:True:False:False BT:S:1:in:False:False:False BT:S:3:held:False:False:False$'
|
||||
# The one row of that garment still out with him, named rather than picked from a list, so a split
|
||||
# that left two rows out or marked the wrong half fails here instead of being read past.
|
||||
out1(){ bk | py "x=[i['id'] for i in d['issues'] if i['staffId']=='$1' and i['itemId']=='$2' and not i['handedIn'] and not i['returnedDate']]; print(x[0] if len(x)==1 else 'expected one row still out, found %d' % len(x))"; }
|
||||
check "one blouse comes back over the counter damaged, and one trouser with it" "$(printf '%s\n' "$(mut issue.return "{\"id\":\"$(out1 "$RORY" "$BB")\",\"qty\":1,\"cond\":\"Returned - Damaged\"}")" "$(mut issue.return "{\"id\":\"$(out1 "$RORY" "$BT")\",\"qty\":1,\"cond\":\"Returned - Damaged\"}")" | grep -c '"ok":true')" '^2$'
|
||||
check " the returned half and the half still out both marked, and the trouser's neither" "$(srows "$RORY")" '^BB:S:1:in:True:False:False BB:S:1:ret-damaged:True:False:False BB:S:2:held:True:False:False BT:S:1:in:False:False:False BT:S:1:ret-damaged:False:False:False BT:S:2:held:False:False:False$'
|
||||
check "one of the two blouses still out is swapped for the other size" "$(mut issue.exchange "{\"id\":\"$(out1 "$RORY" "$BB")\",\"si\":1,\"qty\":1}")" '"size":"M"'
|
||||
check " and one trouser the same way" "$(mut issue.exchange "{\"id\":\"$(out1 "$RORY" "$BT")\",\"si\":1,\"qty\":1}")" '"size":"M"'
|
||||
check " the new size, the size it replaced and the one still out all marked, and the trouser's none of them" "$(srows "$RORY")" '^BB:M:1:held:True:False:False BB:S:1:held:True:False:False BB:S:1:in:True:False:False BB:S:1:ret-damaged:True:False:False BB:S:1:ret-good:True:False:False BT:M:1:held:False:False:False BT:S:1:held:False:False:False BT:S:1:in:False:False:False BT:S:1:ret-damaged:False:False:False BT:S:1:ret-good:False:False:False$'
|
||||
|
||||
echo "== correcting a phone number doesn't clear the cut somebody was set to"
|
||||
# The details form saves the whole record and sends no style, because the style has its own control.
|
||||
# A save that blanked it every time would put people back to "nobody has said" — offered the whole
|
||||
# catalogue again — with nothing on any screen to say it had happened.
|
||||
check "Dana's record saved from the details form, with a phone number added" "$(mut staff.save "{\"id\":\"$DANA\",\"num\":\"11\",\"first\":\"Dana\",\"last\":\"Lee\",\"group\":\"Kitchen\",\"dept\":\"Ward 1\",\"top\":\"M\",\"pants\":\"M\",\"phone\":\"0400 000 111\"}")" '"ok":true'
|
||||
check " leaves her set to Women's" "$(styleOf "$DANA")" "^\[Women.s\]$"
|
||||
check " and the men's shirt is still refused to her" "$(issue "$DANA" "$(L $FS 1 stock)")" "Field Shirt is the Men.s cut"
|
||||
# Blank is a decision a coordinator can go back to: whoever ticked the wrong one has to be able to
|
||||
# undo it, and undoing it offers her everything again, which is where this section started.
|
||||
check "the cut can be cleared back to nobody having said" "$(mut staff.patch "{\"id\":\"$DANA\",\"uniformStyle\":\"\"}")" '"ok":true'
|
||||
check " and her record reads blank" "$(styleOf "$DANA")" '^\[\]$'
|
||||
check " and the men's shirt is on her request screen again" "$(shop "$DJ")" 'Field Shirt'
|
||||
check "and she is set back to Women's, for the restore below" "$(mut staff.patch "{\"id\":\"$DANA\",\"uniformStyle\":\"Women's\"}")" '"ok":true'
|
||||
|
||||
echo "== a restore keeps a garment's groups and cut, both marks, and every wearer's style"
|
||||
# Last, because a restore gives every row a fresh id and the staff sessions above are keyed to the
|
||||
# old ones. Everything is read by staff number and SKU, which survive it. Every issue row is compared,
|
||||
# both marks and neither alike, so a restore that dropped one and one that stamped it on everything
|
||||
# both fail — and the tunic, tagged for two groups, has to come back with both.
|
||||
#
|
||||
# The register's own column is compared the same way and for the same reason: every record at once,
|
||||
# so a restore that lost the cut and one that wrote a cut onto the blanks both fail. Blank has to come
|
||||
# back blank — it is the state nobody has decided, not a value worth guessing at.
|
||||
GBY(){ bk | py "print(','.join([i['groups'] for i in d['items'] if i['sku']=='$1'][0]))"; }
|
||||
every(){ bk | py "sk={i['id']: i['sku'] for i in d['items']}; nm={s['id']: s['num'] for s in d['staff']}; print(' '.join(sorted('%s:%s:%d:%s:%s:%s' % (nm[r['staffId']], sk[r['itemId']], r['qty'], 'in' if r['handedIn'] else 'held', r['offGroup'], r['offStyle']) for r in d['issues'])))"; }
|
||||
# Every staff record's cut, by staff number, in brackets so blank — nobody has said — is a value a
|
||||
# check can name rather than an empty space between two others.
|
||||
styles(){ bk | py "print(' '.join(sorted('[%s=%s]' % (s['num'], s['uniformStyle']) for s in d['staff'])))"; }
|
||||
A0=$(every); S0=$(styles)
|
||||
check "before: the tunic is for two groups" "$(GBY WT)" '^Registered Nurse,Enrolled Nurse$'
|
||||
check " and Gus holds a tunic marked outside his group, and not as the wrong cut" "$A0" '8:WT:1:held:True:False'
|
||||
check " beside Rhea's, which is marked neither" "$A0" '7:WT:1:held:False:False'
|
||||
check " while Dana holds a men's shirt marked the wrong cut, and not as another group's" "$A0" '11:FS:1:held:False:True'
|
||||
check " beside the one she was handed before anybody set her cut" "$A0" '11:FS:1:held:False:False'
|
||||
check " and the register carries her cut" "$S0" "\[11=Women.s\]"
|
||||
check " Ash's Either, and Robin's blank" "$S0" '\[13=Either\] \[14=\]'
|
||||
RF="$T/tc-sets-restore.json"
|
||||
# Written to a file and sent from it: the whole facility's backup is too long to pass as one argument.
|
||||
printf '{"op":"backup.restore","payload":%s}' "$(bk)" > "$RF"
|
||||
check "the backup restores" "$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' --data-binary @"$RF")" '"ok":true'
|
||||
A1=$(every); S1=$(styles)
|
||||
check "after: the tunic is still for both groups" "$(GBY WT)" '^Registered Nurse,Enrolled Nurse$'
|
||||
check " and the cap for the renamed one" "$(GBY GC)" '^Protective Services$'
|
||||
check " the blouse is still the women's cut and the shirt the men's" "$(bk | py "print(' '.join(sorted(i['sku'] + '=' + i['gender'] for i in d['items'] if i['sku'] in ('WB', 'FS'))))")" '^FS=Male WB=Female$'
|
||||
check " Gus's tunic is still marked outside his group" "$A1" '8:WT:1:held:True:False'
|
||||
check " and Dana's shirt is still marked the wrong cut" "$A1" '11:FS:1:held:False:True'
|
||||
if [ -n "$A0" ] && [ "$A1" = "$A0" ]; then ok " and every issue row carries both marks it had before"; else fail " and every issue row carries both marks it had before" "$(echo "$A1" | head -c 300)"; fi
|
||||
if [ -n "$S0" ] && [ "$S1" = "$S0" ]; then ok " and every staff record the cut it was set to, blanks included"; else fail " and every staff record the cut it was set to, blanks included" "$(echo "$S1" | head -c 300)"; fi
|
||||
# Read once more through the rule itself rather than off the record: a restore that brought the word
|
||||
# back as something normalUniformStyle() doesn't know would read blank here and refuse nothing.
|
||||
check "and the men's shirt is still refused to Dana after the restore" "$(issue "$(bk | py 'print([s["id"] for s in d["staff"] if s["num"]=="11"][0])')" "$(L "$(bk | py 'print([i["id"] for i in d["items"] if i["sku"]=="FS"][0])')" 1 stock)")" "Field Shirt is the Men.s cut — Dana Lee is set to Women.s"
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
Executable
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
# Staff self-service: claiming your own record, and the walls around it.
|
||||
#
|
||||
# Most of this file is about what a staff session must NOT be able to do. The whole design rests on
|
||||
# a wearer's cookie being a different kind of thing from a coordinator's, so the checks that matter
|
||||
# are the ones that try to use one as the other.
|
||||
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-ss-coord.txt" # coordinator jar
|
||||
S="$T/tc-ss-staff.txt" # staff jar
|
||||
S2="$T/tc-ss-staff2.txt" # a second staff member
|
||||
rm -f "$C" "$S" "$S2"
|
||||
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 220)"; fi; }
|
||||
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 220)"; 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}"; }
|
||||
spost(){ curl -s -b "$S" -c "$S" -X POST "$B$1" -H 'content-type: application/json' -H "origin: $B" -d "$2"; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
# Everything React sends the browser as data rides in <script> blocks, and that includes whole
|
||||
# component trees the page never draws. Strip them and what is left is the markup a person sees,
|
||||
# which is the only thing a "this must not be on the screen" check should be asking about.
|
||||
markup(){ python3 -c 'import sys,re; sys.stdout.write(re.sub(r"(?is)<script\b[^>]*>.*?</script>","",sys.stdin.read()))'; }
|
||||
|
||||
TS=$(date +%s)
|
||||
CO="ss$TS@example.com"
|
||||
ME="wearer$TS@example.com"
|
||||
|
||||
echo "== setup"
|
||||
check "coordinator signs up" "$(curl -s -c "$C" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.23.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Sam\",\"last\":\"Self\",\"facility\":\"Self Service Hospital $TS\",\"email\":\"$CO\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$C")" '"ok":true'
|
||||
|
||||
A=$(mut staff.save "{\"num\":\"SS1\",\"first\":\"Ada\",\"last\":\"Wearer\",\"group\":\"Support Services\",\"dept\":\"Theatres\",\"top\":\"M\",\"pants\":\"12\"}")
|
||||
check "a staff member exists" "$A" '"id"'
|
||||
AID=$(echo "$A" | py "print(d['result']['id'])")
|
||||
Bx=$(mut staff.save "{\"num\":\"SS2\",\"first\":\"Bo\",\"last\":\"Other\",\"group\":\"Security\",\"dept\":\"Front of house\"}")
|
||||
BID=$(echo "$Bx" | py "print(d['result']['id'])")
|
||||
check "and a second one" "$Bx" '"id"'
|
||||
|
||||
# Give Ada something to look at, so the view has content and the isolation check has a needle.
|
||||
ITEM=$(mut catalog.add '{"item":"Theatre scrub top","type":"Scrub top","sizes":["S","M","L"],"cost":24.5,"opening":[{"si":1,"qty":40}]}')
|
||||
check "a garment is on the shelf" "$ITEM" '"id"'
|
||||
IID=$(echo "$ITEM" | py "print(d['result']['id'])")
|
||||
# The quantity is what is asserted, not just that it worked: every count in the handed-back
|
||||
# section below is arithmetic over this two, so a change to it has to fail here rather than
|
||||
# quietly rewrite what those later checks mean.
|
||||
check "Ada is issued two" "$(mut issue.create "{\"staffId\":\"$AID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":2}]}")" '"stock":2'
|
||||
|
||||
echo "== the code"
|
||||
GEN=$(mut staff.selfCode "{\"id\":\"$AID\"}")
|
||||
check "a coordinator can generate one" "$GEN" '"code"'
|
||||
CODE=$(echo "$GEN" | py "print(d['result']['code'])")
|
||||
check "twelve characters in three groups" "$CODE" '^[2-9A-HJ-NP-Z]\{4\}-[2-9A-HJ-NP-Z]\{4\}-[2-9A-HJ-NP-Z]\{4\}$'
|
||||
no "with no ambiguous characters" "$CODE" '[ILOU01]'
|
||||
# The snapshot is server-rendered into the page rather than served from an endpoint, so the staff
|
||||
# record itself is where to look for what the coordinator's browser was told.
|
||||
REC=$(curl -s -b "$C" "$B/app/staff/$AID")
|
||||
check "the record says a code is outstanding" "$REC" 'code is outstanding'
|
||||
no "but the page never carries the code itself" "$REC" "$CODE"
|
||||
|
||||
echo "== activating"
|
||||
check "a wrong code is refused" "$(spost /api/staff/activate "{\"agreed\":true,\"code\":\"AAAA-BBBB-CCCC\",\"email\":\"x$ME\",\"password\":\"wearerpass1\"}")" "isn't right"
|
||||
check "a short password is refused" "$(spost /api/staff/activate "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"$ME\",\"password\":\"short\"}")" 'at least 8'
|
||||
check "a bad email is refused" "$(spost /api/staff/activate "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"notanemail\",\"password\":\"wearerpass1\"}")" 'email address'
|
||||
ACT=$(spost /api/staff/activate "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"$ME\",\"password\":\"wearerpass1\"}")
|
||||
check "the right code sets the account up" "$ACT" '"ok":true'
|
||||
check "and greets them by name" "$ACT" 'Ada Wearer'
|
||||
check "a staff cookie was set" "$(cat "$S")" 'tc_staff'
|
||||
check "the same code can't be used twice" "$(curl -s -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "{\"agreed\":true,\"code\":\"$CODE\",\"email\":\"second$ME\",\"password\":\"wearerpass1\"}")" 'already been used'
|
||||
|
||||
echo "== what they see"
|
||||
MY=$(curl -s -b "$S" "$B/my")
|
||||
check "their own name on home" "$MY" 'Ada Wearer'
|
||||
check "their ward and staff number" "$MY" 'Theatres · SS1'
|
||||
no "and nobody else on the register" "$MY" 'Bo Other'
|
||||
KIT=$(curl -s -b "$S" "$B/my/kit")
|
||||
check "the kit screen lists what they hold" "$KIT" 'Theatre scrub top'
|
||||
no "and still nobody else" "$KIT" 'Bo Other'
|
||||
check "and nothing has come back off the record yet" "$KIT" 'Nothing handed back since'
|
||||
# Their recorded sizes used to be checked on Home. They were taken off that screen — homeData does
|
||||
# not return them any more — but they were not taken away from the person: the Kit screen carries
|
||||
# them, so the check follows them here rather than disappearing. This is not a duplicate of
|
||||
# anything above. Kit keeps the sizes behind its "My sizes" tab, and which tab is showing is
|
||||
# decided in the browser, so the server sends them as the screen's data rather than as markup.
|
||||
# That is what curl can see of them, and it is what the tab draws from: the two sizes Ada was
|
||||
# saved with at the top of this file, spelled the way the screen was handed them.
|
||||
check " and their recorded top and trouser sizes" "$KIT" '\\"sizes\\":{\\"top\\":\\"M\\",\\"pants\\":\\"12\\"}'
|
||||
|
||||
echo "== handed back counts both ways a garment leaves a person"
|
||||
# Two things take a garment off somebody: a return over the counter, and a hand-in, which never
|
||||
# stamps returnedDate because the garment joins the pre-loved pool instead of coming back to the
|
||||
# shelf. Counting returns alone told somebody who had carried four garments in that morning that
|
||||
# they had handed nothing back all year — on the one screen they would check before arguing about
|
||||
# it. A write-off is neither: nobody handed that one back, and crediting them for it is the same
|
||||
# untruth in the other direction.
|
||||
check "Ada is issued three more" "$(mut issue.create "{\"staffId\":\"$AID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":3}]}")" '"stock":3'
|
||||
ISS=$(curl -s -b "$C" "$B/api/backup" | py "print([i['id'] for i in d['issues'] if i['staffId']=='$AID'][0])")
|
||||
check "one comes back over the counter" "$(mut issue.return "{\"id\":\"$ISS\",\"qty\":1,\"cond\":\"Returned - Good\"}")" '"ok":true'
|
||||
check "the kit screen counts it" "$(curl -s -b "$S" "$B/my/kit")" '1 garment handed back since'
|
||||
check "another is handed in to the pool" "$(mut handin.add "{\"staffId\":\"$AID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1,\"cond\":\"Good\"}]}")" '"good":1'
|
||||
check " and it counts the same" "$(curl -s -b "$S" "$B/my/kit")" '2 garments handed back since'
|
||||
LOST=$(curl -s -b "$C" "$B/api/backup" | py "print([i['id'] for i in d['issues'] if i['staffId']=='$AID' and not i['returnedDate'] and not i['handedIn']][0])")
|
||||
check "one more is written off" "$(mut issue.return "{\"id\":\"$LOST\",\"qty\":1,\"cond\":\"Written Off\"}")" '"ok":true'
|
||||
check " which nobody handed back" "$(curl -s -b "$S" "$B/my/kit")" '2 garments handed back since'
|
||||
|
||||
echo "== leaving the app"
|
||||
HOME_HTML=$(curl -s -b "$S" "$B/my")
|
||||
check "Home offers a way out" "$HOME_HTML" 'Sign out'
|
||||
# Asked of the markup, because the whole response is not the screen. The site's 404 page carries
|
||||
# the marketing nav, "Log in — /auth" and all, and Next serialises that boundary into the payload
|
||||
# of every page under the root layout, this one included. Nothing draws it unless a route calls
|
||||
# notFound(), so grepping the raw HTML found the coordinator's door in a tree the wearer never
|
||||
# sees. What is left after the scripts go is what is on the screen, and the bare path catches the
|
||||
# link however it is written — absolute or not, an href or a form.
|
||||
HOME_MARKUP=$(printf '%s' "$HOME_HTML" | markup)
|
||||
no "and never sends a wearer to the desktop sign-in" "$HOME_MARKUP" '/auth'
|
||||
LOGOUT_JAR="$T/tc-ss-out.txt"; cp "$S" "$LOGOUT_JAR"
|
||||
check "signing out is accepted" "$(curl -s -b "$LOGOUT_JAR" -c "$LOGOUT_JAR" -X POST "$B/api/staff/logout" -H 'content-type: application/json' -H "origin: $B" -d '{}')" '"ok":true'
|
||||
check "and the session is dead afterwards" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$LOGOUT_JAR" "$B/my")" '30[0-9] .*/my/signin'
|
||||
# The "On the website" list — the coordinator's sign-in among it — was removed from this screen at
|
||||
# Kyle's ask (2026-09-12): nothing on it was something a wearer could act on here. The terms and
|
||||
# the policy now travel with the agreement tick instead.
|
||||
no "the sign-in no longer points coordinators at the website" "$(curl -s "$B/my/signin")" 'href="https://threadcount.tech/auth"'
|
||||
check " but the agreement tick links the terms" "$(curl -s "$B/my/signin")" '/terms'
|
||||
|
||||
|
||||
echo "== a staff session is not a coordinator session"
|
||||
check "/app redirects to the coordinator sign-in" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$S" "$B/app")" '30[0-9] .*/auth'
|
||||
check "/m redirects too" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$S" "$B/m")" '30[0-9] .*/m/login'
|
||||
check "the backup export is refused" "$(curl -s -b "$S" "$B/api/backup")" 'Not signed in'
|
||||
check "so is any mutation" "$(curl -s -b "$S" -X POST "$B/api/mutate" -H 'content-type: application/json' -H "origin: $B" -d '{"op":"staff.delete","payload":{"id":"x"}}')" 'Not signed in'
|
||||
check "and the audit log" "$(curl -s -b "$S" "$B/api/activity")" 'Not signed in'
|
||||
|
||||
echo "== the staff token is not a session token"
|
||||
TOK=$(grep tc_staff "$S" | awk '{print $NF}')
|
||||
check "a token was captured for the test" "$TOK" '.'
|
||||
check "presented as tc_session it authenticates nothing" "$(curl -s -H "cookie: tc_session=$TOK" "$B/api/backup")" 'Not signed in'
|
||||
|
||||
echo "== a coordinator is not a staff member either"
|
||||
check "/my sends a coordinator to the staff sign-in" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$C" "$B/my")" '30[0-9] .*/my/signin'
|
||||
|
||||
echo "== signing in again"
|
||||
check "the password signs them in" "$(curl -s -c "$S" -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$ME\",\"password\":\"wearerpass1\"}")" '"ok":true'
|
||||
check "a wrong password does not" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$ME\",\"password\":\"nope\"}")" 'doesn’t match'
|
||||
check "an unknown address does not" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d '{"email":"nobody@example.com","password":"whatever"}')" 'doesn’t match'
|
||||
check "a coordinator's own password does not open a staff account" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$CO\",\"password\":\"password123\"}")" 'doesn’t match'
|
||||
|
||||
echo "== one wearer cannot become another"
|
||||
CODE2=$(mut staff.selfCode "{\"id\":\"$BID\"}" | py "print(d['result']['code'])")
|
||||
check "Bo activates their own" "$(curl -s -c "$S2" -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "{\"agreed\":true,\"code\":\"$CODE2\",\"email\":\"bo$ME\",\"password\":\"wearerpass2\"}")" '"ok":true'
|
||||
MY2=$(curl -s -b "$S2" "$B/my")
|
||||
check "and sees themselves" "$MY2" 'Bo Other'
|
||||
no "not Ada" "$MY2" 'Ada Wearer'
|
||||
no "and not Ada's garments" "$(curl -s -b "$S2" "$B/my/kit")" 'Theatre scrub top'
|
||||
|
||||
echo "== and cannot raise a request in anybody else’s name"
|
||||
# The one door into somebody else's name is being their manager — the ward desk had one too, and
|
||||
# lost it. Ada manages nobody, so the answer she gets is the answer everybody else gets.
|
||||
FOR_BO=$(spost /api/staff/mutate "{\"op\":\"request.create\",\"payload\":{\"subjectId\":\"$BID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}}")
|
||||
check "raising for somebody else is refused" "$FOR_BO" "Only somebody.s own manager"
|
||||
no " and nothing is created by it" "$FOR_BO" '"code"'
|
||||
check "and with no approver they cannot raise for themselves either" "$(spost /api/staff/mutate "{\"op\":\"request.create\",\"payload\":{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}}")" "manager isn't set"
|
||||
|
||||
echo "== the coordinator side afterwards"
|
||||
check "the record shows the linked address" "$(curl -s -b "$C" "$B/app/staff/$AID")" "$ME"
|
||||
check "a second code is refused while they are linked" "$(mut staff.selfCode "{\"id\":\"$AID\"}")" 'already has an account'
|
||||
|
||||
echo "== taking access away"
|
||||
check "unlink works" "$(mut staff.selfUnlink "{\"id\":\"$AID\"}")" '"ok":true'
|
||||
check "their session dies with it" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$S" "$B/my")" '30[0-9] .*/my/signin'
|
||||
check "and the password no longer signs in" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"$ME\",\"password\":\"wearerpass1\"}")" 'doesn’t match'
|
||||
|
||||
echo "== leaving the register ends the view"
|
||||
check "Bo is deactivated" "$(mut staff.patch "{\"id\":\"$BID\",\"inactive\":true}")" '"ok":true'
|
||||
check "and can no longer see their record" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$S2" "$B/my")" '30[0-9] .*/my/signin'
|
||||
check "nor sign in again" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "{\"email\":\"bo$ME\",\"password\":\"wearerpass2\"}")" 'no longer on the register'
|
||||
|
||||
echo "== cross-site requests are refused"
|
||||
# A gate can only be seen working on a request that would otherwise have gone through. These pairs
|
||||
# send the very same body twice, and the Origin header is the only thing that differs between the
|
||||
# two, so nothing but the origin check can account for one being refused and the other landing.
|
||||
# Sent with a spent code or an unlinked account, as this section used to be, a refusal proves
|
||||
# nothing: the request was going to fail whichever origin it came from.
|
||||
CX=$(mut staff.save "{\"num\":\"SS3\",\"first\":\"Cass\",\"last\":\"Third\",\"group\":\"Security\",\"dept\":\"Front of house\"}")
|
||||
check "a third wearer to try it on" "$CX" '"id"'
|
||||
CXID=$(echo "$CX" | py "print(d['result']['id'])")
|
||||
CODE3=$(mut staff.selfCode "{\"id\":\"$CXID\"}" | py "print(d['result']['code'])")
|
||||
CXEMAIL="cass$ME"
|
||||
CXBODY="{\"agreed\":true,\"code\":\"$CODE3\",\"email\":\"$CXEMAIL\",\"password\":\"wearerpass3\"}"
|
||||
check "a good activation from another origin is refused" "$(curl -s -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: https://evil.example" -d "$CXBODY")" 'Cross-site request refused'
|
||||
# Landing now also says the refused attempt never spent the code on its way out.
|
||||
check " and the same one from our own origin is accepted" "$(curl -s -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" -d "$CXBODY")" '"ok":true'
|
||||
CXLOGIN="{\"email\":\"$CXEMAIL\",\"password\":\"wearerpass3\"}"
|
||||
check "a good sign-in from another origin is refused" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: https://evil.example" -d "$CXLOGIN")" 'Cross-site request refused'
|
||||
check " and the same one from our own origin signs them in" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H "origin: $B" -d "$CXLOGIN")" '"ok":true'
|
||||
# Origin is only half the gate. A cross-site form post may carry no Origin at all, but the browser
|
||||
# still stamps Sec-Fetch-Site on it, and a form can only ever send a non-JSON content type.
|
||||
check "a cross-site post the browser labelled as such is refused" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: application/json' -H 'sec-fetch-site: cross-site' -d "$CXLOGIN")" 'Cross-site request refused'
|
||||
check "and a form post from our own origin gets no further" "$(curl -s -X POST "$B/api/staff/login" -H 'content-type: text/plain' -H "origin: $B" -d "$CXLOGIN")" 'Expected JSON'
|
||||
|
||||
echo "== the sign-in page itself"
|
||||
SI=$(curl -s "$B/my/signin")
|
||||
# Being public is the status code. "Your uniform record" is the <title> the whole /my shell sets,
|
||||
# so grepping the body for it proved nothing about this page — it would come back 200 with the
|
||||
# Suspense fallback and no form at all and still look green.
|
||||
check "is public" "$(curl -s -o /dev/null -w '%{http_code}' "$B/my/signin")" '^200$'
|
||||
check "offers both ways in" "$SI" 'I have a code'
|
||||
check "and points coordinators elsewhere" "$SI" '/auth'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]
|
||||
Executable
+718
@@ -0,0 +1,718 @@
|
||||
#!/usr/bin/env bash
|
||||
# The staff app: request → approve → fulfil, and everything that must refuse.
|
||||
#
|
||||
# The design rests on three separations, and most of this file exists to prove they hold:
|
||||
# · the linen room cannot approve — that is the ward's job;
|
||||
# · a manager can only see and decide requests addressed to them;
|
||||
# · a wearer can only see their own record, and nothing of the register.
|
||||
set -u
|
||||
B=${BASE:-http://127.0.0.1:3111}
|
||||
# Refuses early, with the fix, when the server under test is in production mode with
|
||||
# Turnstile refusing every auth route — otherwise the first signup fails and every check
|
||||
# after it reports a security-check error instead of what it was testing.
|
||||
. "$(dirname "$0")/e2e-preflight.sh"; e2e_preflight "$B"
|
||||
T=${TMP:-/tmp}
|
||||
C="$T/tc-sa-coord.txt" # linen room
|
||||
W="$T/tc-sa-wearer.txt" # the staff member
|
||||
M="$T/tc-sa-mgr.txt" # their ward manager
|
||||
D="$T/tc-sa-desk.txt" # the ward clerk
|
||||
O="$T/tc-sa-other.txt" # a manager on another ward
|
||||
X="$T/tc-sa-dir.txt" # the ward manager's own manager, a level up
|
||||
N="$T/tc-sa-noward.txt" # a desk clerk whose ward was never filled in
|
||||
# Three more nurses on Jamila's ward, under Jamila's manager. They are here because the server holds
|
||||
# one person to twelve requests an hour — every request emails a manager, and nobody asks twelve
|
||||
# times in an hour for a reason anybody would recognise. This file does, because it walks every flow
|
||||
# end to end, so the sections below are spread across a ward rather than put through one nurse.
|
||||
V="$T/tc-sa-trials.txt" # the nurse whose asks are all meant to be refused
|
||||
Y="$T/tc-sa-lines.txt" # the nurse who asks for several garments at once
|
||||
Z="$T/tc-sa-bags.txt" # the nurse whose bags wait at the counter and go out on the round
|
||||
K="$T/tc-sa-solo.txt" # somebody recorded as their own manager, with nobody else under them
|
||||
L="$T/tc-sa-lead.txt" # somebody recorded as their own manager, with a report of her own
|
||||
rm -f "$C" "$W" "$M" "$D" "$O" "$X" "$N" "$V" "$Y" "$Z" "$K" "$L"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$(echo "$out" | head -c 200)"; fi; }
|
||||
no() { local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then fail "$name" "$(echo "$out" | head -c 200)"; else ok "$name"; fi; }
|
||||
mut() { curl -s -b "$C" -c "$C" -X POST "$B/api/mutate" -H 'content-type: application/json' -H "origin: $B" -d "{\"op\":\"$1\",\"payload\":$2}"; }
|
||||
smut() { curl -s -b "$2" -c "$2" -X POST "$B/api/staff/mutate" -H 'content-type: application/json' -H "origin: $B" -d "{\"op\":\"$1\",\"payload\":$3}"; }
|
||||
py() { python3 -c "import sys,json; d=json.load(sys.stdin); $1"; }
|
||||
# A screen that refuses somebody: the not-found page comes back and none of the screen's own words
|
||||
# do. Deliberately not a status check -- these routes stream under a loading boundary, so the header
|
||||
# is sent before the page decides, and 200 says nothing either way. Pass the cookie jar, the path,
|
||||
# and a word the real screen would have rendered.
|
||||
refused() { local name=$1 jar=$2 path=$3 leak=$4
|
||||
local body; body=$(curl -s -b "$jar" "$B$path")
|
||||
if ! printf '%s' "$body" | grep -q "That page isn"; then fail "$name" "no refusal: $(printf '%s' "$body" | head -c 160)"; return; fi
|
||||
if printf '%s' "$body" | grep -q "$leak"; then fail "$name" "REFUSED BUT LEAKED $leak"; return; fi
|
||||
ok "$name"
|
||||
}
|
||||
# Claim a staff account: generate a code as the linen room, then activate it into a jar.
|
||||
claim() { local sid=$1 jar=$2 email=$3
|
||||
local code; code=$(mut staff.selfCode "{\"id\":\"$sid\"}" | py "print(d['result']['code'])")
|
||||
curl -s -c "$jar" -X POST "$B/api/staff/activate" -H 'content-type: application/json' -H "origin: $B" \
|
||||
-d "{\"agreed\":true,\"code\":\"$code\",\"email\":\"$email\",\"password\":\"wearerpass1\"}"; }
|
||||
|
||||
TS=$(date +%s)
|
||||
CO="sa$TS@example.com"
|
||||
|
||||
echo "== setup"
|
||||
check "linen room signs up" "$(curl -s -c "$C" -X POST "$B/api/auth/signup" -H 'content-type: application/json' \
|
||||
-H "x-forwarded-for: 10.31.$((RANDOM%250)).$((RANDOM%250))" \
|
||||
-d "{\"first\":\"Sal\",\"last\":\"Linen\",\"facility\":\"Request Hospital $TS\",\"email\":\"$CO\",\"password\":\"password123\"}")" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$C")" '"ok":true'
|
||||
|
||||
WID=$(mut staff.save '{"num":"W1","first":"Jamila","last":"Wearer","group":"Registered Nurse","dept":"Rosewood Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
|
||||
MID=$(mut staff.save '{"num":"M1","first":"Dele","last":"Manager","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
|
||||
DID=$(mut staff.save '{"num":"D1","first":"Ade","last":"Clerk","group":"Admin","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
|
||||
OID=$(mut staff.save '{"num":"O1","first":"Otto","last":"Elsewhere","group":"Security","dept":"Linden Ward"}' | py "print(d['result']['id'])")
|
||||
CID=$(mut staff.save '{"num":"C1","first":"Chidi","last":"Director","group":"Registered Nurse","dept":"Rosewood Ward"}' | py "print(d['result']['id'])")
|
||||
# Two people whose ward was never filled in: a desk clerk and somebody the clerk must not reach.
|
||||
# Sharing a blank ward with somebody used to read as sharing a ward, which was the widest version
|
||||
# of the door the desk raised through. That door is shut now, and this pair is what proves it.
|
||||
NID=$(mut staff.save '{"num":"N1","first":"Nia","last":"Nodesk","group":"Admin"}' | py "print(d['result']['id'])")
|
||||
PID=$(mut staff.save '{"num":"P1","first":"Pat","last":"Noward","group":"Registered Nurse"}' | py "print(d['result']['id'])")
|
||||
# Counted, not concatenated: an id that came back empty because staff.save refused is exactly what
|
||||
# makes the refusals further down pass for the wrong reason, since a blank subjectId falls into a
|
||||
# different branch of request.create entirely.
|
||||
check "seven people on the register" "$(printf '%s\n' "$WID" "$MID" "$DID" "$OID" "$CID" "$NID" "$PID" | grep -c .)" '^7$'
|
||||
|
||||
check "the wearer gets a manager" "$(mut staff.patch "{\"id\":\"$WID\",\"managerId\":\"$MID\"}")" '"ok":true'
|
||||
check "so does the clerk" "$(mut staff.patch "{\"id\":\"$DID\",\"managerId\":\"$MID\"}")" '"ok":true'
|
||||
check "and the ward-less nurse" "$(mut staff.patch "{\"id\":\"$PID\",\"managerId\":\"$MID\"}")" '"ok":true'
|
||||
check "the clerk is put on the desk" "$(mut staff.patch "{\"id\":\"$DID\",\"wardDesk\":true}")" '"ok":true'
|
||||
check "so is the one with no ward" "$(mut staff.patch "{\"id\":\"$NID\",\"wardDesk\":true}")" '"ok":true'
|
||||
# Anybody may be their own manager now (the owner's decision, 12 September 2026) — that is proved on
|
||||
# people of its own further down, so Jamila keeps Dele. What is still refused is a manager who is not
|
||||
# on the register at all, and refusing it must leave her manager where it was.
|
||||
check "a manager nobody on the register is refused" "$(mut staff.patch "{\"id\":\"$WID\",\"managerId\":\"nope\"}")" 'Unknown staff member'
|
||||
check " and the wearer keeps the one she has" "$(curl -s -b "$C" "$B/api/backup" | py "print([s['managerId'] for s in d['staff'] if s['id']=='$WID'][0] == '$MID')")" '^True$'
|
||||
|
||||
# The three whose sections come later. On Jamila's ward, so the desk can sign for their bags on the
|
||||
# round, and under Jamila's manager, so a request of theirs is decided exactly as one of hers is.
|
||||
VID=$(mut staff.save '{"num":"W2","first":"Bea","last":"Trials","group":"Registered Nurse","dept":"Rosewood Ward","top":"S","pants":"10"}' | py "print(d['result']['id'])")
|
||||
YID=$(mut staff.save '{"num":"W3","first":"Rafa","last":"Lines","group":"Registered Nurse","dept":"Rosewood Ward","top":"L","pants":"14"}' | py "print(d['result']['id'])")
|
||||
ZID=$(mut staff.save '{"num":"W4","first":"Ines","last":"Trolley","group":"Registered Nurse","dept":"Rosewood Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
|
||||
check "three more nurses on the ward" "$(printf '%s\n' "$VID" "$YID" "$ZID" | grep -c .)" '^3$'
|
||||
check " all three under the same manager" "$(printf '%s\n' \
|
||||
"$(mut staff.patch "{\"id\":\"$VID\",\"managerId\":\"$MID\"}")" \
|
||||
"$(mut staff.patch "{\"id\":\"$YID\",\"managerId\":\"$MID\"}")" \
|
||||
"$(mut staff.patch "{\"id\":\"$ZID\",\"managerId\":\"$MID\"}")" | grep -c '"ok":true')" '^3$'
|
||||
|
||||
# 143 rather than a dozen, and deliberately not a number that is also one of the sizes: the shelf
|
||||
# check below proves the count never reaches the ward, and it can only prove that against a figure
|
||||
# that has no other reason to be on the page.
|
||||
ITEM=$(mut catalog.add '{"item":"Navy tunic","type":"Tunic","sizes":["10","12","14"],"cost":30,"opening":[{"si":1,"qty":143},{"si":2,"qty":1}]}')
|
||||
IID=$(echo "$ITEM" | py "print(d['result']['id'])")
|
||||
check "a garment is on the shelf" "$ITEM" '"id"'
|
||||
check "with a par level on the thin size" "$(mut stock.reorder "{\"itemId\":\"$IID\",\"si\":2,\"reorder\":3}")" '"ok":true'
|
||||
# Two more garments, so one request can carry three of them and a decline can be told apart from
|
||||
# an approval by which shelf moved.
|
||||
TID=$(mut catalog.add '{"item":"Navy trousers","type":"Trousers","sizes":["10","12","14"],"cost":22,"opening":[{"si":0,"qty":6}]}' | py "print(d['result']['id'])")
|
||||
FID=$(mut catalog.add '{"item":"Fleece jacket","type":"Fleece","sizes":["S","M","L"],"cost":48,"opening":[{"si":0,"qty":5}]}' | py "print(d['result']['id'])")
|
||||
check "and two more beside it" "$(printf '%s\n' "$TID" "$FID" | grep -c .)" '^2$'
|
||||
|
||||
check "the wearer claims an account" "$(claim "$WID" "$W" "w$TS@example.com")" '"ok":true'
|
||||
check "the manager claims one" "$(claim "$MID" "$M" "m$TS@example.com")" '"ok":true'
|
||||
check "the clerk claims one" "$(claim "$DID" "$D" "d$TS@example.com")" '"ok":true'
|
||||
check "the other ward claims one" "$(claim "$OID" "$O" "o$TS@example.com")" '"ok":true'
|
||||
check "the director claims one" "$(claim "$CID" "$X" "c$TS@example.com")" '"ok":true'
|
||||
check "the ward-less clerk claims one" "$(claim "$NID" "$N" "n$TS@example.com")" '"ok":true'
|
||||
check "the nurse whose asks are refused claims one" "$(claim "$VID" "$V" "v$TS@example.com")" '"ok":true'
|
||||
check "the several-garments nurse too" "$(claim "$YID" "$Y" "y$TS@example.com")" '"ok":true'
|
||||
check "and the one whose bags go out" "$(claim "$ZID" "$Z" "z$TS@example.com")" '"ok":true'
|
||||
|
||||
echo "== the two doors into the staff app"
|
||||
check "/api/staff/mutate refuses an anonymous caller" "$(curl -s -X POST "$B/api/staff/mutate" -H 'content-type: application/json' -H "origin: $B" -d '{}')" 'Not signed in'
|
||||
# /api/staff/decide is the one route with no session behind it by design — the manager taps the
|
||||
# button in their mail client, signed out — so refusing an anonymous caller is not a property it
|
||||
# has. Its gate is the token, and a POST carrying none gets the same answer a spent link does.
|
||||
check "and /api/staff/decide refuses one with no token" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d '{"action":"approve"}')" 'expired'
|
||||
|
||||
echo "== wards see words, never counts"
|
||||
SHELF=$(curl -s -b "$W" "$B/my/shelf")
|
||||
check "the shelf check renders" "$SHELF" 'Navy tunic'
|
||||
check "and says In stock" "$SHELF" 'In stock'
|
||||
check "and Low for the thin size" "$SHELF" '>Low<'
|
||||
# A leak would put the figure itself in a size row, in whatever wording the day's code happened to
|
||||
# use, so the check is the number rather than a sentence somebody would have had to write first.
|
||||
#
|
||||
# Read off the words on the page and nothing else. The response also carries the framework's own
|
||||
# payload — a wall of build ids and hashed chunk names — and three digits turn up somewhere in that
|
||||
# by luck often enough, which reports the ward leaking stock counts on a day when nothing changed.
|
||||
# The screen is rendered on the server, so everything a nurse can see is in the text once the
|
||||
# scripts and the mark-up are taken out, and nothing incidental is.
|
||||
WORDS=$(echo "$SHELF" | python3 -c "
|
||||
import re, sys
|
||||
html = re.sub(r'(?is)<(script|style)[^>]*>.*?</\1>', ' ', sys.stdin.read())
|
||||
print(re.sub(r'(?s)<[^>]*>', ' ', html))
|
||||
")
|
||||
# An empty read would make the line below pass without looking at anything.
|
||||
check " and the size rows are what we are reading" "$WORDS" 'Navy tunic'
|
||||
no "and never a bare count of the shelf" "$WORDS" '\b143\b'
|
||||
|
||||
echo "== raising a request"
|
||||
REQ=$(smut request.create "$W" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":2}],\"reason\":\"Worn out\",\"note\":\"Both worn through\"}")
|
||||
check "the wearer can raise one" "$REQ" '"code"'
|
||||
RID=$(echo "$REQ" | py "print(d['result']['id'])")
|
||||
check "and it names the approver" "$REQ" 'Dele Manager'
|
||||
no " and nothing was escalated" "$REQ" '"escalated":true'
|
||||
check "an unknown garment is refused" "$(smut request.create "$W" '{"lines":[{"itemId":"nope","si":0,"qty":1}]}')" "isn't available"
|
||||
check "a size that doesn't exist is refused" "$(smut request.create "$W" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":99,\"qty\":1}]}")" 'Pick a size'
|
||||
check "somebody with no manager cannot raise" "$(smut request.create "$O" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "manager isn't set"
|
||||
|
||||
echo "== what may go on one request"
|
||||
# Bea's section: six asks in a row, all but the last meant to be refused. A refused ask counts
|
||||
# against the hourly ceiling exactly as a kept one does — it has to, since a phone stuck in a retry
|
||||
# loop sends nothing but refusals — so this is somebody's own morning rather than Jamila's.
|
||||
check "a request with no garments is refused" "$(smut request.create "$V" '{"lines":[]}')" 'at least one garment'
|
||||
check "so is one with nothing but a reason" "$(smut request.create "$V" '{"reason":"Lost"}')" 'at least one garment'
|
||||
check "none of a garment is refused" "$(smut request.create "$V" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":0}]}")" 'between 1 and 20'
|
||||
check "and a wild quantity is too" "$(smut request.create "$V" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":99}]}")" 'between 1 and 20'
|
||||
# Eleven lines, built rather than typed out, so the cap is read from the refusal and not from here.
|
||||
ELEVEN=$(python3 -c "import json;print(json.dumps([{'itemId':'$IID','si':i%3,'qty':1} for i in range(11)]))")
|
||||
check "an eleventh garment is refused" "$(smut request.create "$V" "{\"lines\":$ELEVEN}")" 'up to 10 garments'
|
||||
# The same garment and size twice is one line with the quantities added, not two rows the manager
|
||||
# has to decide twice and the linen room has to pick twice.
|
||||
DUP=$(smut request.create "$V" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1},{\"itemId\":\"$IID\",\"si\":1,\"qty\":2}],\"reason\":\"Extra for shifts\"}")
|
||||
DUPID=$(echo "$DUP" | py "print(d['result']['id'])")
|
||||
DUPROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$DUPID'][0]))")
|
||||
check "a repeated garment becomes one line" "$(echo "$DUPROW" | py "print(d['lineCount'])")" '^1$'
|
||||
check " carrying the total" "$(echo "$DUPROW" | py "print(d['lines'][0]['qty'])")" '^3$'
|
||||
check "tidied away again" "$(mut request.withdraw "{\"id\":\"$DUPID\",\"reason\":\"Raised in error\"}")" '"ok":true'
|
||||
|
||||
echo "== the linen room cannot approve"
|
||||
check "no pick before approval" "$(mut request.pick "{\"id\":\"$RID\"}")" "can't move"
|
||||
LR=$(curl -s -b "$C" "$B/api/requests")
|
||||
check "it is visible to the linen room" "$LR" "$RID"
|
||||
check " shown as awaiting" "$LR" '"status":"awaiting"'
|
||||
|
||||
echo "== only the addressed manager can decide"
|
||||
check "another ward's manager cannot approve" "$(smut request.approve "$O" "{\"id\":\"$RID\"}")" 'No such request'
|
||||
check "the wearer cannot approve their own" "$(smut request.approve "$W" "{\"id\":\"$RID\"}")" 'No such request'
|
||||
check "a decline with no reason is refused" "$(smut request.decline "$M" "{\"id\":\"$RID\"}")" 'Pick a reason'
|
||||
check "a made-up reason is refused" "$(smut request.decline "$M" "{\"id\":\"$RID\",\"reason\":\"Because\"}")" 'Pick a reason'
|
||||
APPROVED=$(smut request.approve "$M" "{\"id\":\"$RID\"}")
|
||||
check "the manager approves" "$APPROVED" '"status":"accepted"'
|
||||
# The other half of the self-approval marking further down: a manager deciding somebody else's
|
||||
# request is never handed back as having approved their own.
|
||||
check " as an ordinary approval, not a self-approval" "$APPROVED" '"selfApproved":false'
|
||||
check "and cannot approve twice" "$(smut request.approve "$M" "{\"id\":\"$RID\"}")" 'already been decided'
|
||||
check "nor decline after approving" "$(smut request.decline "$M" "{\"id\":\"$RID\",\"reason\":\"Over allowance\"}")" 'already been decided'
|
||||
|
||||
echo "== fulfilment is the linen room's"
|
||||
check "the manager cannot pick" "$(smut request.pick "$M" "{\"id\":\"$RID\"}")" 'Unknown action'
|
||||
check "the linen room picks" "$(mut request.pick "{\"id\":\"$RID\"}")" '"status":"picking"'
|
||||
check "collected is refused out of order" "$(mut request.collected "{\"id\":\"$RID\"}")" "can't move"
|
||||
HOLD=$(mut request.hold "{\"id\":\"$RID\",\"holdUntil\":\"Fri 6pm\"}")
|
||||
check "held at the counter" "$HOLD" '"status":"ready"'
|
||||
ORDER=$(curl -s -b "$W" "$B/my/orders/$RID")
|
||||
check "the wearer sees a collection code" "$ORDER" 'Show at the counter'
|
||||
check "and the hold" "$ORDER" 'Fri 6pm'
|
||||
check "and the timeline names the approver" "$ORDER" 'Approved by Dele Manager'
|
||||
check "the linen room marks it collected" "$(mut request.collected "{\"id\":\"$RID\"}")" '"status":"collected"'
|
||||
|
||||
echo "== a decline carries its reason to the staff member"
|
||||
R2=$(smut request.create "$Y" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":2,\"qty\":1}],\"reason\":\"Lost\"}" | py "print(d['result']['id'])")
|
||||
DEC2=$(smut request.decline "$M" "{\"id\":\"$R2\",\"reason\":\"Over allowance\"}")
|
||||
check "declined with a reason" "$DEC2" '"status":"declined"'
|
||||
# One garment, so the summary says Declined outright rather than counting it out as 1 of 1.
|
||||
check " and summarised without arithmetic" "$DEC2" '"summary":"Declined"'
|
||||
D2=$(curl -s -b "$Y" "$B/my/orders/$R2")
|
||||
check "the wearer is told which one" "$D2" 'Over allowance'
|
||||
check "and who decided" "$D2" 'Dele Manager'
|
||||
R2ROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$R2'][0]))")
|
||||
check "the garment under it was refused too" "$(echo "$R2ROW" | py "print(d['lines'][0]['status'])")" '^declined$'
|
||||
check " and carries the same reason" "$(echo "$R2ROW" | py "print(d['lines'][0]['declineReason'])")" '^Over allowance$'
|
||||
|
||||
echo "== one request, three garments, one decision"
|
||||
# The whole point of lines. A nurse who needs a tunic, trousers and a fleece asks once; the manager
|
||||
# reads the lot on one screen and answers in one action, but can knock back a single garment. Only
|
||||
# what survives that is picked, bagged and handed over, and the refusal stays on the record so the
|
||||
# wearer can see what happened to the fleece.
|
||||
ML=$(smut request.create "$Y" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":2},{\"itemId\":\"$TID\",\"si\":0,\"qty\":1},{\"itemId\":\"$FID\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\",\"note\":\"Starting on nights\"}")
|
||||
check "three garments go on one request" "$ML" '"code"'
|
||||
MLID=$(echo "$ML" | py "print(d['result']['id'])")
|
||||
ROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$MLID'][0]))")
|
||||
check " three lines on it" "$(echo "$ROW" | py "print(d['lineCount'])")" '^3$'
|
||||
check " four garments between them" "$(echo "$ROW" | py "print(d['garments'])")" '^4$'
|
||||
check " and a one-liner for a collapsed row" "$(echo "$ROW" | py "print(d['summary'])")" '4 garments · Navy tunic, Navy trousers, Fleece jacket'
|
||||
check " with nothing decided yet" "$(echo "$ROW" | py "print(d['decision'] or 'undecided')")" '^undecided$'
|
||||
L1=$(echo "$ROW" | py "print(d['lines'][0]['id'])")
|
||||
L2=$(echo "$ROW" | py "print(d['lines'][1]['id'])")
|
||||
L3=$(echo "$ROW" | py "print(d['lines'][2]['id'])")
|
||||
|
||||
# A half-answered decision would send a bag to the counter with a garment nobody had ruled on.
|
||||
check "leaving a garment undecided is refused" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"}]}")" 'Decide every garment'
|
||||
check "an id that is not on the request is refused" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"rl_nonsense\",\"decision\":\"approved\"}]}")" "doesn.t match the request"
|
||||
# "accepted" is the word for a whole request; a garment is "approved". Conflating the two would let
|
||||
# a typo through as a decision nobody made.
|
||||
check "the request-level word is not a line's word" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"accepted\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"approved\"}]}")" 'Approve or decline each garment'
|
||||
check "a declined garment needs a reason" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"declined\"}]}")" 'Pick a reason for each garment'
|
||||
check "and it has to be one of the reasons" "$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"declined\",\"reason\":\"Because\"}]}")" 'Pick a reason for each garment'
|
||||
check "none of that decided anything" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$MLID'][0]['status'])")" '^awaiting$'
|
||||
|
||||
DEC=$(smut request.approve "$M" "{\"id\":\"$MLID\",\"lines\":[{\"id\":\"$L1\",\"decision\":\"approved\"},{\"id\":\"$L2\",\"decision\":\"approved\"},{\"id\":\"$L3\",\"decision\":\"declined\",\"reason\":\"Over allowance\"}]}")
|
||||
check "two approved, one declined" "$DEC" '"status":"accepted"'
|
||||
check " summarised in one line" "$DEC" '"summary":"2 of 3 approved"'
|
||||
check "and the decision is only made once" "$(smut request.decline "$M" "{\"id\":\"$MLID\",\"reason\":\"Over allowance\"}")" 'already been decided'
|
||||
|
||||
ROW2=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$MLID'][0]))")
|
||||
check "one surviving garment makes the request accepted" "$(echo "$ROW2" | py "print(d['status'])")" '^accepted$'
|
||||
check " the record still holds all three" "$(echo "$ROW2" | py "print(d['lineCount'])")" '^3$'
|
||||
check " the bag holds two" "$(echo "$ROW2" | py "print(len(d['bag']))")" '^2$'
|
||||
check " three garments to pick" "$(echo "$ROW2" | py "print(d['garments'])")" '^3$'
|
||||
no " and the fleece is not one of them" "$(echo "$ROW2" | py "print(json.dumps(d['bag']))")" 'Fleece jacket'
|
||||
check "the refused line says what it was" "$(echo "$ROW2" | py "print([l for l in d['lines'] if l['item']=='Fleece jacket'][0]['status'])")" '^declined$'
|
||||
check " and carries its own reason" "$(echo "$ROW2" | py "print([l for l in d['lines'] if l['item']=='Fleece jacket'][0]['declineReason'])")" '^Over allowance$'
|
||||
check " and is worded for the wearer" "$(echo "$ROW2" | py "print([l for l in d['lines'] if l['item']=='Fleece jacket'][0]['statusLabel'])")" '^Declined$'
|
||||
check " while the approved lines carry no reason" "$(echo "$ROW2" | py "print(len([l for l in d['bag'] if l['declineReason']]))")" '^0$'
|
||||
# One refusal out of three is not a refusal of the request, so no reason is invented for it.
|
||||
check "the request itself is given no reason" "$(echo "$ROW2" | py "print(d['declineReason'] or 'none')")" '^none$'
|
||||
MLORDER=$(curl -s -b "$Y" "$B/my/orders/$MLID")
|
||||
check "the wearer's order names the refused garment" "$MLORDER" 'Fleece jacket'
|
||||
check " with the reason against it" "$MLORDER" 'Over allowance'
|
||||
check " and still lists what is coming" "$MLORDER" 'Navy trousers'
|
||||
|
||||
echo "== only what was approved leaves the shelf"
|
||||
TUNIC_BEFORE=$(curl -s -b "$C" "$B/api/backup" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId']=='$IID' and i['sizeIndex']==1))")
|
||||
check "the linen room picks the bag" "$(mut request.pick "{\"id\":\"$MLID\"}")" '"status":"picking"'
|
||||
check "and holds it at the counter" "$(mut request.hold "{\"id\":\"$MLID\",\"holdUntil\":\"Tue 2pm\"}")" '"status":"ready"'
|
||||
MLCODE=$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$MLID'][0]['collectCode'])")
|
||||
check "one collection code, for the whole request" "$MLCODE" '^[0-9][0-9][0-9][0-9]$'
|
||||
READY=$(curl -s -b "$Y" "$B/my/orders/$MLID")
|
||||
check " and the wearer is pointed at the counter" "$READY" 'Show at the counter'
|
||||
check " under the code the linen room is holding" "$READY" "$MLCODE"
|
||||
# Three garments, two lines, one bag. The screen says so in words, because somebody who asked for
|
||||
# three things and is given one code will otherwise assume the rest is coming separately.
|
||||
# React's server renderer puts an empty comment between a literal and an interpolated value, so
|
||||
# this sentence reaches the browser as `All <!-- -->3<!-- --> garments...`. Read it with the
|
||||
# separators taken out, or the check hunts for a string the server has never once sent.
|
||||
READY_TEXT=$(printf '%s' "$READY" | sed 's/<!-- -->//g')
|
||||
check " said to cover the whole bag" "$READY_TEXT" 'All 3 garments are in one bag'
|
||||
check " covering the tunics" "$READY" 'Navy tunic'
|
||||
check " and the trousers with them" "$READY" 'Navy trousers'
|
||||
check "the linen room hands the bag over" "$(mut request.collected "{\"id\":\"$MLID\"}")" '"status":"collected"'
|
||||
BK=$(curl -s -b "$C" "$B/api/backup")
|
||||
check "both tunics came off the shelf" "$(echo "$BK" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId']=='$IID' and i['sizeIndex']==1) - $TUNIC_BEFORE)")" '^2$'
|
||||
check "and the trousers with them" "$(echo "$BK" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId']=='$TID'))")" '^1$'
|
||||
check "the fleece never moved" "$(echo "$BK" | py "print(len([i for i in d['issues'] if i['itemId']=='$FID']))")" '^0$'
|
||||
check "nor was one ordered to replace it" "$(echo "$BK" | py "print(len([l for o in d['orders'] for l in o['lines'] if l['itemId']=='$FID']))")" '^0$'
|
||||
check " while the trousers were" "$(echo "$BK" | py "print(sum(l['qty'] for o in d['orders'] for l in o['lines'] if l['itemId']=='$TID'))")" '^1$'
|
||||
check "the hand-over was one event, under the one code" "$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$MLID'][0]['events'][-1]['meta'])")" "^Code $MLCODE\$"
|
||||
|
||||
echo "== two bags at the counter never share a code"
|
||||
# The whole transaction at the counter is somebody reading four digits off their phone and the
|
||||
# coordinator finding the bag with that number on it, so two bags waiting at once under the same
|
||||
# number is somebody being handed the wrong uniform. Uniqueness is only asked of the bags actually
|
||||
# out there — a code goes back in the pot once its bag has gone home — which is why both of these
|
||||
# are held before either is checked.
|
||||
CA=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
|
||||
CB=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$TID\",\"si\":0,\"qty\":1}],\"reason\":\"Worn out\"}" | py "print(d['result']['id'])")
|
||||
check "the manager approves the first" "$(smut request.approve "$M" "{\"id\":\"$CA\"}")" '"status":"accepted"'
|
||||
check " and the second" "$(smut request.approve "$M" "{\"id\":\"$CB\"}")" '"status":"accepted"'
|
||||
check "the first is picked" "$(mut request.pick "{\"id\":\"$CA\"}")" '"status":"picking"'
|
||||
check " and held at the counter" "$(mut request.hold "{\"id\":\"$CA\",\"holdUntil\":\"Thu 5pm\"}")" '"status":"ready"'
|
||||
check "the second is picked" "$(mut request.pick "{\"id\":\"$CB\"}")" '"status":"picking"'
|
||||
check " and held beside it" "$(mut request.hold "{\"id\":\"$CB\",\"holdUntil\":\"Thu 5pm\"}")" '"status":"ready"'
|
||||
# Looked up by id, one field each, rather than filtered into a list: a bag that never got as far as
|
||||
# the counter has to read as missing. Filtering shortened the list instead, and a lone code beside
|
||||
# an empty second field then compared as two codes that differ, which is the one answer this pair
|
||||
# of lines exists to rule out.
|
||||
PAIR=$(curl -s -b "$C" "$B/api/requests" | py "codes={r['id']: (r['collectCode'] or 'none') for r in d['requests']}; print(codes.get('$CA','missing'), codes.get('$CB','missing'))")
|
||||
check "both bags carry a code" "$PAIR" '^[0-9][0-9][0-9][0-9] [0-9][0-9][0-9][0-9]$'
|
||||
check " and the two are not the same one" "$(echo "$PAIR" | awk '{ if ($1 !~ /^[0-9][0-9][0-9][0-9]$/ || $2 !~ /^[0-9][0-9][0-9][0-9]$/) print "not two codes: " $0; else if ($1 == $2) print "the same"; else print "different" }')" '^different$'
|
||||
check "the first goes home" "$(mut request.collected "{\"id\":\"$CA\"}")" '"status":"collected"'
|
||||
check " and the second after it" "$(mut request.collected "{\"id\":\"$CB\"}")" '"status":"collected"'
|
||||
|
||||
echo "== the emailed link renders, the button decides"
|
||||
# Two garments on this one deliberately. The email has no room for a garment-by-garment answer —
|
||||
# it is one button — so approving through it has to settle every line on the request. A link that
|
||||
# moved the request to accepted while its lines sat at awaiting would hand the linen room a pick
|
||||
# list with nothing on it.
|
||||
R3=$(smut request.create "$Z" "{\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1},{\"itemId\":\"$TID\",\"si\":0,\"qty\":1}],\"reason\":\"Damaged\"}" | py "print(d['result']['id'])")
|
||||
TOKEN=$(node scripts/approval-mint.cjs "$R3" "$MID" 2>/dev/null)
|
||||
check "a token can be minted for the test" "$TOKEN" '.'
|
||||
PAGE=$(curl -s "$B/my/approve?t=$TOKEN")
|
||||
check "the link opens a page, signed out" "$PAGE" 'needs your approval'
|
||||
check " listing every garment on the ask" "$PAGE" 'Navy trousers'
|
||||
check " which says nothing is decided yet" "$PAGE" 'Nothing has been decided yet'
|
||||
STILL=$(curl -s -b "$C" "$B/api/requests")
|
||||
check " and rendering it decided nothing" "$(echo "$STILL" | py "print([r for r in d['requests'] if r['id']=='$R3'][0]['status'])")" '^awaiting$'
|
||||
check "a garbage token is refused" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d '{"token":"nope.nope","action":"approve"}')" 'expired'
|
||||
check "the POST decides" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d "{\"token\":\"$TOKEN\",\"action\":\"approve\"}")" '"status":"accepted"'
|
||||
check "and the same link will not decide twice" "$(curl -s -X POST "$B/api/staff/decide" -H 'content-type: application/json' -H "origin: $B" -d "{\"token\":\"$TOKEN\",\"action\":\"decline\",\"reason\":\"Over allowance\"}")" 'already been decided'
|
||||
R3ROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$R3'][0]))")
|
||||
check "the one button settled both garments" "$(echo "$R3ROW" | py "print(len([l for l in d['lines'] if l['status']=='approved']))")" '^2$'
|
||||
check " so the whole ask is in the bag" "$(echo "$R3ROW" | py "print(len(d['bag']))")" '^2$'
|
||||
check " and it reads as approved outright" "$(echo "$R3ROW" | py "print(d['decision'])")" '^All 2 approved$'
|
||||
|
||||
echo "== the ward round, and who signed"
|
||||
# Signing for a bag on the ward is the moment its garments leave the linen room's shelf, exactly as
|
||||
# collecting at the counter is, so the whole approved ask has to move — not just the first line on
|
||||
# it. Counted before the round and after, because the shelf is what the linen room reconciles.
|
||||
ROUND_BEFORE=$(curl -s -b "$C" "$B/api/backup" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId'] in ('$IID','$TID')))")
|
||||
check "sent on the round" "$(mut request.pick "{\"id\":\"$R3\"}")" '"status":"picking"'
|
||||
check " routed to the ward" "$(mut request.round "{\"id\":\"$R3\"}")" '"status":"round"'
|
||||
check "another ward cannot sign for it" "$(smut round.sign "$O" "{\"id\":\"$R3\"}")" 'another ward'
|
||||
check "anyone on the ward can sign" "$(smut round.sign "$D" "{\"id\":\"$R3\"}")" '"ok":true'
|
||||
check " and both garments came off the shelf with it" "$(curl -s -b "$C" "$B/api/backup" | py "print(sum(i['qty'] for i in d['issues'] if i['itemId'] in ('$IID','$TID')) - $ROUND_BEFORE)")" '^2$'
|
||||
check "and not twice" "$(smut round.sign "$D" "{\"id\":\"$R3\"}")" 'No such bag'
|
||||
SIGNED=$(curl -s -b "$Z" "$B/my/orders/$R3")
|
||||
check "the requester is told who signed" "$SIGNED" 'Ade Clerk'
|
||||
# Signing is where the linen room's job ends, not where the bag does: it then sits on the desk
|
||||
# until somebody says it was picked up, and the desk's unclaimed pile only grows until they do.
|
||||
# The requester, or the clerk standing next to the pile, may say so — and nobody else.
|
||||
check "a stranger cannot mark it collected" "$(smut round.claim "$O" "{\"id\":\"$R3\"}")" 'somebody else'
|
||||
check "the desk marks it collected" "$(smut round.claim "$D" "{\"id\":\"$R3\"}")" '"ok":true'
|
||||
check " and the requester is told who did" "$(curl -s -b "$Z" "$B/my/orders/$R3")" 'Marked by Ade Clerk'
|
||||
# The requester tapping "I've got it" after the desk has already marked it must leave one claim
|
||||
# and one line on the timeline, not a second one under the other name.
|
||||
check "claiming it again changes nothing" "$(smut round.claim "$Z" "{\"id\":\"$R3\"}")" '"ok":true'
|
||||
# One row, and still the desk's name on it: an implementation that overwrote the first claim with
|
||||
# the second caller's name would also leave exactly one row, so counting them proves half of it.
|
||||
check " and the timeline says it once, in the desk's name" "$(curl -s -b "$C" "$B/api/requests" | py "e=[x for x in [r for r in d['requests'] if r['id']=='$R3'][0]['events'] if x['label']=='Collected from the ward']; print(len(e), e[0]['meta'] if e else '')")" '^1 Marked by Ade Clerk$'
|
||||
R3CODE=$(curl -s -b "$C" "$B/api/requests" | py "print([r for r in d['requests'] if r['id']=='$R3'][0]['code'])")
|
||||
ROUNDPAGE=$(curl -s -b "$D" "$B/my/round")
|
||||
check "the desk's round screen is still there" "$ROUNDPAGE" 'Ward round'
|
||||
no " and the collected bag is off it" "$ROUNDPAGE" "$R3CODE"
|
||||
|
||||
echo "== the ward desk raises for nobody"
|
||||
# The desk used to raise for anyone on its own ward, on the grounds that half a ward would never
|
||||
# install anything. That door is shut: the only person who may put a request in somebody else's
|
||||
# name is their own manager. Ade is on the desk and on Jamila's ward, and between them those two
|
||||
# facts now buy her exactly what they buy an ordinary staff member.
|
||||
DR=$(smut request.create "$D" "{\"subjectId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Extra for shifts\"}")
|
||||
check "the desk cannot raise for its own ward" "$DR" "Only somebody.s own manager"
|
||||
no " and nothing is created by it" "$DR" '"code"'
|
||||
check "nor for another ward" "$(smut request.create "$D" "{\"subjectId\":\"$OID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "Only somebody.s own manager"
|
||||
check "a non-desk staff member cannot either" "$(smut request.create "$W" "{\"subjectId\":\"$DID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "Only somebody.s own manager"
|
||||
# The widest version of that door was a blank ward: two people with nothing recorded read as
|
||||
# members of the same one, which handed a clerk with an empty ward the run of every other
|
||||
# ward-less person in the facility. Nia is that clerk, and Pat reports to Dele, not to her.
|
||||
BLANK=$(smut request.create "$N" "{\"subjectId\":\"$PID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")
|
||||
check "nor a desk clerk with no ward recorded" "$BLANK" "Only somebody.s own manager"
|
||||
no " and nothing is created by that either" "$BLANK" '"code"'
|
||||
# The flag itself stays — it is what signs for a bag on the round — but the screen it used to open
|
||||
# has gone from the product, for the clerk who holds the flag as much as for anybody else.
|
||||
check "and the desk screen went with the door" "$(curl -s -o /dev/null -w '%{http_code}' -b "$D" "$B/my/desk")" '404'
|
||||
|
||||
echo "== a manager raises for their own report, and never approves it"
|
||||
# New in this round: a manager may raise for the people who report to them. The catch is that the
|
||||
# manager is also the person who would approve it, so the request goes a level up instead — and
|
||||
# when there is nobody above, it is created with no approver at all and waits on the linen room.
|
||||
#
|
||||
# The screen is scoped to the reporting line, not to a ward, and it exists only for somebody who
|
||||
# actually has one — an empty version of it would tell a nurse they might have a team.
|
||||
refused "a wearer with nobody under them has no such screen" "$W" "/my/raise" "Who is it for"
|
||||
refused "nor the ward clerk, who manages nobody either" "$D" "/my/raise" "Who is it for"
|
||||
TEAM=$(curl -s -b "$M" "$B/my/raise")
|
||||
check "the manager has one" "$TEAM" 'Raise for your team'
|
||||
check " listing the people who report to them" "$TEAM" 'Jamila Wearer'
|
||||
no " and nobody who doesn.t" "$TEAM" 'Otto Elsewhere'
|
||||
check "and home points them at it" "$(curl -s -b "$M" "$B/my")" 'Raise for someone you manage'
|
||||
SELFR=$(smut request.create "$M" "{\"subjectId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
|
||||
check "the manager can raise for their report" "$SELFR" '"code"'
|
||||
check " and is told it was moved on" "$SELFR" '"escalated":true'
|
||||
check " with nobody above them, it has no approver" "$SELFR" '"manager":""'
|
||||
SRID=$(echo "$SELFR" | py "print(d['result']['id'])")
|
||||
check " and the subject sees whose name it is in" "$(curl -s -b "$W" "$B/my/orders/$SRID")" 'Raised for you by Dele Manager'
|
||||
check "so the raiser cannot approve it" "$(smut request.approve "$M" "{\"id\":\"$SRID\"}")" 'No such request'
|
||||
check "nor decline it" "$(smut request.decline "$M" "{\"id\":\"$SRID\",\"reason\":\"Over allowance\"}")" 'No such request'
|
||||
SROW=$(curl -s -b "$C" "$B/api/requests" | py "print(json.dumps([r for r in d['requests'] if r['id']=='$SRID'][0]))")
|
||||
check "the linen room sees it waiting" "$(echo "$SROW" | py "print(d['status'])")" '^awaiting$'
|
||||
check " with nobody's name against it" "$(echo "$SROW" | py "print(d['managerName'] or 'nobody')")" '^nobody$'
|
||||
check " and the timeline says why" "$(echo "$SROW" | py "print(d['events'][0]['meta'])")" 'the linen room will address it'
|
||||
check "the linen room addresses it to somebody who can decide" "$(mut request.reassign "{\"id\":\"$SRID\",\"managerId\":\"$CID\"}")" '"manager":"Chidi Director"'
|
||||
check "and that manager can" "$(smut request.approve "$X" "{\"id\":\"$SRID\"}")" '"status":"accepted"'
|
||||
|
||||
check "the manager is given a manager of their own" "$(mut staff.patch "{\"id\":\"$MID\",\"managerId\":\"$CID\"}")" '"ok":true'
|
||||
UPR=$(smut request.create "$M" "{\"subjectId\":\"$WID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}],\"reason\":\"Worn out\"}")
|
||||
check "now the same raise goes up a level" "$UPR" '"manager":"Chidi Director"'
|
||||
check " still marked as moved on" "$UPR" '"escalated":true'
|
||||
no " and never back to the raiser" "$UPR" 'Dele Manager'
|
||||
UPID=$(echo "$UPR" | py "print(d['result']['id'])")
|
||||
check "the raiser still cannot decide it" "$(smut request.approve "$M" "{\"id\":\"$UPID\"}")" 'No such request'
|
||||
# Not deciding it is not the same as losing sight of it: whoever raised a request can follow it.
|
||||
check "but can still follow it" "$(curl -s -o /dev/null -w '%{http_code}' -b "$M" "$B/my/orders/$UPID")" '200'
|
||||
check "the level above declines it" "$(smut request.decline "$X" "{\"id\":\"$UPID\",\"reason\":\"Over allowance\"}")" '"status":"declined"'
|
||||
check "and the wearer is told who did" "$(curl -s -b "$W" "$B/my/orders/$UPID")" 'Chidi Director'
|
||||
# Somebody who does not report to you is nobody's to raise for — there is no route left that
|
||||
# reaches them, so a manager gets the same sentence back as the desk does.
|
||||
check "a manager still cannot raise for a stranger" "$(smut request.create "$M" "{\"subjectId\":\"$OID\",\"lines\":[{\"itemId\":\"$IID\",\"si\":1,\"qty\":1}]}")" "Only somebody.s own manager"
|
||||
# A request raised for somebody else sits on nobody's own order list, so without a place of its own
|
||||
# it would go quiet on the person who typed it in. Read off what was drawn rather than off what was
|
||||
# sent: the screen is handed all three lists whichever tab is showing, so the subject's name is in
|
||||
# the bytes of both URLs either way. "for Jamila Wearer" is composed as a row is drawn, so it only
|
||||
# appears on the list the tab actually opened.
|
||||
RAISED=$(curl -s -b "$M" "$B/my/orders?tab=raised")
|
||||
check "what they raised for others has its own list" "$RAISED" 'for Jamila Wearer'
|
||||
OWN=$(curl -s -b "$M" "$B/my/orders")
|
||||
check " and their own list opens with nothing in it" "$OWN" 'Nothing open'
|
||||
no " and it is not folded in with their own orders" "$OWN" 'for Jamila Wearer'
|
||||
|
||||
echo "== anybody may be their own manager"
|
||||
# The owner's decision, 12 September 2026. On a facility where one person is the whole register
|
||||
# there is nobody else to name, and the staff app refuses every request from somebody with no manager
|
||||
# set — so anybody may be recorded as their own, and approve their own requests and signed forms.
|
||||
# None of it passes unseen: every such approval is marked self-approved on the record. This replaced
|
||||
# a rule that allowed it only to somebody with at least one other person reporting to them, which is
|
||||
# why Kofi below has nobody under him.
|
||||
KID=$(mut staff.save '{"num":"K1","first":"Kofi","last":"Solo","group":"Registered Nurse","dept":"Linden Ward","top":"M","pants":"12"}' | py "print(d['result']['id'])")
|
||||
check "somebody with nobody under them" "$KID" '.'
|
||||
check " can be recorded as their own manager" "$(mut staff.patch "{\"id\":\"$KID\",\"managerId\":\"$KID\"}")" '"ok":true'
|
||||
check " and the register holds them as it" "$(curl -s -b "$C" "$B/api/backup" | py "print([s['managerId'] for s in d['staff'] if s['id']=='$KID'][0] == '$KID')")" '^True$'
|
||||
check "Kofi claims an account" "$(claim "$KID" "$K" "k$TS@example.com")" '"ok":true'
|
||||
# The complaint that started this: the staff app kept telling him no manager was set. Otto still has
|
||||
# none, so the same sentence on his home is what proves this line is looking at the right words.
|
||||
no "the staff app no longer says he has no manager" "$(curl -s -b "$K" "$B/my")" 'manager isn’t recorded yet'
|
||||
check " while somebody with none is still told" "$(curl -s -b "$O" "$B/my")" 'manager isn’t 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" "doesn’t 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 ]
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
# ThreadCount update-wave smoke test against a local dev server. Exercises every new op end-to-end.
|
||||
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"
|
||||
J=${TMP:-/tmp}/tc-cj.txt; rm -f "$J"
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; }
|
||||
mut() { curl -s -b "$J" -c "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"$1\",\"payload\":$2}"; }
|
||||
check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$out"; fi; }
|
||||
jget() { python3 -c "import sys,json; d=json.load(sys.stdin); print(eval('d$1'))"; }
|
||||
|
||||
TS=$(date +%s); EMAIL="e2e$TS@example.com"
|
||||
echo "== signup"
|
||||
R=$(curl -s -c "$J" -X POST "$B/api/auth/signup" -H 'content-type: application/json' -H "x-forwarded-for: 10.9.$((RANDOM%250)).$((RANDOM%250))" -d "{\"first\":\"Test\",\"last\":\"Admin\",\"facility\":\"E2E Hospital $TS\",\"email\":\"$EMAIL\",\"password\":\"password123\"}")
|
||||
check "signup" "$R" '"ok":true'
|
||||
check "the facility names its staff groups" "$(e2e_groups "$B" "$J")" '"ok":true'
|
||||
|
||||
echo "== setup: depts, suppliers, catalog, staff, opening"
|
||||
check "dept A" "$(mut dept.save '{"name":"Willow Ward","cc":"RGH-3010"}')" '"ok":true'
|
||||
check "dept B" "$(mut dept.save '{"name":"Security","cc":"RGH-5090"}')" '"ok":true'
|
||||
check "dept dup rejected" "$(mut dept.save '{"name":"willow ward","cc":"1"}')" 'already'
|
||||
R=$(mut supplier.add '{"name":"Northline Workwear"}'); check "supplier add" "$R" '"id"'; SUP=$(echo "$R" | jget '["result"]["id"]')
|
||||
check "supplier update lead" "$(mut supplier.update "{\"id\":\"$SUP\",\"lead\":\"10\",\"contact\":\"Dana\",\"account\":\"ACC-1\"}")" '"ok":true'
|
||||
check "supplier dup rejected" "$(mut supplier.add '{"name":"northline workwear"}')" 'already'
|
||||
# Scrub Pant is for every group. Sam, in Security, has a pair ordered in further down to prove an
|
||||
# order-in takes the product's own supplier; tagged for nurses only, it would be refused at the counter
|
||||
# as outside his staff group before that was ever looked at.
|
||||
R=$(mut import.rows '{"kind":"catalog","rows":[{"item":"RN Scrub Top","gender":"Unisex","sku":"TOP1","supplier":"Northline Workwear","cost":"30","group":"Registered Nurse","sizes":"S|M|L"},{"item":"Scrub Pant","gender":"Unisex","sku":"PANT1","supplier":"Harbour Embroidery","cost":"25","group":"All","sizes":"S|M|L"},{"item":"Security Shirt","gender":"Male","sku":"SEC1","supplier":"Northline Workwear","cost":"40","group":"Security","sizes":"M|L"}]}')
|
||||
check "catalog import 3" "$R" '"created":3'
|
||||
check "supplier auto-created from import" "$(mut supplier.add '{"name":"Harbour Embroidery"}')" 'already'
|
||||
R=$(mut import.rows '{"kind":"staff","rows":[{"num":"1001","first":"Nina","last":"Nurse","group":"Registered Nurse","dept":"Willow Ward","top":"M","pants":"M"},{"num":"2002","first":"Sam","last":"Guard","group":"Security","dept":"Security","ent":"2","top":"L","pants":"L"}]}')
|
||||
check "staff import 2" "$R" '"created":2'
|
||||
check "opening import" "$(mut import.rows '{"kind":"opening","rows":[{"sku":"TOP1","size":"M","opening":"10","reorder":"3"},{"sku":"PANT1","size":"M","opening":"10"},{"sku":"SEC1","size":"L","opening":"1","reorder":"2"},{"sku":"TOP1","size":"S","opening":"0","reorder":"2"}]}')" '"created":4'
|
||||
# Footwear is not issued in this linen room, so a shoe column on the staff template would have wards
|
||||
# filling in sizes for something nobody can ever hand them. Read the template's own header row out of
|
||||
# csv.ts and look at the columns themselves: pinning one long literal header string, as this used to,
|
||||
# stopped matching anything the day a manager column was added between cc and top, and from then on
|
||||
# the check passed whatever the file said.
|
||||
STAFF_HDR=$(sed -n 's/^[[:space:]]*staff:.*headers: "\([^"]*\)".*$/\1/p' "$(dirname "$0")/../lib/csv.ts")
|
||||
check "staff CSV template header row found" "$STAFF_HDR" '^num,'
|
||||
check "staff CSV template has no shoe column" "$(echo ",$STAFF_HDR," | grep -c ',[^,]*[Ss]hoe[^,]*,' || true)" '^0$'
|
||||
|
||||
echo "== fetch ids via backup"
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
check "backup v2" "$BK" 'threadcount-backup-v2'
|
||||
NINA=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([s['id'] for s in d['staff'] if s['num']=='1001'][0])")
|
||||
SAM=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([s['id'] for s in d['staff'] if s['num']=='2002'][0])")
|
||||
TOP=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['sku']=='TOP1'][0])")
|
||||
PANT=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['sku']=='PANT1'][0])")
|
||||
SEC=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['items'] if i['sku']=='SEC1'][0])")
|
||||
check "lastBackup stamped" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; print(json.load(sys.stdin)['facility']['lastBackup'])")" '^20'
|
||||
|
||||
echo "== manager approvals + nursing issue"
|
||||
R=$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"J. Barnes, Ward 3A\",\"sets\":\"3\",\"fte\":\"1.0\"}"); check "approval add" "$R" '"id"'; AP=$(echo "$R" | jget '["result"]["id"]')
|
||||
check "approval needs by" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"\",\"sets\":\"3\"}")" 'required'
|
||||
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":2,\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":2,\"src\":\"stock\"},{\"itemId\":\"$PANT\",\"si\":1,\"qty\":2,\"src\":\"stock\"}]}")
|
||||
check "nursing issue (no limit, 4 items)" "$R" '"stock":4'
|
||||
check "approval deducted 2, 1 remaining" "$R" '"apDeducted":2,"apRemaining":1'
|
||||
check "second approval" "$(mut approval.add "{\"staffId\":\"$NINA\",\"by\":\"K. Lee\",\"sets\":\"2\"}")" '"id"'
|
||||
# Ask for fewer sets than are open — 1 left on Barnes, 2 on Lee — so the split shows. Draining both
|
||||
# in one go proves nothing about order; taking 1+1 does, and both approvals were raised the same day,
|
||||
# so the only thing deciding which one moves first is the order they were entered in.
|
||||
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":2,\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")
|
||||
check "deduct of 2 rolls across approvals (1+1)" "$R" '"apDeducted":2,"apRemaining":1'
|
||||
check "oldest approval drained first" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print(' '.join(a['byName']+'='+str(a['used']) for a in sorted(d['approvals'], key=lambda a: a['byName'])))")" '^J\. Barnes, Ward 3A=3 K\. Lee=1$'
|
||||
# Asking for more sets than the manager has signed for takes what is open and stops there.
|
||||
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":5,\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")
|
||||
check "deduct clamped to the 1 set still open" "$R" '"apDeducted":1,"apRemaining":0'
|
||||
R=$(mut issue.create "{\"staffId\":\"$NINA\",\"apDeduct\":1,\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")
|
||||
check "no open approval -> 0 deducted" "$R" '"apDeducted":0'
|
||||
|
||||
echo "== the six-set ceiling + order-in supplier from product"
|
||||
# Seven shirts ordered in for somebody holding nothing is one past the six tops anyone may hold.
|
||||
# Sam's own figure of 2 on the register is a yearly reporting number now, so it is the ceiling that
|
||||
# has to refuse this — and refusing it leaves no order behind to move the reorder figures below.
|
||||
R=$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$SEC\",\"si\":0,\"qty\":7,\"src\":\"order\"}]}")
|
||||
check "server refuses a seventh top without an override (six sets held is the ceiling)" "$R" 'the most anyone holds is 6 sets'
|
||||
# One Security Shirt L on the shelf and two cart lines asking for one each: either line would pass on
|
||||
# its own, so only a check that adds the lines up first refuses this. Asking 2+2, as this used to, is
|
||||
# refused just as fast by a naive per-line check, which left the rule the label names untested.
|
||||
R=$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$SEC\",\"si\":1,\"qty\":1,\"src\":\"stock\"},{\"itemId\":\"$SEC\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")
|
||||
check "cumulative shelf check (1 on hand, 1+1 asked)" "$R" 'Not enough on the shelf'
|
||||
R=$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$SEC\",\"si\":1,\"qty\":1,\"src\":\"stock\"},{\"itemId\":\"$PANT\",\"si\":0,\"qty\":1,\"src\":\"order\",\"supplier\":\"WRONG\"}]}")
|
||||
check "issue stock + order-in" "$R" '"stock":1,"ordered":1'
|
||||
check "order-in cc uses staff dept (no override)" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([o['cc'] for o in d['orders'] if o['orderFor']=='Staff Member'][0])")" 'Security'
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
check "order-in used product supplier (Harbour), not client value" "$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([o['supplier'] for o in d['orders'] if o['orderFor']=='Staff Member'])")" 'Harbour'
|
||||
check "replenish drafts per supplier" "$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print(sorted(o['supplier'] for o in d['orders'] if o['replenish']))")" "Harbour Embroidery', 'Northline Workwear"
|
||||
|
||||
echo "== order flagged (2x reorder, net of on-order) + duplicate"
|
||||
R=$(mut stock.orderFlagged '{}'); check "order flagged added lines" "$R" '"added":[1-9]'
|
||||
# "added" is the count of need lines the merge looked at, not the count it changed, so it reads the
|
||||
# same on the second run whether the merge tops a line up or piles onto it. The draft's own line count
|
||||
# and total are the only things that tell those two apart.
|
||||
qn(){ curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['replenish'] and o['supplier']=='Northline Workwear'][0]; print(len(o['lines']), sum(l['qty'] for l in o['lines']))"; }
|
||||
Q1=$(qn)
|
||||
check "the draft the merge writes into can be read" "$Q1" '^[0-9][0-9]* [0-9][0-9]*$'
|
||||
mut stock.orderFlagged '{}' >/dev/null
|
||||
check "order flagged idempotent (max merge)" "$(qn)" "^$Q1\$"
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
ORD=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print([o['id'] for o in d['orders'] if o['replenish'] and o['supplier']=='Northline Workwear'][0])")
|
||||
SECQ=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['replenish'] and o['supplier']=='Northline Workwear'][0]; print([l['qty'] for l in o['lines'] if l['itemId']=='$SEC' and l['size']=='L'][0])")
|
||||
check "SEC L topped to 2x reorder (draft line itself not netted: 4-0=4)" "$SECQ" '^4$'
|
||||
R=$(mut order.duplicate "{\"id\":\"$ORD\"}"); check "duplicate order" "$R" '"code":"ORD-'
|
||||
DUP=$(echo "$R" | jget '["result"]["id"]')
|
||||
check "receive on a draft rejected" "$(mut order.receive "{\"id\":\"$DUP\",\"lines\":[]}")" 'Mark the order as ordered'
|
||||
check "dup is Draft with notes" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['id']=='$DUP'][0]; print(o['status'], o['notes'])")" 'Draft Duplicated'
|
||||
|
||||
echo "== staff patch / alterations / catalog addSize / discontinue"
|
||||
check "staff deactivate" "$(mut staff.patch "{\"id\":\"$SAM\",\"inactive\":true}")" '"ok":true'
|
||||
# ok:true only says a row was written. The flag is there to stop the next issue at the counter, so ask
|
||||
# for one — without this, dropping the write to `inactive` leaves the section green.
|
||||
check "an inactive staff member can't be issued to" "$(mut issue.create "{\"staffId\":\"$SAM\",\"lines\":[{\"itemId\":\"$SEC\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}")" 'inactive'
|
||||
check "staff cc override" "$(mut staff.patch "{\"id\":\"$NINA\",\"ccOverride\":\"RGH-5090\"}")" '"ok":true'
|
||||
check "cc override recorded on the staff row" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([s['ccOverride'] for s in d['staff'] if s['num']=='1001'][0])")" '^RGH-5090$'
|
||||
check "staff num immutable" "$(mut staff.save "{\"id\":\"$NINA\",\"num\":\"9999\",\"first\":\"Nina\",\"last\":\"Nurse\"}")" "can't be changed"
|
||||
R=$(mut alteration.add "{\"staffId\":\"$NINA\",\"garment\":\"Scrub pants (M)\",\"desc\":\"hem 4cm\"}"); check "alteration add" "$R" '"id"'; ALT=$(echo "$R" | jget '["result"]["id"]')
|
||||
check "alteration advance" "$(mut alteration.advance "{\"id\":\"$ALT\"}")" '"ok":true'
|
||||
check "alteration at tailor" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['alterations'][0]['status'])")" 'At tailor'
|
||||
check "add size" "$(mut catalog.update "{\"id\":\"$TOP\",\"addSize\":\"XL\"}")" '"ok":true'
|
||||
check "dup size rejected" "$(mut catalog.update "{\"id\":\"$TOP\",\"addSize\":\"XL\"}")" 'already'
|
||||
check "discontinue" "$(mut catalog.update "{\"id\":\"$SEC\",\"archived\":true}")" '"ok":true'
|
||||
check "delete item with history blocked" "$(mut catalog.delete "{\"id\":\"$SEC\"}")" 'Discontinue'
|
||||
|
||||
echo "== settings + logo"
|
||||
check "settings finance" "$(mut settings.update '{"glAccount":"631020","journalDesc":"Uniform issues","exceptionHigh":"3"}')" '"ok":true'
|
||||
check "logo reject non-image" "$(mut settings.update '{"logoData":"data:text/html;base64,PGI+"}')" 'must be'
|
||||
check "logo rejects svg" "$(mut settings.update '{"logoData":"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="}')" 'must be'
|
||||
check "logo accept png" "$(mut settings.update '{"logoData":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="}')" '"ok":true'
|
||||
check "logo route serves png" "$(curl -s -b "$J" -o /dev/null -w '%{content_type}' "$B/api/logo")" 'image/png'
|
||||
check "supplier remove blocked (in use)" "$(mut supplier.remove "{\"id\":\"$SUP\"}")" "can't be removed"
|
||||
|
||||
echo "== stocktake"
|
||||
R=$(mut stocktake.apply "{\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"counted\":3},{\"itemId\":\"$PANT\",\"si\":1,\"counted\":8}]}")
|
||||
check "stocktake apply: 2 counted, 1 variance filed" "$R" '"counted":2,"variances":1'
|
||||
# The tally above is paperwork. Ten RN tops came in and five have been issued, so the shelf believes 5
|
||||
# and the counter found 3 — unless the count writes that gap onto the level, the same two garments go
|
||||
# missing again next month. -2 is the adjustment the count wrote, not the number counted.
|
||||
check "stocktake moved the shelf (5 on hand, counted 3)" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([s['adj'] for s in d['stock'] if s['itemId']=='$TOP' and s['sizeIndex']==1][0])")" '^-2$'
|
||||
R=$(mut stocktake.apply "{\"lines\":[{\"itemId\":\"$PANT\",\"si\":1,\"counted\":8}]}")
|
||||
check "clean count can be filed (0 variances)" "$R" '"counted":1,"variances":0'
|
||||
|
||||
echo "== pages render (200)"
|
||||
for p in /app /app/stock "/app/stock/$TOP" /app/issue /app/stocktake /app/orders "/app/orders/$ORD" /app/report /app/staff "/app/staff/$NINA" /app/settings "/print?type=collection&staffName=Nina"; do
|
||||
C=$(curl -s -b "$J" -o /dev/null -w '%{http_code}' "$B$p"); [ "$C" = "200" ] && ok "GET $p" || fail "GET $p" "$C"
|
||||
done
|
||||
check "slip prints logo img" "$(curl -s -b "$J" "$B/print?type=collection")" 'data:image/png'
|
||||
|
||||
echo "== cost at issue, lead time, back-order parent, admin gates"
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
check "issues carry cost at issue (30.0 for TOP)" "$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); print(sorted(set(i['cost'] for i in d['issues'] if i['itemId']=='$TOP')))")" '\[30'
|
||||
check "replenish draft expected = today+10 (Northline lead 10)" "$(echo "$BK" | python3 -c "import sys,json,datetime; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['replenish'] and o['supplier']=='Northline Workwear'][0]; print((datetime.date.fromisoformat(o['expected'])-datetime.date.fromisoformat(o['date'])).days)")" '^10$'
|
||||
# The other branch, read off the order it belongs to: Harbour was created by the catalogue import and
|
||||
# nobody has given it a lead time, so the order-in raised for a staff member falls back to a fortnight.
|
||||
check "order-in expected = today+14 (Harbour has no lead)" "$(echo "$BK" | python3 -c "import sys,json,datetime; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['orderFor']=='Staff Member'][0]; print((datetime.date.fromisoformat(o['expected'])-datetime.date.fromisoformat(o['date'])).days)")" '^14$'
|
||||
check "mark dup ordered" "$(mut order.status "{\"id\":\"$DUP\",\"status\":\"Ordered\"}")" '"ok":true'
|
||||
LINE=$(echo "$BK" | python3 -c "import sys,json; d=json.load(sys.stdin); o=[o for o in d['orders'] if o['id']=='$DUP'][0]; l=o['lines'][0]; print(l['id'], l['qty'])")
|
||||
LID=${LINE% *}; LQ=${LINE#* }
|
||||
R=$(mut order.receive "{\"id\":\"$DUP\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":1,\"dest\":\"shelf\"}]}")
|
||||
check "short receive ok" "$R" '"ok":true'
|
||||
check "back order links to parent via parentId" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print([o['parentId']=='$DUP' for o in d['orders'] if o['status']=='Back Order'])")" 'True'
|
||||
check "second receive rejected" "$(mut order.receive "{\"id\":\"$DUP\",\"lines\":[{\"lineId\":\"$LID\",\"arrived\":1}]}")" 'receiv'
|
||||
R=$(mut users.add "{\"email\":\"issuer$TS@example.com\",\"password\":\"password123\",\"first\":\"Iss\",\"last\":\"Uer\",\"role\":\"ISSUER\"}"); check "add issuer" "$R" '"id"'
|
||||
J2=${TMP:-/tmp}/tc-cj2.txt; rm -f "$J2"
|
||||
curl -s -c "$J2" -X POST "$B/api/auth/login" -H 'content-type: application/json' -d "{\"email\":\"issuer$TS@example.com\",\"password\":\"password123\"}" >/dev/null
|
||||
check "issuer cannot set reorder" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"stock.reorder\",\"payload\":{\"itemId\":\"$TOP\",\"si\":0,\"reorder\":9}}")" 'Admin only'
|
||||
check "issuer cannot bind barcode" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"barcode.bind\",\"payload\":{\"code\":\"123\",\"itemId\":\"$TOP\",\"si\":0}}")" 'Admin only'
|
||||
check "issuer can still issue" "$(curl -s -b "$J2" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"issue.create\",\"payload\":{\"staffId\":\"$NINA\",\"lines\":[{\"itemId\":\"$TOP\",\"si\":1,\"qty\":1,\"src\":\"stock\"}]}}")" '"stock":1'
|
||||
|
||||
echo "== backup restore round-trip"
|
||||
BK=$(curl -s -b "$J" "$B/api/backup")
|
||||
R=$(curl -s -b "$J" -X POST "$B/api/mutate" -H 'content-type: application/json' -d "{\"op\":\"backup.restore\",\"payload\":$BK}")
|
||||
check "restore ok" "$R" '"ok":true'
|
||||
BK2=$(curl -s -b "$J" "$B/api/backup")
|
||||
# Both approvals are fully spent by now. A row count still passes if the restore drops `used`, and a
|
||||
# facility restored that way hands the wearer five manager-approved sets they have already been given —
|
||||
# so read the balances back, not the tally.
|
||||
check "restore kept suppliers/approvals/alterations" "$(echo "$BK2" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['suppliers']), sorted((a['sets'], a['used']) for a in d['approvals']), len(d['alterations']), [s['lead'] for s in d['suppliers'] if s['name']=='Northline Workwear'][0])")" '^2 \[(2, 2), (3, 3)\] 1 10$'
|
||||
|
||||
echo "== the revision the screens watch"
|
||||
# Every open screen polls /api/rev and reloads only when the number moves, so two things have to
|
||||
# hold: a mutation must move it, and the mutation must hand back the number it produced — without
|
||||
# that, the screen that made the change refreshes itself a second time when it next polls.
|
||||
REV1=$(curl -s -b "$J" "$B/api/rev" | jget '["rev"]')
|
||||
check "a signed-in coordinator can read it" "$REV1" '^[0-9][0-9]*$'
|
||||
R=$(mut dept.save '{"name":"Rev Ward","cc":"RGH-3030"}')
|
||||
check "a mutation hands back the new revision" "$R" '"rev":[0-9]'
|
||||
REV2=$(curl -s -b "$J" "$B/api/rev" | jget '["rev"]')
|
||||
check "and the number it hands back is the one on the facility" "$(echo "$R" | jget '["rev"]')" "^$REV2\$"
|
||||
check "which has moved" "$(python3 -c "print('up' if $REV2 > $REV1 else 'stuck')")" '^up$'
|
||||
# A read is not a change: polling must not make the poll look busy.
|
||||
curl -s -b "$J" "$B/api/backup" > /dev/null
|
||||
check "reading changes nothing" "$(curl -s -b "$J" "$B/api/rev" | jget '["rev"]')" "^$REV2\$"
|
||||
check "and nobody signed in gets a number at all" "$(curl -s "$B/api/rev")" 'Not signed in'
|
||||
|
||||
echo "== wipe"
|
||||
check "wipe" "$(mut data.wipeActivity '{"confirm":"WIPE"}')" '"ok":true'
|
||||
check "wipe cleared approvals" "$(curl -s -b "$J" "$B/api/backup" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['approvals']), len(d['issues']))")" '^0 0$'
|
||||
|
||||
echo; echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ $FAIL -eq 0 ]
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Move existing photos out of the database and onto disk.
|
||||
*
|
||||
* node scripts/photos-to-disk.cjs # report only
|
||||
* node scripts/photos-to-disk.cjs --write # actually move them
|
||||
*
|
||||
* Safe to run repeatedly: it only touches rows that still have base64 in `data` and no `path`,
|
||||
* and it clears `data` only after the file is on disk and has been read back and verified byte
|
||||
* for byte. A crash halfway leaves rows that are readable from either place, which is why the
|
||||
* serving route handles both.
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const path = require("path");
|
||||
const { mkdir, writeFile, readFile } = require("fs/promises");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { PrismaPg } = require("@prisma/adapter-pg");
|
||||
|
||||
const WRITE = process.argv.includes("--write");
|
||||
const ROOT = process.env.PHOTO_DIR || path.join(process.cwd(), ".photos");
|
||||
const EXT = { "image/jpeg": "jpg", "image/png": "png" };
|
||||
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
|
||||
|
||||
function parse(data) {
|
||||
const m = /^data:(image\/(?:jpeg|png));base64,([A-Za-z0-9+/=]+)$/.exec(data || "");
|
||||
return m ? { mime: m[1], bytes: Buffer.from(m[2], "base64") } : null;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const rows = await prisma.photo.findMany({
|
||||
where: { path: "", NOT: { data: "" } },
|
||||
select: { id: true, facilityId: true, data: true },
|
||||
});
|
||||
console.log(`${rows.length} photo(s) still in the database`);
|
||||
if (!rows.length) { await prisma.$disconnect(); return; }
|
||||
|
||||
const total = rows.reduce((n, r) => n + r.data.length, 0);
|
||||
console.log(`about ${(total / 1024 / 1024).toFixed(1)} MB of base64`);
|
||||
console.log(`destination: ${ROOT}`);
|
||||
if (!WRITE) { console.log("\ndry run — pass --write to move them"); await prisma.$disconnect(); return; }
|
||||
|
||||
let moved = 0, skipped = 0;
|
||||
for (const r of rows) {
|
||||
const p = parse(r.data);
|
||||
if (!p) { skipped++; continue; }
|
||||
const rel = `${r.facilityId}/${r.id}.${EXT[p.mime] || "bin"}`;
|
||||
const full = path.join(ROOT, rel);
|
||||
await mkdir(path.dirname(full), { recursive: true });
|
||||
await writeFile(full, p.bytes);
|
||||
// Read it back before dropping the only other copy.
|
||||
const back = await readFile(full);
|
||||
if (!back.equals(p.bytes)) { console.error("verify failed for", r.id); skipped++; continue; }
|
||||
await prisma.photo.update({
|
||||
where: { id: r.id },
|
||||
data: { path: rel, mime: p.mime, bytes: p.bytes.length, data: "" },
|
||||
});
|
||||
moved++;
|
||||
}
|
||||
console.log(`moved ${moved}, skipped ${skipped}`);
|
||||
await prisma.$disconnect();
|
||||
})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Mint a reset token for the e2e suite and print the RAW value.
|
||||
*
|
||||
* The app deliberately never stores the raw token — only its SHA-256 — so there is no way to read
|
||||
* one back out of the database, which is the property the whole design rests on. To test the
|
||||
* happy path end to end without a live mailbox, the suite therefore mints its own: it writes a row
|
||||
* exactly as /api/auth/forgot would and keeps the raw half.
|
||||
*
|
||||
* Refuses to run in production. It needs DATABASE_URL, so anyone who could use it already owns the
|
||||
* database — but a guard costs nothing and states the intent.
|
||||
*
|
||||
* node scripts/reset-mint.cjs <email> [ttlMs] -> prints the raw token
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const { createHash, randomBytes } = require("crypto");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { PrismaPg } = require("@prisma/adapter-pg");
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.error("refusing to run in production");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const email = process.argv[2];
|
||||
const ttl = Number(process.argv[3] || 60 * 60 * 1000);
|
||||
if (!email) { console.error("usage: reset-mint.cjs <email> [ttlMs]"); process.exit(2); }
|
||||
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
|
||||
|
||||
(async () => {
|
||||
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
||||
if (!user) { console.error("no such user"); process.exit(1); }
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
await prisma.passwordReset.create({
|
||||
data: { userId: user.id, tokenHash, expiresAt: new Date(Date.now() + ttl), requestIp: "e2e" },
|
||||
});
|
||||
console.log(token);
|
||||
await prisma.$disconnect();
|
||||
})().catch(async (e) => { console.error(e.message); await prisma.$disconnect(); process.exit(1); });
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Read the newest reset row for an address, for the e2e suite only.
|
||||
*
|
||||
* The suite needs to prove a token is single-use and expires, which means holding one — something
|
||||
* an attacker cannot do, since the table stores only the SHA-256 and the raw token exists solely
|
||||
* in the email. So this prints the hash, and the suite drives the API with a token it mints itself
|
||||
* against a row it inserts. Nothing here weakens the running app.
|
||||
*
|
||||
* node scripts/reset-token.cjs <email> -> prints "<id> <tokenHash> <expiresAt> <usedAt>"
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const { PrismaPg } = require("@prisma/adapter-pg");
|
||||
|
||||
const email = process.argv[2];
|
||||
if (!email) { console.error("usage: reset-token.cjs <email>"); process.exit(2); }
|
||||
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL, max: 1 }) });
|
||||
|
||||
(async () => {
|
||||
const row = await prisma.passwordReset.findFirst({
|
||||
where: { user: { email } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, tokenHash: true, expiresAt: true, usedAt: true },
|
||||
});
|
||||
if (row) console.log(`${row.id} ${row.tokenHash} ${row.expiresAt.toISOString()} ${row.usedAt ? row.usedAt.toISOString() : "-"}`);
|
||||
await prisma.$disconnect();
|
||||
})().catch(async (e) => { console.error(e.message); await prisma.$disconnect(); process.exit(1); });
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the SQL that would bring the current dev database up to prisma/schema.prisma.
|
||||
#
|
||||
# bash scripts/schema-diff.sh > prisma/migrations/<timestamp>_<name>/migration.sql
|
||||
#
|
||||
# `prisma migrate dev` is the usual way to do this, but it needs a clean shadow database and the
|
||||
# local `prisma dev` server's shadow carries residue that makes it fail on the first migration.
|
||||
# Diffing the live dev database against the schema needs no shadow at all.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
npx prisma migrate diff \
|
||||
--from-config-datasource \
|
||||
--to-schema prisma/schema.prisma \
|
||||
--script
|
||||
@@ -0,0 +1,14 @@
|
||||
/* Print the current six-digit code for a base32 TOTP secret.
|
||||
*
|
||||
* TOTP_SECRET=JBSWY3DPEHPK3PXP npx tsx scripts/totp-code.ts
|
||||
*
|
||||
* For the end-to-end suites, which enrol a second factor through the real routes and then need a
|
||||
* live code to sign in with. The secret comes from the environment, never an argument — argv is
|
||||
* visible to every process on the box. Uses the product's own implementation (lib/totp.ts), which
|
||||
* scripts/check-totp.ts proves against RFC 6238's published vectors.
|
||||
*/
|
||||
import { base32Decode, totp } from "../lib/totp";
|
||||
|
||||
const secret = process.env.TOTP_SECRET || "";
|
||||
if (!secret) { console.error("TOTP_SECRET must be set in the environment"); process.exit(1); }
|
||||
process.stdout.write(totp(base32Decode(secret)) + "\n");
|
||||
Reference in New Issue
Block a user