Warrantora · Test run · run 2026-09-25
Warrantora Test run, as shipped in the download
142 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.
== 1. addMonths — the edge PHP's own date maths gets wrong ==
PASS THE ONE THAT MATTERS: 31 Jan + 1 month is 28 Feb, not 3 March (got '2026-02-28', want '2026-02-28')
PASS ...and in a leap year it is 29 Feb (got '2028-02-29', want '2028-02-29')
PASS 31 Aug + 6 months = 28 Feb (a customer does not get three free days) (got '2027-02-28', want '2027-02-28')
PASS 31 May + 1 = 30 Jun (got '2026-06-30', want '2026-06-30')
PASS 29 Feb + 12 months lands on 28 Feb (got '2029-02-28', want '2029-02-28')
PASS 29 Feb + 48 months lands on 29 Feb again (got '2032-02-29', want '2032-02-29')
PASS a day that exists in both months is untouched (got '2028-03-15', want '2028-03-15')
PASS crossing the year boundary (got '2027-01-31', want '2027-01-31')
PASS a five-year term (got '2031-06-10', want '2031-06-10')
PASS zero months is the same day (got '2026-06-10', want '2026-06-10')
PASS a nonsense date gives nothing back, never a guess (got '', want '')
PASS an impossible date is rejected too (got '', want '')
PASS daysBetween counts forward (got 30, want 30)
PASS ...and backward, signed (got -30, want -30)
PASS ...across a leap day (got 2, want 2)
== 2. Expiry is COMPUTED — and says so when it cannot be ==
PASS the warranty term is used (got 12, want 12)
PASS an item override beats the term (got 30, want 30)
PASS THE SUBTLE ONE: an override of 0 means NO COVER, not "unset" (got 0, want 0)
PASS an empty-string override is "unset", and falls through (got 12, want 12)
PASS no term and no override falls back (got 18, want 18)
PASS expiry = sold + term (got '2027-01-15', want '2027-01-15')
PASS a five-year term (got '2031-01-15', want '2031-01-15')
PASS a term of zero expires the day it was sold (got '2026-01-15', want '2026-01-15')
PASS NO SALE DATE means no expiry — never a guess (got '', want '')
PASS ...and a malformed one is the same (got '', want '')
== 3. Status and the 30/60/90 bands ==
PASS comfortably covered (got 'active', want 'active')
PASS inside the soon window (got 'expiring', want 'expiring')
PASS the window is the OPERATOR's, not a constant: 90 days widens it (got 'expiring', want 'expiring')
PASS ...and the same item is merely active at 30 (got 'active', want 'active')
PASS past its date (got 'expired', want 'expired')
PASS expiring EXACTLY today still counts as covered (got 'expiring', want 'expiring')
PASS the day after is expired (got 'expired', want 'expired')
PASS a voided registration is void whatever the dates say (got 'void', want 'void')
PASS no sale date is UNKNOWN, which is not the same as expired (got 'unknown', want 'unknown')
PASS days left (got 283, want 283)
PASS negative once expired (got -82, want -82)
PASS null when there is nothing to count to (got NULL, want NULL)
PASS band 30 (got '30', want '30')
PASS band 60 (got '60', want '60')
PASS band 90 (got '90', want '90')
PASS beyond 90 is no band at all (got '', want '')
PASS already expired is no band (got '', want '')
PASS unknown is no band (got '', want '')
PASS the boundary belongs to the tighter band (got '30', want '30')
PASS ...and 31 to the next (got '60', want '60')
== 4. Claims — the refusals ARE the product ==
PASS raised can be assessed or rejected outright
PASS REFUSAL: raised cannot jump to resolved
PASS REFUSAL: raised cannot jump to approved — something has to be assessed first
PASS assessed can go either way
PASS both decisions settle into resolved
PASS REFUSAL: resolved is terminal
PASS a legal move with a reason is allowed (got '', want '')
PASS THE RULE THIS PRODUCT EXISTS FOR: no reason, no assessment
PASS ...and no reason, no approval
PASS ...and no reason, no rejection
PASS resolving does not demand one — the decision above already carries it (got '', want '')
PASS a resolved claim explains itself
PASS an unknown status is refused
PASS moving to where it already is says so
PASS a claim nobody has touched is unassessed
PASS ...and one that has been is not
== 5. Coverage is decided on the day it was raised, and frozen ==
PASS raised inside the term is covered
PASS raised on the LAST day is still covered
PASS raised the day after is not
PASS THE POINT: a claim raised in time stays in time however long the assessment takes — the answer depends on the RAISED date and nothing else
PASS no sale date means it cannot be claimed as covered
== 6. Returns ==
PASS issued can go in transit (got '', want '')
PASS issued can be received directly (a customer who walks in) (got '', want '')
PASS REFUSAL: a received parcel cannot go back to in transit
PASS REFUSAL: a closed return is terminal
PASS moving to the state it is already in says so
== 7. One rule per door, and the scope at the door ==
PASS claim_advance refuses through Warranty::advanceRefusal(), not its own matrix
PASS api_claim_advance refuses through Warranty::advanceRefusal(), not its own matrix
PASS the MCP tool does too
PASS DECIDING needs its own permission on every door
PASS in-warranty is frozen from the RAISED date on every door
PASS expiry is never STORED — the items table has no expiry column and nothing writes one
PASS every screen decorates through the ONE function, so no two disagree
== 8. MCP tools + key scope ==
PASS EVERY MCP tool declares readOnly — `perm` is a role gate and is NOT the scope annotation
PASS the read tools outnumber the write tools, and every write tool has a role gate
PASS /mcp hands the key scope to the shared core
PASS no tool can void a registration or delete anything — those stay human clicks
PASS api_keys.scope exists and DEFAULTS to full
PASS a read key is refused any non-GET AT THE DOOR
== 9. Evidence, exports and platform truths ==
PASS a PNG is recognised by its bytes (got 'image/png', want 'image/png')
PASS a JPEG is (got 'image/jpeg', want 'image/jpeg')
PASS a PDF is (got 'application/pdf', want 'application/pdf')
PASS a WEBP is (got 'image/webp', want 'image/webp')
PASS REFUSAL: an SVG named .png is not an image here (got NULL, want NULL)
PASS REFUSAL: a text file is not (got NULL, want NULL)
PASS the upload cap is 10 MB and an empty file is refused
PASS the served type is decided by the bytes on the way OUT too
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 '-12.50', want '-12.50')
PASS ...and ordinary text is untouched (got 'Meridian 600', want 'Meridian 600')
PASS ZERO external hosts on any page
PASS every routed handler exists
PASS the certificate .pdf route is registered BEFORE /items/{id}, which would swallow it
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 toasts those away)
PASS the demo seeder is offline and deterministic
== 10. The rules hold against a real database, in a subprocess ==
PASS expiry is derived from the row, not read from a column
PASS THE POINT: editing a term re-dates every item on it
PASS ...and no expiry column exists to go stale
PASS the MCP endpoint offers its tools
PASS MCP: an agent can register a sold item
PASS MCP: 31 March + 12 months is 31 March, not 3 April
PASS MCP: 31 February is refused, not rolled forward
PASS MCP: a claim can be raised
PASS ...and the in-warranty answer is frozen onto it
PASS MCP: a decision with no reason is REFUSED
PASS ...and accepted with one
PASS a member key cannot approve or reject
PASS ...and the claim did not move when it tried
PASS an admin key can
PASS ...and the reason is on the record
PASS an out-of-warranty claim is accepted and flagged, never blocked
PASS the read-only expiring report answers
PASS every mutation left an audit row
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
== F2: fixes ==
PASS F2 security: backup_json is administrators only (a viewer could download the whole register)
PASS F2 security: backup_sqlite is administrators only (a viewer could download the whole register)
PASS F2 PHP: the installer refuses PHP older than 8.1 with a plain message, before anything else runs
PASS F2 PHP: the requirements no longer say PHP 8.0 or "PHP 8+"
PASS F2 security: a signed-in non-admin (viewer, member) gets 403 on every backup and restore route
PASS F2 security: the probe is real — an administrator is let through to the backup download
PASS F2 routes: every POST form in the views reaches a POST route (36 checked)
PASS F2 webhooks: every event offered in Settings is one the code actually sends, and vice versa
PASS F2 webhooks: a receiver added from Settings with every box ticked hears a real claim move
PASS F2 webhooks: an agent raising a claim over MCP reaches the receiver too (agent writes fired nothing)
PASS F2 webhooks: a receiver saved with only the old phantom events is widened to every event; others untouched
PASS F2b ribbon: Warrantora has no public page, so its pages keep the admin wording (/, /claims)
PASS F2b ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and that choice holds for the session
PASS F2b ribbon: injected once, just before </body>; output with no </body> (JSON, CSV, PDF) passes untouched
PASS F2b ribbon: boot() installs it only behind the .demo-mode marker
PASS F2b security: /backup/scheduled refuses a signed-in viewer, member with no token or a wrong one (403)
PASS F2b backup: a value was planted and read back in every credential column (settings.oidc_client_secret, settings.backup_token_hash, settings.feed_token_hash, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, invites.token_hash)
PASS F2b backup: the JSON download carries none of them
PASS F2b backup: the scheduled backup carries none of them
PASS F2b backup: both backup doors use the one list, _as_backup_redact()
————————————————————————————————————————————————————————————
ALL 142 TESTS PASSED (0 failed)