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

Jobora Test run, as shipped in the download

479 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.

[DB: SQLite]

== 1. Money — the SAME engine as Invora, proven against Invora's reference case ==
  PASS  reference: subtotal (got 150000, want 150000)
  PASS  reference: discount (got 7500, want 7500)
  PASS  reference: tax AFTER the discount, reduced in proportion (got 8075, want 8075)
  PASS  reference: total (got 150575, want 150575)
  PASS  reference: tax BEFORE the discount is kept too (got 8500, want 8500)
  PASS  the money core is byte-identical to Invora's (totals + status)
  PASS  a fixed discount can never exceed the subtotal (got 150000, want 150000)
  PASS  no discount leaves tax alone (got 8500, want 8500)
  PASS  an empty document totals zero, not an error (got 0, want 0)
  PASS  quantity is milli-units: 0.5 x 10.00 = 5.00 (got 500, want 500)
  PASS  balance = total - paid (got 58500, want 58500)
  PASS  Money::dec never carries a symbol (got '1480.00', want '1480.00')
  PASS  Money::dec keeps the sign (got '-12.50', want '-12.50')

== 2. Jobs — the life cycle, and the margin that is the reason to buy this ==
  PASS  scheduled can start, hold or be canceled
  PASS  only an in-progress job can complete
  PASS  REFUSAL: a completed job is terminal — no reopening
  PASS  REFUSAL: a canceled job is terminal too
  PASS  the refusal says WHY, in the operator's words
  PASS  a legal move has no refusal (got '', want '')
  PASS  material cost = qty x unit, in cents (got 24900, want 24900)
  PASS  labor cost = minutes x rate / 60 (got 35750, want 35750)
  PASS  a 20-minute call is not rounded to zero hours (got 2000, want 2000)
  PASS  margin: materials (got 24900, want 24900)
  PASS  margin: labor (got 35750, want 35750)
  PASS  margin: cost (got 60650, want 60650)
  PASS  margin: quoted minus cost (got 21910, want 21910)
  PASS  margin: as basis points (got 2654, want 2654)
  PASS  THE ONE THAT MATTERS: a job nobody quoted has margin NULL, not zero
  PASS  ...and its cost is still reported (got 60650, want 60650)
  PASS  ...and it renders as an em dash, never "0%" (got '—', want '—')
  PASS  a loss renders with a minus sign (got '−273.9%', want '−273.9%')
  PASS  a loss is toned bad (got 'bad', want 'bad')
  PASS  a thin margin is toned warn (got 'warn', want 'warn')
  PASS  a healthy margin is toned ok (got 'ok', want 'ok')
  PASS  no quote means no tone at all (got '', want '')
  PASS  conversion refuses a quote that already became a job
  PASS  conversion refuses a draft
  PASS  conversion refuses a declined quote
  PASS  conversion refuses an INVOICE
  PASS  a sent, unconverted quote converts (got '', want '')

== 3. Schedule — ONE conflict rule, called by the board, the form, the API and MCP ==
  PASS  CLASH: an overlap on the same technician and day is refused
  PASS  FREE: the same hours on a DIFFERENT technician are fine
  PASS  FREE: the same hours on a different DAY are fine
  PASS  TOUCHING is not overlapping — 11:00 starts as 11:00 ends
  PASS  ...and the minute before it is
  PASS  a visit never clashes with ITSELF (nudging a block must work)
  PASS  a CANCELED visit holds no time
  PASS  technicians match case- and space-insensitively
  PASS  validate: a good placement passes (got '', want '')
  PASS  validate REFUSES a finish before a start
  PASS  validate REFUSES no technician
  PASS  validate REFUSES a date that is not a date
  PASS  validate REFUSES a 14-hour visit as a typo
  PASS  09:00 parses (got 540, want 540)
  PASS  a bare hour parses (got 540, want 540)
  PASS  25:00 is not a time (got NULL, want NULL)
  PASS  09:75 is not a time (got NULL, want NULL)
  PASS  minutes render back (got '09:00', want '09:00')
  PASS  a drop snaps to the half hour (got 540, want 540)
  PASS  ...and rounds up past the midpoint (got 570, want 570)
  PASS  the board opens on Monday of a working week (got '2026-08-24', want '2026-08-24')
  PASS  ...but on a SATURDAY it opens on the week ahead (got '2026-08-24', want '2026-08-24')
  PASS  ...and on a Sunday too (got '2026-08-24', want '2026-08-24')
  PASS  a week is seven days (got 7, want 7)
  PASS  a 9-11 block sits a sixth down a 07:00-19:00 day and is a sixth tall
  PASS  the board groups by technician (got 2, want 2)
  PASS  ...and lays the right rows in the right cell (got 2, want 2)

