# PDF2HTML SaaS MVP — UI, Credits, and User Journey Test Plan

## Purpose

Prove every UI page, credit-purchase state, one-credit conversion rule, Stripe path, access rule, and user journey defined in `docs/specs/pdf2html-saas-mvp.md` before launch.

This is an acceptance plan, not a request to add features. The product remains the smallest commercial wrapper around the existing visual-parity converter.

## Test environment rules

- Browser E2E runs on designated remote test/build machines (`debian1`, `debian2`, or `debian3`), not the main workstation display.
- Production frontend under test is `https://tools.press.zone`.
- Frontend implementation under test is Astro + React + TypeScript using `facebook/astryx`.
- Use isolated Stripe **test mode** customers/payments/webhooks for automated and manual pre-production tests.
- Never use live card details in automated tests.
- Stripe tests must assert the verified payment -> platform-ledger credit grant, not only a browser success redirect.
- Keep at least two independent customer fixtures (`userA`, `userB`) plus one admin fixture.
- Use deterministic valid/invalid/failure PDF fixtures.
- Where real conversion is slow, UI/E2E may use a deterministic test worker for state-transition coverage, but at least one deployment acceptance test must use the real converter and inspect the ZIP.

## Release gates

- G1 Auth: Login/Register/session routing works.
- G2 Stripe Credits: exact quantity × $5, verified grants, idempotency, and zero-credit blocking work.
- G3 Conversion Accounting: exactly one credit is atomically debited per accepted conversion and failed charged conversions restore exactly one credit once.
- G4 Conversion: positive-credit upload -> persisted job -> worker -> ZIP works.
- G5 Library: history persists; unexpired completed artifacts remain retrievable without additional credit charges, and expired artifacts remain as non-downloadable history.
- G6 Isolation: cross-user access and non-admin admin access are denied.
- G7 Failure handling: invalid uploads, worker failures, timeouts, Stripe failures, and balance races reach correct states.
- G8 Recovery: refresh/tab close/session relogin does not lose server-side jobs or duplicate credit effects.
- G9 Production smoke: `https://tools.press.zone` (Astro + React + TypeScript on Cloudflare) -> `https://api.press.zone` API/worker on CloudPanel01 -> Stripe credit grant -> private ZIP download is wired correctly.
- G10 Output quality and safety: generated HTML meets visual/semantic thresholds and package sanitization rules.
- G11 Worker containment: hostile PDFs and failed jobs remain inside enforced resource and cleanup boundaries.
- G12 Artifact retention: source PDFs and ZIPs expire after 48 hours while safe Library history remains.
- G13 Stripe Tax and post-payment lifecycle: configured jurisdictions, tax, refunds, disputes, and chargebacks are handled consistently.
- G14 Data governance: published legal surfaces and enforced account-data/log lifecycle procedures match actual system behavior.

# Test fixtures

## Users

- `newUser`: no account before test
- `zeroCreditUser`: valid account, balance `0`
- `oneCreditUser`: valid account, balance `1`
- `multiCreditUser`: valid account, balance `10`
- `userA`, `userB`: distinct normal customers
- `adminUser`: admin role

## PDFs

- `valid-small.pdf`: deterministic supported PDF
- `valid-second.pdf`: second valid file
- `not-a-pdf.txt`: wrong MIME/content
- `fake.pdf`: `.pdf` extension with invalid contents
- `too-large.pdf`: exceeds configured maximum
- `converter-fail.pdf`: deterministic converter-failure fixture/test-worker instruction
- `timeout.pdf`: deterministic timeout fixture/test-worker instruction
- `package-fail.pdf`: deterministic packaging-failure fixture/test-worker instruction

## Stripe test fixtures

Use Stripe test mode mechanisms for:

- successful one-time payment
- declined/failed payment
- Checkout cancellation
- duplicate/replayed webhook/payment event
- delayed webhook/payment reconciliation relative to Checkout return
- quantities `1`, `2`, `10`, and a non-round representative quantity such as `37`

# Page-by-page UI acceptance

