Files
bitchat/bitchatTests/ChatViewModelTests.swift
T
304460ee83 Nearby notes: tap-to-reveal (consent-gate the location subscription) + subscription hygiene (#1422)
* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool

The nearby-notes counter used to open a live building-precision (precision-8)
geohash REQ to the closest geo relays whenever the mesh public timeline was
visible — a passive location side-channel with no opt-in. Now nothing
subscribes until one explicit act reveals the counter for the session: tapping
the new "check for notes left here" line on the empty mesh timeline (static,
no network), opening the notices sheet's geo tab, or a successful /drop. The
app-info setting stays the default-ON kill switch and still gates /drop.

Subscription hygiene alongside:
- LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the
  main actor like the timer teardown) instead of only invalidating timers.
- New refcounted LocationNotesPool dedupes the counter's and the notices
  sheet's identical 9-cell kind-1 REQs into one shared manager per geohash;
  both callers release-and-reacquire instead of retargeting in place, and the
  sheet releases its ref on dismissal.

The reveal affordance is localized across all 29 catalog locales, and new
NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell
filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool
refcounting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only

Fixes the verified review findings on the tap-to-reveal PR:

- Permission dead-end: the "check for notes left here" hint now renders
  only when location permission is already authorized (it never prompts).
  Previously a location-denied install could tap it, flip the sticky
  revealed flag, and get nothing for the rest of the session because
  retarget() guards on authorization. Predicate lives on
  NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive
  to the published permission state.

- Building-cell sampling: background geohash sampling subscribed
  geo-sample-<gh> for every regional level including the precision-8
  building cell, pre-reveal — contradicting the claim that nothing
  building-precision hits a relay before the explicit act.
  GeoChannelCoordinator now excludes the building level until
  NearbyNotesCounter.revealed (injectable publisher for tests); coarser
  levels keep the nearby-conversation hint and participant counts
  working, and bookmarks stay exempt (bookmarking is explicit).

- Implicit reveal: opening the notices sheet no longer reveals — the
  sheet auto-lands on the geo tab whenever a location channel is
  selected, so browsing a remote geohash and opening notices revealed
  the LOCAL building subscription. reveal() now fires only on the
  person actively picking the geo segment, and only when the sheet has
  a geo scope (the empty-mesh hint tap and /drop stay as before).

- Subscription hygiene: switching the sheet geo → mesh releases the
  pooled notes manager (the REQ was left streaming behind the mesh
  board); switching back re-acquires from the pool. The dismissal
  release stays balanced — liveGeoManager is nil after the tab-switch
  release.

- VoiceOver: the hint button exposes the plain localized action text
  instead of the decorated "* 📍 … *" label.

- Removed LocationNotesManager.setGeohash: zero callers, and calling it
  on a pooled instance would corrupt the pool's keying and refcounts.

New tests: hint permission gate, explicit-geo-tab reveal contract,
building-cell sampling exclusion before/after reveal, pool
release/re-acquire round trip. Full suite (1467) green; iOS simulator
build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline

CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the
snapshot -> allPeers binding crosses the mock transport's unstructured
Task, UnifiedPeerService.updatePeers, a receive(on: main), and another
Task { @MainActor } in bindPeerService — all contending with every
parallel worker. On the failing runner the whole suite took 10.1s
(usually ~4.4s locally), so the positive 5s defaultTimeout wait lost
the race. That's exactly the case TestConstants.longTimeout documents;
passing runs return as soon as the condition holds and never pay it.

Not caused by the tap-to-reveal changes: nothing on that pipeline was
touched, and 20 full parallel-suite loops each on the branch and on
origin/main reproduce zero failures locally. The two sibling waits on
the same pipeline in this file get the same timeout.

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>
2026-07-09 16:47:08 +02:00

1149 lines
38 KiB
Swift