== 4. Signature — a module whose value is what it refuses ==
  PASS  the fixture really is a PNG
  PASS  HAPPY (no-GD fallback path — this PHP build has no GD): a PNG above the size floor is accepted
  PASS  ...and the KNOWN LIMIT of that fallback is real: a tiny genuine signature would be refused without GD. Documented in the honest-limitations list.
  PASS  REFUSAL: nothing at all
  PASS  REFUSAL: a JPEG data URL is not what the pad produces
  PASS  REFUSAL: an SVG dressed as a PNG — the BYTES decide, not the prefix
  PASS  REFUSAL: garbage that is not base64 at all
  PASS  REFUSAL: something enormous is not a signature
  PASS  the refusals are sentences a person can act on
  PASS  blank-pad detection is covered by the size floor (GD absent in this PHP build)
  PASS  a stored signature round-trips to a data URL (got 'data:image/png;base64,', want 'data:image/png;base64,')
  PASS  no signature means no data URL (got '', want '')
  PASS  the statement says what signing MEANS, not just "sign here"

== 5. ClientMail — two gates, both off, and a demo that opens no socket ==
  PASS  BOTH gates ship OFF in the schema — an upgrade never starts emailing anybody's customers
  PASS  REFUSAL: the per-event flag alone is not enough — the master switch rules
  PASS  REFUSAL: the master switch alone is not enough either
  PASS  HAPPY: both on, and only then
  PASS  ...and another event is still independently off
  PASS  REFUSAL: an unusable address is never mailed (got 'no-email', want 'no-email')
  PASS  REFUSAL: an event that is switched off reports disabled (got 'disabled', want 'disabled')
  PASS  REFUSAL: no SMTP configured is a clean no-op, not a crash (got 'not-configured', want 'not-configured')
  PASS  an https link survives (got 'https://pay.example/abc', want 'https://pay.example/abc')
  PASS  REFUSAL: javascript: is stripped — that would be stored XSS (got '', want '')
  PASS  REFUSAL: data: is stripped (got '', want '')
  PASS  REFUSAL: a header-splitting newline is stripped (got '', want '')
  PASS  the quote mail carries the number, the total, the deadline and their own link
  PASS  ...and says plainly that accepting charges nothing
  PASS  no mail carries a secret — no password, key, token or hash
  PASS  the booking mail carries the appointment and nothing internal — no margin, no cost, no notes
  PASS  a demo install writes to the OUTBOX and opens no socket — the demoMode branch returns before send
  PASS  a send failure is caught — it can never break the request that triggered it

== 6. Acceptance converts a quote into a job — once ==
  PASS  the quote totals through the shared engine: 2 x 250.00 + 20% tax (got 60000, want 60000)
  PASS  HAPPY: accepting creates a job
  PASS  ...and SNAPSHOTS the agreed figure onto it (got 60000, want 60000)
  PASS  ...and records who accepted it (got 'Marta Vieira', want 'Marta Vieira')
  PASS  IDEMPOTENCY: accepting the same quote twice does NOT make a second job
  PASS  ...and says why
  PASS  the job keeps the figure it was accepted at, whatever happens to the quote afterward (got 60000, want 60000)
  PASS  REFUSAL: a draft cannot be accepted — send it first
  PASS  demo seed wrote quotes, invoices and jobs
  PASS  seed leaves next_quote_seq past the highest seeded quote (got 8, want 8)
  PASS  seed leaves next_invoice_seq past the highest seeded invoice (got 3, want 3)
  PASS  seed leaves next_job_seq past the highest seeded job (got 29, want 29)
  PASS  the first quote after a demo load gets the next free number (got 'Q-0008', want 'Q-0008')
  PASS  the first invoice after a demo load gets the next free number (got 'INV-0003', want 'INV-0003')
  PASS  the first job after a demo load gets the next free number (got 'JOB-0029', want 'JOB-0029')
  PASS  with counters reset to 1, nextNumber('quote') skips every taken number (got Q-0008)
  PASS  with counters reset to 1, nextNumber('invoice') skips every taken number (got INV-0003)
  PASS  with counters reset to 1, Jobs::nextNumber() skips every taken number (got JOB-0029)
  PASS  a quote saved straight after "Load demo data" no longer hits UNIQUE(kind, number)
  PASS  board.js say() clears the previous timer before arming a new one

