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