## C1 Login

### Visual/interaction assertions

- product identity, email/auth controls, Sign in, Create account render
- fields are keyboard reachable/labeled
- submitting prevents accidental duplicate requests

### State tests

- C1.1 valid user -> Upload
- C1.2 zero-credit user -> Upload zero-credit state, not a broken/blank page
- C1.3 invalid credentials -> generic error; no session
- C1.4 backend/network failure -> retryable error
- C1.5 already-authenticated user visiting `/login` -> Upload

## C2 Register

### Visual/interaction assertions

- required account fields render
- Terms and Privacy links render and target the published policies
- Create account and Sign in link render
- validation is attached to fields/form

### State tests

- C2.1 valid new account -> session created -> Billing
- C2.2 new account starts at exactly `0` credits
- C2.3 duplicate email -> safe error + Login path
- C2.4 invalid email/password -> no account
- C2.5 double submit -> one account/session outcome

## C3 Billing

### Credit balance and purchase UI

- C3.1 shows authoritative current balance
- C3.2 shows `$5 per credit`
- C3.3 quantity input accepts positive integers and exact user-selected quantity
- C3.4 `1` displays `$5`
- C3.5 `2` displays `$10`
- C3.6 `10` displays `$50`
- C3.7 `37` displays `$185`
- C3.8 zero, negative, fractional, empty, NaN/invalid input cannot create Checkout
- C3.9 Buy credits requests server-created Checkout with quantity and billing jurisdiction, not trusted client total
- C3.9a Terms and Privacy links render before Checkout
- C3.9b missing/unconfigured jurisdiction cannot create Checkout; configured jurisdiction requires Stripe billing-address collection and automatic tax

### Successful purchase

- C3.10 Stripe success return before verified grant -> `Confirming payment…`; balance unchanged
- C3.11 verified payment for quantity N grants exactly N credits
- C3.12 successful purchase is additive to existing balance
- C3.13 tampered `?checkout=success` cannot change balance
- C3.14 payment history records date, quantity, amount, status/reference

### Cancellation/failure

- C3.15 user cancels Checkout -> balance unchanged; canceled state; retry available
- C3.16 payment fails -> balance unchanged; failed state; retry available

### Stripe/webhook security/correctness

- C3.17 missing/invalid Stripe signature rejected
- C3.18 duplicate/replayed successful event does not duplicate credit grant
- C3.19 payment for userA cannot grant userB credits
- C3.20 server total is always exactly `quantity × $5` even if client tampers amount fields
- C3.21 frontend bundle/network responses never expose Stripe secret/restricted key

## C4 Upload / Conversion

### Zero-credit state

- C4.1 balance `0` -> `You're out of credits`
- C4.2 displays `Each PDF conversion costs 1 credit ($5)`
- C4.3 Buy credits CTA -> Billing
- C4.4 no conversion can be submitted from zero-credit state
- C4.5 Library remains reachable

### Ready state

- C4.6 positive balance shows balance + `1 credit per conversion`
- C4.7 picker/dropzone + `Convert PDF — 1 credit`
- C4.8 no file -> Convert disabled
- C4.9 valid PDF selection shows filename/size

### Validation — no charge

- C4.10 non-PDF rejected client/backend; balance unchanged
- C4.11 too-large PDF rejected; balance unchanged
- C4.12 malformed/fake PDF rejected before accepted job when detectable; balance unchanged
- C4.13 invalid file does not enter worker queue

### Upload/acceptance

- C4.14 transport failure before job acceptance -> no credit debit, no orphan job
- C4.15 valid accepted submit -> exactly one credit debit + one job
- C4.16 updated balance returned/rendered after acceptance

### Atomicity and races

- C4.17 double click/repeated same request -> one job + one debit
- C4.18 balance 1 + two concurrent requests -> one accepted/debited, one out-of-credits
- C4.19 balance never goes negative
- C4.20 stale UI balance cannot bypass server atomic balance check

### Processing states

