Files
bitchat/bitchatTests/CommandProcessorTests.swift
T
jackandClaude Fable 5 fd2dffdda7 Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing
Three fixes for PR #1377 review:

1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the
   panicClearAllData / clearCurrentPublicTimeline detached utility-priority
   tasks delete the real ~/Library/Application Support/files tree, which the
   test process shares. The wipe fires at a nondeterministic time and raced
   the sendImage test's JPEG in files/images/outgoing (write then re-read),
   so prepareImagePacket threw and the test timed out. Both wipes are now
   skipped under tests (existing TestEnvironment.isRunningTests pattern);
   this also stops test runs from deleting the developer's real media.

2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on
   packet.senderID let one connected peer rotate forged sender IDs to bypass
   the 5-per-10s budget. The limiter now keys on the ingress link (the
   directly connected peer that delivered the packet); the pong still goes
   to the claimed sender. Regression test proves rotating senders over one
   link exhaust one budget (fails 10 vs 5 pongs on the old code).

3. Codex P2: /ping output arrived up to 10s later and was routed from
   selectedPrivateChatPeer at callback time, misrouting the result after a
   chat switch. The origin conversation is now captured when the command is
   issued (CommandOutputDestination) and deferred output is routed there:
   a DM result lands in the origin chat's history even if deselected, and a
   mesh-timeline result pins to #mesh instead of the active channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:12:18 +02:00

510 lines
19 KiB
Swift

