Home›Rostera›Test run
Rostera · Test run · run 2026-11-07

Rostera Test run, as shipped in the download

474 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-07 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 neutralized (got '\'=SUM(A1)', want '\'=SUM(A1)')
  PASS  +1 neutralized (got '\'+1', want '\'+1')
  PASS  -cmd neutralized (got '\'-cmd|calc', want '\'-cmd|calc')
  PASS  @cmd neutralized (got '\'@cmd', want '\'@cmd')
  PASS  leading TAB neutralized (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 color
  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 v4.1.3 + API.md ships
  PASS  v3: /healthz, api_ping and openapi all report 4.1.3

== Own It 3.0 ==
  PASS  v3: list_shifts_refuses_a_bad_week
  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_and_email
  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  license: every font ships the SIL OFL text for its own family beside it
  PASS  license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
  PASS  api roles: POST /api/shifts refuses every role without the permission (403) and admits the rest
  PASS  api roles: GET /api/rota (a staff login key is refused) refuses every role without the permission (403) and admits the rest
  PASS  api roles: POST /mcp (a staff login key is refused) refuses every role without the permission (403) and admits the rest

————————————————————————————————————————————————————

== 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() honors 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 (59 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  webhooks: swap_sends_shift_swapped_only
  PASS  webhooks: swap_payload_names_giver_and_taker
  PASS  webhooks: swap_rejection_sends_nothing
  PASS  webhooks: shift_swapped_is_subscribable
  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 (15)
  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 the JSON backup leaves out webhook URLs too (ruling 1, 2026-09-25)
  PASS  security page: the backup card says every credential is redacted, not only the SMTP password
  PASS  my rota: employee_role_is_self_only
  PASS  my rota: team_roles_hold_no_self_perms
  PASS  my rota: staff_login_resolves_its_employee
  PASS  my rota: portal_paths_contained
  PASS  my rota: guard_runs_before_any_route
  PASS  my rota: time_off_blocks_the_day
  PASS  my rota: pending_time_off_does_not_block
  PASS  my rota: overnight_into_time_off_blocked
  PASS  my rota: mcp_refuses_time_off
  PASS  my rota: import_refuses_time_off
  PASS  my rota: copy_week_skips_time_off
  PASS  my rota: swap_approval_refuses_time_off
  PASS  my rota: every_door_checks_time_off
  PASS  my rota: claim_refused_on_clash
  PASS  my rota: claim_allowed_when_clear
  PASS  my rota: claim_assigns_the_shift
  PASS  my rota: other_claims_told_it_is_filled
  PASS  my rota: claim_decided_once
  PASS  my rota: claim_sends_shift_claimed
  PASS  my rota: claim_on_filled_shift_refused
  PASS  my rota: staff_see_published_shifts_only
  PASS  my rota: staff_see_published_open_shifts_only
  PASS  my rota: open_shift_that_would_clash_is_marked
  PASS  my rota: staff_see_no_colleague_shift
  PASS  my rota: availability_validates_and_saves
  PASS  my rota: availability_note_fits_and_misfits
  PASS  my rota: time_off_input_validated
  PASS  my rota: gdpr_map_covers_email_and_reason
  PASS  my rota: gdpr_clears_email_and_reason
  PASS  my rota: personal_feed_is_published_only
  PASS  my rota: team_page_excludes_staff_logins
  PASS  my rota: invite_binds_the_employee
  PASS  my rota: every staff write refuses team accounts and admits only a staff login (36 runs)
  PASS  rota extras: template_places_drafts_through_the_guards
  PASS  rota extras: template_twice_places_nothing_more
  PASS  rota extras: template_lands_on_the_right_days
  PASS  rota extras: template_and_copy_week_share_the_placer
  PASS  rota extras: no_budget_no_bar
  PASS  rota extras: budget_compares_scheduled_hours
  PASS  rota extras: lock_stops_staff_changes_before_saving
  PASS  rota extras: clockora_totals_parsed_by_name
  PASS  rota extras: clockora_off_until_connected
  PASS  rota extras: clockora_failure_is_said_not_zeroed
  PASS  rota extras: clockora_key_is_backup_redacted
  PASS  3.3 shift notices: a_label_or_note_edit_is_not_news
  PASS  3.3 shift notices: a_draft_is_not_news
  PASS  3.3 shift notices: a_new_time_tells_the_person_old_and_new
  PASS  3.3 shift notices: a_new_person_tells_both
  PASS  3.3 shift notices: delete_or_unpublish_tells_the_person
  PASS  3.3 shift notices: to_an_open_shift_tells_the_old_person_only
  PASS  3.3 shift notices: a_past_day_is_a_correction_not_news
  PASS  3.3 shift notices: form_edit_marks_the_shift_changed
  PASS  3.3 shift notices: label_edit_leaves_no_mark
  PASS  3.3 shift notices: drag_to_another_person_marks_it
  PASS  3.3 shift notices: four_changes_four_emails_label_and_draft_none
  PASS  3.3 shift notices: email_says_old_and_new_time
  PASS  3.3 shift notices: drag_emails_ana_off_and_ben_on
  PASS  3.3 shift notices: delete_emails_the_person
  PASS  3.3 shift notices: emails_off_still_marks_the_shift
  PASS  3.3 shift notices: agent_door_marks_the_shift_changed
  PASS  3.3 shift notices: my_rota_shows_changed
  PASS  3.3 shift notices: rota_grid_shows_hours_on_each_shift
  PASS  3.3 date style: a_new_install_starts_in_the_uk_style
  PASS  3.3 date style: the_old_schema_had_no_style
  PASS  3.3 date style: an_upgraded_install_keeps_the_us_style
  PASS  3.3 date style: settings_saves_it_and_refuses_anything_else
  PASS  3.3 date style: a_save_without_the_field_leaves_it
  PASS  3.3 date style: the_settings_page_offers_both
  PASS  3.3 date style: short_dates_were_already_day_first
  PASS  texts to staff: off_on_a_fresh_install
  PASS  texts to staff: on_needs_https_and_a_business_name
  PASS  texts to staff: field_mapping_json_and_twilio_form
  PASS  texts to staff: a_number_is_digits_with_a_plus
  PASS  texts to staff: settings_admin_only_https_only_and_named_first
  PASS  texts to staff: settings_switch_on_key_write_only
  PASS  texts to staff: my_rota_offers_both_boxes_unticked
  PASS  texts to staff: my_rota_refuses_a_bad_number_and_keeps_a_good_one
  PASS  texts to staff: a_manager_cannot_tick_it_for_someone
  PASS  texts to staff: publish_texts_two_people_once_each
  PASS  texts to staff: no_text_without_the_tick_or_when_inactive
  PASS  texts to staff: a_text_names_the_business_lists_three_and_counts_the_rest
  PASS  texts to staff: open_shifts_only_for_who_asked
  PASS  texts to staff: every_text_ends_with_my_rota_and_a_stop_link
  PASS  texts to staff: publishing_again_sends_nothing
  PASS  texts to staff: several_changes_in_one_operation_one_text
  PASS  texts to staff: an_edit_texts_old_and_new_time
  PASS  texts to staff: a_label_edit_texts_nobody
  PASS  texts to staff: a_drag_texts_both_people_once
  PASS  texts to staff: a_delete_texts_the_person
  PASS  texts to staff: a_shift_made_open_tells_the_open_shift_people_not_the_one_taken_off
  PASS  texts to staff: the_agent_door_texts_after_its_answer
  PASS  texts to staff: the_stop_link_opens_a_page_and_changes_nothing
  PASS  texts to staff: the_stop_button_stops_and_forgets
  PASS  texts to staff: after_a_stop_nothing_more
  PASS  texts to staff: a_staff_login_may_open_the_stop_link_and_nothing_else_new
  PASS  texts to staff: unticking_on_my_rota_stops_and_forgets
  PASS  texts to staff: a_manager_can_turn_texts_off_never_on
  PASS  texts to staff: a_refusal_never_blocks_the_publish_and_stops_the_batch
  PASS  texts to staff: the_audit_keeps_four_digits_only
  PASS  texts to staff: send_test_shows_the_gateway_answer
  PASS  texts to staff: a_demo_logs_the_text_and_calls_no_gateway
  PASS  texts to staff: a_past_day_is_not_texted
  PASS  texts to staff: the_manager_sees_who_asked_and_what_went
  PASS  texts to staff: settings_lists_texts_with_the_gateway_answer

== 4.0: the Update button is wired ==
  PASS  updates: the release public key is pinned
  PASS  updates: Database::migrate() creates the updater table (in code, not in a comment)
  SKIP  updates: the state row (this suite keeps no open database in its own process; update_e2e.sh installs, migrates and updates for real)
  PASS  updates: index.php clears stale compiled code before anything else loads
  PASS  updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
  PASS  updates: route get /updates is updates_page()
  PASS  updates: route post /updates/key is updates_key_save()
  PASS  updates: route post /updates/check is updates_check()
  PASS  updates: route post /updates/apply is updates_apply()
  PASS  updates: updates_page() opens with the restore page's admin gate
  PASS  updates: updates_key_save() opens with the restore page's admin gate
  PASS  updates: updates_check() opens with the restore page's admin gate
  PASS  updates: updates_apply() opens with the restore page's admin gate
  PASS  updates: every POST checks the CSRF token
  PASS  updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
  PASS  updates: the menu links to the page
  PASS  updates: bin/update.php refuses the web and finishes in a fresh process
  PASS  updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
  PASS  updates: with a key and a newer signed release the page offers "Back up and update"

== 4.0 A2: sample data, backups, health ==
  PASS  A2: the owner's-install checks ran to the end
  PASS  A2: an owner's own install (no Cloud config, no demo marker)
  PASS  A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
  PASS  A2: switched off: an install without "Start with sample data" gets none
  PASS  A2: first request: the marker is used once and the sample business is there
  PASS  A2: every one of the owner's settings is theirs, not the sample's
  PASS  A2: the sample logins cannot sign in: no password matches, and switched off where the product can
  PASS  A2: the owner's own login is untouched
  PASS  A2: the load is in the audit log
  PASS  A2: a second marker on a Rostera with records seeds nothing and changes nothing
  PASS  A2: real use: one of the owner's own rows among the sample
  PASS  A2: remove: the owner's own row stays, and a sample row it points at is kept
  PASS  A2: remove: the report says what went, and what was kept because the owner's records use it
  PASS  A2: remove: the rest of the sample is gone
  PASS  A2: remove: every kept sample row keeps all its own parts
  PASS  A2: remove: no row is left pointing at a row that was removed
  PASS  A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
  PASS  A2: remove: the sample's files are gone from disk (0 written; one a row that stays names stays with it)
  PASS  A2: remove: the owner's own uploaded file is untouched
  PASS  A2: "Load demo data" on a Rostera with records refuses and deletes nothing
  PASS  A2: backup: written, sent to the bucket (a signed PUT), and read back
  PASS  A2: backup: the bucket secret is not in the backup it protects
  PASS  A2: backup: the owner's "keep" count is honored (2 kept)
  PASS  A2: switched off: with automatic backups off, a backup is never due however old the last one
  PASS  A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
  PASS  A2: an offsite endpoint must be https for an owner (plain http only in tests)
  PASS  A2: all 25 owner's-install checks reported
  PASS  A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
  PASS  A2: index.php loads the three cores and the wiring
  PASS  A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
  PASS  A2: route post /sample-data/remove is sample_data_remove()
  PASS  A2: route post /restore/backups is backups_save()
  PASS  A2: route post /restore/backup-now is backup_now()
  PASS  A2: route get /health is health_page()
  PASS  A2: sample_data_remove() opens with the restore page's admin gate
  PASS  A2: backups_save() opens with the restore page's admin gate
  PASS  A2: backup_now() opens with the restore page's admin gate
  PASS  A2: health_page() opens with the restore page's admin gate
  PASS  A2: every POST checks the CSRF token
  PASS  A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
  PASS  A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
  PASS  A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
  PASS  A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
  PASS  A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Rostera, removable)
  PASS  A2: the menu links to Health
  PASS  A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
  PASS  A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
  SKIP  A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)