- C4.21 Queued displays no Download
- C4.22 Processing displays no fake percent unless real progress exists
- C4.23 reload during Queued/Processing -> same job/balance accounting remains
- C4.24 closing tab does not stop accepted job

### Complete

- C4.25 completion -> Complete + Download ZIP
- C4.26 successful job keeps exactly one credit spent
- C4.27 Download ZIP does not spend another credit
- C4.28 Convert another PDF returns ready or zero-credit state according to remaining balance
- C4.29 Complete job appears in Library

### Failure and application-credit restore

- C4.30 converter failure -> Failed + exactly one credit restored
- C4.31 timeout -> Failed + exactly one credit restored + worker cleanup
- C4.32 packaging failure -> Failed + exactly one credit restored + no partial download
- C4.33 repeated failure finalization/retry callback does not restore twice
- C4.34 customer sees `Your credit was returned` or equivalent safe confirmation
- C4.35 no raw stack/path exposed

## C5 Library

- C5.1 empty positive-balance user -> No conversions + Convert PDF -> Upload
- C5.2 empty zero-credit user -> No conversions + Buy credits to convert -> Billing
- C5.3 newest-first ordering
- C5.4 Queued/Processing rows survive reload/login
- C5.5 unexpired Complete row exposes Download ZIP
- C5.6 Failed row exposes safe status/reason; no Download
- C5.7 zero-credit user can download a previously completed owned ZIP before `zip_expires_at`
- C5.8 one or repeated unexpired downloads do not change credit balance
- C5.9 direct download requires authentication
- C5.10 direct download enforces ownership
- C5.11 invalid or expired artifact reference returns controlled error, never raw path
- C5.12 expired row remains visible as Expired without Download and offers the standard new-conversion flow

## S1 Stripe Checkout

- S1.1 displays the exact requested quantity
- S1.2 total equals exact `quantity × $5`
- S1.3 successful one-time payment returns to Billing success/confirming state
- S1.4 cancel returns to Billing canceled state
- S1.5 Checkout is associated with correct application user/purchase intent
- S1.6 browser return alone never grants credits
- S1.7 no subscription/recurring charge is created

## A1 Admin Overview

- A1.1 admin can access
- A1.2 normal user cannot access
- A1.3 counts reflect seeded queued/processing/failed/recent-complete jobs
- A1.4 recent job/failure links open correct Job Detail

## A2 Admin Jobs

- A2.1 rows show required job/user/file/time/status data
- A2.2 all filter works
- A2.3 queued/processing filter works
- A2.4 failed filter works
- A2.5 no customer secret/payment data exposed

## A3 Admin Job Detail

- A3.1 displays ownership/status/timestamps/failure metadata
- A3.2 displays credit debit/refund references when specified/available
- A3.3 raw private storage paths are not public links
- A3.4 Retry absent/disabled for queued/processing/complete job
- A3.5 failed retry creates controlled traceable attempt only while source is unexpired
- A3.6 retry cannot double-run when clicked twice/repeated
- A3.7 operational retry creates no Stripe credit purchase
- A3.8 expired source disables admin retry and directs the customer to a standard new conversion

## A4 Admin Users

- A4.1 displays email/registration/current credit balance
- A4.2 purchased/spent credit totals correct when exposed
- A4.3 conversion count correct when implemented
- A4.4 related jobs navigation correct
- A4.5 Stripe customer/payment identifiers shown only to admins when included
- A4.6 no Stripe secrets/payment card data shown

# User-journey E2E matrix

Each journey ID is defined in the product spec and must have at least one E2E scenario.

| Journey | E2E scenario | Required assertions |
|---|---|---|
| J1 | new customer happy path | Register -> buy exact credits -> verified grant -> one-credit conversion -> ZIP -> Library |
| J2 | Checkout canceled | balance unchanged at 0; no conversion; Buy credits visible |
| J3 | payment failed | balance unchanged; Billing error; no conversion |
| J4 | delayed grant | success redirect does not grant; verified event grants exact quantity |
| J5 | returning user with credits | Login -> Upload -> one-credit conversion accepted |
| J6 | returning zero-credit user | Upload shows out-of-credits + Buy credits; Library available |
| J7 | invalid uploads | rejected safely; no job; no credit debit |
| J8 | transport failure | no debit/orphan job; retry succeeds |
| J9 | navigation/reload during work | same persisted job completes; only one debit |
| J10 | converter failure | Failed; exactly one application credit restored |
| J11 | timeout/package failure | Failed + cleanup + exactly one restore |
| J12 | duplicate submit | one intended job and one debit |
| J13 | unexpired prior conversion retrieval | correct ZIP before expiry; repeated downloads cost 0 credits |
| J14 | zero-credit Library and expiry | unexpired ZIP available; expired row has no download; new conversion blocked |
| J15 | ownership isolation | userB denied userA status/download/source/output |
| J16 | additive purchase | balance 3 + buy 10 = exactly 13 |
| J17 | exact quantity pricing | 1/2/10/37 all equal N × $5 server-side and UI |
| J18 | replayed payment event | one successful quantity grant only |
| J19 | concurrent final-credit race | one accepted, one rejected, no negative balance |
| J20 | admin observes work/credits | admin surfaces reflect live job and balance |
| J21 | admin retries failed conversion | retry controlled; credit refund traceable; no Stripe purchase |
| J22 | non-admin admin access | denied; no job/user/credit data leak |
| J23 | session expiry | protected action denied; no debit; Login |
| J24 | stale balance race | server rejects after another tab consumed final credit |

# API/integration test matrix

## Auth/session

- unauthenticated protected calls return auth error
- session maps to exactly one user
- logout invalidates current session according to platform behavior
- admin role checked server-side

## Billing / Stripe / ledger

- Checkout creation requires authenticated user
- quantity must be a positive integer
- server computes cents as `quantity × 500`
- client-supplied dollar/cents total is ignored/rejected
- successful verified payment grants exact integer quantity
- canceled/failed payment grants zero
- duplicate payment/webhook event is idempotent
- grants are additive
- user mapping cannot be switched by client metadata
- payment history maps to correct user/purchase
- credit balance cannot be changed by Checkout return query params

## Conversion creation / credit debit

- auth required
- PDF validation enforced server-side
- balance `0` -> explicit out-of-credits response, no job
- balance `>=1` -> exactly one atomic debit with one job
- repeated request idempotency prevents duplicate job/debit
- concurrent requests against balance `1` accept at most one
- balance never negative
- source ownership recorded
- accepted job starts Queued without synchronously waiting for conversion completion

## Worker / credit compensation

- only one worker claim owns a job attempt
- Queued -> Processing -> Complete keeps one debit spent
- converter failure -> Failed + one compensating +1 credit ledger entry
- timeout -> Failed + one compensating +1
- packaging failure -> Failed + one compensating +1
- repeat failure finalization cannot compensate twice
- output storage key remains private
- successful ZIP extracts and contains entry HTML/assets

## Library/download

- list scoped to current user
- statuses persist across sessions
- download auth + ownership check
- completed artifact downloadable at balance 0
- download/re-download does not mutate credits
- incomplete/failed job not downloadable

## Admin

- admin can list/read jobs/users/credit visibility specified
- normal customer denied
- retry only valid on failed/retryable jobs
- retry is guarded/idempotent
- admin retry never creates a Stripe purchase

# Real converter acceptance

At least one gate must use the actual current converter, not a mocked worker.

Procedure:

1. Create a test user with balance `0`.
2. Buy a known Stripe-test quantity, e.g. `2` credits for `$10`.
3. Verify ledger balance becomes exactly `2` after verified payment.
4. Upload `valid-small.pdf` through deployed customer UI/API.
5. Verify job acceptance changes balance exactly `2 -> 1`.
6. Observe Queued -> Processing -> Complete.
7. Download ZIP through authenticated endpoint.
8. Verify download leaves balance at `1`.
9. Verify ZIP extraction succeeds and entry HTML exists.
10. Verify referenced CSS/font/image assets required by output exist.
11. Serve extracted package on a remote test machine and verify it renders without an embedded PDF viewer.
12. Logout/login -> Library -> redownload same artifact -> balance still `1`.

Visual-parity quality thresholds remain governed by the converter-specific visual-parity spec/tests; this SaaS plan proves the commercial workflow/accounting wraps it correctly.

# Security/negative acceptance

Must explicitly attempt:

- guessed conversion/download id
- userA artifact with userB session
- non-admin admin endpoints
- unauthenticated upload/download/purchase
- frontend-forged positive balance
- frontend-forged `$0` or cheaper Checkout total
- frontend quantity tampering after displayed total
- forged Checkout success query parameter
- invalid Stripe webhook signature
- replayed successful Stripe event
- user/payment metadata swapping attempt
- path traversal-like filename
- hostile/invalid PDF against worker boundary
- oversized upload
- concurrent requests against one remaining credit

Expected: no unauthorized data/work/credit/payment-state change.

# Resilience acceptance

- API restart does not lose persisted jobs or ledger state
- worker restart leaves jobs recoverable according to selected `jobs` semantics
- browser tab closure has no effect on accepted conversion
- temporary frontend/API polling failure recovers to persisted state
- Stripe event may arrive before or after browser return without granting wrong/duplicate credits
- repeated Stripe event converges to same credit balance
- repeated failed-job finalization converges to one credit restore
- one failed conversion does not block later unrelated jobs


# Frontend stack/deployment acceptance

- Astro build succeeds in the Cloudflare-targeted production configuration.
- TypeScript typecheck succeeds with no production-blocking errors.
- React interactive components hydrate and function on Login, Register, Billing, Upload, Library, and admin pages where React is used.
- ASTRYX components render without requiring a separate UI runtime/server.
- direct navigation/refresh works for all application routes under `tools.press.zone`; Cloudflare routing does not return an unintended 404.
- browser-origin API calls to `https://api.press.zone` succeed only from the configured production frontend origin(s).
- CORS/session/cookie policy is validated for the split-origin deployment between `https://tools.press.zone` and `https://api.press.zone`.
- Stripe success/cancel return URLs point to `https://tools.press.zone/app/billing` (or the exact equivalent route defined by implementation).
- no frontend build output contains backend secrets, Stripe secret keys, database credentials, or private CloudPanel01 credentials.
- production links/navigation stay on `https://tools.press.zone` except intentional Stripe-hosted Checkout redirects.

# Manual UI review checklist

For every customer/admin page at desktop and narrow/mobile-sized viewport:

- no horizontal overflow for normal content
- primary action obvious
- current credit balance understandable where relevant
- `0 credits` has obvious Buy credits CTA
- quantity and total clearly linked on Billing
- disabled/blocked states visually distinct
- keyboard focus visible
- form controls have accessible labels
- error text readable and not color-only
- status text present in addition to icon/color
- long filenames truncate/wrap safely
- loading state prevents duplicate actions
- no raw server path, stack trace, secret, Stripe secret key, or card data appears

# Production smoke sequence

Run after deployment/configuration changes against `https://tools.press.zone`:

