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>
This commit is contained in:
Tom Brow
2026-11-03 10:44:07 -08:00
committed by GitHub
parent 40fe4b5d9a
commit 7d40a66126
3 changed files with 351 additions and 70 deletions

View File

@@ -81,8 +81,31 @@ public final class BuzzNotificationCache: @unchecked Sendable {
var metadata: [BuzzNotificationMetadata] = []
var avatars: [BuzzNotificationAvatar] = []
var avatarRequests: [String: String]?
/// Messages already shown in a notification, newest first. Optional so stores
/// written before it existed still decode.
var presented: [PresentedMessage]?
}
private struct PresentedMessage: Codable {
let id: String
/// The event's own `created_at`. Retention follows it, so an entry stays while the
/// relay still ranks the event among the newest results.
let createdAt: Int
/// `createdAt` capped at the time it was shown, so a future-dated event can't hold
/// back later messages.
let watermark: Int
}
/// How far before the newest shown message a message may be and still be shown.
/// Pushes can resolve out of order, and the relay accepts a `created_at` up to 15
/// minutes off (`MAX_TIMESTAMP_DRIFT_SECS` in the relay's ingest handler), so a new
/// message can carry an earlier time than one already shown. Anything older is
/// history, not the message a push is for.
public static let presentationReorderWindow = 15 * 60
/// Retention keeps up to this many entries by `watermark` and as many by `createdAt`,
/// so neither future-dated entries nor a burst of recent ones can evict the other.
static let maximumPresentedMessages = 256
public init(
directory: URL,
now: @escaping () -> Date = Date.init,
@@ -253,6 +276,61 @@ public final class BuzzNotificationCache: @unchecked Sendable {
}
}
/// Claims the first of `candidates`, newest first, that hasn't been shown and isn't
/// older than the newest message shown in any of `scopes` by more than
/// ``presentationReorderWindow``, and returns its index. A push carries no event
/// reference, so without this every push whose own event isn't the newest match would
/// repeat the same message. One lock covers the whole selection, so concurrent pushes
/// can't claim against a stale view, and only a claim writes. A revoked scope is
/// skipped. Broken storage must not hide messages, so a candidate whose store can't
/// be read or written is returned without being recorded.
public func claimFirstUnpresented(
_ candidates: [(event: VerifiedNostrEvent, scope: BuzzNotificationCacheScope)],
scopes: [BuzzNotificationCacheScope]
) throws -> Int? {
try locked {
var shown: [String: Result<[PresentedMessage], Error>] = [:]
func presented(_ scope: BuzzNotificationCacheScope) -> Result<[PresentedMessage], Error> {
if let cached = shown[scope.directoryName] { return cached }
let result = Result { try unlockedAccess(scope) { $0.presented ?? [] } }
shown[scope.directoryName] = result
return result
}
let newestShown = (scopes + candidates.map(\.scope))
.compactMap { try? presented($0).get().map(\.watermark).max() }.max()
let now = Int(now().timeIntervalSince1970)
for (index, candidate) in candidates.enumerated() {
let watermark = min(candidate.event.createdAt, now)
switch presented(candidate.scope) {
case .failure(BuzzNotificationCacheError.revoked):
continue
case .failure(let error):
NSLog("Notification dedupe unavailable: %@", String(describing: error))
return index
case .success(let entries):
guard !entries.contains(where: { $0.id == candidate.event.id }) else { continue }
if let newestShown, watermark < newestShown - Self.presentationReorderWindow {
continue
}
}
do {
try unlockedAccess(candidate.scope, write: true) { state in
state.presented =
[PresentedMessage(
id: candidate.event.id, createdAt: candidate.event.createdAt,
watermark: watermark)] + (state.presented ?? [])
}
} catch BuzzNotificationCacheError.revoked {
continue
} catch {
NSLog("Notification dedupe not recorded: %@", String(describing: error))
}
return index
}
return nil
}
}
/// Deletes abandoned sign-ins on the extension's next execution.
public func removeRevokedScopes(keeping scopes: [BuzzNotificationCacheScope]) throws {
try locked {
@@ -291,53 +369,74 @@ public final class BuzzNotificationCache: @unchecked Sendable {
_ scope: BuzzNotificationCacheScope, write: Bool = false,
_ operation: (inout State) throws -> T
) throws -> T {
try locked {
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
let directory = root.appendingPathComponent(scope.directoryName, isDirectory: true)
let file = directory.appendingPathComponent("metadata.json")
var state = State()
if FileManager.default.fileExists(atPath: file.path) {
let attributes = try file.resourceValues(forKeys: [.fileSizeKey])
guard let size = attributes.fileSize, size <= Self.maximumBytes else {
try FileManager.default.removeItem(at: file)
throw BuzzNotificationCacheError.corruptStore
}
do {
state = try JSONDecoder().decode(State.self, from: Data(contentsOf: file))
} catch {
try FileManager.default.removeItem(at: file)
throw BuzzNotificationCacheError.corruptStore
}
guard state.version == 1 else {
try FileManager.default.removeItem(at: file)
throw BuzzNotificationCacheError.corruptStore
}
try locked { try unlockedAccess(scope, write: write, operation) }
}
/// ``access(_:write:_:)`` for a caller that already holds the lock.
private func unlockedAccess<T>(
_ scope: BuzzNotificationCacheScope, write: Bool = false,
_ operation: (inout State) throws -> T
) throws -> T {
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
let directory = root.appendingPathComponent(scope.directoryName, isDirectory: true)
let file = directory.appendingPathComponent("metadata.json")
var state = State()
if FileManager.default.fileExists(atPath: file.path) {
let attributes = try file.resourceValues(forKeys: [.fileSizeKey])
guard let size = attributes.fileSize, size <= Self.maximumBytes else {
try FileManager.default.removeItem(at: file)
throw BuzzNotificationCacheError.corruptStore
}
let result = try operation(&state)
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
if write {
state.metadata = Array(state.metadata.sorted { $0.checkedAt > $1.checkedAt }.prefix(512))
state.avatars = Array(state.avatars.sorted { $0.savedAt > $1.savedAt }.prefix(128))
let profileAuthors = Set(
state.metadata.filter { $0.event.kind == 0 }.map { $0.event.pubkey })
state.avatarRequests = state.avatarRequests?.filter { profileAuthors.contains($0.key) }
let encoder = JSONEncoder()
var data = try encoder.encode(state)
while data.count > Self.maximumBytes {
if !state.avatars.isEmpty {
state.avatars.removeLast()
} else if !state.metadata.isEmpty {
state.metadata.removeLast()
} else {
throw BuzzNotificationCacheError.oversizedRecord
}
data = try encoder.encode(state)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: file, options: .atomic)
do {
state = try JSONDecoder().decode(State.self, from: Data(contentsOf: file))
} catch {
try FileManager.default.removeItem(at: file)
throw BuzzNotificationCacheError.corruptStore
}
guard state.version == 1 else {
try FileManager.default.removeItem(at: file)
throw BuzzNotificationCacheError.corruptStore
}
return result
}
let result = try operation(&state)
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
if write {
state.metadata = Array(state.metadata.sorted { $0.checkedAt > $1.checkedAt }.prefix(512))
state.avatars = Array(state.avatars.sorted { $0.savedAt > $1.savedAt }.prefix(128))
let profileAuthors = Set(
state.metadata.filter { $0.event.kind == 0 }.map { $0.event.pubkey })
state.avatarRequests = state.avatarRequests?.filter { profileAuthors.contains($0.key) }
if let presented = state.presented, let newest = presented.map(\.watermark).max() {
// The age check rejects anything older than the window, so those entries can
// go. A future-dated entry stays while the relay may still rank it first.
let cutoff = newest - Self.presentationReorderWindow
// Entries are newest claim first, so ties keep the most recent claims.
let current = Array(presented.filter { $0.createdAt >= cutoff }.enumerated())
func newestIDs(by key: (PresentedMessage) -> Int) -> [String] {
current.sorted {
key($0.element) == key($1.element)
? $0.offset < $1.offset : key($0.element) > key($1.element)
}.prefix(Self.maximumPresentedMessages).map(\.element.id)
}
let retained = Set(newestIDs(by: \.watermark)).union(newestIDs(by: \.createdAt))
state.presented = current.map(\.element).filter { retained.contains($0.id) }
}
let encoder = JSONEncoder()
var data = try encoder.encode(state)
while data.count > Self.maximumBytes {
if !state.avatars.isEmpty {
state.avatars.removeLast()
} else if !state.metadata.isEmpty {
state.metadata.removeLast()
} else {
throw BuzzNotificationCacheError.oversizedRecord
}
data = try encoder.encode(state)
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: file, options: .atomic)
}
return result
}
private func locked<T>(_ operation: () throws -> T) throws -> T {

View File

@@ -54,7 +54,7 @@ public protocol BuzzPushNotificationResolving {
func cancel()
}
/// Reads configured Buzz communities and resolves their newest unread event.
/// Reads configured Buzz communities and resolves their newest message not yet shown.
public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
// Each independent refresh has a hard transfer limit, allowing bounded
// content plus JSON escaping, tags, and envelopes.
@@ -108,20 +108,18 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
var candidates: [(VerifiedNostrEvent, PushLeaseCommunity)] = []
for community in communities {
group.enter()
query(community) { candidate in
if let candidate {
lock.lock()
candidates.append(candidate)
lock.unlock()
}
query(community) { messages in
lock.lock()
candidates += messages.map { ($0, community) }
lock.unlock()
group.leave()
}
}
group.notify(queue: .global(qos: .userInitiated)) {
let newest = candidates.max {
$0.0.createdAt == $1.0.createdAt ? $0.0.id > $1.0.id : $0.0.createdAt < $1.0.createdAt
let newestFirst = candidates.sorted {
$0.0.createdAt == $1.0.createdAt ? $0.0.id < $1.0.id : $0.0.createdAt > $1.0.createdAt
}
guard let newest else {
guard let newest = self.firstUnpresented(newestFirst, communities: communities) else {
completion(nil)
return
}
@@ -129,6 +127,33 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
}
}
/// The push doesn't say which event it is for, so take the newest message not yet
/// shown, in any community. Without extension storage, fall back to the newest message.
/// The claim is made before presentation: if presenting then fails or expires, that
/// push shows the generic text and the message isn't offered again, which is better
/// than repeating a message in later notifications.
private func firstUnpresented(
_ newestFirst: [(VerifiedNostrEvent, PushLeaseCommunity)],
communities: [PushLeaseCommunity]
) -> (VerifiedNostrEvent, PushLeaseCommunity)? {
guard let notificationCache else { return newestFirst.first }
let candidates = newestFirst.compactMap { event, community in
BuzzNotificationCacheScope(community: community).map { (candidate: (event, community), scope: $0) }
}
do {
// Every community's history bounds the others, even one whose query failed, so a
// community with nothing shown yet can't surface its old messages.
return try notificationCache.claimFirstUnpresented(
candidates.map { (event: $0.candidate.0, scope: $0.scope) },
scopes: communities.compactMap(BuzzNotificationCacheScope.init(community:))
).map { candidates[$0].candidate }
} catch {
// Unusable storage must not hide new messages; it can only fail to dedupe them.
NSLog("Notification dedupe unavailable: %@", String(describing: error))
return candidates.first?.candidate
}
}
private func matchingPrivateKey(for community: PushLeaseCommunity) -> String? {
guard let scope = BuzzNotificationCacheScope(community: community),
loadCommunities().contains(where: { BuzzNotificationCacheScope(community: $0) == scope })
@@ -147,10 +172,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
private func query(
_ community: PushLeaseCommunity,
completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void
completion: @escaping ([VerifiedNostrEvent]) -> Void
) {
guard let privateKey = accountPrivateKey(for: community), community.pubkey?.isEmpty == false else {
completion(nil)
completion([])
return
}
guard
@@ -161,7 +186,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
withJSONObject: community.policies.map { $0.filter.queryFilter(since: nil, limit: 10) }
)
else {
completion(nil)
completion([])
return
}
send(
@@ -172,19 +197,19 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else {
completion(nil)
completion([])
return
}
let candidate = Self.newestMessage(
events: events.filter { event in
event.hasValidIDAndSignature()
&& community.policies.contains { policy in
PushLeaseMatcher.matches(event: event, policy: policy)
}
},
community: community
)
completion(candidate.map { ($0, community) })
completion(
Self.messages(
events: events.filter { event in
event.hasValidIDAndSignature()
&& community.policies.contains { policy in
PushLeaseMatcher.matches(event: event, policy: policy)
}
},
community: community
))
}
}
@@ -382,12 +407,20 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
events: [VerifiedNostrEvent],
community: PushLeaseCommunity
) -> VerifiedNostrEvent? {
guard let mine = community.pubkey?.lowercased() else { return nil }
messages(events: events, community: community).first
}
/// Messages from others that a notification can show, newest first.
private static func messages(
events: [VerifiedNostrEvent],
community: PushLeaseCommunity
) -> [VerifiedNostrEvent] {
guard let mine = community.pubkey?.lowercased() else { return [] }
return events.filter {
$0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind)
}.sorted {
$0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt
}.first
}
}
static func makeResolution(

View File

@@ -386,6 +386,155 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
)
}
/// A push carries no event reference. Each push must show a message not shown before,
/// including after the extension process restarts, instead of the newest match again.
func testEachPushShowsANewMessageAndNeverRepeatsOne() throws {
let snapshot = try snapshotData([community()])
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community()))
let now = Date(timeIntervalSince1970: TimeInterval(Self.now))
// A fresh cache instance per push models a fresh extension process.
func push() -> String? {
resolve(makeResolver(communitiesData: snapshot,
cacheOverride: BuzzNotificationCache(directory: root, now: { now }, isCurrent: { $0 == scope }),
now: now))?.body
}
var relayEvents: [VerifiedNostrEvent] = []
URLProtocolStub.handler = { request in
Self.response(request, status: 200, data: try JSONEncoder().encode(relayEvents))
}
func message(_ content: String, secondsAgo: Int) throws -> VerifiedNostrEvent {
try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: Self.now - secondsAgo,
kind: 9, tags: [["h", Self.channelID]], content: content)
}
let window = BuzzNotificationCache.presentationReorderWindow
let history = try message("Last week", secondsAgo: 7 * 24 * 3600)
let first = try message("First", secondsAgo: 3600)
relayEvents = [history, first]
XCTAssertEqual(push(), "First", "with nothing shown yet, the newest message")
XCTAssertNil(push(), "a push for a non-message event must not repeat it")
// Two messages whose pushes resolve out of order: the later push wins the first
// resolution, so the earlier push must still find its own message.
let early = try message("Early", secondsAgo: 60)
let late = try message("Late", secondsAgo: 59)
relayEvents = [late, early, first, history]
XCTAssertEqual(push(), "Late")
XCTAssertEqual(push(), "Early")
XCTAssertNil(push())
// Older history is never surfaced by a later push.
let stale = try message("Stale", secondsAgo: 59 + window + 1)
relayEvents = [late, early, stale, first, history]
XCTAssertNil(push(), "a message older than the reorder window is history")
// A future-dated message is shown once and doesn't hide messages that follow it.
let future = try message("Future", secondsAgo: -3600)
relayEvents = [future, late]
XCTAssertEqual(push(), "Future")
let next = try message("Next", secondsAgo: 0)
relayEvents = [future, next, late]
XCTAssertEqual(push(), "Next")
XCTAssertNil(push())
}
func testShownMessagesStayShownAcrossCommunitiesFailuresAndBursts() throws {
let a = community(id: "community-a", relayUrl: "https://a.example")
let b = community(id: "community-b", relayUrl: "https://b.example")
let snapshot = try snapshotData([a, b])
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
let scopes = [a, b].compactMap { BuzzNotificationCacheScope(community: $0) }
var clock = Self.now
func push() -> String? {
let now = Date(timeIntervalSince1970: TimeInterval(clock))
return resolve(makeResolver(communitiesData: snapshot,
privateKeys: ["community-a": Self.privateKey, "community-b": Self.privateKey],
cacheOverride: BuzzNotificationCache(
directory: root, now: { now }, isCurrent: { scopes.contains($0) }),
now: now))?.body
}
var relayEvents: [String: [VerifiedNostrEvent]] = [:]
var failingHosts: Set<String> = []
URLProtocolStub.handler = { request in
let host = request.url?.host ?? ""
return Self.response(request, status: failingHosts.contains(host) ? 500 : 200,
data: try JSONEncoder().encode(relayEvents[host] ?? []))
}
func message(_ content: String, createdAt: Int) throws -> VerifiedNostrEvent {
try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: createdAt,
kind: 9, tags: [["h", Self.channelID]], content: content)
}
// B has shown nothing yet, but A's shown message still marks B's last week as
// history, even when A's query fails.
let mention = try message("A mention", createdAt: clock - 60)
relayEvents["a.example"] = [mention]
relayEvents["b.example"] = [try message("B last week", createdAt: clock - 7 * 24 * 3600)]
XCTAssertEqual(push(), "A mention")
XCTAssertNil(push(), "an old message in another community is not new")
failingHosts = ["a.example"]
XCTAssertNil(push(), "a failed query doesn't drop its community's history")
failingHosts = []
// A burst of newer messages doesn't evict one the relay still returns.
var newest = mention
for index in 0..<100 {
clock += 1
newest = try message("Burst \(index)", createdAt: clock)
relayEvents["a.example"] = [newest, mention]
XCTAssertEqual(push(), "Burst \(index)")
}
XCTAssertNil(push(), "a shown message is not shown again after a burst")
// A store that can't be written still remembers what it already shows.
let directory = root.appendingPathComponent(scopes[0].directoryName, isDirectory: true)
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory.path)
addTeardownBlock {
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path)
}
XCTAssertNil(push(), "a read-only store does not repeat a shown message")
relayEvents["a.example"] = [try message("Unrecorded", createdAt: clock), newest, mention]
XCTAssertEqual(push(), "Unrecorded", "an unwritable store never hides a new message")
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path)
// A future-dated message stays remembered while the relay keeps returning it first,
// even after later messages push its capped time out of the window.
let future = try message("Future", createdAt: clock + 3600)
relayEvents["a.example"] = [future]
XCTAssertEqual(push(), "Future")
for index in 0..<40 {
clock += 30
relayEvents["a.example"] = [future, try message("Later \(index)", createdAt: clock)]
XCTAssertEqual(push(), "Later \(index)")
}
XCTAssertNil(push(), "a future-dated message is not shown again")
}
func testFutureDatedEntriesDoNotEvictAClaimedMessage() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community()))
let cache = BuzzNotificationCache(
directory: root, now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) },
isCurrent: { $0 == scope })
func claim(_ event: VerifiedNostrEvent) throws -> Bool {
try cache.claimFirstUnpresented([(event: event, scope: scope)], scopes: [scope]) == 0
}
for index in 0..<(BuzzNotificationCache.maximumPresentedMessages + 1) {
let ahead = try Self.signedEvent(privateKey: Self.profilePrivateKey,
createdAt: Self.now + 600 + index, kind: 9, tags: [["h", Self.channelID]],
content: "Ahead \(index)")
XCTAssertTrue(try claim(ahead))
}
let current = try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: Self.now,
kind: 9, tags: [["h", Self.channelID]], content: "Current")
XCTAssertTrue(try claim(current))
XCTAssertFalse(try claim(current), "a claimed message survives retention")
}
func testFreshVerifiedCacheResolvesSenderAvatarAndChannelWithoutRefresh() throws {
let message = try Self.signedEvent(
privateKey: Self.profilePrivateKey,