== 4.0 A3: switch from another tool ==
  PASS  A3: the switch checks ran to the end
  PASS  A3: the maps load, none refused, the team before the roster
  PASS  A3: every shipped map has its real export as a fixture, reports exactly what is pinned beside it, imports exactly that, and a second import adds nothing (2 shipped)
  PASS  A3: the real exports kept as fixtures carry no address outside example.com (their personal data was replaced)
  PASS  A3: each person comes across under the name Deputy shows, with their email; someone who left, not active
  PASS  A3: a leaving date still ahead stays active; a bad one is a problem; an email already here is named, not added twice
  PASS  A3: the overnight shift (22:00 on 30 September to 06:00 on 1 October) comes across as one shift across midnight
  PASS  A3: the roster finds each person by email first: "Siobhán O'Connor" in the export is "Shiv" here
  PASS  A3: the plain CSV imports still work and leave import_ref empty
  PASS  A3: a waiting report whose upload was swept is dropped: no Import button on stale data
  PASS  A3: a report with nothing to import needs no file: it is shown once, then gone
  PASS  A3: each real export's report reads right on the page: every row accounted for, what does not come across, one Import button (2)
  PASS  A3: all 11 switch checks reported
  PASS  A3: index.php loads the SDK and the wiring
  PASS  A3: POST /import/switch is switch_upload(), registered before /import/{kind} (the first match wins)
  PASS  A3: Database::migrate() gives employees and shifts their import_ref column (in code, not in a comment)
  PASS  A3: switch_upload() opens with the import's own admin gate and the CSRF check
  PASS  A3: the import page is given the maps and the waiting report, and shows the card only when a map exists
  PASS  A3: import-maps/ holds its README and the maps, nothing else; each map's real export and pinned report are in tests/fixtures
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 8 personal columns
  PASS  erase planting · sqlite: the app's own erase runs through its route and reports no failure
  PASS  erase planting · sqlite: no planted marker survives anywhere (25 tables dumped)
ALL 474 TESTS PASSED (0 failed)

← Back to Rostera · Manual · Quickstart · API