== 7. One rule per door, and the scope enforced at the door ==
  PASS  visit_move calls Schedule::validate() and Schedule::conflict(), in that order
  PASS  visit_create calls Schedule::validate() and Schedule::conflict(), in that order
  PASS  api_visit_create calls Schedule::validate() and Schedule::conflict(), in that order
  PASS  the MCP book_visit tool calls the same two, and never its own overlap query
  PASS  POST /visits/{id}/move is registered
  PASS  the drag endpoint answers JSON to the board and a redirect to a plain form
  PASS  a refused drag answers 409 with the ENGINE's sentence, not a generic error
  PASS  job_status refuses through Jobs::transitionRefusal(), not its own matrix
  PASS  api_job_status refuses through Jobs::transitionRefusal(), not its own matrix
  PASS  MCP advance_job does too
  PASS  conversion happens in ONE place — accept_quote() — called by staff, the customer page, the API and MCP

== 8. MCP tools + key scope ==
  PASS  EVERY MCP tool declares readOnly — a `perm` is a role gate and is NOT the scope annotation
  PASS  the read-only tools are the ones that only read
  PASS  every WRITE tool also carries a role gate and calls _mcp_require
  PASS  /mcp hands the key scope to the shared core, which hides and refuses write tools
  PASS  GET /mcp answers without a key — a probing client sends GET before it sends one
  PASS  signing a job off is NOT exposed to an agent — a machine cannot hold the pen
  PASS  api_keys.scope exists and DEFAULTS to full — an upgrade must not silently tighten or loosen
  PASS  a read key is refused any non-GET AT THE DOOR, once, not per route
  PASS  Api::mintKey persists the scope it was asked for, and nothing else
  PASS  the key form offers the choice

== 9. Platform truths ==
  PASS  a CSV cell starting with = is defused (got '\'=cmd|calc', want '\'=cmd|calc')
  PASS  ...and a negative number is NOT (it is data) (got '-125.00', want '-125.00')
  PASS  ...and ordinary text is untouched (got 'Dock Road', want 'Dock Road')
  PASS  every export routes cells through the guard
  PASS  ZERO external hosts on any page — fonts, scripts and styles are all local
  PASS  every App:: method the views and controllers call actually exists
  PASS  every Jobs::/Schedule::/Signature::/Money:: method called from a view or controller exists
  PASS  every routed handler is a function that exists
  PASS  every .pdf route is registered BEFORE the bare route that would swallow it
  PASS  the demo seeder is offline and deterministic — no clock beyond App::today(), no network
  PASS  the kit is used, not forked — the app layer sets an accent and app rules only
  PASS  no empty-state asks the kit for an icon it does not draw
  PASS  no standing copy sits in a bare .main > .alert (the kit turns those into toasts and removes them)

== 10. The guard holds on every door — against a real database, in a subprocess ==
  PASS  MCP: an agent can book a visit
  PASS  MCP: a double-booking is refused
  PASS  MCP: the refusal is the ENGINE's sentence, not a generic error
  PASS  MCP: a refusal THROWS McpRefusal — never a result an agent reads as success
  PASS  MCP: back-to-back visits are allowed (touching is not overlapping)
  PASS  MCP: two technicians at the same hour are allowed
  PASS  a completed job is terminal on every door
  PASS  Invora's reference case reproduces THROUGH the database
  PASS  accepting a quote creates the job
  PASS  accepting twice does NOT create a second job
  PASS  every mutation above left an audit row
  PASS  the drag endpoint answered (guards against a false pass below)
  PASS  DRAG: a refused move left the visit exactly where it was
  PASS  DRAG: and answered with the engine's own sentence
  PASS  every .kv uses the kit's own dt/dd markup, not hand-rolled div/span rows
  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

