mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 11:05:19 +00:00
[codex] Refactor app runtime and ownership architecture (#1104)
* Refactor app runtime and view model architecture * Move app ownership into stores and coordinators * Fix smoke test environment injection * Stabilize fragmentation package tests * Fix coordinator build warnings * Clean up chat view model warnings * Fix Nostr relay startup coalescing --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -67,6 +67,135 @@ struct ChatViewModelInitializationTests {
|
||||
// Nickname should be set during init
|
||||
#expect(!transport.myNickname.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func initialization_bindsPeerSnapshotsIntoAllPeers() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "00000000000000a1")
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
|
||||
transport.updatePeerSnapshots([
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: "Alice",
|
||||
isConnected: true,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
])
|
||||
|
||||
let updated = await TestHelpers.waitUntil({
|
||||
viewModel.allPeers.contains { $0.peerID == peerID && $0.nickname == "Alice" }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(updated)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identity Tests
|
||||
|
||||
struct ChatViewModelIdentityTests {
|
||||
|
||||
@Test @MainActor
|
||||
func updatePrivateChatPeerIfNeeded_migratesSelectionHistoryByFingerprint() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let oldPeerID = PeerID(str: "00000000000000d1")
|
||||
let newPeerID = PeerID(str: "00000000000000d2")
|
||||
let sharedFingerprint = "fingerprint-shared"
|
||||
let sharedNoiseKey = Data(repeating: 0xAB, count: 32)
|
||||
|
||||
transport.peerFingerprints[oldPeerID] = sharedFingerprint
|
||||
transport.peerFingerprints[newPeerID] = sharedFingerprint
|
||||
transport.peerNicknames[oldPeerID] = "Alice"
|
||||
transport.peerNicknames[newPeerID] = "Alice"
|
||||
transport.updatePeerSnapshots([
|
||||
TransportPeerSnapshot(
|
||||
peerID: oldPeerID,
|
||||
nickname: "Alice",
|
||||
isConnected: true,
|
||||
noisePublicKey: sharedNoiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
])
|
||||
|
||||
let oldPeerBound = await TestHelpers.waitUntil({
|
||||
viewModel.connectedPeers.contains(oldPeerID)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#expect(oldPeerBound)
|
||||
|
||||
let existingMessage = BitchatMessage(
|
||||
id: "pm-migrate-1",
|
||||
sender: "Alice",
|
||||
content: "Still here",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: oldPeerID,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.privateChats[oldPeerID] = [existingMessage]
|
||||
viewModel.startPrivateChat(with: oldPeerID)
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == oldPeerID)
|
||||
#expect(viewModel.hasTrackedPrivateChatSelection)
|
||||
#expect(viewModel.selectedPrivateChatFingerprint == viewModel.getFingerprint(for: oldPeerID))
|
||||
|
||||
transport.updatePeerSnapshots([
|
||||
TransportPeerSnapshot(
|
||||
peerID: newPeerID,
|
||||
nickname: "Alice",
|
||||
isConnected: true,
|
||||
noisePublicKey: sharedNoiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
])
|
||||
|
||||
let newPeerBound = await TestHelpers.waitUntil({
|
||||
viewModel.connectedPeers.contains(newPeerID) && !viewModel.connectedPeers.contains(oldPeerID)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#expect(newPeerBound)
|
||||
|
||||
viewModel.updatePrivateChatPeerIfNeeded()
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == newPeerID)
|
||||
#expect(viewModel.privateChats[oldPeerID] == nil)
|
||||
#expect(viewModel.privateChats[newPeerID]?.contains(where: { $0.id == "pm-migrate-1" }) == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func resolveNickname_prefersLocalPetnameOverClaimedNickname() async {
|
||||
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
|
||||
)
|
||||
let peerID = PeerID(str: "00000000000000d3")
|
||||
let fingerprint = "fingerprint-petname"
|
||||
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
identityManager.updateSocialIdentity(
|
||||
SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: "Buddy",
|
||||
claimedNickname: "Alice",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
)
|
||||
|
||||
#expect(viewModel.getFingerprint(for: peerID) == fingerprint)
|
||||
#expect(viewModel.resolveNickname(for: peerID) == "Buddy")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Sending Tests
|
||||
@@ -135,6 +264,116 @@ struct ChatViewModelCommandTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Composer Tests
|
||||
|
||||
struct ChatViewModelComposerTests {
|
||||
|
||||
@Test @MainActor
|
||||
func updateAutocomplete_suggestsKnownPeersAndExcludesSelf() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let alice = PeerID(str: "00000000000000a1")
|
||||
let selfAlias = PeerID(str: "00000000000000a2")
|
||||
|
||||
transport.simulateConnect(alice, nickname: "Alice")
|
||||
transport.simulateConnect(selfAlias, nickname: viewModel.nickname)
|
||||
|
||||
viewModel.updateAutocomplete(for: "hello @Al", cursorPosition: 9)
|
||||
|
||||
#expect(viewModel.showAutocomplete)
|
||||
#expect(viewModel.autocompleteSuggestions == ["@Alice"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func completeNickname_replacesTokenAndClearsAutocompleteState() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
var text = "hello @Al"
|
||||
|
||||
viewModel.autocompleteSuggestions = ["@Alice"]
|
||||
viewModel.autocompleteRange = NSRange(location: 6, length: 3)
|
||||
viewModel.showAutocomplete = true
|
||||
viewModel.selectedAutocompleteIndex = 0
|
||||
|
||||
_ = viewModel.completeNickname("@Alice", in: &text)
|
||||
|
||||
#expect(text == "hello @Alice")
|
||||
#expect(viewModel.autocompleteSuggestions.isEmpty)
|
||||
#expect(viewModel.autocompleteRange == nil)
|
||||
#expect(!viewModel.showAutocomplete)
|
||||
#expect(viewModel.selectedAutocompleteIndex == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func parseMentions_filtersToKnownPeerAndSelfTokens() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let alice = PeerID(str: "00000000000000b1")
|
||||
|
||||
transport.simulateConnect(alice, nickname: "Alice")
|
||||
|
||||
let mentions = Set(
|
||||
viewModel.parseMentions(
|
||||
from: "hi @Alice @nobody @\(viewModel.nickname)"
|
||||
)
|
||||
)
|
||||
|
||||
#expect(mentions.contains("Alice"))
|
||||
#expect(mentions.contains(viewModel.nickname))
|
||||
#expect(!mentions.contains("nobody"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle Tests
|
||||
|
||||
struct ChatViewModelServiceLifecycleTests {
|
||||
|
||||
@Test @MainActor
|
||||
func handleDidBecomeActive_marksVisiblePrivateMessagesAsRead() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000001")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "read-1",
|
||||
sender: "Alice",
|
||||
content: "Hello from Alice",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID,
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.unreadPrivateMessages.insert(peerID)
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
|
||||
viewModel.handleDidBecomeActive()
|
||||
|
||||
let sentReadReceipt = await TestHelpers.waitUntil({
|
||||
transport.sentReadReceipts.contains {
|
||||
$0.peerID == peerID && $0.receipt.originalMessageID == "read-1"
|
||||
}
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(sentReadReceipt)
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.handleScreenshotCaptured()
|
||||
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Cap Tests
|
||||
|
||||
struct ChatViewModelTimelineCapTests {
|
||||
@@ -159,7 +398,7 @@ struct ChatViewModelReceivingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveMessage_callsDelegate() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "msg-001",
|
||||
@@ -181,7 +420,7 @@ struct ChatViewModelReceivingTests {
|
||||
|
||||
// Message may or may not appear due to rate limiting/pipeline batching
|
||||
// The important thing is no crash and delegate was called
|
||||
#expect(transport.delegate != nil)
|
||||
#expect(transport.delegate === viewModel)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -204,6 +443,176 @@ struct ChatViewModelReceivingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Payload Tests
|
||||
|
||||
struct ChatViewModelNoisePayloadTests {
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveNoisePayload_privateMessageStoresMessageAndSendsDeliveryAck() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000003")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
|
||||
let payload = PrivateMessagePacket(messageID: "pm-noise-1", content: "Secret hello").encode()
|
||||
#expect(payload != nil)
|
||||
guard let payload else { return }
|
||||
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: peerID,
|
||||
type: .privateMessage,
|
||||
payload: payload,
|
||||
timestamp: Date()
|
||||
)
|
||||
|
||||
let stored = await TestHelpers.waitUntil({
|
||||
viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
let acked = await TestHelpers.waitUntil({
|
||||
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(stored)
|
||||
#expect(acked)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didReceiveNoisePayload_deliveredUpdatesExistingPrivateMessageStatus() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000004")
|
||||
transport.simulateConnect(peerID, nickname: "Bob")
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: "pm-delivered-1",
|
||||
sender: viewModel.nickname,
|
||||
content: "Waiting on ack",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Bob",
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: peerID,
|
||||
type: .delivered,
|
||||
payload: Data("pm-delivered-1".utf8),
|
||||
timestamp: Date()
|
||||
)
|
||||
|
||||
let delivered = await TestHelpers.waitUntil({
|
||||
guard let status = viewModel.privateChats[peerID]?.first?.deliveryStatus else { return false }
|
||||
if case .delivered(let name, _) = status {
|
||||
return name == "Bob"
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(delivered)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatting Tests
|
||||
|
||||
struct ChatViewModelFormattingTests {
|
||||
|
||||
@Test @MainActor
|
||||
func formatMessageAsText_formatsSenderContentAndTimestamp() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let message = BitchatMessage(
|
||||
id: "fmt-1",
|
||||
sender: "Alice#a1b2",
|
||||
content: "hello #mesh",
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_010_000),
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(str: "00000000000000b1")
|
||||
)
|
||||
|
||||
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
|
||||
|
||||
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func formatMessageHeader_formatsSenderHeader() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let message = BitchatMessage(
|
||||
id: "fmt-2",
|
||||
sender: "Alice#a1b2",
|
||||
content: "hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
senderPeerID: PeerID(str: "00000000000000b2")
|
||||
)
|
||||
|
||||
let header = viewModel.formatMessageHeader(message, colorScheme: .dark)
|
||||
|
||||
#expect(String(header.characters) == "<@Alice#a1b2> ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification Tests
|
||||
|
||||
struct ChatViewModelVerificationTests {
|
||||
|
||||
@Test @MainActor
|
||||
func beginQRVerification_unknownNoiseKeyReturnsFalse() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let qr = VerificationService.VerificationQR(
|
||||
v: 1,
|
||||
noiseKeyHex: String(repeating: "a", count: 64),
|
||||
signKeyHex: String(repeating: "b", count: 64),
|
||||
npub: nil,
|
||||
nickname: "Alice",
|
||||
ts: 1_700_000_000,
|
||||
nonceB64: "nonce",
|
||||
sigHex: "sig"
|
||||
)
|
||||
|
||||
#expect(!viewModel.beginQRVerification(with: qr))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func beginQRVerification_knownPeerTriggersHandshakeWhenSessionNotEstablished() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "00000000000000c1")
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
|
||||
transport.updatePeerSnapshots([
|
||||
TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: "Alice",
|
||||
isConnected: true,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
])
|
||||
|
||||
let bound = await TestHelpers.waitUntil({
|
||||
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#expect(bound)
|
||||
|
||||
let qr = VerificationService.VerificationQR(
|
||||
v: 1,
|
||||
noiseKeyHex: noiseKey.hexEncodedString(),
|
||||
signKeyHex: String(repeating: "c", count: 64),
|
||||
npub: nil,
|
||||
nickname: "Alice",
|
||||
ts: 1_700_000_000,
|
||||
nonceB64: "nonce",
|
||||
sigHex: "sig"
|
||||
)
|
||||
|
||||
#expect(viewModel.beginQRVerification(with: qr))
|
||||
#expect(transport.triggeredHandshakes == [peerID])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiting Tests
|
||||
|
||||
struct ChatViewModelRateLimitingTests {
|
||||
@@ -238,6 +647,35 @@ struct ChatViewModelRateLimitingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Conversation Tests
|
||||
|
||||
struct ChatViewModelPublicConversationTests {
|
||||
|
||||
@Test @MainActor
|
||||
func addPublicSystemMessage_persistsAcrossTimelineRefresh() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
viewModel.addPublicSystemMessage("system refresh test")
|
||||
viewModel.messages.removeAll()
|
||||
viewModel.refreshVisibleMessages(from: .mesh)
|
||||
|
||||
#expect(viewModel.messages.last?.content == "system refresh test")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearCurrentPublicTimeline_clearsVisibleAndBackedMessages() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
viewModel.addPublicSystemMessage("system clear test")
|
||||
#expect(!viewModel.messages.isEmpty)
|
||||
|
||||
viewModel.clearCurrentPublicTimeline()
|
||||
viewModel.refreshVisibleMessages(from: .mesh)
|
||||
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer Connection Tests
|
||||
|
||||
struct ChatViewModelPeerTests {
|
||||
@@ -275,6 +713,46 @@ struct ChatViewModelPeerTests {
|
||||
|
||||
#expect(transport.isPeerConnected(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let stalePeer = PeerID(str: "00000000000000a2")
|
||||
viewModel.unreadPrivateMessages = [stalePeer]
|
||||
|
||||
viewModel.didUpdatePeerList([])
|
||||
|
||||
let cleaned = await TestHelpers.waitUntil({
|
||||
!viewModel.unreadPrivateMessages.contains(stalePeer)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(cleaned)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdatePeerList_preservesStableUnreadPeerWhenMessagesExist() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let stablePeer = PeerID(str: String(repeating: "a", count: 64))
|
||||
let message = BitchatMessage(
|
||||
id: "stable-noise-1",
|
||||
sender: "Alice",
|
||||
content: "Offline hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: stablePeer,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.privateChats[stablePeer] = [message]
|
||||
viewModel.unreadPrivateMessages = [stablePeer]
|
||||
|
||||
viewModel.didUpdatePeerList([])
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
#expect(viewModel.unreadPrivateMessages.contains(stablePeer))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deduplication Integration Tests
|
||||
@@ -328,7 +806,7 @@ struct ChatViewModelPrivateChatTests {
|
||||
|
||||
// The message routing depends on connection state and other factors
|
||||
// At minimum, it should not crash
|
||||
#expect(true) // If we get here without crash, the test passes
|
||||
#expect(Bool(true)) // If we get here without crash, the test passes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user