//
// ChatViewModelTests.swift
// bitchatTests
//
// Tests for ChatViewModel using MockTransport for isolation.
// This is free and unencumbered software released into the public domain.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Test Helpers
/// Creates a ChatViewModel with mock dependencies for testing
@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: - Initialization Tests
struct ChatViewModelInitializationTests {
@Test @MainActor
func initialization_setsDelegate() async {
let (viewModel, transport) = makeTestableViewModel()
// The viewModel should set itself as the transport delegate
#expect(transport.delegate === viewModel)
#expect(transport.eventDelegate === viewModel)
}
@Test @MainActor
func initialization_startsServices() async {
let (_, transport) = makeTestableViewModel()
// Services should be started during init
#expect(transport.startServicesCallCount == 1)
}
@Test @MainActor
func initialization_hasEmptyMessageList() async {
let (viewModel, _) = makeTestableViewModel()
// Initial messages may include system messages, but should be limited
#expect(viewModel.messages.count < 10)
}
@Test @MainActor
func initialization_setsNickname() async {
let (_, transport) = makeTestableViewModel()
// 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()
)
])
// The snapshot → allPeers binding hops the transport's unstructured
// Task, UnifiedPeerService, a receive(on: main), and another Task —
// all contending with every parallel worker, so a loaded CI runner
// can exceed defaultTimeout (observed: one 5s miss on a run where
// the whole suite took 10s instead of the usual ~4s).
let updated = await TestHelpers.waitUntil({
viewModel.allPeers.contains { $0.peerID == peerID && $0.nickname == "Alice" }
}, timeout: TestConstants.longTimeout)
#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()
)
])
// Same multi-hop snapshot pipeline as above: longTimeout for load.
let oldPeerBound = await TestHelpers.waitUntil({
viewModel.connectedPeers.contains(oldPeerID)
}, timeout: TestConstants.longTimeout)
#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.seedPrivateChat([existingMessage], for: oldPeerID)
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.longTimeout)
#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
struct ChatViewModelSendingTests {
@Test @MainActor
func sendMessage_delegatesToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
viewModel.sendMessage("Hello World")
#expect(transport.sentMessages.count == 1)
#expect(transport.sentMessages.first?.content == "Hello World")
}
@Test @MainActor
func sendMessage_emptyContent_ignored() async {
let (viewModel, transport) = makeTestableViewModel()
viewModel.sendMessage("")
viewModel.sendMessage(" ")
viewModel.sendMessage("\n\t")
#expect(transport.sentMessages.isEmpty)
}
@Test @MainActor
func sendMessage_withMentions_sendsContent() async {
let (viewModel, transport) = makeTestableViewModel()
viewModel.sendMessage("Hello @alice")
#expect(transport.sentMessages.count == 1)
#expect(transport.sentMessages.first?.content == "Hello @alice")
}
@Test @MainActor
func sendMessage_command_notSentToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
viewModel.sendMessage("/help")
// Commands are processed locally, not sent to transport
#expect(transport.sentMessages.isEmpty)
}
}
// MARK: - Command Handling Tests
struct ChatViewModelCommandTests {
@Test @MainActor
func sendMessage_commandsNotSentToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
let commands = ["/nick bob", "/who", "/help", "/clear"]
for command in commands {
transport.resetRecordings()
viewModel.sendMessage(command)
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(transport.sentMessages.isEmpty)
#expect(transport.sentPrivateMessages.isEmpty)
}
}
@Test @MainActor
func handleCommand_outputRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/help")
#expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText)
#expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText })
}
@Test @MainActor
func handleCommand_errorRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/bogus")
let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? []
#expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") })
#expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") })
}
@Test @MainActor
func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.handleCommand("/bogus")
#expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true)
}
@Test @MainActor
func handleCommand_msgSuccessLandsInNewlyOpenedChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
let resolved = await TestHelpers.waitUntil({
viewModel.getPeerIDForNickname("Alice") == peerID
}, timeout: TestConstants.defaultTimeout)
#expect(resolved)
viewModel.handleCommand("/msg Alice")
#expect(viewModel.selectedPrivateChatPeer == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice")
#expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" })
}
}
// 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.seedPrivateChat([message], for: peerID)
viewModel.markPrivateChatUnread(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 {
@Test @MainActor
func sendMessage_trimsTimelineToCap() async {
let (viewModel, _) = makeTestableViewModel()
let total = TransportConfig.meshTimelineCap + 5
for i in 0..<total {
viewModel.sendMessage("cap-msg-\(i)")
}
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
}
}
// MARK: - Message Receiving Tests
struct ChatViewModelReceivingTests {
@Test @MainActor
func didReceiveMessage_callsDelegate() async {
let (viewModel, transport) = makeTestableViewModel()
let message = BitchatMessage(
id: "msg-001",
sender: "Alice",
content: "Hello from Alice",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "PEER001"),
mentions: nil
)
transport.simulateIncomingMessage(message)
// Give time for Task and pipeline processing
try? await Task.sleep(nanoseconds: 200_000_000)
// 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 === viewModel)
#expect(transport.eventDelegate === viewModel)
}
@Test @MainActor
func didReceivePublicMessage_addsToTimeline() async {
let (viewModel, transport) = makeTestableViewModel()
transport.simulateIncomingPublicMessage(
from: PeerID(str: "PEER002"),
nickname: "Bob",
content: "Public hello from Bob",
timestamp: Date(),
messageID: "pub-001"
)
let found = await TestHelpers.waitUntil({
viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" }
}, timeout: TestConstants.defaultTimeout)
#expect(found)
}
}
// 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.seedPrivateChat([message], for: peerID)
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)
}
@Test @MainActor
func didReceiveNoisePayload_readReceiptUpdatesBeforePeerNicknameIsKnown() async {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000005")
let message = BitchatMessage(
id: "pm-read-before-name",
sender: viewModel.nickname,
content: "Waiting on read receipt",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: viewModel.meshService.myPeerID,
mentions: nil,
deliveryStatus: .sent
)
viewModel.seedPrivateChat([message], for: peerID)
viewModel.didReceiveNoisePayload(
from: peerID,
type: .readReceipt,
payload: Data("pm-read-before-name".utf8),
timestamp: Date()
)
let privateChatUpdated = await TestHelpers.waitUntil({
guard let status = viewModel.privateChats[peerID]?.first?.deliveryStatus else { return false }
if case .read = status {
return true
}
return false
}, timeout: TestConstants.defaultTimeout)
let conversationStoreUpdated = await TestHelpers.waitUntil({
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
guard let status = messages.first?.deliveryStatus else { return false }
if case .read = status {
return true
}
return false
}, timeout: TestConstants.defaultTimeout)
#expect(privateChatUpdated)
#expect(conversationStoreUpdated)
}
}
// 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 {
@Test @MainActor
func handlePublicMessage_rateLimitsBurstBySender() async {
let (viewModel, _) = makeTestableViewModel()
let senderID = PeerID(str: "1122334455667788")
let now = Date()
for i in 0..<6 {
let message = BitchatMessage(
id: "rate-\(i)",
sender: "Spammer",
content: "rate-msg-\(i)",
timestamp: now,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderID,
mentions: nil
)
viewModel.handlePublicMessage(message)
}
viewModel.publicMessagePipeline.flushIfNeeded()
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
#expect(burstMessages.count == 5)
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
}
}
// MARK: - Public Conversation Tests
struct ChatViewModelPublicConversationTests {
@Test @MainActor
func addPublicSystemMessage_persistsAcrossTimelineRefresh() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.addPublicSystemMessage("system refresh test")
viewModel.refreshVisibleMessages(from: .mesh)
// The system message lives in the mesh conversation itself, so the
// derived `messages` view still surfaces it after a refresh.
#expect(viewModel.messages.last?.content == "system refresh test")
#expect(viewModel.publicMessages(for: .mesh).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)
#expect(viewModel.publicMessages(for: .mesh).isEmpty)
}
@Test @MainActor
func queuedGeohashSystemMessages_drainOnce() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.queueGeohashSystemMessage("first")
viewModel.queueGeohashSystemMessage("second")
#expect(viewModel.drainPendingGeohashSystemMessages() == ["first", "second"])
#expect(viewModel.drainPendingGeohashSystemMessages().isEmpty)
}
}
// MARK: - Peer Connection Tests
struct ChatViewModelPeerTests {
@Test @MainActor
func didConnectToPeer_notifiesDelegate() async {
let (_, transport) = makeTestableViewModel()
let peerID = PeerID(str: "NEWPEER")
transport.simulateConnect(peerID, nickname: "NewUser")
#expect(transport.connectedPeers.contains(peerID))
}
@Test @MainActor
func didDisconnectFromPeer_notifiesDelegate() async {
let (_, transport) = makeTestableViewModel()
let peerID = PeerID(str: "OLDPEER")
transport.simulateConnect(peerID, nickname: "OldUser")
transport.simulateDisconnect(peerID)
#expect(!transport.connectedPeers.contains(peerID))
}
@Test @MainActor
func isPeerConnected_delegatesToTransport() async {
let (_, transport) = makeTestableViewModel()
let peerID = PeerID(str: "TESTPEER")
// Not connected initially
#expect(!transport.isPeerConnected(peerID))
transport.connectedPeers.insert(peerID)
#expect(transport.isPeerConnected(peerID))
}
@Test @MainActor
func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async {
let (viewModel, _) = makeTestableViewModel()
let stalePeer = PeerID(str: "00000000000000a2")
viewModel.markPrivateChatUnread(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.seedPrivateChat([message], for: stablePeer)
viewModel.markPrivateChatUnread(stablePeer)
viewModel.didUpdatePeerList([])
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.unreadPrivateMessages.contains(stablePeer))
}
}
// MARK: - Deduplication Integration Tests
//
// Note: Detailed deduplication logic is tested in MessageDeduplicationServiceTests.
// These tests verify that ChatViewModel has a deduplication service configured.
struct ChatViewModelDeduplicationTests {
@Test @MainActor
func deduplicationService_isConfigured() async {
let (viewModel, _) = makeTestableViewModel()
// Verify the deduplication service is available and functional
// by checking that we can record and query content
let testContent = "Test dedup content \(UUID().uuidString)"
let testDate = Date()
viewModel.deduplicationService.recordContent(testContent, timestamp: testDate)
let retrieved = viewModel.deduplicationService.contentTimestamp(for: testContent)
#expect(retrieved == testDate)
}
@Test @MainActor
func deduplicationService_normalizedKey_consistent() async {
let (viewModel, _) = makeTestableViewModel()
let content = "Hello World"
let key1 = viewModel.deduplicationService.normalizedContentKey(content)
let key2 = viewModel.deduplicationService.normalizedContentKey(content)
#expect(key1 == key2)
}
}
// MARK: - Private Chat Tests
struct ChatViewModelPrivateChatTests {
@Test @MainActor
func sendPrivateMessage_delegatesToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
let recipientID = PeerID(str: "RECIPIENT")
// Set up connected peer for routing
transport.connectedPeers.insert(recipientID)
transport.peerNicknames[recipientID] = "Recipient"
viewModel.sendPrivateMessage("Secret message", to: recipientID)
// The message routing depends on connection state and other factors
// At minimum, it should not crash
#expect(Bool(true)) // If we get here without crash, the test passes
}
}
// MARK: - Private Chat Selection Tests
struct ChatViewModelPrivateChatSelectionTests {
@Test @MainActor
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
let (viewModel, _) = makeTestableViewModel()
let peerA = PeerID(str: "PEER_A")
let peerB = PeerID(str: "PEER_B")
let older = Date().addingTimeInterval(-120)
let newer = Date().addingTimeInterval(-30)
viewModel.seedPrivateChat([
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
], for: peerA)
viewModel.seedPrivateChat([
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
], for: peerB)
viewModel.markPrivateChatUnread(peerA)
viewModel.markPrivateChatUnread(peerB)
viewModel.openMostRelevantPrivateChat()
#expect(viewModel.selectedPrivateChatPeer == peerB)
}
@Test @MainActor
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
let (viewModel, _) = makeTestableViewModel()
let peerA = PeerID(str: "PEER_A")
let peerB = PeerID(str: "PEER_B")
let older = Date().addingTimeInterval(-200)
let newer = Date().addingTimeInterval(-20)
viewModel.seedPrivateChat([
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
], for: peerA)
viewModel.seedPrivateChat([
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
], for: peerB)
viewModel.openMostRelevantPrivateChat()
#expect(viewModel.selectedPrivateChatPeer == peerB)
}
}
// MARK: - Bluetooth State Tests
struct ChatViewModelBluetoothTests {
@Test @MainActor
func didUpdateBluetoothState_poweredOn_noAlert() async {
let (viewModel, transport) = makeTestableViewModel()
transport.simulateBluetoothStateChange(.poweredOn)
// Give time for async processing
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(!viewModel.showBluetoothAlert)
}
@Test @MainActor
func didUpdateBluetoothState_poweredOff_showsAlert() async {
let (viewModel, transport) = makeTestableViewModel()
transport.simulateBluetoothStateChange(.poweredOff)
// Give time for async processing
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.showBluetoothAlert)
}
@Test @MainActor
func didUpdateBluetoothState_unauthorized_showsAlert() async {
let (viewModel, transport) = makeTestableViewModel()
transport.simulateBluetoothStateChange(.unauthorized)
// Give time for async processing
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.showBluetoothAlert)
}
}
// MARK: - Panic Clear Tests
struct ChatViewModelPanicTests {
@Test @MainActor
func panicClearAllData_delegatesToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
// Set up some state
transport.connectedPeers.insert(PeerID(str: "PEER1"))
viewModel.seedPublicMessages([
BitchatMessage(
id: "panic-1",
sender: "Tester",
content: "Before",
timestamp: Date(),
isRelay: false
)
])
viewModel.seedPrivateChat([
BitchatMessage(
id: "pm-1",
sender: "Peer",
content: "Secret",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: PeerID(str: "PEER1")
)
], for: PeerID(str: "PEER1"))
viewModel.markPrivateChatUnread(PeerID(str: "PEER1"))
viewModel.panicClearAllData()
// After panic, emergency disconnect should be called
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(viewModel.messages.isEmpty)
#expect(viewModel.privateChats.isEmpty)
#expect(viewModel.unreadPrivateMessages.isEmpty)
#expect(viewModel.selectedPrivateChatPeer == nil)
}
@Test @MainActor
func panicClearAllData_resetsLiveGeohashAndNostrState() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruy"
let channel = GeohashChannel(level: .city, geohash: geohash)
let identity = try NostrIdentity.generate()
let pubkey = String(repeating: "ab", count: 32)
let peerID = PeerID(nostr: pubkey)
viewModel.activeChannel = .location(channel)
viewModel.setGeoChatSubscriptionID("geo-\(geohash)")
viewModel.setGeoDmSubscriptionID("geo-dm-\(geohash)")
viewModel.addGeoSamplingSub("geo-sample-\(geohash)", forGeohash: geohash)
viewModel.cachedGeohashIdentity = (geohash, identity)
viewModel.registerNostrKeyMapping(pubkey, for: peerID)
viewModel.currentGeohash = geohash
viewModel.geoNicknames = [pubkey: "alice"]
viewModel.teleportedGeo = [pubkey]
viewModel.panicClearAllData()
#expect(viewModel.activeChannel == .mesh)
#expect(viewModel.geoSubscriptionID == nil)
#expect(viewModel.geoDmSubscriptionID == nil)
#expect(viewModel.geoSamplingSubs.isEmpty)
#expect(viewModel.cachedGeohashIdentity == nil)
#expect(viewModel.nostrKeyMapping.isEmpty)
#expect(viewModel.currentGeohash == nil)
#expect(viewModel.geoNicknames.isEmpty)
#expect(viewModel.teleportedGeo.isEmpty)
}
}
// MARK: - Service Lifecycle Tests
struct ChatViewModelLifecycleTests {
@Test @MainActor
func startServices_calledOnInit() async {
let (_, transport) = makeTestableViewModel()
#expect(transport.startServicesCallCount == 1)
}
}