== fix wave: every REST write checks the key holder's role ==
  PASS  api: the viewer key is refused POST /api/jobs (403, needs job.edit)
  PASS  api: the member key passes the role check on POST /api/jobs
  PASS  api: the admin key passes the role check on POST /api/jobs
  PASS  api: the viewer key is refused POST /api/jobs/999/status (403, needs job.edit)
  PASS  api: the member key passes the role check on POST /api/jobs/999/status
  PASS  api: the viewer key is refused POST /api/visits (403, needs job.edit)
  PASS  api: the member key passes the role check on POST /api/visits
  PASS  api: the viewer key is refused POST /api/quotes/999/accept (403, needs job.edit)
  PASS  api: the member key passes the role check on POST /api/quotes/999/accept
  PASS  api: every /api/* write route checks the key holder's role (no key-only writes)

== fix wave: a viewer cannot read the audit trail ==
  PASS  audit: the viewer is refused /audit (403)
  PASS  audit: an admin still opens /audit
  PASS  audit: the viewer is refused /audit.csv (403)
  PASS  audit: an admin still opens /audit.csv
  PASS  audit: the sidebar shows the viewer no audit link
  PASS  audit: the viewer still opens /
  PASS  audit: the viewer still opens /schedule
  PASS  audit: the viewer still opens /customers
  PASS  audit: the viewer still opens /jobs
  PASS  audit: the viewer still opens /quotes
  PASS  audit: the viewer still opens /invoices
  PASS  audit: every *.view permission the code asks for is granted to the viewer, except audit.view

== fix wave: every backup and restore route is administrator-only ==
  PASS  backup: a signed-in viewer gets 403 on GET /backup.json
  PASS  backup: a signed-in viewer gets 403 on GET /backup.sqlite
  PASS  backup: a signed-in viewer gets 403 on GET /restore
  PASS  backup: a signed-in viewer gets 403 on POST /restore/dry-run
  PASS  backup: a signed-in viewer gets 403 on POST /restore/commit
  PASS  backup: a signed-in viewer gets 403 on POST /backup/token
  PASS  backup: a signed-in viewer without the token gets nothing from GET /backup/scheduled
  PASS  backup: a signed-in member gets 403 on GET /backup.json
  PASS  backup: a signed-in member gets 403 on GET /backup.sqlite
  PASS  backup: a signed-in member gets 403 on GET /restore
  PASS  backup: a signed-in member gets 403 on POST /restore/dry-run
  PASS  backup: a signed-in member gets 403 on POST /restore/commit
  PASS  backup: a signed-in member gets 403 on POST /backup/token
  PASS  backup: a signed-in member without the token gets nothing from GET /backup/scheduled
  PASS  backup: an admin still opens the restore page

== fix wave: the webhook events on offer are the ones Jobora fires ==
  PASS  webhooks: the events offered are exactly the events fired
  PASS  webhooks: no work-order or meter event is offered

== fix wave: the demo shows the margin view catching a loss ==
  PASS  demo: loading the demo data works
  PASS  demo: margin on completed work is positive
  PASS  demo: the under-quoted drain job still shows its loss
  PASS  demo: the margin tile reads green (15% or more): a healthy business with one job under water
  PASS  demo: someone is out on site today, whatever day the demo is opened
  PASS  demo: money is shown in dollars, not pounds
  PASS  ui: the sidebar brand cannot be shrunk to nothing (flex-shrink:0)
  PASS  ui: with no company name the sidebar falls back to "Jobora", not another product's name

== fix wave: the webhook buttons work ==
  PASS  webhooks: Add creates the endpoint (no 404)
  PASS  webhooks: Pause switches it off (no 404)
  PASS  webhooks: pressing it again switches it back on
  PASS  webhooks: the page offers Pause and Delete for the endpoint, at those same addresses
  PASS  webhooks: Delete removes it (no 404)

-- F2b: the demo ribbon's wording and size; the JSON backup's secrets --
  PASS  demo ribbon: on the customer's quote or invoice link (/q/…) it says what the page is, not "signed in as the admin"
  PASS  demo ribbon: an administrator's page keeps the admin wording
  PASS  demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session
  PASS  demo ribbon: registered only behind the .demo-mode marker, and non-HTML output passes through untouched
  PASS  backup: the JSON download carries none of the 17 credentials this schema holds (settings.smtp_pass, settings.backup_token_hash, settings.ical_token_hash, settings.oidc_client_secret, settings.stripe_secret_key, settings.stripe_webhook_secret, users.password_hash, users.totp_secret, users.totp_recovery, customers.sms_token, quotes.public_token, api_keys.token_hash, webhooks.secret, backup_state.s3_secret, invites.token_hash, feed_tokens.token_hash, booking_requests.public_token)
  PASS  backup: the JSON download carries no quote link (the link opens the quote and can accept it)
  PASS  restore: a backup that brings back two deleted quotes restores
  PASS  restore: a quote that still exists keeps the link its customer already has
  PASS  restore: a quote brought back gets a unique link of its own; the old one, not in the file, stays dead

== 1.1 · get paid from the invoice (the owner's own Stripe) ==
  PASS  card payment is OFF on a fresh install (no key, no secret, no currency)
  PASS  a Stripe secret or restricted key is accepted; a publishable key or a webhook secret is not
  PASS  switched on with a key, a signing secret and a currency, card payment is ready
  PASS  checkout: a part-paid invoice asks only for what is still owed (250.00 - 50.00 = 200.00)
  PASS  checkout: the invoice travels as client_reference_id + its number, back to the customer's own page
  PASS  checkout: no customer_email is sent for an address that is not one
  PASS  checkout: Stripe is asked with the OWNER's key, and the customer is sent to Stripe's own page
  PASS  checkout: a Stripe error gives the customer a plain sentence, not Stripe's internals
  PASS  checkout: a paid invoice asks Stripe for nothing
  PASS  webhook: a correctly signed event verifies
  PASS  webhook: the wrong signing secret is refused
  PASS  webhook: a body changed after signing is refused
  PASS  webhook: an event older than five minutes is refused (no replay)
  PASS  webhook: no header, or an empty secret, is refused
  PASS  webhook: a header carrying one stale and one good v1 signature verifies (Stripe rotates secrets this way)
  PASS  webhook: a paid checkout records the payment on the invoice (50.00 + 75.00 = 125.00 paid, part-paid)
  PASS  webhook: Stripe retrying the same event records nothing the second time
  PASS  webhook: a payment in another currency is refused, never added to a dollar invoice
  PASS  webhook: an unpaid checkout, another event type, or a number that does not match records nothing
  PASS  webhook: the rest arriving (a delayed bank payment) settles the invoice: paid, balance 0
  PASS  the invoice email sends the customer to their own invoice page to pay (a Stripe page would expire)
  PASS  backup + audit: both Stripe secrets are on the redaction lists

== 1.1 · the chaser: invoice reminders, quote follow-ups, visit reminders ==
  PASS  chaser: every kind is OFF by default — a run sends nothing and writes nothing
  PASS  chaser: switched on but customer email (the master switch) off — still nothing
  PASS  chaser: the preview (plan) shows today's reminder and writes nothing
  PASS  chaser: due today → the "on the due date" step goes, once, recorded with its claim
  PASS  chaser: the reminder says what is owed and ends with the customer's own invoice link
  PASS  chaser: a second run the same day sends nothing more
  PASS  chaser: two sends holding the same claim — the first goes, the second is refused and sends nothing
  PASS  chaser: a due-date step that goes out late says "was due on", never "is due today"
  PASS  chaser: an invoice first seen long overdue gets at most ONE reminder (the current step), never a burst
  PASS  chaser: a paid invoice and a draft are never reminded
  PASS  chaser: a customer paused for follow-ups is held, with the reason
  PASS  chaser: an invoice paused on its own page is held
  PASS  chaser: a quiet day — nothing automatic the day the customer was emailed about this invoice by hand
  PASS  chaser: the daily cap holds — one more send allowed, one held "over today's cap"
  PASS  chaser: a quote sent 3 days ago and not answered gets its follow-up, counted from the day it was SENT
  PASS  chaser: an accepted, declined, expired or draft quote is never followed up
  PASS  chaser: quote steps before day 1 are refused (a follow-up cannot come before the quote)
  PASS  chaser: a planned visit tomorrow → one reminder with the date, the window and the address
  PASS  chaser: a completed job, a canceled visit, or a visit the day after tomorrow is not reminded
  PASS  chaser: the visit reminder goes once
  PASS  chaser: a refused send is recorded as failed and ends the run
  PASS  chaser: after a refusal the same step is held today and retried tomorrow
  PASS  chaser: the API schedule carries no addresses and no message bodies

== 1.1 · the doors, through the real front controller ==
  PASS  door: POST /hooks/stripe with no Stripe signature is refused 400 and records nothing
  PASS  door: "Pay by card" with card payment off asks Stripe for nothing and says so
  PASS  door: the customer's invoice page shows no card button while card payment is off
  PASS  door: the follow-up preview opens for an admin and says it sends nothing
  PASS  door: a member (technician) cannot open the follow-up preview
  PASS  door: /cron/chaser without the token is refused 401
  PASS  door: the settings page carries both 1.1 sections
  PASS  door: GET /api/chaser answers a viewer's key with the read-only schedule of all three kinds
  PASS  door: /api/chaser is GET-only (a POST is not a route)
  PASS  door: a technician can pause automatic emails for a customer
  PASS  door: recording 100.00 of a 400.00 invoice leaves 300.00 owed (payments add up; no false "paid")
  PASS  door: an empty amount records the rest, and the invoice reads paid

== 1.1 · repeat work: plans that place visits by themselves ==
  PASS  recurring: weekly repeats on the first visit's weekday
  PASS  recurring: every 2 weeks
  PASS  recurring: monthly on the 31st lands on the last day of a shorter month
  PASS  recurring: nothing before the first visit or after the last
  PASS  recurring: a plan needs a customer, the work, a real first date and a real time
  PASS  recurring: a weekly plan places every visit for the next eight weeks (9 dates), each a job with its visit and the agreed price
  PASS  recurring: running it again places nothing new (one job per date, ever)
  PASS  recurring: when the technician is already booked, that date's job is created with NO visit and flagged — never double-booked
  PASS  recurring: moving one visit is a one-off, and a deleted visit is remembered as skipped — neither comes back
  PASS  recurring: stopping with "remove upcoming" removes the untouched future visits and keeps one somebody logged a cost on
  PASS  door: a viewer cannot set up repeat work (403)
  PASS  door: a technician sets up "every 2 weeks" and the board gets its visits (5 in eight weeks)
  PASS  door: the viewer sees the plan in Repeat work
  PASS  door: a repeat visit is invoiced at its price, for that visit

== 1.1 · job checklists ==
  PASS  checklists: one check per line; bullets, numbering and blank lines are dropped
  PASS  checklists: attaching a template copies its checks onto the job, open
  PASS  checklists: a job with open checks cannot be completed, and says how many
  PASS  checklists: "not applicable" is an answer; one open check still refuses
  PASS  checklists: an answer that is not done / not applicable / open is refused
  PASS  checklists: every check answered, the job may be completed
  PASS  checklists: a completed job's checklist is part of the record and does not change
  PASS  checklists: the same rule guards the REST API and the agent tool (after the life-cycle check, before the write)
  PASS  checklists: repeat work carrying a checklist places every visit with its checks
  PASS  door: only an admin manages checklist templates
  PASS  door: a technician adds a checklist; completing with open checks is refused and the job stays in progress
  PASS  door: every check done, the technician completes it, and the job page shows the answered checklist
  PASS  door: the job sheet PDF carries the checklist and its answers

== 1.1 · online booking ==
  PASS  booking: a window where the only bookable technician is busy is not offered; the free half of that day is
  PASS  booking: nothing today, and no weekend unless weekends are on
  PASS  booking: a request needs a name, an email, an address and the work
  PASS  booking: a window that is no longer free is refused when the request is sent
  PASS  booking: at most 5 requests an hour from one address
  PASS  booking: accepting creates the customer, raises the job and places the visit in the window
  PASS  booking: accepted once only
  PASS  booking: accepting into a time the technician is already booked is refused, like any visit
  PASS  booking: a returning customer (same email, any case) is matched, not duplicated
  PASS  booking: declining needs a reason
  PASS  door: the booking page is a 404 while booking is off
  PASS  door: switched on, the page offers free windows, with no login and out of search engines
  PASS  door: a request is received with a reference and the customer's own link
  PASS  door: a viewer cannot accept a booking
  PASS  door: a technician accepts it: the job is raised and the visit is on the board
  PASS  door: the customer's link now shows the confirmed day and time
  PASS  door: the hidden trap field — a bot is thanked and nothing is kept

== 1.1 · the day's route ==
  PASS  route: the area is the postcode district, the ZIP, a 4-digit code, or else the town
  PASS  route: directions go through every stop in order and end at the last
  PASS  route: a technician's stops are in booked order, each with its site address or else the customer's
  PASS  route: an open job with nothing booked is listed under its area
  PASS  door: a viewer opens the day's route
  PASS  api: a job carries its site address, for a permit tracker or your own code

== 1.1 · QuickBooks Online import files ==
  PASS  quickbooks: an invoice's rows (lines, their tax, the discount row) add up to the total Jobora shows
  PASS  quickbooks: drafts and invoices outside the dates stay out
  PASS  quickbooks: a customer row carries the contact and a one-line billing address

== 1.1 · a deposit when a quote is accepted ==
  PASS  deposit: a percent of the quote total, nothing when either is zero
  PASS  deposit: off by default, accepting raises no deposit invoice
  PASS  deposit: accepting raises one deposit invoice for 25% of the total, sent, due that day, with no tax of its own
  PASS  deposit: once per quote, however often acceptance is tried
  PASS  deposit: the job invoice subtracts exactly the deposit, so the two invoices come to the quote
  PASS  door: office acceptance raises the 30% deposit, and the job invoice then carries "Less deposit": the two add up to the quote
  PASS  door: the customer's accepted quote page points to the deposit invoice

== 1.2 · field staff see no prices ==
  PASS  prices: control — with the setting off a member sees the quote, the rate, the invoice, the repeat price and the API money
  PASS  prices: a member or a viewer cannot switch it (403)
  PASS  prices: an administrator switches it on and is told what it does
  PASS  prices: control — with it on, an administrator and a viewer still see prices, on the page and in the API
  PASS  prices: a member crawling every page sees no price, rate, total or margin, and every money page is 403 (39 pages, 19 opened)
  PASS  prices: the member still gets the job, its costs to log (no rate field) and no invoice button
  PASS  prices: a member's hours are costed at the company rate whatever is posted, and a repeat plan keeps its price
  PASS  prices: a member's API key gets jobs, visits and customers without money fields; quotes, a quote and the reminders are 403
  PASS  prices: the four MCP tools that carry money are gated (jobs and a job stripped; margin report and quote acceptance refused)
  PASS  prices: switched off again, the member sees prices
  PASS  prices: the strip takes every money field out at any depth and keeps the rest
  PASS  prices: the money pages are the quotes, invoices, reminders, exports and raising an invoice — and nothing else

== 1.2.1 · the import preview runs the import's own checks ==
  PASS  import preview: it counts exactly what the import brings in and names the same skipped rows, writing nothing itself
  PASS  import preview: a customer who exists, a name twice in the file and a bad email are each named as skipped
  PASS  import preview: the upload step calls the import's own handler in normal mode, never the dry-run flag

== 4.0: the Update button is wired ==
  PASS  updates: a signed-in viewer gets 403 on GET /updates
  PASS  updates: a signed-in viewer gets 403 on POST /updates/key
  PASS  updates: a signed-in viewer gets 403 on POST /updates/check
  PASS  updates: a signed-in viewer gets 403 on POST /updates/apply
  PASS  updates: a signed-in member gets 403 on GET /updates
  PASS  updates: a signed-in member gets 403 on POST /updates/key
  PASS  updates: a signed-in member gets 403 on POST /updates/check
  PASS  updates: a signed-in member gets 403 on POST /updates/apply
  PASS  updates: an admin opens the page and sees the running version
  PASS  updates: on a Cloud tenant the page says updates are handled, and offers no button
  PASS  updates: migrate() created the updater table
  PASS  updates: a maintenance flag left after a finished update clears itself; the page serves
  PASS  updates: while another request finishes an update, visitors get the one-minute holding page (503)
  PASS  updates: bin/update.php refuses the web and finishes in a fresh process

== 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: the owner's company name, currency and rates are theirs, not the sample's
  PASS  A2: the sample staff cannot sign in: switched off, and no password matches
  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 Jobora with records seeds nothing and changes nothing
  PASS  A2: remove: every sample job goes but the ones a kept row points at; the owner's job and its cost stay
  PASS  A2: remove: the sample customer the owner's job uses is kept, and the report says what was kept and why
  PASS  A2: remove: the sample quote the owner's job was made from is kept WHOLE, with every one of its lines
  PASS  A2: remove: a sample job kept because the kept quote points at it keeps all its own parts
  PASS  A2: remove: no row is left pointing at a row that was removed
  PASS  A2: remove: the sample staff are gone, the owner stays, and nothing counts as sample any more
  PASS  A2: "Load demo data" on a Jobora 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 22 owner's-install checks reported
  PASS  A2: a signed-in viewer gets 403 on GET /health
  PASS  A2: a signed-in viewer gets 403 on POST /sample-data/remove
  PASS  A2: a signed-in viewer gets 403 on POST /restore/backups
  PASS  A2: a signed-in viewer gets 403 on POST /restore/backup-now
  PASS  A2: a signed-in member gets 403 on GET /health
  PASS  A2: a signed-in member gets 403 on POST /sample-data/remove
  PASS  A2: a signed-in member gets 403 on POST /restore/backups
  PASS  A2: a signed-in member gets 403 on POST /restore/backup-now
  PASS  A2: an admin opens Health: one line per check, each with its status
  PASS  A2: with sample data present, an admin sees the banner and its Remove button
  PASS  A2: a member never sees it
  PASS  A2: Remove it: the admin's post removes the sample and says what went
  PASS  A2: after removal the banner is gone
  PASS  A2: on a Cloud tenant Backup & restore says backups are handled, and offers no bucket form
  PASS  A2: on a Cloud tenant saving a bucket is refused and nothing is stored
  PASS  A2: on a Cloud tenant "Back up now" is refused (the plane backs tenants up)
  PASS  A2: the installer offers sample data, ticked, and only leaves the first-run marker

== 4.0 A3: switch from another tool ==
  PASS  A3: the switch checks ran to the end
  PASS  A3: the export is recognized by its columns
  PASS  A3: the switch report runs Jobora's own customer checks: 2 will import, a bad email and a customer already typed in are named by line
  PASS  A3: the report writes nothing
  PASS  A3: the import brings in exactly what the report said, with the tool's id kept
  PASS  A3: importing the same export again adds nothing: those rows are already here
  PASS  A3: the plain CSV import still works and leaves 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: the page shows the tool, where its export is, the numbers, the problems, what does not come across, and one Import button
  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 (1 shipped)
  PASS  A3: the real exports kept as fixtures carry no address outside example.com (their personal data was replaced)
  PASS  A3: each real export's report reads right on the page: every row accounted for, what does not come across, one Import button (1)
  PASS  A3: all 12 switch checks reported
  PASS  A3: a signed-in viewer gets 403 on POST /import/switch
  PASS  A3: /import offers "Switch from another tool" with each shipped map's tool and where to find its export (the honesty gate: only those)
  PASS  A3: a Read with no file chosen asks for the export and writes nothing
  PASS  A3 (F5): an export uploaded a day ago and never imported is gone after the next visit to the route
  PASS  A3: import-maps/ holds its README and the maps, nothing else; each map's real export and pinned report are in tests/fixtures

== Text messages: own gateway, opted-in customers only, exactly once, failures recorded ==
  PASS  texts: off by default — no gateway, no key, the master switch off, reminders 24 h, window 09:00-20:00, every customer not agreed
  PASS  texts: switched off, nothing is sent and the gateway is never called
  PASS  texts: on, but nobody has agreed — the customer with a phone number is NOT texted (the gate is consent, not the number)
  PASS  texts: "on my way" to a customer who did not agree is refused, and says why
  PASS  texts: the agreed customer gets the reminder; JSON with to/from/text by default, the key as a Bearer token
  PASS  texts: the reminder names the business first, the visit day and time, and ends with the customer's own stop link
  PASS  texts: exactly once — a second run (or page load) sends nothing more for the same visit
  PASS  texts: a visit moved to a new time gets one reminder for the new time
  PASS  texts: nothing automatic outside the owner's send window (22:00), even with a reminder due
  PASS  texts: inside the window again, the waiting reminder goes
  PASS  texts: "on my way" goes once per visit — a second tap is refused, nothing sent twice
  PASS  texts: field mapping — Twilio's form body To/From/Body and the key as Basic AccountSid:AuthToken
  PASS  texts: a number that cannot be a phone number is never sent to
  PASS  texts: a gateway refusal is recorded with its answer and reported, never thrown; it is not retried in a loop
  PASS  texts: a failed "on the way" says so, on the tap and on a second tap, and is not sent twice
  PASS  texts: an unreachable gateway is a recorded failure too
  PASS  texts: invoice-due texts are off until switched on
  PASS  texts: on the due date, the unpaid invoice is texted once with its balance and link; a paid one is not
  PASS  texts: "your quote is ready" once per quote, with its link; sending it again does not text again
  PASS  texts: the automatic run stops at the first text the gateway refuses; the next one is not even claimed
  PASS  texts: the same day, the failed text is not tried again; the one behind it goes
  PASS  texts: the next day, the failed reminder is claimed once more (tries 2) and goes; then never again
  PASS  texts: the daily cap — two a day here: two go, the third waits for the next day and goes then
  PASS  texts: a send window whose start is not before its end falls back to 09:00-20:00 (no texting overnight by a typo)
  PASS  texts: the visit start is read on the owner's wall clock, so a reminder on a clock-change day is due on time
  PASS  texts: every text attempt is in the audit log with the number masked to its last four digits, the automatic ones by "schedule"
  PASS  texts: the online booking keeps "texts: yes" only when the box was ticked, texts are on and the number can be texted
  PASS  texts: a booking that ticked the box makes the new customer "agreed to texts"; an existing customer with a DIFFERENT number is not switched on
  PASS  texts: the stop link opens a page with one button; pressing it stops all texts to that customer, and the audit log says so
  PASS  texts: a made-up stop token is a 404
  PASS  texts: the technician sees "Text: on my way" on a planned visit only for a customer who agreed to texts, and "Texted" once it went
  PASS  texts: a viewer cannot send "on my way"; a technician's tap for a customer who did not agree is refused and nothing is queued
  PASS  texts: Settings has the card, never shows the saved key, and lists the texts sent with only the last four digits
  PASS  texts: the GDPR erase covers the texts sent to a customer (the number and the words)
  PASS  texts: the gateway key is redacted from the shareable backup
  PASS  quote form: it has spare rows to type into (one row plus four)
  PASS  quote form: saving one filled row and the untouched spare rows gives exactly one line (got 1, want 1)
  PASS  quote form: a row with a price but no description, or a description at no charge, is still a line (got 2, want 2)
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 24 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 (33 tables dumped), outside the 7 column(s) kept by ruling (possible legal records)
  PASS  erase planting · sqlite: the erase page (/gdpr) says what an erase keeps: "Kept: the customer's sign-off on a job (the name, role, stat…"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: the customer's sign-off on a job (the name, role, stat…"

————————————————————————————————————————————————————————————
ALL 479 TESTS PASSED (0 failed)

← Back to Jobora · Manual · Quickstart · API