Files
bitchat/bitchatTests/LegacyConversationStoreBridgeTests.swift
T
jackandClaude Fable 5 879d8cba12 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>
2026-06-11 12:19:11 +02:00

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"])
}
}