Ownware
Home›Jobora›Test run
Jobora · Test run · run 2026-09-25

Jobora Test run, as shipped in the download

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

[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 cancelled
  PASS  only an in-progress job can complete
  PASS  REFUSAL: a completed job is terminal — no reopening
  PASS  REFUSAL: a cancelled 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  labour 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: labour (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 CANCELLED 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 afterwards (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 21, want 21)
  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-0021', want 'JOB-0021')
  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-0021)
  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  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

== 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: 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 12 credentials this schema holds (settings.smtp_pass, settings.backup_token_hash, settings.ical_token_hash, settings.oidc_client_secret, users.password_hash, users.totp_secret, users.totp_recovery, quotes.public_token, api_keys.token_hash, webhooks.secret, invites.token_hash, feed_tokens.token_hash)
  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

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

← Back to Jobora · Manual · Quickstart · API

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →