Skip to content

Pro coverage for mobile and Desktop, and a mock that can express an unverified plan - #128

Draft
mpretty-cyro wants to merge 9 commits into
feat/desktop-pro-specsfrom
feat/pro-restore-from-seed
Draft

Pro coverage for mobile and Desktop, and a mock that can express an unverified plan#128
mpretty-cyro wants to merge 9 commits into
feat/desktop-pro-specsfrom
feat/pro-restore-from-seed

Conversation

@mpretty-cyro

Copy link
Copy Markdown
Collaborator

Draft — parked for review, not ready to merge.

What this is

Pro coverage for mobile and Desktop, plus the harness changes that make the interesting Pro states reachable at all.

The clients have been split so that two questions are answered by two values:

  • ACCESS — "what may this device do right now" — comes from the proof in config, validated on every read against expiry and the revocation list.
  • DISPLAY — "what state is the plan in" — comes from the backend status, seeded from the proof at launch when there is no response yet.

They are meant to disagree. A plan that has lapsed while the proof still has time left displays as expired while the features keep working; that overhang is intended behaviour, not drift.

The harness change worth reviewing first

proBackendStatus used to say what state the plan was in and grant access. That made one state unreachable by mock on every platform: an active plan with no usable proof. It is the state the message limit is wrong in — the composer offers the Pro limit, the message goes out with no proof attached, and every recipient silently truncates it. Measured at 3000 characters sent and 2000 stored, with nothing shown at either end.

proProof is now the access half, with its own key on each client. A fixture that means "this user is Pro" says both halves; the pair is only interesting when they disagree.

proProof: 'none' overrides a real proof rather than falling back to it — a mock a real proof could outvote would be useless on the devices most worth running it on.

What is deliberately not here

  • The contrast case for the truncation spec — "a properly Pro client sends a long message and it arrives intact" — is not reachable by mock on any client, because the recipient validates against a real proof and no mock synthesises one. That needs a real grant and is a separate spec.
  • A spec for the overhang (proof valid, plan expired) is expressible for the first time now but not written.
  • Some Pro specs will be red until the client-side branches land; they are being reviewed alongside this.

Scope

Everything here is test-side: specs, fixtures, locators and harness. The client behaviour it exercises is in the corresponding branches on the three client repos.

Pay, lose the install, restore, still Pro - the worst outcome for a subscriber and the one the suite
did not guard. Covered on all three platforms.

A real grant rather than a display mock, because the point is that Pro survives onto a client that
has never seen it: the restored install derives the Pro master key from the recovery phrase and asks
the backend, so this exercises that derivation end to end. A mock would only convince the client that
already had it.

The second device stands in for a reinstall rather than a linked device - nothing is carried across
but the phrase, which is what a user has after losing their phone. The assertion is the stats section
of the Pro settings screen, gated on an active plan everywhere, so its presence means the restored
client fetched a real entitlement rather than rendering a Pro-looking screen.

restoreFromSeed on Desktop gains an optional fallback name. Restoring an account created moments
earlier prompts for a display name, since its profile has not reached the network yet, and without a
fallback that is an error rather than something to type past. Mobile's restore has no such escape, so
it asserts the name comes back where Desktop tolerates it missing - flagged rather than reconciled.

Every Desktop window now carries a launch identity, not only those from the account builders, so a
spec using sessionTestTwoWindows can restart one.
Covers the warning a non-renewing subscriber gets as their access approaches its
end, and that dismissing it leaves them looking like a subscriber.

`proLoadingState: 'success'` is load-bearing rather than incidental: every client
arms this CTA only once a status fetch has been confirmed in the current process,
so a mocked expiry alone reaches the state but not the gate. Android could not
express that until its debug menu gained a force-success value, and its launch
extra for the access expiry was never emitted.

The CTA table carries no body for this variant because the copy interpolates the
remaining time, which differs per platform fixture — hence the optional body in
`checkCTAStrings` on both wrappers.
Both halves of the cold-launch status fetch, against a real grant: a launch that
cannot know it is expiring must not warn, and the launch after the client learns
its access expiry must. Nothing covered the decline before, so a client that
warned off unconfirmed state would have gone unnoticed.