```text
1. `https://tools.press.zone/` -> Login when anonymous
2. Register throwaway smoke user
3. Confirm redirect -> Billing and balance 0
4. Confirm Upload shows out-of-credits + Buy credits
5. Buy known test/smoke quantity, e.g. 2 credits -> $10
6. Confirm verified payment grants exactly 2 once
7. Upload deterministic PDF
8. Confirm accepted job changes balance 2 -> 1
9. Observe Queue -> Processing -> Complete
10. Download/extract ZIP; balance remains 1
11. Logout/login
12. Library -> redownload same ZIP; balance remains 1
13. Admin -> locate smoke job/user/credit state
14. Cross-user access negative check with second smoke user
```

For a live-production smoke where real charging is undesirable, use an approved Stripe production-safe verification procedure. Do not add a hidden endpoint/query parameter that grants credits directly.

# Converter safety, quality, tax, and retention addendum

## G10 Output quality and safety

- representative corpus meets committed screenshot-similarity and text-recall thresholds
- generated output has meaningful title, metadata, and reading order
- semantic headings/paragraphs/lists/tables are present where detected
- browser-native text remains searchable and selectable
- generated package contains no embedded PDF, PDF viewer, or canvas renderer
- generated URLs use an allowlisted scheme
- no `javascript:` links, archive traversal, absolute output paths, symlinks, device files, or missing local assets
- active scripts are limited to pinned product-approved runtime assets
- default CSP is present and compatible with the generated package
- package file count, individual asset bytes, and total bytes are validated
- ZIP extraction cannot escape its destination
- Chromium, Firefox, and WebKit smoke renders pass

## G11 Worker containment and cleanup

- worker runs non-root with no network, read-only root, dropped capabilities, and no-new-privileges
- CPU, memory, PID, temporary-storage, output-size, and wall-time ceilings are enforced
- timeout/cancel kills the complete job process tree
- every terminal state removes the per-job work directory
- safety-ceiling rejection before acceptance consumes no credit
- safety-ceiling failure after charged acceptance restores exactly one credit once
- containment is verified without destructive resource-exhaustion testing on the laptop or buildboxes

## G12 Artifact retention

- source PDF uses persisted `source_expires_at = accepted_at + 48 hours`
- completed ZIP uses persisted `zip_expires_at = completed_at + 48 hours`
- a worker cannot claim an expired source; the job fails safely and any charged credit is restored exactly once
- a worker that claimed before source expiry may finish from its isolated private copy, which terminal cleanup removes
- object-store lifecycle and application reconciliation converge on the persisted deadlines
- Library retains history after artifact deletion and displays Expired without Download
- signed download URL TTL is clamped so it never outlives `zip_expires_at`; expired URLs cannot retrieve an artifact
- admin retry is unavailable after `source_expires_at`
- regeneration uses the normal new one-credit conversion flow
- source content, HTML content, and private paths do not appear in normal logs

## G13 Stripe Tax and post-payment lifecycle

- server-side launch-jurisdiction allowlist matches configured Stripe Tax registrations
- Billing requires jurisdiction input and Checkout requires authoritative billing-address collection
- Checkout creation is blocked before payment for missing or unconfigured jurisdictions
- Stripe automatic tax is enabled for every configured launch jurisdiction
- verified Checkout location/tax state is persisted and must remain eligible before credits are granted
- a post-Checkout jurisdiction mismatch enters explicit reconciliation/refund state and grants no credits
- Checkout tax calculation matches Stripe's authoritative state
- the client cannot override jurisdiction eligibility or tax state
- verified refunds, disputes, and chargebacks reverse the original credit grant exactly once
- if reversed credits were already spent, the account enters a deficit and cannot start new conversions
- later valid grants or operator resolution can clear the deficit without rewriting prior ledger history
- completed outputs are not retroactively removed solely due to payment reversal
- duplicate refund/dispute events are idempotent
- payment, grant, reversal, conversion debit, and compensation entries remain auditable

## G14 Data governance

- published Terms and Privacy links are present on registration and billing/Checkout entry surfaces
- policies accurately describe PDF processing, 48-hour source/ZIP deadlines, payment processing, and retained Library/account records
- account deletion and data export requests follow a documented, ownership-verified procedure
- deletion/export behavior covers auth records, conversion history, payment/ledger records that must be legally retained, and already-expired artifacts
- structured logs enforce redaction and the documented retention deadline
- source PDF content, generated HTML content, secrets, and private storage paths do not appear in normal logs, analytics, or error traces
- production configuration and runbooks name the owner and verification method for each lifecycle control

# Traceability requirement

Implementation tests should preserve these IDs (`C*`, `S*`, `A*`, `J*`, `G*`) in test names or metadata so a release report can show exactly which spec pages/journeys passed or failed.

No page or journey in the specification is considered implemented until its mapped acceptance tests exist and pass at the appropriate layer.
