Warrantora · Test run · run 2026-11-07
Warrantora Test run, as shipped in the download
347 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.
== 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 recognized 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 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
== 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 (60 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, settings.smtp_pass, settings.sms_api_key, users.password_hash, users.totp_secret, users.totp_recovery, claims.public_token, api_keys.token_hash, webhooks.secret, updater_state.license_api_key, backup_state.s3_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()
== 1.1 · customer email: what a customer reads, and when ==
PASS mail: "we have your claim" names the claim, the product and serial, and carries the claim's own link
PASS mail: a decision carries the decision, the outcome and the written REASON, and never the internal assessment
PASS mail: "your cover ends" states the date in words
PASS mail: every customer email is OFF on a fresh install (master switch and each kind)
PASS mail: a kind ticked without the master switch still sends nothing
PASS mail: a claim raised → the customer's "we have your claim" lands (demo: in the outbox, no socket) with a fresh 32-hex claim link
PASS mail: the decision goes once per decision, with the reason (a second call records nothing)
PASS mail: a customer with no address on file is not written to
PASS mail: "cover ending" goes to the one item inside its window — not a later one, an ended one, a voided one or one with no address
PASS mail: run again, nothing more — one note per item per expiry date (the reference is claimed first)
PASS mail: switched off, the cover-ending run sends nothing
== 1.1 · supplier claim-back ==
PASS recovery: approved, in-warranty repairs, replacements and refunds wait under their supplier; goodwill, rejected and out-of-warranty do not
PASS recovery: a pack takes only that supplier's eligible claims (another supplier's and a rejected one are left out)
PASS recovery: a claim sits in one pack at most
PASS recovery: no supplier named, no pack
PASS recovery: the CSV carries every decision and its reason, and no amount column
PASS recovery: marking it sent stamps the sent date
PASS recovery: a pack that went to the supplier cannot be taken apart
PASS recovery: the supplier's answer, reference and note are recorded, with the date answered
PASS recovery: an unknown status is refused
PASS recovery: an open pack can be taken apart and its claims wait again
PASS backup + audit: the customer's claim link and the SMTP password are on the redaction lists
== 1.1 pass B · faults by product and batch ==
PASS faults: claims per 100 units, and none when nothing is registered
PASS faults: by product, units registered against claims raised in the period, with each outcome counted
PASS faults: by batch, the batch with three claims at twice its product's rate stands out, the clean one does not
PASS faults: two claims are not enough to mark a batch, however high its rate
PASS faults: with no period, the old claim counts too
== 1.1 pass B · the evidence archive ==
PASS archive: every evidence file with its claim, original name and checksum; a missing file and a non-minted name are listed, not read
PASS archive: restore puts back the missing files byte for byte, and a second run overwrites nothing
PASS archive: a file no evidence row names is not written
PASS archive: bytes that do not match the checksum are left out
PASS archive: something that is not a Warrantora archive is refused
== 1.1 pass B · registering at the point of sale ==
PASS sale: a sale needs its reference, a real date, a valid email and at least one line with a product and a quantity
PASS sale: the term is the line's own, else the first rule that matches the product or model, else the default
PASS sale: every unit is registered: one per serial, one per quantity, on the term its rule or the default gives, to the customer
PASS sale: the same sale sent twice registers nothing twice
PASS sale: a term that does not exist refuses that unit rather than registering it on the wrong term
== 1.1 · the doors, through the real front controller ==
PASS portal: OFF on a fresh install — /warranty is a 404
PASS portal: a member cannot switch it on
PASS portal: switched on, the page opens with no login, the owner's line on top, and out of search engines
PASS portal: the right serial with the wrong email shows nothing
PASS portal: serial and email together find it (case and outer spaces do not matter)
PASS portal: the customer sees the product, "Covered" and until when
PASS portal: a one-word fault is refused with a plain sentence
PASS portal: the claim lands as raised, marked as the customer's, in warranty on the day, with its own link
PASS portal: one open claim per item from the page
PASS portal: the hidden trap field — a bot is thanked and nothing is kept
PASS portal: the claim's own link shows the decision and its reason, and nothing internal
PASS portal: a made-up claim link is a 404
PASS portal: at most 20 look-ups an hour from one address (a guesser is stopped, even with the right pair)
PASS portal: switched off again, the page is a 404
PASS decide: rejecting a claim tries to email the customer the reason; a refused send is recorded and never blocks the decision
PASS recovery door: a viewer sees the claim-back page
PASS recovery door: starting a pack is the owner's act (a member is refused)
PASS recovery door: an admin starts a pack
PASS recovery door: the pack downloads as CSV (with the reason) and as a PDF
PASS item: the supplier is saved from the registration form
————————————————————————————————————————————————————————————
PASS api roles: POST /api/items refuses every role without the permission (403) and admits the rest
PASS api roles: POST /api/sales refuses every role without the permission (403) and admits the rest
PASS api roles: POST /api/claims refuses every role without the permission (403) and admits the rest
PASS api roles: POST /api/claims/{id}/advance refuses every role without the permission (403) and admits the rest
== 1.1 pass B · the doors ==
PASS door: a viewer reads the faults page and its CSV
PASS door: the evidence archive is for administrators, like the backups
PASS door: restoring evidence is for administrators
PASS door: sale rules are for whoever sets the warranty terms
== 1.2 · your own reports (in-process) ==
PASS reports: a definition keeps only known columns (in their fixed order), statuses and sort keys; anything else is dropped
PASS reports: a product filter shaped like SQL is only text: it matches nothing and changes nothing
PASS reports: items filtered by the status the app works out (expiring), and by product
PASS reports: sorted by sale date either way, and by days left as a number
PASS reports: items sold in a date range
PASS reports: claims filtered by status, in warranty and product, with labels and outcomes
PASS reports: the table has the chosen columns, headed by their names
PASS reports: saved under a name used once (any case), up to 80 characters, and stored as a clean definition
== 1.2 · the items import's preview runs the import's own checks ==
PASS import preview: it counts exactly what the import brings in, names the same skipped rows, and writes nothing
PASS import preview: a serial already on the register, one twice in the file, an impossible sale date and a bad email are each named
PASS import preview: the upload step runs the import's own handler in normal mode, never the dry-run flag
== 1.2 · the reports doors ==
PASS door: a viewer reads a report, and has no save form
PASS door: a viewer cannot save or remove a report (403)
PASS door: a member saves a report; the same name again is refused
PASS door: a saved report opens by its link, and downloads as CSV
PASS door: the CSV keeps a formula-looking value as text
PASS door: a member removes a saved report
== Claim updates by text, on the owner's own SMS gateway (in-process) ==
PASS texts: OFF on a fresh install (no switch, no gateway, hours 9 to 20), and a customer who ticked the box is not texted
PASS texts: switched on they need an https gateway AND a business name (every text starts with it)
PASS texts: field mapping — JSON to/from/text with a Bearer key by default; Twilio-style To/From/Body form-encoded with Basic; no key header when "not sent"; a blank sender or field name falls back
PASS texts: a number is digits with an optional + (00 becomes +); words, or too few or too many digits, are not a number
PASS texts: the opt-in decides, not the phone number — a number without the tick, or a tick without a number, is never texted
PASS texts: "received" goes at once, to the customer's number, starting with the business name, with the claim's own link where they can stop texts
PASS texts: never the same text twice — a second call is refused by the claimed key and the gateway is not called
PASS texts: the decision and the close each go once, with the business name and the stop link; assessing texts nobody
PASS texts: the sending hours are counted in the business time zone (08:00 out, 09:00 in, 19:59 in, 20:00 out)
PASS texts: a decision made at 22:00 waits in the outbox; "received" (the customer's own action) goes at once
PASS texts: the waiting text goes on the first run inside the hours (not at 01:00), once, with the text written at the decision
PASS texts: a customer who stops texts while one waits never gets it (the opt-in is checked again when it goes)
PASS texts: the business day starts at local midnight, daylight saving included (New York: 04:00 UTC in October, 05:00 UTC in November; 1 November 2026 runs 25 hours)
PASS texts: a gateway refusal is recorded with the gateway's answer and returned to whoever acted; asking again sends nothing
PASS texts: an unreachable gateway (connection error, or no HTTP answer) reads "Gateway unreachable." and breaks nothing
PASS texts: a refused text is never tried twice on one day
PASS texts: the next day it is tried once more; a second refusal ends that run (the other refused text is left for later)
PASS texts: on a later day a working gateway takes both, each once (the first on its third and last attempt)
PASS texts: a refused text is left alone after 3 attempts, and once the customer stops texts
PASS texts: a waiting text the gateway refuses is marked failed with its answer, and the run stops there (the next one keeps waiting)
PASS texts: the daily cap — past it a text waits (even "claim received") and goes the next day
PASS texts: every attempt is in the audit trail with the last four digits only, sent or refused
PASS texts: a demo install writes the text to the outbox and never calls the gateway, not even for a test text
PASS texts: a decision made by an agent through the MCP tool texts the customer too
PASS texts: every door that moves a claim on (the screen, the API, the MCP tool) calls the one sender, and the waiting texts ride the dashboard and the cron
PASS texts: the gateway key is on the backup and the audit redaction lists
== Claim updates by text: the doors, through the real front controller ==
PASS door: the SMS settings are for administrators (a member is refused)
PASS door: with texts off, the claim form has no text box
PASS door: an http gateway is refused
PASS door: hours that end before they start are refused
PASS door: switched on with the business still unnamed, texts stay off and the owner is told why
PASS door: named, they switch on with the daily cap set; the key is kept from before and never shown back (write-only)
PASS door: with texts on, the claim form offers the box, unticked, and says who texts, how often and how to stop
PASS door: the box ticked with something that is not a number is refused with a plain sentence, and nothing is kept or sent
PASS door: ticked with a number, the claim keeps the opt-in and the number, and one text goes: the business name, the claim, the stop link
PASS door: a number typed WITHOUT the tick is not kept and nobody is texted
PASS door: the customer's claim page shows the last digits and a Stop button; the team's claim page shows the number and the text sent
PASS door: deciding on the claim page texts the customer once, and says so
PASS door: the customer stops texts from the claim's page: the tick goes, the number is forgotten, the button goes
PASS door: after a stop, closing the claim texts nobody
PASS door: a made-up claim link cannot stop anything (404)
PASS door: a gateway refusal never blocks the decision; the team is told what the gateway said, and the outbox and claim page show it
PASS door: the team's Stop texts button: a viewer is refused, a member stops them
PASS door: Send test sends one text with the business name and shows the gateway's answer; a member cannot
PASS door: switching texts off cancels a text still waiting for the sending hours (it is never sent late)
PASS door: with texts off, the claim form shows no box again
== 4.0: the Update button is wired ==
PASS updates: the release public key is pinned
PASS updates: Database::migrate() creates the updater table (in code, not in a comment)
PASS updates: the updater table holds exactly one state row
PASS updates: index.php clears stale compiled code before anything else loads
PASS updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
PASS updates: route get /updates is updates_page()
PASS updates: route post /updates/key is updates_key_save()
PASS updates: route post /updates/check is updates_check()
PASS updates: route post /updates/apply is updates_apply()
PASS updates: updates_page() opens with the restore page's admin gate
PASS updates: updates_key_save() opens with the restore page's admin gate
PASS updates: updates_check() opens with the restore page's admin gate
PASS updates: updates_apply() opens with the restore page's admin gate
PASS updates: every POST checks the CSRF token
PASS updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
PASS updates: the menu links to the page
PASS updates: bin/update.php refuses the web and finishes in a fresh process
PASS updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
PASS updates: with a key and a newer signed release the page offers "Back up and update"
== 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: every one of the owner's settings is theirs, not the sample's
PASS A2: the sample logins cannot sign in: no password matches, and switched off where the product can
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 Warrantora with records seeds nothing and changes nothing
PASS A2: real use: one of the owner's own rows among the sample
PASS A2: remove: the owner's own row stays, and a sample row it points at is kept
PASS A2: remove: the report says what went, and what was kept because the owner's records use it
PASS A2: remove: the rest of the sample is gone
PASS A2: remove: every kept sample row keeps all its own parts
PASS A2: remove: no row is left pointing at a row that was removed
PASS A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
PASS A2: remove: the sample's files are gone from disk (0 written; one a row that stays names stays with it)
PASS A2: remove: the owner's own uploaded file is untouched
PASS A2: "Load demo data" on a Warrantora 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 25 owner's-install checks reported
PASS A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
PASS A2: index.php loads the three cores and the wiring
PASS A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
PASS A2: route post /sample-data/remove is sample_data_remove()
PASS A2: route post /restore/backups is backups_save()
PASS A2: route post /restore/backup-now is backup_now()
PASS A2: route get /health is health_page()
PASS A2: sample_data_remove() opens with the restore page's admin gate
PASS A2: backups_save() opens with the restore page's admin gate
PASS A2: backup_now() opens with the restore page's admin gate
PASS A2: health_page() opens with the restore page's admin gate
PASS A2: every POST checks the CSRF token
PASS A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
PASS A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
PASS A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
PASS A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
PASS A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Warrantora, removable)
PASS A2: the menu links to Health
PASS A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
PASS A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
SKIP A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)
PASS erase planting · sqlite: the subject is created and a marker is planted in all 8 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 (25 tables dumped)
PASS erase planting · sqlite: the photo they sent is deleted from disk
PASS erase planting · sqlite: the claim keeps its record: fault and outcome unchanged, raised_by says the customer raised it
PASS erase planting · sqlite: a service-desk member cannot erase: HTTP 403, nothing changed
PASS erase planting · sqlite: a service-desk member cannot open the Data requests page: HTTP 403
PASS erase planting · sqlite: an admin is refused while a claim on their items is open, and nothing changes
PASS erase planting · sqlite: an admin opens the Data requests page and the customer is listed
PASS erase planting · sqlite: the erase page (/gdpr) says what an erase keeps: "Kept: the fault as the customer described it in their own wo…"
PASS erase planting · sqlite: MANUAL.md says the same: "Kept: the fault as the customer described it in their own wo…"
ALL 347 TESTS PASSED (0 failed)