mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:05:20 +00:00
All private-message mutations now flow through store intents: coordinators, PrivateChatManager (its @Published dicts deleted - now read-only views over the store), outbound sends, delivery status, and chat migration. The O(1) store dedup replaces the full-scan duplicate check; insertion order is maintained by the store so sanitizeChat's re-sort is a documented no-op. Both bootstrapper Combine bridges and the Task.yield store synchronization are deleted. ChatViewModel.privateChats/unreadPrivateMessages become get-only derived views (measured: naive rebuild equals a change-invalidated cache within noise, so the simpler form stays). Feature models still read the legacy store, fed by a coalescing LegacyConversationStoreBridge (one mirror per burst, marked for step-5 deletion). pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
178 lines
7.0 KiB
Swift
178 lines
7.0 KiB
Swift
//
|
|
// LegacyConversationStoreBridgeTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Focused tests for migration step 2 (docs/CONVERSATION-STORE-DESIGN.md §4):
|
|
// the private write path goes through the single-writer `ConversationStore`
|
|
// intents, and `LegacyConversationStoreBridge` keeps the replace-based
|
|
// `LegacyConversationStore` consistent — per-message changes via a
|
|
// `Task.yield`-coalesced per-conversation mirror, unread flips and
|
|
// structural changes (migration/removal) synchronously — until the feature
|
|
// models cut over in step 5.
|
|
//
|
|
// DELETE IN STEP 5 together with the bridge.
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
@MainActor
|
|
private func makeBridgedFixture() -> (
|
|
viewModel: ChatViewModel,
|
|
store: ConversationStore,
|
|
legacy: LegacyConversationStore,
|
|
transport: MockTransport
|
|
) {
|
|
let keychain = MockKeychain()
|
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
let identityManager = MockIdentityManager(keychain)
|
|
let transport = MockTransport()
|
|
let legacy = LegacyConversationStore()
|
|
let store = ConversationStore()
|
|
let viewModel = ChatViewModel(
|
|
keychain: keychain,
|
|
idBridge: idBridge,
|
|
identityManager: identityManager,
|
|
transport: transport,
|
|
conversationStore: legacy,
|
|
conversations: store
|
|
)
|
|
return (viewModel, store, legacy, transport)
|
|
}
|
|
|
|
@MainActor
|
|
private func makeInboundPrivateMessage(
|
|
id: String,
|
|
from peerID: PeerID,
|
|
sender: String = "alice",
|
|
content: String = "hello",
|
|
timestamp: Date = Date()
|
|
) -> BitchatMessage {
|
|
BitchatMessage(
|
|
id: id,
|
|
sender: sender,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "me",
|
|
senderPeerID: peerID,
|
|
deliveryStatus: .delivered(to: "me", at: timestamp)
|
|
)
|
|
}
|
|
|
|
@Suite("LegacyConversationStoreBridge")
|
|
struct LegacyConversationStoreBridgeTests {
|
|
|
|
@Test("inbound private message lands in the store and is mirrored into Legacy")
|
|
@MainActor
|
|
func inboundPrivateMessageLandsInStoreAndLegacy() async {
|
|
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
|
// Stable Noise-key peer ID, like the perf pipeline fixture.
|
|
let peerID = PeerID(str: String(repeating: "ab", count: 32))
|
|
let message = makeInboundPrivateMessage(id: "bridge-pm-1", from: peerID)
|
|
|
|
viewModel.handlePrivateMessage(message)
|
|
|
|
// New store is the synchronous source of truth.
|
|
#expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["bridge-pm-1"])
|
|
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["bridge-pm-1"])
|
|
// Unread flips mirror into Legacy synchronously.
|
|
#expect(viewModel.unreadPrivateMessages.contains(peerID))
|
|
#expect(legacy.unreadDirectPeerIDs().contains(peerID))
|
|
|
|
// Message arrays mirror via the bridge's coalesced flush
|
|
// (one Legacy replace per burst, on the next main-actor turn).
|
|
let mirrored = await TestHelpers.waitUntil(
|
|
{ legacy.directMessagesByPeerID()[peerID]?.map(\.id) == ["bridge-pm-1"] },
|
|
timeout: 1.0
|
|
)
|
|
#expect(mirrored)
|
|
}
|
|
|
|
@Test("store dedups a private message delivered twice")
|
|
@MainActor
|
|
func storeDedupsDuplicateInboundMessage() async {
|
|
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
|
let peerID = PeerID(str: String(repeating: "cd", count: 32))
|
|
let message = makeInboundPrivateMessage(id: "bridge-dup-1", from: peerID)
|
|
|
|
viewModel.handlePrivateMessage(message)
|
|
viewModel.handlePrivateMessage(message)
|
|
|
|
#expect(store.conversation(for: .directPeer(peerID)).messages.count == 1)
|
|
#expect(viewModel.privateChats[peerID]?.count == 1)
|
|
|
|
// The intent itself reports the duplicate.
|
|
#expect(!viewModel.appendPrivateMessage(message, to: peerID))
|
|
|
|
let mirrored = await TestHelpers.waitUntil(
|
|
{ legacy.directMessagesByPeerID()[peerID]?.count == 1 },
|
|
timeout: 1.0
|
|
)
|
|
#expect(mirrored)
|
|
}
|
|
|
|
@Test("peer-ID migration through store intents keeps Legacy consistent")
|
|
@MainActor
|
|
func migrationThroughStoreIntentsKeepsLegacyConsistent() {
|
|
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
|
let oldPeerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
|
let newPeerID = PeerID(str: "bbbbbbbbbbbbbbbb")
|
|
|
|
viewModel.seedPrivateChat(
|
|
[
|
|
makeInboundPrivateMessage(id: "mig-1", from: oldPeerID, timestamp: Date().addingTimeInterval(-60)),
|
|
makeInboundPrivateMessage(id: "mig-2", from: oldPeerID, timestamp: Date().addingTimeInterval(-30)),
|
|
],
|
|
for: oldPeerID
|
|
)
|
|
viewModel.markPrivateChatUnread(oldPeerID)
|
|
|
|
viewModel.migratePrivateChat(from: oldPeerID, to: newPeerID)
|
|
|
|
// Store: messages moved in order, old chat gone, unread carried.
|
|
#expect(store.conversationsByID[.directPeer(oldPeerID)] == nil)
|
|
#expect(store.conversation(for: .directPeer(newPeerID)).messages.map(\.id) == ["mig-1", "mig-2"])
|
|
#expect(viewModel.unreadPrivateMessages == [newPeerID])
|
|
|
|
// Legacy: structural changes resynchronize immediately (stale
|
|
// messages removed, destination present, unread re-keyed). The old
|
|
// peer may linger as a registered-but-empty handle — Legacy has
|
|
// always kept handle registrations from markRead/selection around.
|
|
let legacyChats = legacy.directMessagesByPeerID()
|
|
#expect(legacyChats[oldPeerID] ?? [] == [])
|
|
#expect(legacyChats[newPeerID]?.map(\.id) == ["mig-1", "mig-2"])
|
|
#expect(legacy.unreadDirectPeerIDs() == [newPeerID])
|
|
}
|
|
|
|
@Test("coordinator chat migration uses the store migrate intent end to end")
|
|
@MainActor
|
|
func coordinatorMigrationFlowsThroughStore() {
|
|
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
|
let oldPeerID = PeerID(str: "1111111111111111")
|
|
let newPeerID = PeerID(str: "2222222222222222")
|
|
|
|
// Recent messages from "alice" under the old peer ID; no fingerprints
|
|
// on either side → nickname-match migration path.
|
|
viewModel.seedPrivateChat(
|
|
[makeInboundPrivateMessage(id: "coord-mig-1", from: oldPeerID, timestamp: Date().addingTimeInterval(-5))],
|
|
for: oldPeerID
|
|
)
|
|
|
|
viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "alice")
|
|
|
|
#expect(store.conversationsByID[.directPeer(oldPeerID)] == nil)
|
|
#expect(store.conversation(for: .directPeer(newPeerID)).messages.map(\.id) == ["coord-mig-1"])
|
|
// Migration resynchronizes Legacy immediately (the old peer may
|
|
// linger as a registered-but-empty handle, see above).
|
|
#expect(legacy.directMessagesByPeerID()[oldPeerID] ?? [] == [])
|
|
#expect(legacy.directMessagesByPeerID()[newPeerID]?.map(\.id) == ["coord-mig-1"])
|
|
}
|
|
}
|