mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Mesh and geohash timelines are now store conversations. All public mutation sites flow through store intents; PublicMessagePipeline keeps its 80ms UI batching but commits batches via store appends with each buffered entry carrying its destination conversation (a mid-batch channel switch now flushes instead of dropping the buffer). ChatViewModel.messages becomes a cached get-only view of the active conversation, invalidated through the change subject. The mesh late-insert threshold is consciously removed: it only ever ordered the non-rendered messages copy, so strict timestamp insertion makes the working set agree with rendered order. PublicTimelineStore and the per-message full-array legacy sync are deleted; the coalescing bridge mirrors public conversations for the remaining legacy readers. pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady; store.append 237k/s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
247 lines
9.4 KiB
Swift
247 lines
9.4 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"])
|
|
}
|
|
|
|
// MARK: - Public mirroring (migration step 3)
|
|
|
|
@Test("public messages mirror into Legacy via the coalesced flush")
|
|
@MainActor
|
|
func publicMessagesMirrorIntoLegacy() async {
|
|
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
|
|
|
viewModel.appendPublicMessage(
|
|
BitchatMessage(
|
|
id: "bridge-pub-1",
|
|
sender: "alice",
|
|
content: "hello mesh",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
),
|
|
to: .mesh
|
|
)
|
|
viewModel.appendGeohashMessageIfAbsent(
|
|
BitchatMessage(
|
|
id: "bridge-geo-1",
|
|
sender: "bob#abcd",
|
|
content: "hello geohash",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
),
|
|
toGeohash: "U4PRUYD"
|
|
)
|
|
|
|
// The new store is synchronously authoritative (geohash keys are
|
|
// normalized to lowercase).
|
|
#expect(store.conversation(for: .mesh).messages.map(\.id) == ["bridge-pub-1"])
|
|
#expect(store.conversation(for: .geohash("u4pruyd")).messages.map(\.id) == ["bridge-geo-1"])
|
|
|
|
// Legacy catches up within one coalesced flush.
|
|
let mirrored = await TestHelpers.waitUntil(
|
|
{
|
|
legacy.messages(for: .mesh).map(\.id) == ["bridge-pub-1"]
|
|
&& legacy.messages(for: .geohash("u4pruyd")).map(\.id) == ["bridge-geo-1"]
|
|
},
|
|
timeout: 1.0
|
|
)
|
|
#expect(mirrored)
|
|
}
|
|
|
|
@Test("removing a public conversation empties its Legacy mirror immediately")
|
|
@MainActor
|
|
func publicConversationRemovalClearsLegacy() async {
|
|
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
|
viewModel.appendPublicMessage(
|
|
BitchatMessage(
|
|
id: "bridge-pub-2",
|
|
sender: "alice",
|
|
content: "soon gone",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
),
|
|
to: .mesh
|
|
)
|
|
let mirrored = await TestHelpers.waitUntil(
|
|
{ legacy.messages(for: .mesh).map(\.id) == ["bridge-pub-2"] },
|
|
timeout: 1.0
|
|
)
|
|
#expect(mirrored)
|
|
|
|
// Panic-style removal: Legacy must never show stale public messages.
|
|
store.removeConversation(.mesh)
|
|
#expect(legacy.messages(for: .mesh).isEmpty)
|
|
}
|
|
}
|