|
| 1 | +@testable import ConvosCore |
| 2 | +import Combine |
| 3 | +import Foundation |
| 4 | +import GRDB |
| 5 | +import Testing |
| 6 | + |
| 7 | +/// Determines whether the conversation-list `ValueObservation` re-fires when a |
| 8 | +/// member's canonical `profileAvatar` changes. |
| 9 | +/// |
| 10 | +/// The list joins each member's avatar through the `profileAvatarLatest` *view* |
| 11 | +/// (`DBConversationMember.avatarSlot`), fetched via an `.including(all:)` |
| 12 | +/// prefetch. Whether GRDB's region tracking reaches the underlying `profileAvatar` |
| 13 | +/// table through that view is the open question behind the "stale cluster until |
| 14 | +/// you open the conversation" bug: |
| 15 | +/// |
| 16 | +/// - If `reEmitsOnMemberProfileAvatarWrite` passes, the data layer is already |
| 17 | +/// reactive and the stale-cluster bug is a UI cell-reload problem (fix the |
| 18 | +/// list/diffable-data-source, not the observation). |
| 19 | +/// - If it fails while the `reEmitsOnConversationChange` control passes, the |
| 20 | +/// observation is not tracking `profileAvatar` through the view and needs an |
| 21 | +/// explicit tracked region. |
| 22 | +@Suite("Conversation list observation - profileAvatar reactivity", .serialized) |
| 23 | +struct ConversationsRepositoryAvatarObservationTests { |
| 24 | + private let salt = Data(repeating: 1, count: 32) |
| 25 | + private let nonce = Data(repeating: 2, count: 12) |
| 26 | + private let key = Data(repeating: 3, count: 32) |
| 27 | + |
| 28 | + private func seedGroup(_ db: Database) throws { |
| 29 | + try DBMember(inboxId: "me").save(db, onConflict: .ignore) |
| 30 | + try DBInbox(inboxId: "me", clientId: "client-me", createdAt: Date()).save(db, onConflict: .ignore) |
| 31 | + try DBMember(inboxId: "alice").save(db, onConflict: .ignore) |
| 32 | + |
| 33 | + try DBConversation( |
| 34 | + id: "c1", |
| 35 | + clientConversationId: "client-c1", |
| 36 | + inviteTag: "tag-c1", |
| 37 | + creatorId: "me", |
| 38 | + kind: .group, |
| 39 | + consent: .allowed, |
| 40 | + createdAt: Date(), |
| 41 | + name: nil, |
| 42 | + description: nil, |
| 43 | + imageURLString: nil, |
| 44 | + publicImageURLString: nil, |
| 45 | + includeInfoInPublicPreview: false, |
| 46 | + expiresAt: nil, |
| 47 | + debugInfo: .empty, |
| 48 | + isLocked: false, |
| 49 | + imageSalt: nil, |
| 50 | + imageNonce: nil, |
| 51 | + imageEncryptionKey: nil, |
| 52 | + conversationEmoji: nil, |
| 53 | + imageLastRenewed: nil, |
| 54 | + isUnused: false, |
| 55 | + hasHadVerifiedAgent: false |
| 56 | + ).insert(db) |
| 57 | + |
| 58 | + try ConversationLocalState( |
| 59 | + conversationId: "c1", |
| 60 | + isPinned: false, |
| 61 | + isUnread: false, |
| 62 | + isUnreadUpdatedAt: Date(), |
| 63 | + isMuted: false, |
| 64 | + pinnedOrder: nil, |
| 65 | + hidesInviteCard: false, |
| 66 | + leftHostedInviteSession: false, |
| 67 | + wasRemoved: false, |
| 68 | + hasHadOtherMembers: false, |
| 69 | + hasSharedInvite: false |
| 70 | + ).insert(db) |
| 71 | + |
| 72 | + for (inboxId, role) in [("me", MemberRole.superAdmin), ("alice", MemberRole.member)] { |
| 73 | + try DBConversationMember( |
| 74 | + conversationId: "c1", |
| 75 | + inboxId: inboxId, |
| 76 | + role: role, |
| 77 | + consent: .allowed, |
| 78 | + createdAt: Date(), |
| 79 | + invitedByInboxId: nil |
| 80 | + ).insert(db) |
| 81 | + } |
| 82 | + try DBProfile( |
| 83 | + inboxId: "alice", name: "Alice", profileSource: .profileUpdate, |
| 84 | + updatedAt: Date(timeIntervalSince1970: 1) |
| 85 | + ).save(db) |
| 86 | + } |
| 87 | + |
| 88 | + @Test("re-emits when a member's profileAvatar is written (through the profileAvatarLatest view)") |
| 89 | + func reEmitsOnMemberProfileAvatarWrite() async throws { |
| 90 | + let dbManager = MockDatabaseManager.makeTestDatabase() |
| 91 | + let writer = dbManager.dbWriter |
| 92 | + try await writer.write { db in try self.seedGroup(db) } |
| 93 | + |
| 94 | + let counter = EmissionCounter() |
| 95 | + let repo = ConversationsRepository(dbReader: writer, consent: [.allowed]) |
| 96 | + let cancellable = repo.conversationsPublisher.sink { _ in counter.increment() } |
| 97 | + defer { cancellable.cancel() } |
| 98 | + |
| 99 | + try await Task.sleep(nanoseconds: 300_000_000) |
| 100 | + let afterInitial = counter.count |
| 101 | + #expect(afterInitial >= 1, "Observation should deliver an initial value") |
| 102 | + |
| 103 | + // Simulate Alice updating her photo: a new canonical avatar row. |
| 104 | + try await writer.write { db in |
| 105 | + try DBProfileAvatar( |
| 106 | + inboxId: "alice", conversationId: "c1", |
| 107 | + url: "https://example.com/alice-new.bin", |
| 108 | + salt: self.salt, nonce: self.nonce, encryptionKey: self.key, |
| 109 | + profileSource: .profileUpdate, updatedAt: Date() |
| 110 | + ).save(db) |
| 111 | + } |
| 112 | + |
| 113 | + try await Task.sleep(nanoseconds: 800_000_000) |
| 114 | + #expect( |
| 115 | + counter.count > afterInitial, |
| 116 | + "Expected the conversation-list observation to re-emit when a member's profileAvatar changed" |
| 117 | + ) |
| 118 | + } |
| 119 | + |
| 120 | + @Test("control: re-emits when the conversation row itself changes") |
| 121 | + func reEmitsOnConversationChange() async throws { |
| 122 | + let dbManager = MockDatabaseManager.makeTestDatabase() |
| 123 | + let writer = dbManager.dbWriter |
| 124 | + try await writer.write { db in try self.seedGroup(db) } |
| 125 | + |
| 126 | + let counter = EmissionCounter() |
| 127 | + let repo = ConversationsRepository(dbReader: writer, consent: [.allowed]) |
| 128 | + let cancellable = repo.conversationsPublisher.sink { _ in counter.increment() } |
| 129 | + defer { cancellable.cancel() } |
| 130 | + |
| 131 | + try await Task.sleep(nanoseconds: 300_000_000) |
| 132 | + let afterInitial = counter.count |
| 133 | + #expect(afterInitial >= 1, "Observation should deliver an initial value") |
| 134 | + |
| 135 | + try await writer.write { db in |
| 136 | + guard let conversation = try DBConversation.fetchOne(db, key: "c1") else { return } |
| 137 | + try conversation.with(name: "Renamed").save(db) |
| 138 | + } |
| 139 | + |
| 140 | + try await Task.sleep(nanoseconds: 800_000_000) |
| 141 | + #expect( |
| 142 | + counter.count > afterInitial, |
| 143 | + "Control failed: the observation did not re-emit even for a direct conversation change" |
| 144 | + ) |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +private final class EmissionCounter: @unchecked Sendable { |
| 149 | + private let lock = NSLock() |
| 150 | + private var value = 0 |
| 151 | + func increment() { |
| 152 | + lock.lock() |
| 153 | + value += 1 |
| 154 | + lock.unlock() |
| 155 | + } |
| 156 | + var count: Int { |
| 157 | + lock.lock() |
| 158 | + defer { lock.unlock() } |
| 159 | + return value |
| 160 | + } |
| 161 | +} |
0 commit comments