diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 93947a280..d8a300d7c 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -522,6 +522,11 @@ final class ConversationController { // unknown chat here means the feed hasn't landed, and the feed will bring the state // with it. Nothing to fetch. return + case .titleChanged, .pictureChanged: + // Only delivered to a chat's members, same reasoning as `.viewerStateChanged`: an unknown + // chat here means the feed hasn't landed yet, and it will bring the current title/picture + // with it. Nothing to fetch. + return } guard !store.conversations.contains(where: { $0.id == conversationID }), !hydratingConversationIDs.contains(conversationID) else { @@ -874,6 +879,11 @@ final class ConversationController { // Mute is cached so a chat restored cold renders muted rather than flickering unmuted // until the next metadata fetch — the same reason the roster summary is cached. persistConversation(conversationID) + case .titleChanged(let conversationID, _), + .pictureChanged(let conversationID, _): + // Cached like the roster summary/mute above, so a cold restore shows the edited + // title/picture rather than the stale one until the next full metadata fetch. + persistConversation(conversationID) case .typingChanged: break } diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 2cb1855fd..ddc50f5d7 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.5.0" - case .flipcash2: return "0.10.0" + case .flipcash2: return "0.11.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index b9a23af03..72e0711fc 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -97,6 +97,55 @@ extension FlipClient { } } + /// Represents one paged read of a chat's roster: the members returned, most recently joined + /// first, and the roster summary as of that page's read. + /// + /// This is a single pinned read, not a merged view — a large group's roster can page from an + /// index that trails the event stream (see `chat.v1.GetRoster`'s staleness contract), so the + /// caller must reconcile `members` against whatever it already holds from the stream, by + /// ``ConversationMember/version``, greater winning, rather than trusting this to be current or + /// complete on its own. + public struct RosterPage: Sendable { + public let members: [ConversationMember] + public let rosterSummary: ConversationRosterSummary + } + + /// Pages a chat's roster to exhaustion against a single pinned read. The caller must already be + /// consuming its `subscribeConversationStream` events — as with `getDmChatFeed`/ + /// `getGroupChatFeed` — and must merge the result against them by ``ConversationMember/version`` + /// rather than treating it as an authoritative snapshot; see ``RosterPage``. + public func getRoster(owner: KeyPair, conversationID: ConversationID) async throws -> RosterPage { + var members: [ConversationMember] = [] + var rosterSummary = ConversationRosterSummary(memberCount: 0, version: 0) + var pagingToken: Data? + + while true { + let page = try await withCheckedThrowingContinuation { c in + chatService.getRoster(owner: owner, conversationID: conversationID, pagingToken: pagingToken) { c.resume(with: $0) } + } + members.append(contentsOf: page.members) + rosterSummary = page.rosterSummary + if !page.hasMore { break } + pagingToken = page.pagingToken + } + + return RosterPage(members: members, rosterSummary: rosterSummary) + } + + /// Edits a group chat's title and/or picture; every parameter left `nil` leaves that field + /// unchanged, and passing both `nil` is a no-op that still returns the current metadata. Only a + /// member with ``ConversationViewerState/canEdit`` may call this. + /// + /// `pictureBlobID` must already be `READY` (uploaded via `BlobService`) — this call does not + /// upload it. Real changes also arrive on the event stream as `MetadataUpdate.titleChanged`/ + /// `.pictureChanged` for the chat's other members (and the caller's other devices); this call's + /// return value is only this device's confirmation. + public func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, pictureBlobID: BlobID?) async throws -> Conversation { + try await withCheckedThrowingContinuation { c in + chatService.editChat(owner: owner, conversationID: conversationID, title: title, pictureBlobID: pictureBlobID) { c.resume(with: $0) } + } + } + public func getMessages(owner: KeyPair, conversationID: ConversationID, before: MessageID?, viewMode: ConversationViewMode = .full) async throws -> [ConversationMessage] { try await withCheckedThrowingContinuation { c in chatMessagingService.getMessages(owner: owner, conversationID: conversationID, pagingToken: before?.pagingToken, viewMode: viewMode) { c.resume(with: $0) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 54899baaf..8baf36108 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -271,6 +271,104 @@ final class ChatService: Sendable { } } } + + struct RosterFeedPage: Sendable { + let members: [ConversationMember] + let rosterSummary: ConversationRosterSummary + let pagingToken: Data + let hasMore: Bool + } + + /// Pages a chat's roster, most recently joined first. Leave `pagingToken` `nil` on the first + /// call; on every later call pass back the previous page's `pagingToken` — it is opaque, + /// server-generated, and bound to `conversationID`. `pageSize` is capped at 100 server-side. + /// + /// A page may lag the returned `rosterSummary` for a large group (see `chat.v1.GetRoster`'s + /// staleness contract) — callers must merge each page against what the event stream has already + /// told them, by ``ConversationMember/version``, greater winning, rather than trusting a page to + /// be a complete, current snapshot. + func getRoster(owner: KeyPair, conversationID: ConversationID, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_GetRosterRequest.with { + $0.chatID = conversationID.proto + $0.queryOptions = .with { + $0.pageSize = Int32(pageSize) + if let pagingToken { + $0.pagingToken = .with { $0.value = pagingToken } + } + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getRoster(request, options: .unaryDefault) + guard response.result == .ok else { + logger.error("Failed to fetch roster") + await MainActor.run { completion(.failure(ErrorGetRoster(response.result))) } + return + } + guard response.hasRosterSummary else { + logger.error("Failed to fetch roster") + await MainActor.run { completion(.failure(.unknown)) } + return + } + let page = RosterFeedPage( + members: response.members.map(ConversationMember.init), + rosterSummary: ConversationRosterSummary(response.rosterSummary), + pagingToken: response.pagingToken.value, + hasMore: response.hasMore_p + ) + await MainActor.run { completion(.success(page)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Edits a group chat's title and/or picture. Every field is optional — only fields set on the + /// request change, atomically; a request that sets nothing is a no-op returning `.ok`. Only a + /// member the server permits to edit (``ConversationViewerState/canEdit``) may call this; anyone + /// else is `.denied`. + /// + /// `pictureBlobID`, when supplied, must already be `READY` (uploaded via `BlobService`) — this + /// call does not upload it, mirroring `startChat`'s `pictureBlobID` contract. On `.titleModerated` + /// the server also reports which category flagged the title, carried the same way + /// `ErrorStartChat.titleModerated` carries it. + func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, pictureBlobID: BlobID?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_EditChatRequest.with { + $0.chatID = conversationID.proto + if let title { + $0.title = .with { $0.value = title } + } + if let pictureBlobID { + $0.picture = .with { $0.blobID = .with { $0.value = pictureBlobID.data } } + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.editChat(request, options: .unaryDefault) + guard response.result == .ok else { + logger.error("Failed to edit chat") + await MainActor.run { completion(.failure(ErrorEditChat(response.result, flaggedCategory: response.flaggedCategory))) } + return + } + guard response.hasChat else { + logger.error("Failed to edit chat") + await MainActor.run { completion(.failure(.unknown)) } + return + } + await MainActor.run { completion(.success(Conversation(response.chat))) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } } // MARK: - Errors - @@ -362,6 +460,35 @@ public enum ErrorUnmuteChat: Int, Error { case rejected = -4 } +/// No `.ok` case: a success response resolves to `RosterFeedPage` in `ChatService.getRoster`'s `Result`, +/// it never reaches this type. Mapped explicitly from `GetRosterResponse.Result` — see +/// `ErrorGetRoster.init(_:)` — rather than via positional `rawValue:`, per this repo's convention for +/// a new result enum (`ErrorStartChat`, `ErrorEditChat`). +public enum ErrorGetRoster: Error, Sendable, Equatable { + case denied + case notFound + case unknown + case transportFailure + case cancelled + case rejected +} + +/// No `.ok` case, and modelled on `ErrorStartChat` for the same reason: `.titleModerated` carries the +/// `flaggedCategory` the server reports for it. Mapped explicitly from `EditChatResponse.Result` — see +/// `ErrorEditChat.init(_:flaggedCategory:)` — never via positional `rawValue:`, since +/// `EditChatResponse.Result` has five cases against this file's usual three and a coincidental +/// positional match would silently break the day a case is inserted upstream. +public enum ErrorEditChat: Error, Sendable, Equatable { + case denied + case notFound + case titleModerated(Flipcash_Moderation_V1_FlaggedCategory) + case pictureBlobNotAccepted + case unknown + case transportFailure + case cancelled + case rejected +} + extension ErrorGetDmChatFeed: ServerError, TransportClassifiableError { public var reportingLevel: ErrorReportingLevel { switch self { @@ -475,3 +602,67 @@ extension ErrorUnmuteChat: ServerError, TransportClassifiableError { } } } + +extension ErrorGetRoster: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorGetRoster { + /// Maps a non-`.ok` `GetRosterResponse.Result` to its domain error. Pure and synchronous so the + /// mapping is unit-testable without a live RPC, and total over the proto enum (`.ok` and + /// `.UNRECOGNIZED` both fold to `.unknown`) even though callers only reach this once they've + /// confirmed `result != .ok`. + init(_ result: Flipcash_Chat_V1_GetRosterResponse.Result) { + switch result { + case .ok: + self = .unknown + case .denied: + self = .denied + case .notFound: + self = .notFound + case .UNRECOGNIZED: + self = .unknown + } + } +} + +extension ErrorEditChat: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .titleModerated, .pictureBlobNotAccepted: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorEditChat { + /// Maps a non-`.ok` `EditChatResponse.Result` to its domain error, carrying `flaggedCategory` + /// through on `.titleModerated` the same way `ErrorStartChat.init(_:flaggedCategory:)` does. + /// Pure, synchronous, and total over the proto enum; callers only reach this once they've + /// confirmed `result != .ok`. + init(_ result: Flipcash_Chat_V1_EditChatResponse.Result, flaggedCategory: Flipcash_Moderation_V1_FlaggedCategory) { + switch result { + case .ok: + self = .unknown + case .denied: + self = .denied + case .notFound: + self = .notFound + case .titleModerated: + self = .titleModerated(flaggedCategory) + case .pictureBlobNotAccepted: + self = .pictureBlobNotAccepted + case .UNRECOGNIZED: + self = .unknown + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift index ffeceae97..a56592306 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/Conversation.swift @@ -226,9 +226,26 @@ public struct ConversationMember: Hashable, Sendable, Identifiable { /// conversation and needs no separate profile fetch. public var username: Username? + /// When this member most recently joined the chat (a rejoin carries the rejoin time, not the + /// original one). Set for a group's members on a `Chat.GetRoster` page and on + /// `RosterUpdate.MemberJoined` — the two carriers a client builds its member list from. `nil` on + /// `Metadata.members` (which carries only the viewer's own entry) and for every DM participant, + /// whose roster is fixed at creation. + public var joinedAt: Date? + + /// The roster version at which this member most recently joined — equal to + /// ``ConversationRosterSummary/version`` at the moment of that join. Zero for a member joined at + /// the chat's creation and for every DM participant. + /// + /// This is the merge key a client must use when reconciling a `Chat.GetRoster` page against what + /// the event stream has already told it: keep the entry with the greater `version` so a page that + /// trails the stream (see the `GetRoster` staleness contract) cannot resurrect a member the stream + /// has already removed. Set wherever ``joinedAt`` is. + public var version: UInt64 + public var id: String { userID?.uuidString ?? displayName } - public init(userID: UserID?, displayName: String, phoneE164: String? = nil, readPointer: MessageID? = nil, readPointerTimestamp: Date? = nil, profilePicture: ProfilePicture? = nil, username: Username? = nil) { + public init(userID: UserID?, displayName: String, phoneE164: String? = nil, readPointer: MessageID? = nil, readPointerTimestamp: Date? = nil, profilePicture: ProfilePicture? = nil, username: Username? = nil, joinedAt: Date? = nil, version: UInt64 = 0) { self.userID = userID self.displayName = displayName self.phoneE164 = phoneE164 @@ -236,6 +253,8 @@ public struct ConversationMember: Hashable, Sendable, Identifiable { self.readPointerTimestamp = readPointerTimestamp self.profilePicture = profilePicture self.username = username + self.joinedAt = joinedAt + self.version = version } /// The member's phone number formatted for display, used as a conversation @@ -272,5 +291,7 @@ extension ConversationMember { self.username = proto.userProfile.hasUsername ? Username(proto.userProfile.username) : nil + self.joinedAt = proto.hasJoinedAt ? proto.joinedAt.date : nil + self.version = proto.version } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index 5d86c0295..c4d2abc43 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -48,13 +48,22 @@ public struct ConversationViewerState: Hashable, Codable, Sendable { /// client can store the message) but flagged for the client to suppress the notification. public let mute: ConversationMuteState? + /// Whether the viewer may call `Chat.EditChat` on this chat. `false` for every DM and for a + /// group member the server does not permit to edit it. + /// + /// Server-computed from the viewer's standing and the chat's rules and never derivable + /// client-side: show an edit affordance if and only if this is `true`. Defaults to `false`, so + /// an unset permission always means not permitted. + public let canEdit: Bool + /// Opaque version, advanced by exactly one on every real change. Compared like /// ``ConversationRosterSummary/version``: apply the greater value and drop the rest — delivery /// order does not matter, and there is no delta to fetch, only a refetch. public let version: UInt64 - public init(mute: ConversationMuteState? = nil, version: UInt64 = 0) { + public init(mute: ConversationMuteState? = nil, canEdit: Bool = false, version: UInt64 = 0) { self.mute = mute + self.canEdit = canEdit self.version = version } } @@ -63,6 +72,7 @@ extension ConversationViewerState { public init(_ proto: Flipcash_Chat_V1_ViewerState) { self.init( mute: (proto.hasSettings && proto.settings.hasMute) ? ConversationMuteState(proto.settings.mute) : nil, + canEdit: proto.hasPermissions ? proto.permissions.canEdit : false, version: proto.version ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index 47d74d4ee..f8c612a90 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -337,6 +337,12 @@ public struct ConversationStore: Sendable { case .viewerStateChanged(let conversationID, let viewerState): applyViewerStateChanged(viewerState, in: conversationID) return .none + case .titleChanged(let conversationID, let title): + applyTitleChanged(title, in: conversationID) + return .none + case .pictureChanged(let conversationID, let picture): + applyPictureChanged(picture, in: conversationID) + return .none } } @@ -490,6 +496,23 @@ public struct ConversationStore: Sendable { conversations[index].viewerState = viewerState } + /// Apply a title change delivered via `MetadataUpdate.TitleChanged`. Best-effort and applied as + /// received — the update carries no version to compare, unlike ``applyRosterUpdates(_:in:)`` and + /// ``applyViewerStateChanged(_:in:)`` — so a delayed or reordered delivery can in principle + /// overwrite a newer title with an older one until the next full refresh corrects it. No-ops for + /// a chat the store doesn't hold. + public mutating func applyTitleChanged(_ title: String, in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + conversations[index].title = title + } + + /// Apply a picture change delivered via `MetadataUpdate.PictureChanged`. Same best-effort, + /// no-version caveat as ``applyTitleChanged(_:in:)``. No-ops for a chat the store doesn't hold. + public mutating func applyPictureChanged(_ picture: ProfilePicture, in conversationID: ConversationID) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + conversations[index].picture = picture + } + /// Drops the cached viewer state for a chat the signed-in user just left. /// /// The server clears mute on leave and restarts the version with it, so this discards the diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift index 82738ea43..6e090d52a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStreamEvent.swift @@ -38,9 +38,19 @@ public enum ConversationStreamEvent: Sendable { /// doesn't matter. case rosterChanged(conversationID: ConversationID, updates: [DecodedRosterUpdate]) - /// The signed-in user's viewer state changed (currently: mute status). Compared like - /// `rosterChanged` — apply by ``ConversationViewerState/version``, greater wins, drop the rest. + /// The signed-in user's viewer state changed (currently: mute status, permissions). Compared + /// like `rosterChanged` — apply by ``ConversationViewerState/version``, greater wins, drop the + /// rest. case viewerStateChanged(conversationID: ConversationID, viewerState: ConversationViewerState) + + /// A group chat's title changed (via `Chat.EditChat`), including on the editor's other devices. + /// Best-effort and applied as received, with no version to compare — a client that suspects a + /// miss refetches the chat via `Chat.GetChat`. + case titleChanged(conversationID: ConversationID, title: String) + + /// A group chat's picture changed (via `Chat.EditChat`), including on the editor's other + /// devices. Best-effort and applied as received, like ``titleChanged``. + case pictureChanged(conversationID: ConversationID, picture: ProfilePicture) } /// One durable event in a chat's log: a contiguous run of mutations delivered atomically. `sequence` @@ -140,6 +150,13 @@ extension ConversationStreamEvent { case .viewerStateChanged(let changed): guard changed.hasViewerState else { break } events.append(.viewerStateChanged(conversationID: conversationID, viewerState: ConversationViewerState(changed.viewerState))) + case .titleChanged(let changed): + events.append(.titleChanged(conversationID: conversationID, title: changed.newTitle)) + case .pictureChanged(let changed): + // `ProfilePicture.init?` fails without an original rendition — required on the + // wire, but treated the same as an absent picture rather than force-unwrapped. + guard changed.hasNewPicture, let picture = ProfilePicture(changed.newPicture) else { break } + events.append(.pictureChanged(conversationID: conversationID, picture: picture)) case nil: break } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift new file mode 100644 index 000000000..ff2edd0ec --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationMetadataEditTests.swift @@ -0,0 +1,74 @@ +// +// ConversationMetadataEditTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashCore + +/// `Chat.EditChat`'s title/picture updates: unlike the roster summary and viewer state, these +/// arrive with no version to compare, so the store applies them as received. +@Suite("Conversation metadata edits (title, picture)") +struct ConversationMetadataEditTests { + + private func conversationID(_ byte: UInt8) -> ConversationID { + ConversationID(data: Data(repeating: byte, count: 32)) + } + + private func group(_ byte: UInt8, title: String? = nil) -> Conversation { + Conversation( + id: conversationID(byte), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: .group, + title: title + ) + } + + private func picture(_ byte: UInt8) -> ProfilePicture { + ProfilePicture(blobID: BlobID(data: Data(repeating: byte, count: 16)), thumbnailBlobID: BlobID(data: Data(repeating: byte, count: 16))) + } + + @Test("A title change is applied to the matching chat") + func titleChangeApplies() { + var store = ConversationStore() + store.apply(.metadataRefresh(group(1, title: "Old title"))) + + store.applyTitleChanged("New title", in: conversationID(1)) + + #expect(store.conversations[0].title == "New title") + } + + @Test("A title change for a chat the store doesn't hold is a no-op") + func titleChangeForUnknownChatNoOps() { + var store = ConversationStore() + + store.applyTitleChanged("New title", in: conversationID(1)) + + #expect(store.conversations.isEmpty) + } + + @Test("A picture change is applied to the matching chat") + func pictureChangeApplies() { + var store = ConversationStore() + store.apply(.metadataRefresh(group(1))) + + let newPicture = picture(0x02) + store.applyPictureChanged(newPicture, in: conversationID(1)) + + #expect(store.conversations[0].picture == newPicture) + } + + @Test("A picture change for a chat the store doesn't hold is a no-op") + func pictureChangeForUnknownChatNoOps() { + var store = ConversationStore() + + store.applyPictureChanged(picture(0x02), in: conversationID(1)) + + #expect(store.conversations.isEmpty) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index b164e9856..cf3a290a9 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -367,6 +367,50 @@ struct ConversationModelMappingTests { #expect(member.formattedPhoneNumber == nil) } + @Test("Member maps joinedAt and version, the GetRoster merge key") + func memberMapsJoinedAtAndVersion() { + let joinedAt = Date(timeIntervalSince1970: 1_700_000_000) + let proto = Flipcash_Chat_V1_Member.with { + $0.userID = .with { $0.value = UUID().data } + $0.joinedAt = .init(date: joinedAt) + $0.version = 5 + } + + let member = ConversationMember(proto) + #expect(member.joinedAt == joinedAt) + #expect(member.version == 5) + } + + @Test("Member without joinedAt/version maps to nil/zero (a DM participant, or a member joined at creation)") + func memberWithoutJoinedAtOrVersion() { + let proto = Flipcash_Chat_V1_Member.with { + $0.userID = .with { $0.value = UUID().data } + } + + let member = ConversationMember(proto) + #expect(member.joinedAt == nil) + #expect(member.version == 0) + } + + @Test("ViewerState maps permissions.canEdit") + func viewerStateMapsCanEdit() { + let proto = Flipcash_Chat_V1_ViewerState.with { + $0.version = 2 + $0.permissions = .with { $0.canEdit = true } + } + + #expect(ConversationViewerState(proto).canEdit) + } + + @Test("ViewerState without permissions defaults canEdit to false (never derivable client-side)") + func viewerStateWithoutPermissionsDefaultsCanEditFalse() { + let proto = Flipcash_Chat_V1_ViewerState.with { + $0.version = 2 + } + + #expect(ConversationViewerState(proto).canEdit == false) + } + @Test("counterpartReadReceipt returns the other member's pointer and read time") func counterpartReadReceiptReturnsOtherMember() { let me = UUID() diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift index 8fa91ec3d..f6b567b31 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStreamEventDecodeTests.swift @@ -64,6 +64,66 @@ struct ConversationStreamEventDecodeTests { #expect(date == Date(timeIntervalSince1970: 900)) } + @Test("TitleChanged decodes to a titleChanged event") + func titleChanged() { + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { + $0.titleChanged = .with { $0.newTitle = "New title" } + }] + } + } + + let decoded = ConversationStreamEvent.decode(event) + guard case .titleChanged(let conversationID, let title) = decoded.first else { + Issue.record("expected .titleChanged"); return + } + #expect(conversationID == ConversationID(data: conversationBytes)) + #expect(title == "New title") + } + + @Test("PictureChanged decodes to a pictureChanged event") + func pictureChanged() { + let blobBytes = Data(repeating: 0x01, count: 16) + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { + $0.pictureChanged = .with { + $0.newPicture = .with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = blobBytes } + }] + } + } + }] + } + } + + let decoded = ConversationStreamEvent.decode(event) + guard case .pictureChanged(let conversationID, let picture) = decoded.first else { + Issue.record("expected .pictureChanged"); return + } + #expect(conversationID == ConversationID(data: conversationBytes)) + #expect(picture.blobID == BlobID(data: blobBytes)) + } + + @Test("PictureChanged with no picture set decodes to nothing") + func pictureChangedWithoutPicture() { + let event = Flipcash_Event_V1_Event.with { + $0.chatUpdate = .with { + $0.chat = .with { $0.value = conversationBytes } + $0.metadataUpdates = [.with { + $0.pictureChanged = .init() + }] + } + } + + #expect(ConversationStreamEvent.decode(event).isEmpty) + } + @Test("An event batch and a metadata update decode to both events in order") func combined() { let event = Flipcash_Event_V1_Event.with { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index ce3ff2128..048a607fb 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -83,6 +83,8 @@ struct TransportClassificationTests { @Test func errorLeaveChat() { assertClassifies(ErrorLeaveChat.self) } @Test func errorMuteChat() { assertClassifies(ErrorMuteChat.self) } @Test func errorUnmuteChat() { assertClassifies(ErrorUnmuteChat.self) } + @Test func errorGetRoster() { assertClassifies(ErrorGetRoster.self) } + @Test func errorEditChat() { assertClassifies(ErrorEditChat.self) } @Test func errorReport() { assertClassifies(ErrorReport.self) } // In-band outcomes fall outside the generic four-case contract. @@ -116,6 +118,36 @@ struct TransportClassificationTests { #expect(ErrorStartChat(.rulesNotSatisfied, flaggedCategory: .nsfw) == .rulesNotSatisfied) } + // `ErrorEditChat` carries the same kind of payload on `.titleModerated`, mapped explicitly from + // `EditChatResponse.Result` rather than by positional `rawValue:` — this result enum has five + // cases against this file's usual three, so a coincidental positional match would silently break + // the day a case is inserted upstream. + @Test("ErrorEditChat.titleModerated carries the flagged category from a TITLE_MODERATED response") + func errorEditChatTitleModeratedCarriesCategory() { + let error = ErrorEditChat(.titleModerated, flaggedCategory: .nsfw) + guard case .titleModerated(let category) = error else { + Issue.record("Expected .titleModerated, got \(error)") + return + } + #expect(category == .nsfw) + #expect(error.reportingLevel == .info) + #expect(!error.isRetryable) + + // Every other result maps to its payload-less case, unaffected by the category argument. + #expect(ErrorEditChat(.denied, flaggedCategory: .nsfw) == .denied) + #expect(ErrorEditChat(.notFound, flaggedCategory: .nsfw) == .notFound) + #expect(ErrorEditChat(.pictureBlobNotAccepted, flaggedCategory: .nsfw) == .pictureBlobNotAccepted) + } + + // `ErrorGetRoster` has no payload-carrying case, but it's still mapped explicitly from + // `GetRosterResponse.Result` rather than by positional `rawValue:`, per this file's convention + // for a freshly introduced result enum. + @Test("ErrorGetRoster maps GetRosterResponse.Result explicitly") + func errorGetRosterMapsResult() { + #expect(ErrorGetRoster(.denied) == .denied) + #expect(ErrorGetRoster(.notFound) == .notFound) + } + // MARK: - Tier 2: associated-value errors that capture the transport error - // These don't conform to TransportClassifiableError (they carry the error in a // case rather than mapping to a dedicated one), so their `reportingLevel` is