mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
nostrKeyMapping, sentGeoDeliveryAcks/sentReadReceipts dedupe, isBatchingPublic, geo subscription lifecycle, and private-chat selection hand-off now mutate through single intent operations on ChatViewModel, with backing storage locked down via private(set) so the single-writer property is compiler-enforced. Context protocols downgrade to read-only access where reads remain. 8 new contract tests. Known remaining writers outside the protocols: sentReadReceipts is passed inout to PrivateChatManager.syncReadReceiptsForSentMessages and un-marked by ChatTransportEventCoordinator on disconnect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
578 lines
20 KiB
Swift
578 lines
20 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 pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
|
let oldCount = sentReadReceipts.count
|
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
|
return oldCount - sentReadReceipts.count
|
|
}
|
|
|
|
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 middleInsertedMessage_isFoundAfterIndexWasBuilt() async {
|
|
let context = MockChatDeliveryContext()
|
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
|
let makePublic = { (id: String) in
|
|
BitchatMessage(
|
|
id: id,
|
|
sender: "me",
|
|
content: "Public message",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
deliveryStatus: .sending
|
|
)
|
|
}
|
|
context.messages = [makePublic("public-a"), makePublic("public-b")]
|
|
|
|
// Build the incremental index.
|
|
#expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent))
|
|
|
|
// Out-of-order arrival: PublicMessagePipeline inserts by timestamp,
|
|
// so the count grows while the tail ID stays the same.
|
|
context.messages.insert(makePublic("public-mid"), at: 1)
|
|
|
|
// The inserted message must be locatable, and the shifted tail must
|
|
// not be updated through a stale index entry.
|
|
#expect(coordinator.updateMessageDeliveryStatus("public-mid", status: .sent))
|
|
#expect(isSent(context.messages[1].deliveryStatus))
|
|
#expect(coordinator.updateMessageDeliveryStatus("public-b", status: .sent))
|
|
#expect(isSent(context.messages[2].deliveryStatus))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func middleInsertedPrivateMessage_isFoundAfterIndexWasBuilt() async {
|
|
let context = MockChatDeliveryContext()
|
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
context.privateChats[peerID] = [
|
|
makePrivateMessage(id: "pm-a", status: .sending),
|
|
makePrivateMessage(id: "pm-b", status: .sending)
|
|
]
|
|
|
|
#expect(coordinator.updateMessageDeliveryStatus("pm-a", status: .sent))
|
|
|
|
// Timestamp re-sort (sanitizeChat) can place a late arrival mid-array.
|
|
context.privateChats[peerID]?.insert(makePrivateMessage(id: "pm-mid", status: .sending), at: 1)
|
|
|
|
#expect(coordinator.updateMessageDeliveryStatus("pm-mid", status: .sent))
|
|
#expect(isSent(context.privateChats[peerID]?[1].deliveryStatus))
|
|
#expect(coordinator.updateMessageDeliveryStatus("pm-b", status: .sent))
|
|
#expect(isSent(context.privateChats[peerID]?[2].deliveryStatus))
|
|
}
|
|
|
|
@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
|
|
}
|