import Foundation
import Testing
import BitFoundation
@testable import bitchat
@Suite(.serialized)
struct CommandProcessorTests {
@MainActor
@Test func slapNotFoundGrammar() {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap @system")
switch result {
case .error(let message):
#expect(message == "cannot slap system: not found")
default:
Issue.record("Expected error result")
}
}
@MainActor
@Test func hugNotFoundGrammar() {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/hug @system")
switch result {
case .error(let message):
#expect(message == "cannot hug system: not found")
default:
Issue.record("Expected error result")
}
}
@MainActor
@Test func slapUsageMessage() {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap")
switch result {
case .error(let message):
#expect(message == "usage: /slap <nickname>")
default:
Issue.record("Expected error result for usage message")
}
}
@MainActor
@Test func msgStartsPrivateChatAndSendsMessage() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["alice"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/msg @alice hello there")
}
switch result {
case .success(let message):
#expect(message == "started private chat with alice")
default:
Issue.record("Expected success result")
}
#expect(context.startedPrivateChats == [peerID])
#expect(context.sentPrivateMessages.count == 1)
#expect(context.sentPrivateMessages.first?.content == "hello there")
#expect(context.sentPrivateMessages.first?.peerID == peerID)
}
@MainActor
@Test func whoInMeshListsSortedPeerNicknames() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
transport.peerNicknames = [
PeerID(str: "b"): "bob",
PeerID(str: "a"): "alice"
]
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: alice, bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws {
let bridge = NostrIdentityBridge(keychain: MockKeychain())
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(idBridge: bridge)
let geohash = "u4pruy"
let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased()
context.visibleGeoParticipants = [
CommandGeoParticipant(id: selfPubkey, displayName: "me"),
CommandGeoParticipant(id: String(repeating: "b", count: 64), displayName: "bob")
]
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let result = await withSelectedChannel(channel, context: context) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func clearInPrivateChatRemovesOnlySelectedConversation() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let activePeer = PeerID(str: "active")
let otherPeer = PeerID(str: "other")
context.selectedPrivateChatPeer = activePeer
context.privateChats = [
activePeer: [makeMessage(sender: "alice", content: "secret")],
otherPeer: [makeMessage(sender: "bob", content: "keep")]
]
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.privateChats[activePeer] == [])
#expect(context.privateChats[otherPeer]?.count == 1)
}
@MainActor
@Test func clearInPublicChatClearsTimeline() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.clearCurrentPublicTimelineCallCount == 1)
}
@MainActor
@Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
context.selectedPrivateChatPeer = peerID
context.nicknameToPeerID["bob"] = peerID
transport.peerNicknames[peerID] = "Bob"
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/hug @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content == "* 🫂 me hugs you *")
#expect(context.localPrivateSystemMessages.first?.content == "🫂 you hugged bob")
#expect(context.localPrivateSystemMessages.first?.peerID == peerID)
}
@MainActor
@Test func slapInPublicChatSendsPublicRawAndEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/slap @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.sentPublicRawMessages == ["* 🐟 me slaps bob around a bit with a large trout *"])
#expect(context.publicSystemMessages == ["🐟 me slaps bob around a bit with a large trout"])
}
@MainActor
@Test func blockWithoutArgsListsMeshAndGeohashBlocks() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerNicknames[peerID] = "bob"
transport.peerFingerprints[peerID] = "fp-bob"
context.blockedUsers = ["fp-bob"]
context.visibleGeoParticipants = [
CommandGeoParticipant(id: String(repeating: "c", count: 64), displayName: "carol")
]
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/block")
}
switch result {
case .success(let message):
#expect(message == "blocked peers: bob | geohash blocks: carol")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func blockAndUnblockMeshPeerUpdateIdentityState() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerFingerprints[peerID] = "fp-bob"
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/block @bob")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked bob. you will no longer receive messages from them")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
let unblockResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/unblock bob")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked bob")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isBlocked(fingerprint: "fp-bob"))
}
@MainActor
@Test func blockAndUnblockGeohashPeerUseNostrBlockList() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/block carol")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
let unblockResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/unblock @carol")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
}
/// /fav must go through toggleFavorite (which persists by the real noise
/// key) — not write the hex peer ID into the favorites store, and not
/// send a second favorite notification.
@MainActor
@Test func favoriteCommandTogglesWithoutDirectStoreWrite() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(
contextProvider: context,
meshService: MockTransport(),
identityManager: identityManager
)
let peerID = PeerID(str: "00aa00bb00cc00dd")
context.nicknameToPeerID["alice"] = peerID
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/fav alice")
}
switch result {
case .success(let message):
#expect(message == "added alice to favorites")
default:
Issue.record("Expected success result")
}
#expect(context.toggledFavorites == [peerID])
#expect(context.favoriteNotifications.isEmpty)
// The 8-byte routing ID must never be stored as a "noise key".
let bogusKey = Data(hexString: peerID.id)!
#expect(FavoritesPersistenceService.shared.getFavoriteStatus(for: bogusKey) == nil)
// Unfavoriting someone who is not a favorite is a no-op.
let unfavResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/unfav alice")
}
switch unfavResult {
case .success(let message):
#expect(message == "alice is not a favorite")
default:
Issue.record("Expected success result")
}
#expect(context.toggledFavorites == [peerID])
}
@MainActor
@Test func favoriteCommandIsRejectedOutsideMesh() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(
contextProvider: context,
meshService: MockTransport(),
identityManager: identityManager
)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
let result = await withSelectedChannel(channel, context: context) {
processor.process("/fav alice")
}
switch result {
case .error(let message):
#expect(message == "favorites are only for mesh peers in #mesh")
default:
Issue.record("Expected error result")
}
}
@MainActor
private func withSelectedChannel<T>(
_ channel: ChannelID,
context: MockCommandContextProvider? = nil,
perform work: @escaping () throws -> T
) async rethrows -> T {
let originalChannel = LocationChannelManager.shared.selectedChannel
let originalContextChannel = context?.activeChannel
await setSelectedChannel(channel, context: context)
do {
let result = try work()
await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel)
return result
} catch {
await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel)
throw error
}
}
@MainActor
private func setSelectedChannel(
_ channel: ChannelID,
context: MockCommandContextProvider? = nil,
explicitChannel: ChannelID? = nil
) async {
context?.activeChannel = explicitChannel ?? channel
LocationChannelManager.shared.select(channel)
for _ in 0..<40 {
if LocationChannelManager.shared.selectedChannel == channel {
return
}
await Task.yield()
try? await Task.sleep(nanoseconds: 5_000_000)
}
}
private func makeMessage(sender: String, content: String) -> BitchatMessage {
BitchatMessage(
sender: sender,
content: content,
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
isRelay: false
)
}
}
@MainActor
private final class MockCommandContextProvider: CommandContextProvider {
var nickname: String
var activeChannel: ChannelID = .mesh
var selectedPrivateChatPeer: PeerID?
var blockedUsers: Set<String> = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
let idBridge: NostrIdentityBridge
var nicknameToPeerID: [String: PeerID] = [:]
var visibleGeoParticipants: [CommandGeoParticipant] = []
var displayNameToNostrPubkey: [String: String] = [:]
private(set) var startedPrivateChats: [PeerID] = []
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []
private(set) var clearCurrentPublicTimelineCallCount = 0
private(set) var sentPublicRawMessages: [String] = []
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
private(set) var publicSystemMessages: [String] = []
private(set) var commandOutputs: [String] = []
private(set) var commandOutputDestinations: [CommandOutputDestination] = []
private(set) var toggledFavorites: [PeerID] = []
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) {
self.nickname = nickname
self.idBridge = idBridge
}
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
nicknameToPeerID[nickname]
}
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
visibleGeoParticipants
}
func nostrPubkeyForDisplayName(_ displayName: String) -> String? {
displayNameToNostrPubkey[displayName]
}
func startPrivateChat(with peerID: PeerID) {
startedPrivateChats.append(peerID)
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
sentPrivateMessages.append((content, peerID))
}
func clearCurrentPublicTimeline() {
clearCurrentPublicTimelineCallCount += 1
}
private(set) var clearedPrivateChats: [PeerID] = []
func clearPrivateChat(_ peerID: PeerID) {
clearedPrivateChats.append(peerID)
privateChats[peerID] = []
}
func sendPublicRaw(_ content: String) {
sentPublicRawMessages.append(content)
}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
localPrivateSystemMessages.append((content, peerID))
}
func addPublicSystemMessage(_ content: String) {
publicSystemMessages.append(content)
}
func currentCommandDestination() -> CommandOutputDestination {
if let peerID = selectedPrivateChatPeer {
return .privateChat(peerID)
}
return .meshTimeline
}
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
commandOutputs.append(content)
commandOutputDestinations.append(destination)
}
func toggleFavorite(peerID: PeerID) {
toggledFavorites.append(peerID)
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
favoriteNotifications.append((peerID, isFavorite))
}
}