Rostera · Test run · run 2026-09-25
Rostera Test run, as shipped in the download
274 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 with php tests/run.php. Run it yourself after you download.
== 0. weekday anchor (0=Sun..6=Sat), UTC-anchored ==
PASS 2026-01-05 is Monday(1) (got 1, want 1)
PASS 2026-01-07 is Wednesday(3) (got 3, want 3)
PASS 2026-01-11 is Sunday(0) (got 0, want 0)
PASS 2026-01-03 is Saturday(6) (got 6, want 6)
== 1. parseTime / fmtTime / clampTime: clock time as INTEGER minutes-since-midnight ==
PASS "09:30" -> 570 (got 570, want 570)
PASS "9:05" -> 545 (got 545, want 545)
PASS "00:00" -> 0 (got 0, want 0)
PASS "23:59" -> 1439 (got 1439, want 1439)
PASS "22:00" -> 1320 (got 1320, want 1320)
PASS "24:00" -> null (not a clock time) (got NULL, want NULL)
PASS "23:60" -> null (got NULL, want NULL)
PASS "" -> null (got NULL, want NULL)
PASS "nope" -> null (got NULL, want NULL)
PASS "12" -> null (got NULL, want NULL)
PASS 570 -> "09:30" (got '09:30', want '09:30')
PASS 0 -> "00:00" (got '00:00', want '00:00')
PASS 1439 -> "23:59" (got '23:59', want '23:59')
PASS 1440 wraps -> "00:00" (got '00:00', want '00:00')
PASS clampTime(2000) -> 1439 (got 1439, want 1439)
PASS clampTime(-5) -> 0 (got 0, want 0)
PASS clampTime(600) -> 600 (got 600, want 600)
== 2. wallDuration + crossesMidnight: integer minutes, midnight-crossing, zero-length ==
PASS 09:00-17:00 = 480 (got 480, want 480)
PASS 22:00-06:00 crosses = 480 (got 480, want 480)
PASS 23:30-00:00 crosses = 30 (got 30, want 30)
PASS 00:00-08:00 = 480 (got 480, want 480)
PASS equal (zero-length) = 0 (got 0, want 0)
PASS 12:15-12:45 = 30 (got 30, want 30)
PASS crossesMidnight 22:00-06:00 true
PASS crossesMidnight 09:00-17:00 false
PASS crossesMidnight 17:00-09:00 true
== 3. realDuration on a NON-DST timezone (UTC) == wallDuration exactly ==
PASS UTC 09:00-17:00 = 480 (got 480, want 480)
PASS UTC 22:00-06:00 overnight=480 (got 480, want 480)
PASS UTC spring-day 01:00-05:00=240 (no DST in UTC) (got 240, want 240)
PASS invalid tz falls back to UTC = 480 (got 480, want 480)
PASS equal times = 0 (real) (got 0, want 0)
== 4. realDuration across DST boundaries (America/New_York 2026) — COMPUTED then asserted ==
PASS NY 2026-06-15 09:00-17:00 = 480 (got 480, want 480)
PASS NY 2026-06-15 22:00-06:00 overnight=480 (got 480, want 480)
PASS NY spring 01:00-05:00 spans gap = 180 (not 240) (got 180, want 180)
PASS NY spring 00:00-02:00 before gap = 120 (got 120, want 120)
PASS NY spring 03:00-06:00 after gap = 180 (got 180, want 180)
PASS NY spring 09:00-17:00 whole day = 480 (got 480, want 480)
PASS NY spring overnight 2026-03-07 22:00->06:00 = 420 (loses an hour) (got 420, want 420)
PASS NY fall 01:00-05:00 spans repeat = 300 (not 240) (got 300, want 300)
PASS NY fall 00:00-02:00 = 180 (got 180, want 180)
PASS NY fall 09:00-17:00 whole day = 480 (got 480, want 480)
PASS NY fall overnight 2026-10-31 22:00->06:00 = 540 (gains an hour) (got 540, want 540)
PASS London spring 00:30-03:30 = 120 (loses an hour) (got 120, want 120)
PASS London fall 00:30-03:30 = 240 (gains an hour) (got 240, want 240)
== 5. duration formatting: fmtHm / fmtDuration / fmtHoursDecimal ==
PASS fmtHm 480 = "8:00" (got '8:00', want '8:00')
PASS fmtHm 2310 = "38:30" (got '38:30', want '38:30')
PASS fmtHm 90 = "1:30" (got '1:30', want '1:30')
PASS fmtHm 0 = "0:00" (got '0:00', want '0:00')
PASS fmtDuration 480 = "8h" (got '8h', want '8h')
PASS fmtDuration 450 = "7h 30m" (got '7h 30m', want '7h 30m')
PASS fmtDuration 45 = "45m" (got '45m', want '45m')
PASS fmtHoursDecimal 480 = "8" (got '8', want '8')
PASS fmtHoursDecimal 450 = "7.5" (got '7.5', want '7.5')
PASS fmtHoursDecimal 30 = "0.5" (got '0.5', want '0.5')
PASS fmtHoursDecimal 470 = "7.83" (got '7.83', want '7.83')
PASS fmtHoursDecimal 2400 = "40" (got '40', want '40')
== 6. parseHours: hours -> whole minutes (string maths, no float round-trip) ==
PASS "40" -> 2400 (got 2400, want 2400)
PASS "37.5" -> 2250 (got 2250, want 2250)
PASS "0.5" -> 30 (got 30, want 30)
PASS "1.75" -> 105 (got 105, want 105)
PASS "8" -> 480 (got 480, want 480)
PASS "" -> null (got NULL, want NULL)
PASS "x" -> null (got NULL, want NULL)
== 7. weekStart / weekDates: configurable week-start weekday ==
PASS Mon-start: Wed 01-07 -> 01-05 (got '2026-01-05', want '2026-01-05')
PASS Mon-start: Mon 01-05 -> 01-05 (got '2026-01-05', want '2026-01-05')
PASS Mon-start: Sun 01-11 -> 01-05 (got '2026-01-05', want '2026-01-05')
PASS Mon-start: next Mon 01-12 -> 01-12 (got '2026-01-12', want '2026-01-12')
PASS Sun-start: Wed 01-07 -> 01-04 (got '2026-01-04', want '2026-01-04')
PASS Sun-start: Sun 01-11 -> 01-11 (got '2026-01-11', want '2026-01-11')
PASS Sat-start: Wed 01-07 -> 01-03 (got '2026-01-03', want '2026-01-03')
PASS weekDates count = 7 (got 7, want 7)
PASS weekDates[0] = start (got '2026-01-05', want '2026-01-05')
PASS weekDates[6] = start+6 (got '2026-01-11', want '2026-01-11')
== 8. dayOrdinal / absInterval: DST-immune day-ordinal timeline ==
PASS consecutive days differ by 1 (got 1, want 1)
PASS DST spring day still +1 (ordinal is UTC-anchored) (got 1, want 1)
PASS absInterval length = wall 480 (got 480, want 480)
PASS overnight absInterval length = 480 (got 480, want 480)
PASS overnight interval extends past its own midnight
== 9. overlaps / findClash: half-open (back-to-back OK), correct across midnight ==
PASS back-to-back shifts do NOT overlap (clean hand-over)
PASS genuinely overlapping shifts DO overlap
PASS a shift overlaps itself
PASS overnight Mon shift overlaps early Tue shift
PASS overnight Mon shift does NOT overlap late Tue shift
PASS shifts on different days (no spill) do not overlap
PASS findClash detects overlap with #1
PASS findClash: back-to-back with #1 is NOT a clash
PASS findClash: excludeId ignores the edited row
PASS findClash: empty set -> null
== 10. sumMinutes: weekly totals in PHP, never SQL (with overflow clamp) ==
PASS sum = 900 (15h) (got 900, want 900)
PASS empty = 0 (got 0, want 0)
PASS overflow clamps to 2e9 (got 2000000000, want 2000000000)
PASS clamp_minutes floors at 0 (got 0, want 0)
PASS week sum = 3240 (54h) (got 3240, want 3240)
PASS week sum formats to 54:00 (got '54:00', want '54:00')
== 11. status normalization + pill ==
PASS normalizeStatus "PUBLISHED" -> published (got 'published', want 'published')
PASS normalizeStatus "draft" -> draft (got 'draft', want 'draft')
PASS normalizeStatus junk -> draft (got 'draft', want 'draft')
PASS statusPill published (got array (
0 => 'ok',
1 => 'Published',
), want array (
0 => 'ok',
1 => 'Published',
))
PASS statusPill draft (got array (
0 => 'off',
1 => 'Draft',
), want array (
0 => 'off',
1 => 'Draft',
))
== 12. csvCell: numeric-aware formula-injection guard ==
PASS =SUM neutralised (got '\'=SUM(A1)', want '\'=SUM(A1)')
PASS +1 neutralised (got '\'+1', want '\'+1')
PASS -cmd neutralised (got '\'-cmd|calc', want '\'-cmd|calc')
PASS @cmd neutralised (got '\'@cmd', want '\'@cmd')
PASS leading TAB neutralised (got '\' x', want '\' x')
PASS CR payload quoted+prefixed (got '"\'
X"', want '"\'
X"')
PASS -1 kept numeric (got '-1', want '-1')
PASS 7.5 kept numeric (got '7.5', want '7.5')
PASS 40 kept numeric (got '40', want '40')
PASS plain text untouched (got 'Ava Thompson', want 'Ava Thompson')
PASS mid-string = untouched (got 'a=b', want 'a=b')
PASS comma/quote RFC-4180 quoted (got '"Kitchen, ""close"""', want '"Kitchen, ""close"""')
== 13. toCsv: header + rows, RFC-4180, CRLF ==
PASS header row (got 'Staff,Hours,Status', want 'Staff,Hours,Status')
PASS row count (header+2) (got 3, want 3)
PASS CRLF line endings
PASS comma field quoted
PASS negative number stays numeric
== 14. csrf_valid: empty-token bypass rejected (hash_equals('','') is TRUE) ==
PASS empty stored + empty given -> REJECTED
PASS empty stored + any given -> REJECTED
PASS matching token -> accepted
PASS mismatched token -> rejected
== 15. Pdf: produces a valid, non-trivial PDF document ==
PASS starts with %PDF header
PASS ends with %%EOF
PASS has xref table
PASS has a page object
PASS non-trivial length
PASS long line wraps to 2+ chunks
== PWA ==
PASS manifest.json is valid JSON
PASS manifest has required fields
PASS manifest URLs are RELATIVE (subpath-install safe)
PASS manifest icons exist on disk (192 + 512 + apple-touch)
PASS sw.js never caches non-GET
PASS sw.js never caches page navigations (auth HTML can not enter cache storage)
PASS sw.js cache name carries the version (old caches purged on activate)
PASS offline fallback page ships
PASS every app layout links the manifest + registers the SW
-- Own It 2.0 --
PASS v2: tables
PASS v2: totp_cols
PASS v2: smtp_cols
PASS v2: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: rate
PASS v2: csv_dry
PASS v2: backup_tables
PASS v2: clash_guard_catches_overlap
PASS v2: clash_guard_allows_gap
PASS v2: import_obeys_clash_guard
PASS v2: import_preview_matches_commit
PASS v2: import_no_partial_write
PASS v2: import_preview_writes_nothing
PASS v2: import_commit_writes_one
PASS v2: employee_import_rejects_bad_hours
PASS v2: employee_import_parses_hours
PASS v2: preview_catches_intrafile_overlap
PASS v2: commit_matches_intrafile_preview
PASS v2: preview_resets_between_runs
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: migrate() runs on every boot (existing installs get the 2.0 tables)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: the shift API reuses the FORM validator, it does not re-derive one
PASS v2: the shift API reuses the SAME double-booking guard as the form
PASS v2: the hours API reuses the report aggregation, it does not re-sum
PASS v2: both front doors fire the same webhook
PASS v2: publishing a rota fires its webhook AND the notification
PASS v2: no PHP-8 ternary key re-reads (PLAN item 10)
PASS v2: the CSV preview uses the same applier as the commit (dry-run cannot lie)
PASS v2/3.0: backup redacts credential material via the one shared list
PASS v2: cli-server base guard (PLAN item 9)
PASS v2: core modules stay dependency-free (no product helpers inside them)
PASS v2/kit: dark palette ships (kit) + kit js + qr.js
PASS v2/kit: layout loads the kit js and offers the toggle
PASS v2: no themed background paired with an un-overridden hardcoded colour
PASS v2: light accent is dark enough for white button text (WCAG AA)
PASS v2/kit: palette tokens are WIRED (kit css) and the app layer overrides the accent
PASS v2: sw cache is v3.1.5 + API.md ships
PASS v3: /healthz, api_ping and openapi all report 3.1.5
== Own It 3.0 ==
PASS v3: v3_tables
PASS v3: users_role_column
PASS v3: migrate_restores_v3_tables
PASS v3: role_member_no_publish
PASS v3: role_viewer_read_only
PASS v3: unknown_role_fails_closed
PASS v3: guard_catches_overlap
PASS v3: guard_allows_gap
PASS v3: guard_excludes_self
PASS v3: open_shift_never_clashes
PASS v3: guard_spans_midnight
PASS v3: copy_week_copies_as_drafts_and_skips_double_bookings
PASS v3: copy_week_recomputes_the_true_duration_for_the_new_date
PASS v3: copy_week_twice_copies_nothing_more
PASS v3: copy_week_route_is_member_gated
PASS v3: swap_refused_when_acceptor_clashes
PASS v3: swap_offer_not_yet_decidable
PASS v3: swap_allowed_when_clear
PASS v3: swap_moves_the_shift
PASS v3: swap_decide_once
PASS v3: swap_reassignment_audited
PASS v3: gdpr_map_is_name_only
PASS v3: gdpr_blocks_future_shifts
PASS v3: gdpr_allows_when_no_future
PASS v3: gdpr_clears_name
PASS v3: mcp_spec_uses_core_keys
PASS v3: mcp_five_tools
PASS v3: mcp_descriptions_state_writes
PASS v3: argguard_refuses_nested
PASS v3: argguard_refuses_unknown
PASS v3: mcp_reuses_form_validator
PASS v3: mcp_reuses_the_one_guard
PASS v3: mcp_does_no_arithmetic
PASS v3: audit_append_only
PASS v3: pdf_engine_renders
PASS v3: roster_pdf_uses_shift_helpers
PASS v3: feed_scopes_per_token
PASS v3: feed_handles_midnight
PASS v3: branding_uses_plain_filename
PASS v3: restore_reads_parse_contract
PASS v3: healthz_before_install_guard
PASS v3: all_v3_views_present
PASS v3: p9_attachments_are_na
PASS v3: db_files_cleaned
PASS v3: BACKUP: both downloads are administrators-only, and only administrators are shown them
PASS v3: the setup checklist speaks to a rota-maker (no "n8n" jargon on the first screen)
PASS v3: MCP: the probe, the tool listing and the REST API all name the server "rostera"
SKIP v3: deploy kit — product root not mounted
PASS v3: migrate() runs at boot
PASS v3: /mcp route registered
PASS v3: all 9 shared cores present
PASS v3: cores byte-identical to the shared core
PASS v3: the layout INJECTS the brand CSS (a helper nothing calls brands nothing)
PASS v3: the branding form exists and posts to the branding route
PASS v3: the logo is rendered, not merely stored
PASS v3: the brand accent is written for BOTH themes (dark re-declares --accent and outranks :root)
PASS v3: ONE redaction list covers the download AND the scheduled backup
PASS v3: no backup writer carries a redaction list of its own
PASS v3: migrate() ensures the settings singleton (an UPDATE … WHERE id = 1 needs a row)
PASS v3: GET /mcp answers the transport hint BEFORE the key check (settings relay item 3)
PASS v3: the deploy kit ships INSIDE app/ (package.sh only packages app/ + the root docs)
PASS v3: audit view reads the core's `at` column, never created_at
PASS v3: api-settings keys query supplies user_name + user_role the view renders
PASS v3: every api-settings key/webhook action is admin-gated (_ro_require('*')), not merely logged in
PASS v3: no ungated write route beyond the by-design baseline (router-derived)
PASS nav: sidebar guard NOT decidable here (1 computed nav key(s)) — covered by the live audit
PASS licence: every font ships the SIL OFL text for its own family beside it
PASS licence: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
————————————————————————————————————————————————————
== v3.1 P1: staff email on publish ==
PASS P1: migration adds employees.email and notify_staff_publish (OFF by default)
PASS P1: notify_staff_publish() honours the OFF toggle, requires configured SMTP, mails only active employees WITH an address, and never breaks the publish
PASS P1: rota_publish calls the staff notice; the form validates the address (invalid → blank, never wrong); the settings save persists the toggle
PASS P1: the employee form carries the optional email field with its promise
PASS scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
PASS scope: requireKey reads the key scope and refuses a write on a read-only key
PASS scope: mintKey persists the chosen scope, and anything but read is full access
PASS scope: /mcp hands the key scope to the core, which refuses write tools per tool
PASS scope: the key form lets an administrator mint a read-only key
PASS demo ribbon: every path renders the card with the admin wording (no public page)
PASS demo ribbon: below 600px it starts as the small pill; a tap opens it and the choice holds for the session
PASS demo ribbon: a response with no </body> (CSV, JSON, PDF) passes through untouched
PASS forms: every POST form on a screen reaches a registered route (26 checked)
PASS webhooks: duplicate_is_a_draft_copy
PASS webhooks: duplicate_sends_shift_created
PASS webhooks: import_writes_the_shift
PASS webhooks: import_sends_shift_created
PASS webhooks: preview_and_refusal_send_nothing
PASS webhooks: drag_duplicate_goes_through_the_helper
PASS backups: every non-admin role (viewer, member) gets 403 on every backup, restore and backup-token route
PASS backup JSON: an administrator gets a backup, with every credential column planted (10)
PASS backup JSON: the download carries none of them
PASS backup JSON: the scheduled backup carries none of them
PASS backup JSON: the download and the scheduled writer read the one redaction list
PASS security page: the backup card says every credential is redacted, not only the SMTP password
ALL 274 TESTS PASSED (0 failed)
← Back to Rostera · Manual · Quickstart · API