`makeAccountPro` gains `durationSeconds` because the QA backend's compressed
clock shortens proof expiry and not the account entitlement — a nominal 1M plan
really does land thirty days out, which no expiry-window spec can use. It throws
when the backend grants materially more than asked, since a route that ignored
the override would otherwise quietly return the full plan length.
Every real-grant spec restarted the app and assumed the client would notice.
It does not: each client declines the cold-launch status fetch for an account
with no local expiry and no proof, which is exactly what a grant minted straight
into the backend looks like. `Pinned conversation limit (Pro)` was asserting Pro
behaviour against a non-Pro client on both platforms, and passing.

- Replace the restart with `observeProGrant`, which restarts and then opens Pro
  settings — the restart makes the visit's fetch eligible rather than
  discovering anything, since both clients floor further fetches at 60s from the
  last attempt and only the first in a process is exempt.
- Assert the stats header with `skipHealing`. Healing falls back to a fuzzy id
  match, which on the non-Pro version of that screen resolves to a neighbouring
  `pro-settings-*` element — so the wait passed while the client was
  NeverSubscribed and every later assertion ran against a non-Pro account.
- Drop the equivalent restart on Desktop, whose `waitForProActive` already
  reaches Pro settings.
`Pro survives a restore from seed` reaches the same entitlement through the Pro
settings screen, which refreshes status on arrival — so it passes whether or not
the config-driven refresh works. These specs never open settings on the restored
device.

The composer's character countdown is the observable: it needs no navigation and
no second party, and a client that believes it is not Pro caps at 2000 chars.

Covers the proof half only. The client's Pro *status* can still be unknown while
the proof is good — measured on a restored Android and Desktop client, whose
settings row reads "Upgrade Session" while the Pro cap applies correctly. The row
that would show it puts its id on a container with no text of its own, so that
assertion is not expressible yet; noted so the gap is not mistaken for coverage.
`observeProGrant` proved a grant had landed by opening Pro settings and waiting
for the stats header. That reads the active plan; the row on the parent settings
list reads the fetched status, and the two disagree more often than expected — a
client can hold a good proof, apply the Pro message cap, and still offer to sell
you Pro on that row. Asserting it costs nothing, since the row is on screen on
the way out.

`assertProFromSettingsRow` is separated out for reuse as a cheap "is this client
Pro" precondition. It reads the parent list deliberately: no client refreshes on
opening it, so it observes what the client already believed instead of provoking
the answer. Tapping the row is not passive, so it reads and leaves.

Requires the new `pro-menu-item-title` id on both mobile clients — the existing
`pro-menu-item` sits on the tap target, which carries no text of its own, so the
row's state was not readable through it.
Status and proof answer different questions. The status is the plan's state,
which only the backend knows; the proof is the entitlement that travels with the
message and is what a recipient validates against. A message length is the second
question, so it has to read the proof.

Reading the status instead leaves the two ends permanently disagreeing: measured
at 3000 characters sent and 2000 stored by the recipient, with nothing shown at
either end. The sender's own copy keeps the full text, so the conversation holds
two different messages.

RED until the compose limit reads the proof. That change is in flight on all
three clients as part of the access/display split; this spec is the check on it.

The fixture needs no grant and no restore — a mocked active status supplies the
status half while the config stays empty, which is the state exactly.
`proBackendStatus` said what state the plan was in AND granted access, so a client
with an active plan and no usable proof was unreachable by mock on every platform.
That is the state the message limit is wrong in: the composer offers the Pro limit
and every recipient silently truncates, because no proof travels with the message.

`proProof` is the access half. A fixture that wants an ordinary Pro user now sets
both; the pair is only interesting when they disagree. `none` overrides a real
proof rather than falling back, since a mock a real proof could outvote is useless
on the devices most worth running it on.

The app-side key differs per platform because the harness already translates
per platform: mockCurrentUserSessionProProof, sessionProProof, and
SESSION_PRO_MOCK_PROOF. The lowercasing for Desktop is load-bearing — it throws at
flag-init on anything else, so an untranslated value stops the renderer starting.
…riber"

The status mock no longer grants access, so a fixture claiming an active plan
renders a subscriber whose features are all switched off. Anything meaning "this
user is Pro" now says so twice, once per question.

`iosActiveProContext` carries it for the mobile specs, so the per-spec changes are
the Desktop ones plus the mobile Pro settings screen.

The no-proof message limit spec gets the opposite pairing, stated explicitly: an
active plan with `proProof: 'none'`. Until the mocks were split that state could
not be reached, so the spec could not have measured what it claims to — it would
have passed by offering the Pro limit, which is the behaviour it exists to catch.
@mpretty-cyro
mpretty-cyro changed the base branch from main to feat/desktop-pro-specs August 14, 2026 07:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant