mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 10:45:20 +00:00
BLEService's per-packet-type orchestration moves into owned, tested components (BLEAnnounceHandler, BLEPublicMessageHandler, BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler), each taking an environment struct of closures so every queue hop stays in BLEService and the handlers are synchronously testable. Behavior is preserved verbatim, including Noise session recovery on decrypt failure and single-block UI event ordering. handleLeave/handleRequestSync stay in place as already-thin delegations. BLEService drops to 3393 lines. Four coordinators (delivery, private conversation, Nostr, public conversation) drop their unowned/weak ChatViewModel back-references for narrow @MainActor context protocols, with ChatViewModel conformances as single shared witnesses for overlapping members. Their true coupling is now an explicit, reviewable surface, and each gains a mock-context test suite covering flows previously testable only through the full view model. Delivery/read acks now also clear the router's retained-send outbox via the delivery context. New LargeTopologyTests exercise production-shaped meshes with the in-memory harness: an 8-peer relay chain with per-hop TTL decay, a 14-peer cyclic mesh with exactly-once delivery, partition/heal, and topology churn. App-layer runtime/model files updated alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
519 lines
18 KiB
Swift
519 lines
18 KiB
Swift
//
|
|
// ChatViewModelDeliveryStatusTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Tests for ChatViewModel delivery status state machine.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
// MARK: - Test Helpers
|
|
|
|
@MainActor
|
|
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
|
let keychain = MockKeychain()
|
|
let keychainHelper = MockKeychainHelper()
|
|
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
|
let identityManager = MockIdentityManager(keychain)
|
|
let transport = MockTransport()
|
|
|
|
let viewModel = ChatViewModel(
|
|
keychain: keychain,
|
|
idBridge: idBridge,
|
|
identityManager: identityManager,
|
|
transport: transport
|
|
)
|
|
|
|
return (viewModel, transport)
|
|
}
|
|
|
|
// MARK: - Delivery Status Tests
|
|
|
|
struct ChatViewModelDeliveryStatusTests {
|
|
|
|
// MARK: - Status Transition Tests
|
|
|
|
@Test @MainActor
|
|
func deliveryStatus_noDowngrade_readToDelivered() async {
|
|
let (viewModel, transport) = makeTestableViewModel()
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let messageID = "test-msg-1"
|
|
|
|
// Setup: create a message with .read status
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Test message",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .read(by: "Peer", at: Date())
|
|
)
|
|
viewModel.privateChats[peerID] = [message]
|
|
|
|
// Action: try to downgrade to .delivered
|
|
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
|
|
|
// Assert: status should remain .read (no downgrade)
|
|
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
#expect({
|
|
if case .read = currentStatus { return true }
|
|
return false
|
|
}())
|
|
}
|
|
|
|
@Test @MainActor
|
|
func deliveryStatus_upgrade_sentToDelivered() async {
|
|
let (viewModel, transport) = makeTestableViewModel()
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let messageID = "test-msg-2"
|
|
|
|
// Setup: create a message with .sent status
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Test message",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .sent
|
|
)
|
|
viewModel.privateChats[peerID] = [message]
|
|
|
|
// Action: upgrade to .delivered
|
|
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
|
|
|
// Assert: status should be .delivered
|
|
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
#expect({
|
|
if case .delivered = currentStatus { return true }
|
|
return false
|
|
}())
|
|
}
|
|
|
|
@Test @MainActor
|
|
func deliveryStatus_identicalUpdateIsNoop() async {
|
|
let (viewModel, transport) = makeTestableViewModel()
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let messageID = "test-msg-identical"
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Test message",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .sent
|
|
)
|
|
viewModel.privateChats[peerID] = [message]
|
|
|
|
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: .sent)
|
|
|
|
#expect(!didUpdate)
|
|
#expect(isSent(viewModel.privateChats[peerID]?.first?.deliveryStatus))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func deliveryStatus_upgrade_deliveredToRead() async {
|
|
let (viewModel, transport) = makeTestableViewModel()
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let messageID = "test-msg-3"
|
|
|
|
// Setup: create a message with .delivered status
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Test message",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
|
)
|
|
viewModel.privateChats[peerID] = [message]
|
|
|
|
// Action: upgrade to .read
|
|
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
|
|
|
// Assert: status should be .read
|
|
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
#expect({
|
|
if case .read = currentStatus { return true }
|
|
return false
|
|
}())
|
|
}
|
|
|
|
// MARK: - Read Receipt Handling
|
|
|
|
@Test @MainActor
|
|
func didReceiveReadReceipt_updatesMessageStatus() async {
|
|
let (viewModel, transport) = makeTestableViewModel()
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let messageID = "test-msg-4"
|
|
|
|
// Setup: create a message with .sent status
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Test message",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .sent
|
|
)
|
|
viewModel.privateChats[peerID] = [message]
|
|
|
|
// Action: receive read receipt
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: messageID,
|
|
readerID: peerID,
|
|
readerNickname: "Peer"
|
|
)
|
|
viewModel.didReceiveReadReceipt(receipt)
|
|
|
|
// Assert: status should be .read
|
|
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
|
|
#expect({
|
|
if case .read = currentStatus { return true }
|
|
return false
|
|
}())
|
|
}
|
|
|
|
@Test @MainActor
|
|
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
|
|
let (viewModel, transport) = makeTestableViewModel()
|
|
let peerID = PeerID(str: "0102030405060709")
|
|
|
|
let message = BitchatMessage(
|
|
id: "keep-receipt",
|
|
sender: viewModel.nickname,
|
|
content: "Keep me",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .sent
|
|
)
|
|
viewModel.privateChats[peerID] = [message]
|
|
viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
|
viewModel.isStartupPhase = false
|
|
|
|
viewModel.cleanupOldReadReceipts()
|
|
|
|
#expect(viewModel.sentReadReceipts == ["keep-receipt"])
|
|
}
|
|
|
|
// MARK: - Public Timeline Status Tests
|
|
|
|
@Test @MainActor
|
|
func deliveryStatus_publicTimeline_updatesCorrectly() async {
|
|
let (viewModel, _) = makeTestableViewModel()
|
|
let messageID = "public-msg-1"
|
|
|
|
// Setup: add a message to public timeline with .sending status
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Public message",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
deliveryStatus: .sending
|
|
)
|
|
viewModel.messages.append(message)
|
|
|
|
// Action: update to .sent
|
|
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
|
|
|
// Assert
|
|
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
|
|
#expect({
|
|
if case .sent = updatedMessage?.deliveryStatus { return true }
|
|
return false
|
|
}())
|
|
}
|
|
|
|
@Test @MainActor
|
|
func deliveryStatus_updatesPublicAndMirroredPrivateMessages() async {
|
|
let (viewModel, transport) = makeTestableViewModel()
|
|
let messageID = "mirrored-msg-1"
|
|
let firstPeerID = PeerID(str: "0102030405060708")
|
|
let secondPeerID = PeerID(str: "1112131415161718")
|
|
|
|
viewModel.messages = [
|
|
BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Public copy",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .sent
|
|
)
|
|
]
|
|
viewModel.privateChats[firstPeerID] = [
|
|
BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Private copy A",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer A",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .sent
|
|
)
|
|
]
|
|
viewModel.privateChats[secondPeerID] = [
|
|
BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Private copy B",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer B",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .sent
|
|
)
|
|
]
|
|
|
|
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
|
messageID,
|
|
status: .delivered(to: "Peer", at: Date())
|
|
)
|
|
|
|
#expect(didUpdate)
|
|
#expect(isDelivered(viewModel.messages.first?.deliveryStatus))
|
|
#expect(isDelivered(viewModel.privateChats[firstPeerID]?.first?.deliveryStatus))
|
|
#expect(isDelivered(viewModel.privateChats[secondPeerID]?.first?.deliveryStatus))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func deliveryStatus_indexRefreshesAfterPrivateChatReorder() async {
|
|
let (viewModel, transport) = makeTestableViewModel()
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let messageID = "reordered-msg-1"
|
|
let olderMessage = BitchatMessage(
|
|
id: "older-msg",
|
|
sender: viewModel.nickname,
|
|
content: "Older message",
|
|
timestamp: Date(timeIntervalSince1970: 1),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .sent
|
|
)
|
|
let targetMessage = BitchatMessage(
|
|
id: messageID,
|
|
sender: viewModel.nickname,
|
|
content: "Target message",
|
|
timestamp: Date(timeIntervalSince1970: 2),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer",
|
|
senderPeerID: transport.myPeerID,
|
|
deliveryStatus: .sent
|
|
)
|
|
|
|
viewModel.privateChats[peerID] = [targetMessage, olderMessage]
|
|
#expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID)))
|
|
|
|
viewModel.privateChats[peerID] = [olderMessage, targetMessage]
|
|
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
|
messageID,
|
|
status: .read(by: "Peer", at: Date())
|
|
)
|
|
|
|
#expect(didUpdate)
|
|
#expect(isRead(viewModel.privateChats[peerID]?.last?.deliveryStatus))
|
|
}
|
|
|
|
// MARK: - Status Rank Tests (for deduplication)
|
|
|
|
@Test @MainActor
|
|
func statusRank_orderingIsCorrect() async {
|
|
// This tests the implicit ordering used in refreshVisibleMessages
|
|
// failed < sending < sent < partiallyDelivered < delivered < read
|
|
|
|
let statuses: [DeliveryStatus] = [
|
|
.failed(reason: "test"),
|
|
.sending,
|
|
.sent,
|
|
.partiallyDelivered(reached: 1, total: 3),
|
|
.delivered(to: "B", at: Date()),
|
|
.read(by: "C", at: Date())
|
|
]
|
|
|
|
// Verify each status has a logical progression
|
|
// This is more of a documentation test to ensure the ranking logic is understood
|
|
for (index, status) in statuses.enumerated() {
|
|
switch status {
|
|
case .failed: #expect(index == 0)
|
|
case .sending: #expect(index == 1)
|
|
case .sent: #expect(index == 2)
|
|
case .partiallyDelivered: #expect(index == 3)
|
|
case .delivered: #expect(index == 4)
|
|
case .read: #expect(index == 5)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Mock Delivery Context
|
|
|
|
/// Lightweight stand-in for `ChatDeliveryContext` proving that
|
|
/// `ChatDeliveryCoordinator` is testable without constructing a `ChatViewModel`.
|
|
@MainActor
|
|
private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|
var messages: [BitchatMessage] = []
|
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
var sentReadReceipts: Set<String> = []
|
|
var isStartupPhase = false
|
|
private(set) var notifyUIChangedCount = 0
|
|
private(set) var markedDeliveredMessageIDs: [String] = []
|
|
|
|
func notifyUIChanged() {
|
|
notifyUIChangedCount += 1
|
|
}
|
|
|
|
func markMessageDelivered(_ messageID: String) {
|
|
markedDeliveredMessageIDs.append(messageID)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMessage {
|
|
BitchatMessage(
|
|
id: id,
|
|
sender: "me",
|
|
content: "Test message",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "Peer",
|
|
senderPeerID: PeerID(str: "aabbccddeeff0011"),
|
|
deliveryStatus: status
|
|
)
|
|
}
|
|
|
|
// MARK: - Coordinator Tests Against Mock Context
|
|
|
|
/// Exercises `ChatDeliveryCoordinator` against `MockChatDeliveryContext` —
|
|
/// the exemplar for the narrow-dependency coordinator pattern.
|
|
struct ChatDeliveryCoordinatorContextTests {
|
|
|
|
@Test @MainActor
|
|
func updateDeliveryStatus_updatesPrivateChatNotifiesAndMarksDelivered() async {
|
|
let context = MockChatDeliveryContext()
|
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let messageID = "mock-msg-1"
|
|
context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .sent)]
|
|
|
|
let didUpdate = coordinator.updateMessageDeliveryStatus(
|
|
messageID,
|
|
status: .delivered(to: "Peer", at: Date())
|
|
)
|
|
|
|
#expect(didUpdate)
|
|
#expect(isDelivered(context.privateChats[peerID]?.first?.deliveryStatus))
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
#expect(context.markedDeliveredMessageIDs == [messageID])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func readReceipt_marksDeliveredAndUpgradesStatus() async {
|
|
let context = MockChatDeliveryContext()
|
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let messageID = "mock-msg-2"
|
|
context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date()))]
|
|
|
|
coordinator.didReceiveReadReceipt(
|
|
ReadReceipt(originalMessageID: messageID, readerID: peerID, readerNickname: "Peer")
|
|
)
|
|
|
|
#expect(isRead(context.privateChats[peerID]?.first?.deliveryStatus))
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
#expect(context.markedDeliveredMessageIDs == [messageID])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async {
|
|
let context = MockChatDeliveryContext()
|
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
|
context.messages = [
|
|
BitchatMessage(
|
|
id: "public-mock-1",
|
|
sender: "me",
|
|
content: "Public message",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
deliveryStatus: .sending
|
|
)
|
|
]
|
|
|
|
// .sent is not a confirmed receipt — must not reach markMessageDelivered.
|
|
let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent)
|
|
#expect(didUpdate)
|
|
#expect(isSent(context.messages.first?.deliveryStatus))
|
|
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
|
|
// Unknown message: no state change, no extra UI notification.
|
|
let didUpdateUnknown = coordinator.updateMessageDeliveryStatus("missing-msg", status: .sent)
|
|
#expect(!didUpdateUnknown)
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func cleanupOldReadReceipts_prunesReceiptsAgainstMockContext() async {
|
|
let context = MockChatDeliveryContext()
|
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
context.privateChats[peerID] = [makePrivateMessage(id: "keep-receipt", status: .sent)]
|
|
context.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
|
|
|
// Startup phase: cleanup must be a no-op.
|
|
context.isStartupPhase = true
|
|
coordinator.cleanupOldReadReceipts()
|
|
#expect(context.sentReadReceipts == ["keep-receipt", "drop-receipt"])
|
|
|
|
context.isStartupPhase = false
|
|
coordinator.cleanupOldReadReceipts()
|
|
#expect(context.sentReadReceipts == ["keep-receipt"])
|
|
}
|
|
}
|
|
|
|
private func isSent(_ status: DeliveryStatus?) -> Bool {
|
|
if case .sent = status { return true }
|
|
return false
|
|
}
|
|
|
|
private func isDelivered(_ status: DeliveryStatus?) -> Bool {
|
|
if case .delivered = status { return true }
|
|
return false
|
|
}
|
|
|
|
private func isRead(_ status: DeliveryStatus?) -> Bool {
|
|
if case .read = status { return true }
|
|
return false
|
|
}
|