mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
* 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>
368 lines
15 KiB
Swift
368 lines
15 KiB
Swift
//
|
|
// ChatLifecycleCoordinatorContextTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Exercises `ChatLifecycleCoordinator` against a mock `ChatLifecycleContext`
|
|
// — proving the coordinator works without a `ChatViewModel`, following the
|
|
// `ChatDeliveryCoordinatorContextTests` /
|
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
|
//
|
|
// Scope note: the geohash-screenshot branch publishes via
|
|
// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered
|
|
// by the full view-model tests. The GeoDM read pass, the favorites-backed
|
|
// mesh/Nostr read-receipt branch (favorites are injected through the
|
|
// context), message merging, screenshot notices, and lifecycle persistence
|
|
// flows are covered here.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
// MARK: - Mock Context
|
|
|
|
/// Lightweight stand-in for `ChatLifecycleContext` proving that
|
|
/// `ChatLifecycleCoordinator` is testable without a `ChatViewModel`.
|
|
@MainActor
|
|
private final class MockChatLifecycleContext: ChatLifecycleContext {
|
|
// Chat & receipt state
|
|
var messages: [BitchatMessage] = []
|
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
|
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
|
privateChats[peerID] ?? []
|
|
}
|
|
var unreadPrivateMessages: Set<PeerID> = []
|
|
var selectedPrivateChatPeer: PeerID?
|
|
var sentReadReceipts: Set<String> = []
|
|
var nickname = "me"
|
|
var myPeerID = PeerID(str: "0011223344556677")
|
|
var activeChannel: ChannelID = .mesh
|
|
var nostrKeyMapping: [PeerID: String] = [:]
|
|
private(set) var ownerLevelReadPasses: [PeerID] = []
|
|
private(set) var managerReadMarks: [PeerID] = []
|
|
private(set) var systemMessages: [String] = []
|
|
|
|
// Conversation store intents
|
|
@discardableResult
|
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
|
|
var chat = privateChats[peerID] ?? []
|
|
guard !chat.contains(where: { $0.id == message.id }) else { return false }
|
|
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
|
|
chat.insert(message, at: index)
|
|
privateChats[peerID] = chat
|
|
return true
|
|
}
|
|
|
|
func markPrivateChatRead(_ peerID: PeerID) {
|
|
unreadPrivateMessages.remove(peerID)
|
|
}
|
|
|
|
@discardableResult
|
|
func markReadReceiptSent(_ messageID: String) -> Bool {
|
|
sentReadReceipts.insert(messageID).inserted
|
|
}
|
|
|
|
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
|
ownerLevelReadPasses.append(peerID)
|
|
}
|
|
|
|
func markChatAsRead(from peerID: PeerID) {
|
|
managerReadMarks.append(peerID)
|
|
}
|
|
|
|
// Scheduled work runs synchronously so tests never poll wall-clock queues.
|
|
private(set) var scheduledDelays: [TimeInterval] = []
|
|
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
|
|
scheduledDelays.append(delay)
|
|
work()
|
|
}
|
|
|
|
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
|
|
|
// Peers & sessions
|
|
var nicknamesByPeerID: [PeerID: String] = [:]
|
|
var peersByID: [PeerID: BitchatPeer] = [:]
|
|
var noiseSessionStates: [PeerID: LazyHandshakeState] = [:]
|
|
private(set) var stopMeshServicesCount = 0
|
|
private(set) var refreshBluetoothStateCount = 0
|
|
|
|
func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] }
|
|
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
|
|
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
|
|
noiseSessionStates[peerID] ?? .none
|
|
}
|
|
func stopMeshServices() { stopMeshServicesCount += 1 }
|
|
func refreshBluetoothState() { refreshBluetoothStateCount += 1 }
|
|
|
|
// Routing & receipts
|
|
private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = []
|
|
private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = []
|
|
private(set) var meshBroadcasts: [String] = []
|
|
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
|
|
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
|
routedPrivateMessages.append((content, peerID, recipientNickname))
|
|
}
|
|
|
|
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) {
|
|
meshBroadcasts.append(content)
|
|
}
|
|
|
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
geoReadReceipts.append((messageID, recipientHex))
|
|
}
|
|
|
|
// Nostr & geohash
|
|
var isTeleported = false
|
|
private(set) var recordedGeoParticipants: [String] = []
|
|
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
|
|
func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) }
|
|
|
|
// Favorites
|
|
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
|
|
|
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
|
|
favoriteRelationshipsByNoiseKey[noiseKey]
|
|
}
|
|
|
|
// Identity persistence
|
|
private(set) var forceSaveIdentityCount = 0
|
|
private(set) var verifyIdentityKeyExistsCount = 0
|
|
|
|
func forceSaveIdentity() { forceSaveIdentityCount += 1 }
|
|
|
|
@discardableResult
|
|
func verifyIdentityKeyExists() -> Bool {
|
|
verifyIdentityKeyExistsCount += 1
|
|
return true
|
|
}
|
|
|
|
static let dummyIdentity = NostrIdentity(
|
|
privateKey: Data(repeating: 0x11, count: 32),
|
|
publicKey: Data(repeating: 0x22, count: 32),
|
|
npub: "npub1mock",
|
|
createdAt: Date(timeIntervalSince1970: 0)
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func makeFavoriteRelationship(
|
|
noiseKey: Data,
|
|
nostrPublicKey: String? = nil,
|
|
nickname: String = "alice",
|
|
isFavorite: Bool = false,
|
|
theyFavoritedUs: Bool = false
|
|
) -> FavoritesPersistenceService.FavoriteRelationship {
|
|
FavoritesPersistenceService.FavoriteRelationship(
|
|
peerNoisePublicKey: noiseKey,
|
|
peerNostrPublicKey: nostrPublicKey,
|
|
peerNickname: nickname,
|
|
isFavorite: isFavorite,
|
|
theyFavoritedUs: theyFavoritedUs,
|
|
favoritedAt: Date(timeIntervalSince1970: 0),
|
|
lastUpdated: Date(timeIntervalSince1970: 0)
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
private func makePrivateMessage(
|
|
id: String,
|
|
sender: String = "alice",
|
|
timestamp: Date = Date(),
|
|
senderPeerID: PeerID? = nil,
|
|
isRelay: Bool = false,
|
|
deliveryStatus: DeliveryStatus? = nil
|
|
) -> BitchatMessage {
|
|
BitchatMessage(
|
|
id: id,
|
|
sender: sender,
|
|
content: "hello",
|
|
timestamp: timestamp,
|
|
isRelay: isRelay,
|
|
isPrivate: true,
|
|
recipientNickname: "me",
|
|
senderPeerID: senderPeerID,
|
|
deliveryStatus: deliveryStatus
|
|
)
|
|
}
|
|
|
|
// MARK: - Coordinator Tests Against Mock Context
|
|
|
|
/// Exercises `ChatLifecycleCoordinator` against `MockChatLifecycleContext`
|
|
/// with no `ChatViewModel`.
|
|
struct ChatLifecycleCoordinatorContextTests {
|
|
|
|
@Test @MainActor
|
|
func getPrivateChatMessages_mergesEphemeralAndStableKeepingBestStatus() async {
|
|
let context = MockChatLifecycleContext()
|
|
let coordinator = ChatLifecycleCoordinator(context: context)
|
|
let peerID = PeerID(str: "1122334455667788")
|
|
let noiseKey = Data(repeating: 0xAB, count: 32)
|
|
let stablePeerID = PeerID(hexData: noiseKey)
|
|
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
|
|
|
|
let t1 = Date(timeIntervalSince1970: 1)
|
|
let t2 = Date(timeIntervalSince1970: 2)
|
|
// Same message under both keys: the read copy must win over sent.
|
|
context.privateChats[peerID] = [
|
|
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
|
|
makePrivateMessage(id: "m2", timestamp: t2)
|
|
]
|
|
context.privateChats[stablePeerID] = [
|
|
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2))
|
|
]
|
|
|
|
let merged = coordinator.getPrivateChatMessages(for: peerID)
|
|
#expect(merged.map(\.id) == ["m1", "m2"])
|
|
if case .read? = merged.first?.deliveryStatus {
|
|
} else {
|
|
Issue.record("expected the .read copy of m1 to win the merge")
|
|
}
|
|
|
|
// getMessages(for: nil) falls back to the public timeline.
|
|
context.messages = [makePrivateMessage(id: "pub")]
|
|
#expect(coordinator.getMessages(for: nil).map(\.id) == ["pub"])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func markPrivateMessagesAsRead_geoDM_sendsReadReceiptsOnce() async {
|
|
let context = MockChatLifecycleContext()
|
|
let coordinator = ChatLifecycleCoordinator(context: context)
|
|
let convKey = PeerID(nostr_: "feedface00112233")
|
|
let recipientHex = "feedface00112233"
|
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
|
|
context.nostrKeyMapping[convKey] = recipientHex
|
|
context.sentReadReceipts = ["already-acked"]
|
|
context.privateChats[convKey] = [
|
|
makePrivateMessage(id: "m1", senderPeerID: convKey),
|
|
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
|
|
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
|
|
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID)
|
|
]
|
|
|
|
coordinator.markPrivateMessagesAsRead(from: convKey)
|
|
|
|
#expect(context.managerReadMarks == [convKey])
|
|
// Only the peer's own un-acked, non-relay message gets a READ.
|
|
#expect(context.geoReadReceipts.map(\.messageID) == ["m1"])
|
|
#expect(context.geoReadReceipts.first?.recipientHex == recipientHex)
|
|
#expect(context.sentReadReceipts.contains("m1"))
|
|
|
|
// Second pass: nothing new to send.
|
|
coordinator.markPrivateMessagesAsRead(from: convKey)
|
|
#expect(context.geoReadReceipts.count == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async {
|
|
let context = MockChatLifecycleContext()
|
|
let coordinator = ChatLifecycleCoordinator(context: context)
|
|
let peerID = PeerID(str: "1122334455667788")
|
|
context.selectedPrivateChatPeer = peerID
|
|
context.nicknamesByPeerID[peerID] = "alice"
|
|
|
|
// No established session: local notice only, no network send.
|
|
coordinator.handleScreenshotCaptured()
|
|
#expect(context.routedPrivateMessages.isEmpty)
|
|
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
|
|
#expect(context.privateChats[peerID]?.first?.sender == "system")
|
|
|
|
// Established session: the peer is notified too.
|
|
context.noiseSessionStates[peerID] = .established
|
|
coordinator.handleScreenshotCaptured()
|
|
#expect(context.routedPrivateMessages.count == 1)
|
|
#expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *")
|
|
#expect(context.routedPrivateMessages.first?.recipientNickname == "alice")
|
|
#expect(context.privateChats[peerID]?.count == 2)
|
|
// The public-channel system message is not used for private chats.
|
|
#expect(context.systemMessages.isEmpty)
|
|
#expect(context.meshBroadcasts.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async {
|
|
let context = MockChatLifecycleContext()
|
|
let coordinator = ChatLifecycleCoordinator(context: context)
|
|
|
|
coordinator.handleScreenshotCaptured()
|
|
|
|
#expect(context.meshBroadcasts == ["* me took a screenshot *"])
|
|
#expect(context.systemMessages == ["you took a screenshot"])
|
|
#expect(context.privateChats.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func lifecycleEvents_persistIdentityAndScheduleReadPasses() async {
|
|
let context = MockChatLifecycleContext()
|
|
let coordinator = ChatLifecycleCoordinator(context: context)
|
|
|
|
coordinator.applicationWillTerminate()
|
|
#expect(context.stopMeshServicesCount == 1)
|
|
#expect(context.forceSaveIdentityCount == 1)
|
|
#expect(context.verifyIdentityKeyExistsCount == 1)
|
|
|
|
// Becoming active with no open chat only refreshes Bluetooth state.
|
|
coordinator.handleDidBecomeActive()
|
|
#expect(context.refreshBluetoothStateCount == 1)
|
|
#expect(context.managerReadMarks.isEmpty)
|
|
|
|
// With an open chat the read pass runs immediately (manager-level) and
|
|
// a delayed owner-level pass is scheduled.
|
|
let peerID = PeerID(nostr_: "feedface00112233")
|
|
context.selectedPrivateChatPeer = peerID
|
|
coordinator.handleDidBecomeActive()
|
|
#expect(context.refreshBluetoothStateCount == 2)
|
|
#expect(context.managerReadMarks == [peerID])
|
|
|
|
// The mock executes scheduled work synchronously, so the delayed
|
|
// owner-level pass has already run - no wall-clock polling.
|
|
#expect(context.scheduledDelays == [TransportConfig.uiAnimationMediumSeconds])
|
|
#expect(context.ownerLevelReadPasses == [peerID])
|
|
}
|
|
@Test @MainActor
|
|
func markPrivateMessagesAsRead_routesReceiptsForFavoritesAndNonFavorites() {
|
|
let context = MockChatLifecycleContext()
|
|
let coordinator = ChatLifecycleCoordinator(context: context)
|
|
let noiseKey = Data(repeating: 0xAB, count: 32)
|
|
let peerID = PeerID(hexData: noiseKey)
|
|
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
|
noiseKey: noiseKey,
|
|
nostrPublicKey: "npub1alice"
|
|
)
|
|
context.privateChats[peerID] = [
|
|
makePrivateMessage(id: "in-1", senderPeerID: peerID),
|
|
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true)
|
|
]
|
|
|
|
coordinator.markPrivateMessagesAsRead(from: peerID)
|
|
|
|
// Favorite with a Nostr key: READ receipts routed for non-relay
|
|
// inbound messages and recorded as sent.
|
|
#expect(context.managerReadMarks == [peerID])
|
|
#expect(context.routedReadReceipts.map(\.messageID) == ["in-1"])
|
|
#expect(context.routedReadReceipts.map(\.peerID) == [peerID])
|
|
#expect(context.sentReadReceipts.contains("in-1"))
|
|
|
|
// No favorite relationship: receipts still route — the router picks
|
|
// whatever transport can reach the peer (mesh included). Gating on a
|
|
// stored Nostr key silently starved mesh-connected non-favorites.
|
|
let otherKey = Data(repeating: 0xCD, count: 32)
|
|
let otherPeer = PeerID(hexData: otherKey)
|
|
context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)]
|
|
coordinator.markPrivateMessagesAsRead(from: otherPeer)
|
|
#expect(context.routedReadReceipts.map(\.messageID) == ["in-1", "in-2"])
|
|
#expect(context.sentReadReceipts.contains("in-2"))
|
|
}
|
|
|
|
}
|