mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 (#1418)
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed, well-scoped findings; deeper architectural/security items are tracked separately. - PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s 150ms retry pause left the mic live and streaming for up to 120s, because cancel() no-op'd once `completed` was set. Bail after the sleep if the hold was released, and make cancel() always tear down a late-started capture. - Bridge courier depositDrop reported success and burned the dedup slot before the drop was actually published (evicted/compose-fail = lying 📦 "carried" with no retry). Only consume publishedDropKeys on durable accept; add BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey). - Blocked senders resurfaced via archived "heard here earlier" echoes, the one path that bypassed the live block filter — filter at seed time. - A late optimistic .sent clobbered the router's .carried state; extend ConversationStore.shouldSkipStatusUpdate to a full precedence guard (sending < sent < carried < delivered < read). - Read receipts were permanently burned when the router dropped them (marked sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only record as sent on a successful route, else retry on the next read scan. - MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a peer that never reconnects sat on .sending until relaunch — run it in the 120s bridge sweep. - Sightings tally now rolls over at midnight while idle; wave notification action localized across all 29 locales; bridged anon#tag uses suffix(4) like everything else; makeThrowawayIdentity delegates to NostrIdentity.generate(); .swiftlint.yml excludes .claude worktrees. - Add regression tests: carried→sent no-downgrade, carried→delivered upgrade, evicted pending drop stays retryable. - Bump MARKETING_VERSION to 1.7.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage The stricter no-downgrade guard (delivered/carried never regress to sent) broke two things the earlier commit didn't catch locally (perf tests are skipped in the default run, and Periphery runs only in CI): - PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered assuming both directions apply; the delivered -> sent half is now correctly skipped, so the pass measured 0 updates. Alternate two delivered timestamps instead — every update is real, no downgrade. - Routing the geoDM "not in a location channel" error into the thread removed the only caller of ChatPrivateConversationContext.addSystemMessage, leaving it (and its mock) dead per Periphery. Drop the protocol requirement, the mock impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is compile-time enforced: the context can no longer emit a public system line). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge - Read receipts: claim the receipt in sentReadReceipts synchronously before spawning the routing task (chat open runs two read scans in one MainActor stretch, so the async insert let every unread message route twice), and release the claim when the route fails so the retry-on-failed-route behavior is preserved. - Delivery status: extend the no-downgrade guard so the `.sending` stamp a pre-handshake resend emits can no longer clobber carried/delivered/read (the 📦 indicator survived `.sent` but not `.sending`). - Archived echoes: blocking a peer now purges their carried public messages from the gossip archive at block time (UnifiedPeerService and /block), while the fingerprint-to-peerID mapping is still known — the seed-time filter can't resolve offline non-favorite strangers and stays only as defense-in-depth. New Transport hook (default no-op) + GossipSyncManager.removePublicMessages with immediate persist. - Bridge courier: an envelope that can't encode within the drop size caps fails identically on every attempt; consume the dedup slot so the 120s retry sweep stops re-running Noise sealing on it. - MeshSightingsTracker: cache the day-key DateFormatter instead of building one per call. Tests: double-markAsRead dedup + failed-route retry, carried→sending no-downgrade matrix, block-time purge (manager + service wiring), oversize-drop slot consumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Also skip the sent→sending downgrade in the delivery-status guard Codex review follow-up: sendPrivateMessage without an established Noise session emits `.sending` asynchronously, so it can land after the message already reached `.sent` and visibly walk "Sent" back to "Sending...". Treat `.sending` as weaker than `.sent` too — the status was already truthful. `.failed` → `.sending` stays allowed so a retry after a real failure remains visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Retain Codable properties in Periphery scan (same fix as #1421) The noiseKey assign-only false positive fired persistently on this branch (twice, including a rerun) despite the baselined USR. Byte-identical to the fix on fix/announce-replay-link-steal so the branches merge cleanly in either order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Opus 4.8
parent
021af3a22d
commit
dd6b624cae
@@ -106,8 +106,10 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
routedPrivateMessages.append((content, peerID, recipientNickname))
|
||||
}
|
||||
|
||||
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
var routeReadReceiptResult = true
|
||||
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool {
|
||||
routedReadReceipts.append((receipt.originalMessageID, peerID))
|
||||
return routeReadReceiptResult
|
||||
}
|
||||
|
||||
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
|
||||
@@ -181,8 +181,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
routedPrivateMessages.append((content, peerID, messageID))
|
||||
}
|
||||
|
||||
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
var routeReadReceiptResult = true
|
||||
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool {
|
||||
routedReadReceipts.append((receipt.originalMessageID, peerID))
|
||||
return routeReadReceiptResult
|
||||
}
|
||||
|
||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
@@ -223,13 +225,8 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
}
|
||||
|
||||
// System messages
|
||||
private(set) var systemMessages: [String] = []
|
||||
private(set) var meshOnlySystemMessages: [String] = []
|
||||
|
||||
func addSystemMessage(_ content: String) {
|
||||
systemMessages.append(content)
|
||||
}
|
||||
|
||||
func addMeshOnlySystemMessage(_ content: String) {
|
||||
meshOnlySystemMessages.append(content)
|
||||
}
|
||||
@@ -657,7 +654,6 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.routedPrivateMessages.map(\.content) == ["hello bob"])
|
||||
#expect(context.privateChats[peerID]?.first?.deliveryStatus == .sent)
|
||||
#expect(context.privateChats[peerID]?.first?.recipientNickname == "bob")
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
}
|
||||
|
||||
/// Same as above, but the conversation is keyed by the SHORT mesh ID —
|
||||
@@ -683,7 +679,6 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.routedPrivateMessages.map(\.content) == ["hello again"])
|
||||
#expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent)
|
||||
#expect(context.privateChats[shortID]?.first?.recipientNickname == "bob")
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
}
|
||||
|
||||
/// Field-found: pre-judging reachability here marked the message failed
|
||||
@@ -701,6 +696,5 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
|
||||
#expect(context.routedPrivateMessages.map(\.content) == ["hello?"])
|
||||
#expect(context.privateChats[peerID]?.first?.deliveryStatus == .sending)
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,105 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_noDowngrade_carriedToSent() async {
|
||||
// Regression: the optimistic `.sent` stamp the send path writes after
|
||||
// routing must not clobber the `.carried` the router already set when
|
||||
// it handed a copy to a courier/bridge (store-and-forward), or the
|
||||
// offline-favorite flow shows "sent" instead of 📦 carried.
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-carried"
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .carried
|
||||
)
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .carried = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_noDowngrade_carriedToSending() async {
|
||||
// Regression: a pre-handshake resend stamps `.sending`; it must not
|
||||
// wipe the 📦 carried indicator (nor a delivered/read ack).
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-carried-sending"
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .carried
|
||||
)
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sending)
|
||||
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .carried = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
|
||||
#expect(Conversation.shouldSkipStatusUpdate(current: .delivered(to: "Peer", at: Date()), new: .sending))
|
||||
#expect(Conversation.shouldSkipStatusUpdate(current: .read(by: "Peer", at: Date()), new: .sending))
|
||||
// A late async `.sending` (pre-handshake resend) must not visibly
|
||||
// downgrade a truthful "Sent" either...
|
||||
#expect(Conversation.shouldSkipStatusUpdate(current: .sent, new: .sending))
|
||||
// ...but a retry after a real failure stays visible.
|
||||
#expect(!Conversation.shouldSkipStatusUpdate(current: .failed(reason: "no route"), new: .sending))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_carriedToDelivered() async {
|
||||
// A delivery ack must still promote a carried message.
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "test-msg-carried-delivered"
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .carried
|
||||
)
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
|
||||
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
||||
#expect({
|
||||
if case .delivered = currentStatus { return true }
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_upgrade_sentToDelivered() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
@@ -761,9 +761,11 @@ struct ChatViewModelGeoDMTests {
|
||||
|
||||
viewModel.sendGeohashDM("hello", to: convKey)
|
||||
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
#expect(viewModel.messages.count == 1)
|
||||
#expect(viewModel.messages.last?.sender == "system")
|
||||
// The failure is surfaced inside the geoDM thread, not on the public
|
||||
// timeline (matches the sibling in-thread errors from #1415).
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
#expect(viewModel.privateChats[convKey]?.count == 1)
|
||||
#expect(viewModel.privateChats[convKey]?.last?.sender == "system")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -91,6 +91,47 @@ struct GossipSyncManagerTests {
|
||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
||||
}
|
||||
|
||||
@Test func removePublicMessagesPurgesOnlyThatSender() throws {
|
||||
// Block-time archive hygiene: purging a blocked sender's carried
|
||||
// public messages must not touch other senders' messages or the
|
||||
// blocked sender's announcement.
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager)
|
||||
let blockedHex = "00112233445566aa"
|
||||
let otherHex = "00112233445566bb"
|
||||
let blockedData = try #require(Data(hexString: blockedHex))
|
||||
let otherData = try #require(Data(hexString: otherHex))
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
manager.onPublicPacketSeen(BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: blockedData,
|
||||
recipientID: nil,
|
||||
timestamp: nowMs,
|
||||
payload: Data(),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
))
|
||||
for (index, sender) in [blockedData, blockedData, otherData].enumerated() {
|
||||
manager.onPublicPacketSeen(BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: nowMs + UInt64(index),
|
||||
payload: Data([UInt8(index)]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
))
|
||||
}
|
||||
#expect(manager._messageCount(for: PeerID(str: blockedHex)) == 2)
|
||||
|
||||
manager.removePublicMessages(from: PeerID(str: blockedHex))
|
||||
|
||||
#expect(manager._messageCount(for: PeerID(str: blockedHex)) == 0)
|
||||
#expect(manager._messageCount(for: PeerID(str: otherHex)) == 1)
|
||||
#expect(manager._hasAnnouncement(for: PeerID(str: blockedHex)))
|
||||
}
|
||||
|
||||
@Test func ignoresAnnounceOlderThanStaleTimeout() throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
|
||||
@@ -45,6 +45,7 @@ final class MockTransport: Transport {
|
||||
private(set) var emergencyDisconnectCallCount = 0
|
||||
private(set) var broadcastAnnounceCallCount = 0
|
||||
private(set) var triggeredHandshakes: [PeerID] = []
|
||||
private(set) var purgedArchivePeers: [PeerID] = []
|
||||
|
||||
// MARK: - Configurable Mock State
|
||||
|
||||
@@ -107,6 +108,10 @@ final class MockTransport: Transport {
|
||||
triggeredHandshakes.append(peerID)
|
||||
}
|
||||
|
||||
func purgeArchivedPublicMessages(from peerID: PeerID) {
|
||||
purgedArchivePeers.append(peerID)
|
||||
}
|
||||
|
||||
// Noise identity wrappers backed by a mock-keychain encryption service
|
||||
// (mirrors the previous `getNoiseService()` placeholder behavior: a real
|
||||
// identity, but no peer sessions). Exposed so tests can assert against
|
||||
|
||||
@@ -225,8 +225,9 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
/// `ConversationStore`'s message-ID → conversation map: 2000 public
|
||||
/// (split mesh + geohash to stay under the per-conversation cap) + 50x40
|
||||
/// private messages, 500 status updates per pass. Statuses alternate
|
||||
/// sent <-> delivered so every call performs a real update (never the
|
||||
/// skip path).
|
||||
/// between two `delivered` timestamps so every call performs a real update
|
||||
/// (never the skip path). A sent <-> delivered alternation would now hit
|
||||
/// the store's no-downgrade guard on the delivered -> sent half.
|
||||
func testDeliveryStatusIncrementalUpdates() {
|
||||
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
@@ -234,12 +235,13 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
XCTAssertEqual(targetIDs.count, 500)
|
||||
|
||||
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let fixedDate2 = Date(timeIntervalSince1970: 1_700_000_001)
|
||||
var toggle = false
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
toggle.toggle()
|
||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .delivered(to: "peer", at: fixedDate2)
|
||||
let start = Date()
|
||||
var updated = 0
|
||||
for id in targetIDs where coordinator.updateMessageDeliveryStatus(id, status: status) {
|
||||
@@ -267,13 +269,16 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
||||
XCTAssertEqual(targetIDs.count, 500)
|
||||
|
||||
// Alternate two delivered timestamps so every update is real; a
|
||||
// sent <-> delivered swing would hit the no-downgrade guard.
|
||||
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let fixedDate2 = Date(timeIntervalSince1970: 1_700_000_001)
|
||||
var toggle = false
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
toggle.toggle()
|
||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .delivered(to: "peer", at: fixedDate2)
|
||||
let start = Date()
|
||||
var updated = 0
|
||||
for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) {
|
||||
|
||||
@@ -133,6 +133,48 @@ struct BridgeCourierServiceTests {
|
||||
#expect(fixture.service.pendingDrops.isEmpty)
|
||||
}
|
||||
|
||||
@Test func evictedPendingDropStaysRetryable() {
|
||||
// Regression: a drop queued while relays are down but then evicted
|
||||
// (oldest-out at capacity) before it ever published must release its
|
||||
// sender-side dedup slot, or the router marks it "carried" and can
|
||||
// never re-deposit it.
|
||||
let fixture = Fixture()
|
||||
fixture.relaysConnected = false
|
||||
let key = Fixture.randomKey()
|
||||
fixture.sealResult = makeEnvelope(recipientKey: key)
|
||||
|
||||
let firstID = UUID().uuidString
|
||||
#expect(fixture.service.depositDrop(content: "0", messageID: firstID, recipientNoiseKey: key))
|
||||
// Fill past capacity so the first drop is evicted.
|
||||
for i in 1...BridgeCourierService.Limits.maxPendingDrops {
|
||||
fixture.service.depositDrop(content: "\(i)", messageID: UUID().uuidString, recipientNoiseKey: key)
|
||||
}
|
||||
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
|
||||
|
||||
// The evicted first drop is deposit-able again (slot released).
|
||||
#expect(fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key))
|
||||
}
|
||||
|
||||
@Test func oversizeDropConsumesSlotInsteadOfChurning() {
|
||||
// An envelope that encodes over the size cap fails identically on
|
||||
// every attempt; the dedup slot must be consumed so the retry sweep
|
||||
// doesn't re-run Noise sealing forever.
|
||||
let fixture = Fixture()
|
||||
let key = Fixture.randomKey()
|
||||
fixture.sealResult = makeEnvelope(
|
||||
recipientKey: key,
|
||||
ciphertext: Data(repeating: 7, count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1)
|
||||
)
|
||||
let messageID = UUID().uuidString
|
||||
|
||||
#expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key))
|
||||
#expect(fixture.publishedEvents.isEmpty)
|
||||
|
||||
// The retry sweep must not seal the same payload again.
|
||||
#expect(!fixture.service.depositDrop(content: "big", messageID: messageID, recipientNoiseKey: key))
|
||||
#expect(fixture.sealRequests.count == 1)
|
||||
}
|
||||
|
||||
@Test func distinctDropsUseDistinctThrowawayKeys() {
|
||||
let fixture = Fixture()
|
||||
let keyA = Fixture.randomKey()
|
||||
|
||||
@@ -494,7 +494,7 @@ struct BridgeServiceTests {
|
||||
"kind": event.kind,
|
||||
"tags": event.tags,
|
||||
"content": event.content + " (tampered)",
|
||||
"sig": event.sig ?? "",
|
||||
"sig": event.sig ?? ""
|
||||
]
|
||||
let forged = try NostrEvent(from: dict)
|
||||
|
||||
|
||||
@@ -108,6 +108,76 @@ struct PrivateChatManagerTests {
|
||||
#expect(transport.sentReadReceipts.first?.receipt.originalMessageID == "pm-fallback")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markAsRead_calledTwiceSynchronously_routesOneReceiptPerMessage() async {
|
||||
// Regression: opening a chat runs two read scans in the same
|
||||
// synchronous MainActor stretch (beginPrivateChatSession and
|
||||
// markPrivateMessagesAsRead). The receipt must be claimed before the
|
||||
// routing task gets a chance to run, or both scans route a copy.
|
||||
let transport = MockTransport()
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let (manager, store) = Self.makeManager(transport: transport)
|
||||
manager.messageRouter = router
|
||||
|
||||
let peerID = PeerID(str: "00000000000000DE")
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "pm-double",
|
||||
sender: "Peer",
|
||||
content: "Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
),
|
||||
to: .directPeer(peerID)
|
||||
)
|
||||
store.markUnread(.directPeer(peerID))
|
||||
|
||||
manager.markAsRead(from: peerID)
|
||||
manager.markAsRead(from: peerID)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(transport.sentReadReceipts.count == 1)
|
||||
#expect(manager.sentReadReceipts.contains("pm-double"))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func markAsRead_failedRouteReleasesClaimForRetry() async {
|
||||
// No reachable transport: the receipt is not sent, and the eager
|
||||
// claim must be released so a later read scan retries.
|
||||
let transport = MockTransport()
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let (manager, store) = Self.makeManager(transport: transport)
|
||||
manager.messageRouter = router
|
||||
|
||||
let peerID = PeerID(str: "00000000000000DF")
|
||||
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "pm-unroutable",
|
||||
sender: "Peer",
|
||||
content: "Hi",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
),
|
||||
to: .directPeer(peerID)
|
||||
)
|
||||
store.markUnread(.directPeer(peerID))
|
||||
|
||||
manager.markAsRead(from: peerID)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(transport.sentReadReceipts.isEmpty)
|
||||
#expect(!manager.sentReadReceipts.contains("pm-unroutable"))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async {
|
||||
let transport = MockTransport()
|
||||
|
||||
@@ -53,17 +53,22 @@ struct UnifiedPeerServiceTests {
|
||||
let fingerprint = "fp-target"
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
|
||||
// Blocking resolves and persists by the peer's fingerprint.
|
||||
// Blocking resolves and persists by the peer's fingerprint, and
|
||||
// scrubs the peer's carried public messages from the gossip archive
|
||||
// while the fingerprint↔peerID mapping is still known (the
|
||||
// archived-echo seed filter can't resolve offline strangers).
|
||||
let resolved = service.setBlocked(peerID, blocked: true)
|
||||
#expect(resolved == fingerprint)
|
||||
#expect(identity.isBlocked(fingerprint: fingerprint))
|
||||
#expect(service.isBlocked(peerID))
|
||||
#expect(transport.purgedArchivePeers == [peerID])
|
||||
|
||||
// Unblocking clears it against the same identity.
|
||||
// Unblocking clears it against the same identity, without purging.
|
||||
let unresolved = service.setBlocked(peerID, blocked: false)
|
||||
#expect(unresolved == fingerprint)
|
||||
#expect(!identity.isBlocked(fingerprint: fingerprint))
|
||||
#expect(!service.isBlocked(peerID))
|
||||
#expect(transport.purgedArchivePeers == [peerID])
|
||||
}
|
||||
|
||||
// MARK: - Offline-favorite dedup (updatePeers phase 2)
|
||||
|
||||
Reference in New Issue
Block a user