Files
buzz/mobile/ios/BuzzPushKit/Tests
Tom Brow 7d40a66126 fix(ios): stop repeating an old message in push notifications (#8973)
## Context

Pushes from the gateway carry no event reference
(`crates/buzz-push-gateway/src/model.rs`), so the iOS Notification
Service Extension resolves each push by querying the user's NIP-PL lease
filters and showing the newest matching message. Any push whose trigger
wasn't the newest message (a reaction, an edit or deletion, a community
query that failed, or several pushes arriving for one message) showed
that same newest message again. Tom saw one message notify four times
over three hours.

## Behavior

- The extension now remembers which messages it has shown, per community
and account, in its existing notification cache
(`BuzzNotificationCache`).
- Each push shows the newest message not yet shown, across all
communities. If every candidate was already shown, it shows the generic
"You have a new message." text.
- A message more than 15 minutes older than the newest one already
shown, in any configured community (including one whose query failed),
is treated as already covered. So a stale backlog item can't surface
hours later, even from a community where nothing has been shown yet. 15
minutes matches the relay's `created_at` drift bound
(`MAX_TIMESTAMP_DRIFT_SECS`), so a sender with a slow clock still gets
through. A future-dated `created_at` is capped at the time it was shown,
so it can't hold back later messages.
- Selection and claim happen in one locked cache call. It reads each
community's store once and writes only when it records a claim, so
concurrent pushes can't claim against a stale view, and a rejected
candidate never rewrites the store.
- Retention drops only entries the age check would reject anyway. The
rest are capped at the union of the newest 256 by capped time and the
newest 256 by `created_at`. So neither a burst of messages nor
future-dated entries can evict a message the relay still returns. The
field is optional, so existing caches still decode with no version bump.
- Only the newest 10 matches per community are fetched, as before. If
more than 10 messages arrive before their pushes resolve, the extra
pushes show the generic text.
- The claim happens before presentation. If presentation then fails,
that push shows the generic text and the message isn't offered again,
which is better than repeating.
- If the cache can't be read, dedupe is skipped (fail open), so new
messages are never hidden. If a claim can't be written, the new message
is still shown. An already-shown message is still rejected, since
rejecting needs no write. A revoked scope is skipped.
- Dedupe state lives in the extension's `Caches` directory, which iOS
can purge. After a purge, the next push can show the newest message
again, once.

This is the NSE-only fix. It doesn't change the gateway payload or
NIP-PL.

## Validation

- New test `testEachPushShowsANewMessageAndNeverRepeatsOne` uses a fresh
cache instance per push, to model separate extension processes. It
covers: a repeat push giving the generic text; out-of-order resolution
(late, then early, then nothing); a stale message outside the window;
and the future-date clamp. I checked by mutation that removing any one
of the three guards (id check, window, clamp) fails it.
- New test
`testShownMessagesStayShownAcrossCommunitiesFailuresAndBursts` covers
the review findings. It checks that:
- an old message isn't shown from a community with nothing shown yet,
including when the other community's query fails;
- a burst of 100 newer messages doesn't evict a shown mention the relay
still returns;
- a read-only store neither repeats a shown message nor hides a new one;
  - a future-dated message isn't shown again after later messages.
- New test `testFutureDatedEntriesDoNotEvictAClaimedMessage`: 257
future-dated claims don't evict a just-claimed message.
- Mutation checks all fail these tests: a candidate-only watermark,
retention by `created_at` only, a 64-entry cap, and pruning by capped
time.
- Full BuzzPushKit suite on `15913d0`: 106 XCTest and 65 Swift Testing
tests passed.
- Live relay run (on `baac384`): a scratch test drove the real resolver
and cache against a local relay. One key posted messages and another
resolved five pushes (not committed):

  | Push | `main` | This branch |
  | --- | --- | --- |
  | 1 (after "Hello 1") | Hello 1 | Hello 1 |
  | 2 (no new message) | Hello 1 | You have a new message. |
  | 3 (after "Hello 2", "Hello 3") | Hello 3 | Hello 3 |
  | 4 (no new message) | Hello 3 | Hello 2 |
  | 5 (no new message) | Hello 3 | You have a new message. |

- Automated app check: built and launched the branch on an iOS 27
simulator. The app started and showed the signed-in home screen. Only
the extension uses the changed types.
- An independent fresh-context review found no blockers. Its comment
suggestions were applied.

### Limitations

I couldn't run a real APNs push end to end. Dev-signed builds get no
gateway pushes, and `simctl push` on iOS 27 doesn't invoke the
Notification Service Extension. The live relay run covers the resolver
and cache logic the extension runs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:44:07 -07:00
..