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