mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 04:05:19 +00:00
Cut private message path over to ConversationStore
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>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
//
|
||||
// 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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user