From a5c565cacb853b3399f98d9d7dbff41e6da70d17 Mon Sep 17 00:00:00 2001 From: Morgan Pretty <morgan.t.pretty@gmail.com> Date: Fri, 11 Oct 2024 12:17:39 +1100 Subject: [PATCH] Default community logic refactoring and bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Moved the default community retrieval logic to be in it's job • Fixed a bug where a parsing failure could be incorrectly reported as a successful request --- Session.xcodeproj/project.pbxproj | 6 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 2 +- .../RetrieveDefaultOpenGroupRoomsJob.swift | 129 +++- .../Open Groups/OpenGroupAPI.swift | 6 +- .../Open Groups/OpenGroupManager.swift | 162 +---- ...RetrieveDefaultOpenGroupRoomsJobSpec.swift | 608 ++++++++++++++++++ .../Open Groups/OpenGroupAPISpec.swift | 11 +- .../Open Groups/OpenGroupManagerSpec.swift | 244 ++----- .../Pollers/CommunityPollerSpec.swift | 3 - .../_TestUtilities/MockOGMCache.swift | 9 +- SessionSnodeKit/Types/BatchResponse.swift | 3 +- SessionSnodeKit/Types/PreparedRequest.swift | 3 +- 12 files changed, 847 insertions(+), 339 deletions(-) create mode 100644 SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 16e788239..ed745726e 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -680,6 +680,7 @@ FD481A9A2CB4CAE500ECC4CF /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */; }; FD481A9B2CB4CAF100ECC4CF /* CustomArgSummaryDescribable+SMK.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SMK.swift */; }; FD481A9C2CB4D58300ECC4CF /* MockSnodeAPICache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */; }; + FD481AA32CB889AE00ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD481AA22CB889A400ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift */; }; FD49E2462B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; FD49E2472B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; FD49E2482B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; @@ -1885,6 +1886,7 @@ FD481A8F2CAD16EA00ECC4CF /* LibSessionGroupInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionGroupInfoSpec.swift; sourceTree = "<group>"; }; FD481A912CAD17D900ECC4CF /* LibSessionGroupMembersSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibSessionGroupMembersSpec.swift; sourceTree = "<group>"; }; FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAppContext.swift; sourceTree = "<group>"; }; + FD481AA22CB889A400ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetrieveDefaultOpenGroupRoomsJobSpec.swift; sourceTree = "<group>"; }; FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = "<group>"; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = "<group>"; }; FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayPictureError.swift; sourceTree = "<group>"; }; @@ -4357,8 +4359,9 @@ FD96F3A229DBC3BA00401309 /* Jobs */ = { isa = PBXGroup; children = ( - FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */, FD3FAB662AF0C47000DC5421 /* DisplayPictureDownloadJobSpec.swift */, + FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */, + FD481AA22CB889A400ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift */, ); path = Jobs; sourceTree = "<group>"; @@ -6696,6 +6699,7 @@ FD72BDA42BE3690B00CF6CF6 /* CryptoSMKSpec.swift in Sources */, FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, FD336F6C2CAA29C600C0B51B /* CommunityPollerSpec.swift in Sources */, + FD481AA32CB889AE00ECC4CF /* RetrieveDefaultOpenGroupRoomsJobSpec.swift in Sources */, FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */, FD01502A2CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index cef04d866..15f4eb8af 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -158,7 +158,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight) widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true - dependencies[singleton: .openGroupManager].getDefaultRoomsIfNeeded() + dependencies[cache: .openGroupManager].defaultRoomsPublisher .subscribe(on: DispatchQueue.global(qos: .default)) .receive(on: DispatchQueue.main) .sinkUntilComplete( diff --git a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift index a174893e2..3a1740c54 100644 --- a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import SessionSnodeKit import SessionUtilitiesKit // MARK: - Log.Category @@ -25,10 +26,18 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { deferred: @escaping (Job) -> Void, using dependencies: Dependencies ) { - // Don't run when inactive or not in main app - guard dependencies[defaults: .appGroup, key: .isMainAppActive] else { - return deferred(job) // Don't need to do anything if it's not the main app - } + /// Don't run when inactive or not in main app + /// + /// Additionally, since this job can be triggered by the user viewing the "Join Community" screen it's possible for multiple jobs to run at + /// the same time, we don't want to waste bandwidth by making redundant calls to fetch the default rooms so don't do anything if there + /// is already a job running + guard + dependencies[defaults: .appGroup, key: .isMainAppActive], + dependencies[singleton: .jobRunner] + .jobInfoFor(state: .running, variant: .retrieveDefaultOpenGroupRooms) + .filter({ key, info in key != job.id }) // Exclude this job + .isEmpty + else { return deferred(job) } // The OpenGroupAPI won't make any API calls if there is no entry for an OpenGroup // in the database so we need to create a dummy one to retrieve the default room data @@ -49,10 +58,19 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { .upserted(db) } - dependencies[singleton: .openGroupManager] - .getDefaultRoomsIfNeeded() - .subscribe(on: queue) - .receive(on: queue) + /// Try to retrieve the default rooms 8 times + dependencies[singleton: .storage] + .readPublisher { [dependencies] db -> Network.PreparedRequest<OpenGroupAPI.CapabilitiesAndRoomsResponse> in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + } + .flatMap { [dependencies] request in request.send(using: dependencies) } + .subscribe(on: queue, using: dependencies) + .receive(on: queue, using: dependencies) + .retry(8, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in switch result { @@ -64,7 +82,102 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { Log.error(.cat, "Failed to get default Community rooms due to error: \(error)") failure(job, error, false) } + }, + receiveValue: { info, response in + let defaultRooms: [OpenGroupManager.DefaultRoomInfo]? = dependencies[singleton: .storage].write { db -> [OpenGroupManager.DefaultRoomInfo] in + // Store the capabilities first + OpenGroupManager.handleCapabilities( + db, + capabilities: response.capabilities.data, + on: OpenGroupAPI.defaultServer + ) + + let existingImageIds: [String: String] = try OpenGroup + .filter(OpenGroup.Columns.server == OpenGroupAPI.defaultServer) + .filter(OpenGroup.Columns.imageId != nil) + .fetchAll(db) + .reduce(into: [:]) { result, next in result[next.id] = next.imageId } + let result: [OpenGroupManager.DefaultRoomInfo] = try response.rooms.data + .compactMap { room -> OpenGroupManager.DefaultRoomInfo? in + /// Try to insert an inactive version of the OpenGroup (use `insert` rather than + /// `save` as we want it to fail if the room already exists) + do { + return ( + room, + try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: room.token, + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: room.name, + roomDescription: room.roomDescription, + imageId: room.imageId, + userCount: room.activeUsers, + infoUpdates: room.infoUpdates + ) + .inserted(db) + ) + } + catch { + return try OpenGroup + .fetchOne( + db, + id: OpenGroup.idFor( + roomToken: room.token, + server: OpenGroupAPI.defaultServer + ) + ) + .map { (room, $0) } + } + } + + /// Schedule the room image download (if it doesn't match out current one) + result.forEach { room, openGroup in + let openGroupId: String = OpenGroup.idFor(roomToken: room.token, server: OpenGroupAPI.defaultServer) + + guard + let imageId: String = room.imageId, + imageId != existingImageIds[openGroupId] || + openGroup.displayPictureFilename == nil + else { return } + + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .community( + imageId: imageId, + roomToken: room.token, + server: OpenGroupAPI.defaultServer + ), + timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + ) + ), + canStartJob: true + ) + } + + return result + } + + /// Update the `openGroupManager` cache to have the default rooms + dependencies.mutate(cache: .openGroupManager) { cache in + cache.setDefaultRoomInfo(defaultRooms ?? []) + } } ) } + + public static func run(using dependencies: Dependencies) { + RetrieveDefaultOpenGroupRoomsJob.run( + Job(variant: .retrieveDefaultOpenGroupRooms, behaviour: .runOnce), + queue: DispatchQueue.global(qos: .default), + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index d86238ead..b2fef8b8a 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -386,12 +386,14 @@ public enum OpenGroupAPI { let capabilitiesInfo: ResponseInfoType = maybeCapabilities, let capabilities: Capabilities = maybeCapabilities?.body, let roomsInfo: ResponseInfoType = maybeRooms, - let rooms: [Room] = maybeRooms?.body + let roomsResponse: Network.BatchSubResponse<[Room]> = maybeRooms, + !roomsResponse.failedToParseBody else { throw NetworkError.parsingFailed } + // We might want to remove all default rooms for some reason so support that case return ( capabilities: (info: capabilitiesInfo, data: capabilities), - rooms: (info: roomsInfo, data: rooms) + rooms: (info: roomsInfo, data: (roomsResponse.body ?? [])) ) } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 28760df85..e3c8169c9 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -20,7 +20,7 @@ public extension Singleton { public extension Cache { static let openGroupManager: CacheConfig<OGMCacheType, OGMImmutableCacheType> = Dependencies.create( identifier: "openGroupManager", - createInstance: { _ in OpenGroupManager.Cache() }, + createInstance: { dependencies in OpenGroupManager.Cache(using: dependencies) }, mutableInstance: { $0 }, immutableInstance: { $0 } ) @@ -925,137 +925,40 @@ public final class OpenGroupManager { case .group: return false } } - - @discardableResult public func getDefaultRoomsIfNeeded() -> AnyPublisher<[DefaultRoomInfo], Error> { - // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again - if let existingPublisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies[cache: .openGroupManager].defaultRoomsPublisher { - return existingPublisher - } - - // Try to retrieve the default rooms 8 times - let publisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> Network.PreparedRequest<OpenGroupAPI.CapabilitiesAndRoomsResponse> in - try OpenGroupAPI.preparedCapabilitiesAndRooms( - db, - on: OpenGroupAPI.defaultServer, - using: dependencies - ) - } - .flatMap { [dependencies] request in request.send(using: dependencies) } - .subscribe(on: OpenGroupAPI.workQueue, using: dependencies) - .receive(on: OpenGroupAPI.workQueue, using: dependencies) - .retry(8, using: dependencies) - .map { [dependencies] info, response -> [DefaultRoomInfo]? in - dependencies[singleton: .storage].write { db -> [DefaultRoomInfo] in - // Store the capabilities first - OpenGroupManager.handleCapabilities( - db, - capabilities: response.capabilities.data, - on: OpenGroupAPI.defaultServer - ) - - let existingImageIds: [String: String] = try OpenGroup - .filter(OpenGroup.Columns.server == OpenGroupAPI.defaultServer) - .filter(OpenGroup.Columns.imageId != nil) - .fetchAll(db) - .reduce(into: [:]) { result, next in result[next.id] = next.imageId } - let result: [DefaultRoomInfo] = try response.rooms.data - .compactMap { room -> DefaultRoomInfo? in - // Try to insert an inactive version of the OpenGroup (use 'insert' - // rather than 'save' as we want it to fail if the room already exists) - do { - return ( - room, - try OpenGroup( - server: OpenGroupAPI.defaultServer, - roomToken: room.token, - publicKey: OpenGroupAPI.defaultServerPublicKey, - isActive: false, - name: room.name, - roomDescription: room.roomDescription, - imageId: room.imageId, - userCount: room.activeUsers, - infoUpdates: room.infoUpdates - ) - .inserted(db) - ) - } - catch { - return try OpenGroup - .fetchOne( - db, - id: OpenGroup.idFor( - roomToken: room.token, - server: OpenGroupAPI.defaultServer - ) - ) - .map { (room, $0) } - } - } - - /// Schedule the room image download (if it doesn't match out current one) - result.forEach { room, _ in - let openGroupId: String = OpenGroup.idFor(roomToken: room.token, server: OpenGroupAPI.defaultServer) - - guard - let imageId: String = room.imageId, - imageId != existingImageIds[openGroupId] - else { return } - - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: imageId, - roomToken: room.token, - server: OpenGroupAPI.defaultServer - ), - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ) - ), - canStartJob: true - ) - } - - return result - } - } - .map { ($0 ?? []) } - .handleEvents( - receiveCompletion: { [dependencies] result in - switch result { - case .finished: break - case .failure: - dependencies.mutate(cache: .openGroupManager) { cache in - cache.defaultRoomsPublisher = nil - } - } - } - ) - .shareReplay(1) - .eraseToAnyPublisher() - - dependencies.mutate(cache: .openGroupManager) { cache in - cache.defaultRoomsPublisher = publisher - } - - // Hold on to the publisher until it has completed at least once - publisher.sinkUntilComplete() - - return publisher - } } // MARK: - OpenGroupManager Cache public extension OpenGroupManager { class Cache: OGMCacheType { - public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error>? + private let dependencies: Dependencies + private let defaultRoomsSubject: CurrentValueSubject<[DefaultRoomInfo], Error> = CurrentValueSubject([]) + private var _timeSinceLastOpen: TimeInterval? + public var pendingChanges: [OpenGroupAPI.PendingChange] = [] + + public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error> { + defaultRoomsSubject + .handleEvents( + receiveSubscription: { [weak defaultRoomsSubject, dependencies] _ in + /// If we don't have any default rooms in memory then we haven't fetched this launch so schedule + /// the `RetrieveDefaultOpenGroupRoomsJob` if one isn't already running + if defaultRoomsSubject?.value.isEmpty == true { + RetrieveDefaultOpenGroupRoomsJob.run(using: dependencies) + } + } + ) + .filter { !$0.isEmpty } + .eraseToAnyPublisher() + } + + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + // MARK: - Functions - fileprivate var _timeSinceLastOpen: TimeInterval? public func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval { if let storedTimeSinceLastOpen: TimeInterval = _timeSinceLastOpen { return storedTimeSinceLastOpen @@ -1070,7 +973,9 @@ public extension OpenGroupManager { return dependencies.dateNow.timeIntervalSince(lastOpen) } - public var pendingChanges: [OpenGroupAPI.PendingChange] = [] + public func setDefaultRoomInfo(_ info: [DefaultRoomInfo]) { + defaultRoomsSubject.send(info) + } } } @@ -1078,15 +983,16 @@ public extension OpenGroupManager { /// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way public protocol OGMImmutableCacheType: ImmutableCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get } + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { get } var pendingChanges: [OpenGroupAPI.PendingChange] { get } } public protocol OGMCacheType: OGMImmutableCacheType, MutableCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get set } + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { get } var pendingChanges: [OpenGroupAPI.PendingChange] { get set } func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval + func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) } diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift new file mode 100644 index 000000000..7126ce2e9 --- /dev/null +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -0,0 +1,608 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +import Quick +import Nimble + +@testable import SessionSnodeKit +@testable import SessionMessagingKit +@testable import SessionUtilitiesKit + +class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { + override class func spec() { + // MARK: Configuration + + @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in + dependencies.forceSynchronous = true + dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) + } + @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + customWriter: try! DatabaseQueue(), + migrationTargets: [ + SNUtilitiesKit.self, + SNMessagingKit.self + ], + using: dependencies, + initialData: { db in + try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) + try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) + try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) + try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) + } + ) + @TestState(defaults: .appGroup, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults( + initialSetup: { defaults in + defaults.when { $0.bool(forKey: .any) }.thenReturn(true) + } + ) + @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( + initialSetup: { network in + network + .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .thenReturn( + MockNetwork.batchResponseData( + with: [ + ( + OpenGroupAPI.Endpoint.capabilities, + OpenGroupAPI.Capabilities(capabilities: [.blind, .reactions]).batchSubResponse() + ), + ( + OpenGroupAPI.Endpoint.rooms, + [ + OpenGroupAPI.Room.mock.with( + token: "testRoom", + name: "TestRoomName" + ), + OpenGroupAPI.Room.mock.with( + token: "testRoom2", + name: "TestRoomName2", + infoUpdates: 12, + imageId: "12" + ) + ].batchSubResponse() + ) + ] + ) + ) + } + ) + @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( + initialSetup: { jobRunner in + jobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + jobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + jobRunner + .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } + .thenReturn([:]) + } + ) + @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( + initialSetup: { cache in + cache.when { $0.setDefaultRoomInfo(.any) }.thenReturn(()) + } + ) + @TestState var job: Job! = Job(variant: .retrieveDefaultOpenGroupRooms) + @TestState var error: Error? = nil + @TestState var permanentFailure: Bool! = false + @TestState var wasDeferred: Bool! = false + + // MARK: - a RetrieveDefaultOpenGroupRoomsJob + describe("a RetrieveDefaultOpenGroupRoomsJob") { + // MARK: -- defers the job if the main app is not running + it("defers the job if the main app is not running") { + mockUserDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(false) + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in wasDeferred = true }, + using: dependencies + ) + + expect(wasDeferred).to(beTrue()) + } + + // MARK: -- does not defer the job when the main app is running + it("does not defer the job when the main app is running") { + mockUserDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in wasDeferred = true }, + using: dependencies + ) + + expect(wasDeferred).to(beFalse()) + } + + // MARK: -- defers the job if there is an existing job running + it("defers the job if there is an existing job running") { + mockJobRunner + .when { $0.jobInfoFor(jobs: .any, state: .running, variant: .retrieveDefaultOpenGroupRooms) } + .thenReturn([ + 101: JobRunner.JobInfo( + variant: .retrieveDefaultOpenGroupRooms, + threadId: nil, + interactionId: nil, + detailsData: nil, + uniqueHashValue: nil + ) + ]) + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in wasDeferred = true }, + using: dependencies + ) + + expect(wasDeferred).to(beTrue()) + } + + // MARK: -- does not defer the job when there is no existing job + it("does not defer the job when there is no existing job") { + mockJobRunner + .when { $0.jobInfoFor(jobs: .any, state: .running, variant: .retrieveDefaultOpenGroupRooms) } + .thenReturn([:]) + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in wasDeferred = true }, + using: dependencies + ) + + expect(wasDeferred).to(beFalse()) + } + + // MARK: -- creates an inactive entry in the database if one does not exist + it("creates an inactive entry in the database if one does not exist") { + mockNetwork + .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .thenReturn(MockNetwork.errorResponse()) + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } + expect(openGroups?.count).to(equal(1)) + expect(openGroups?.map { $0.server }).to(equal([OpenGroupAPI.defaultServer])) + expect(openGroups?.map { $0.roomToken }).to(equal([""])) + expect(openGroups?.map { $0.publicKey }).to(equal([OpenGroupAPI.defaultServerPublicKey])) + expect(openGroups?.map { $0.isActive }).to(equal([false])) + expect(openGroups?.map { $0.name }).to(equal([""])) + } + + // MARK: -- does not create a new entry if one already exists + it("does not create a new entry if one already exists") { + mockNetwork + .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .thenReturn(MockNetwork.errorResponse()) + + mockStorage.write { db in + try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "TestExisting", + userCount: 0, + infoUpdates: 0 + ) + .insert(db) + } + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } + expect(openGroups?.count).to(equal(1)) + expect(openGroups?.map { $0.server }).to(equal([OpenGroupAPI.defaultServer])) + expect(openGroups?.map { $0.roomToken }).to(equal([""])) + expect(openGroups?.map { $0.publicKey }).to(equal([OpenGroupAPI.defaultServerPublicKey])) + expect(openGroups?.map { $0.isActive }).to(equal([false])) + expect(openGroups?.map { $0.name }).to(equal(["TestExisting"])) + } + + // MARK: -- sends the correct request + it("sends the correct request") { + mockStorage.write { db in + try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "TestExisting", + userCount: 0, + infoUpdates: 0 + ) + .insert(db) + } + let expectedRequest: Network.PreparedRequest<OpenGroupAPI.CapabilitiesAndRoomsResponse>! = mockStorage.read { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: OpenGroupAPI.defaultServer, + using: dependencies + ) + } + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + expect(mockNetwork) + .to(call { network in + network.send( + expectedRequest.body, + to: expectedRequest.destination, + requestTimeout: expectedRequest.requestTimeout, + requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + ) + }) + } + + // MARK: -- will retry 8 times before it fails + it("will retry 8 times before it fails") { + mockNetwork + .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .thenReturn(MockNetwork.nullResponse()) + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, error_, permanentFailure_ in + error = error_ + permanentFailure = permanentFailure_ + }, + deferred: { _ in }, + using: dependencies + ) + + expect(error).to(matchError(NetworkError.parsingFailed)) + expect(mockNetwork) // First attempt + 8 retries + .to(call(.exactly(times: 9)) { network in + network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) + }) + } + + // MARK: -- stores the updated capabilities + it("stores the updated capabilities") { + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + let capabilities: [Capability]? = mockStorage.read { db in try Capability.fetchAll(db) } + expect(capabilities?.count).to(equal(2)) + expect(capabilities?.map { $0.openGroupServer }) + .to(equal([OpenGroupAPI.defaultServer, OpenGroupAPI.defaultServer])) + expect(capabilities?.map { $0.variant }).to(equal([.blind, .reactions])) + expect(capabilities?.map { $0.isMissing }).to(equal([false, false])) + } + + // MARK: -- inserts the returned rooms + it("inserts the returned rooms") { + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } + expect(openGroups?.count).to(equal(3)) // 1 for the entry used to fetch the default rooms + expect(openGroups?.map { $0.server }) + .to(equal([OpenGroupAPI.defaultServer, OpenGroupAPI.defaultServer, OpenGroupAPI.defaultServer])) + expect(openGroups?.map { $0.roomToken }).to(equal(["", "testRoom", "testRoom2"])) + expect(openGroups?.map { $0.publicKey }) + .to(equal([ + OpenGroupAPI.defaultServerPublicKey, + OpenGroupAPI.defaultServerPublicKey, + OpenGroupAPI.defaultServerPublicKey + ])) + expect(openGroups?.map { $0.isActive }).to(equal([false, false, false])) + expect(openGroups?.map { $0.name }).to(equal(["", "TestRoomName", "TestRoomName2"])) + } + + // MARK: -- does not override existing rooms that were returned + it("does not override existing rooms that were returned") { + mockStorage.write { db in + try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "testRoom", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "TestExisting", + userCount: 0, + infoUpdates: 0 + ) + .insert(db) + } + mockNetwork + .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .thenReturn( + MockNetwork.batchResponseData( + with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + ( + OpenGroupAPI.Endpoint.rooms, + try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( + Network.BatchSubResponse( + code: 200, + headers: [:], + body: [ + OpenGroupAPI.Room.mock.with( + token: "testRoom", + name: "TestReplacementName" + ) + ], + failedToParseBody: false + ) + ) + ) + ] + ) + ) + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } + expect(openGroups?.count).to(equal(2)) // 1 for the entry used to fetch the default rooms + expect(openGroups?.map { $0.server }) + .to(equal([OpenGroupAPI.defaultServer, OpenGroupAPI.defaultServer])) + expect(openGroups?.map { $0.roomToken }.sorted()).to(equal(["", "testRoom"])) + expect(openGroups?.map { $0.publicKey }) + .to(equal([OpenGroupAPI.defaultServerPublicKey, OpenGroupAPI.defaultServerPublicKey])) + expect(openGroups?.map { $0.isActive }).to(equal([false, false])) + expect(openGroups?.map { $0.name }.sorted()).to(equal(["", "TestExisting"])) + } + + // MARK: -- schedules a display picture download + it("schedules a display picture download") { + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + expect(mockJobRunner) + .to(call(matchingParameters: .all) { + $0.add( + .any, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .community( + imageId: "12", + roomToken: "testRoom2", + server: OpenGroupAPI.defaultServer + ), + timestamp: 1234567890 + ) + ), + dependantJob: nil, + canStartJob: true + ) + }) + } + + // MARK: -- schedules a display picture download if the imageId has changed + it("schedules a display picture download if the imageId has changed") { + mockStorage.write { db in + try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "testRoom2", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "TestExisting", + imageId: "10", + userCount: 0, + infoUpdates: 10, + displayPictureFilename: "TestFilename" + ) + .insert(db) + } + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + expect(mockJobRunner) + .to(call(matchingParameters: .all) { + $0.add( + .any, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .community( + imageId: "12", + roomToken: "testRoom2", + server: OpenGroupAPI.defaultServer + ), + timestamp: 1234567890 + ) + ), + dependantJob: nil, + canStartJob: true + ) + }) + } + + // MARK: -- does not schedule a display picture download if there is no imageId + it("does not schedule a display picture download if there is no imageId") { + mockNetwork + .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .thenReturn( + MockNetwork.batchResponseData( + with: [ + ( + OpenGroupAPI.Endpoint.capabilities, + OpenGroupAPI.Capabilities(capabilities: [.blind, .reactions]).batchSubResponse() + ), + ( + OpenGroupAPI.Endpoint.rooms, + [ + OpenGroupAPI.Room.mock.with( + token: "testRoom", + name: "TestRoomName" + ), + OpenGroupAPI.Room.mock.with( + token: "testRoom2", + name: "TestRoomName2" + ) + ].batchSubResponse() + ) + ] + ) + ) + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + expect(mockJobRunner) + .toNot(call { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }) + } + + // MARK: -- does not schedule a display picture download if the imageId matches and the image has already been downloaded + it("does not schedule a display picture download if the imageId matches and the image has already been downloaded") { + mockStorage.write { db in + try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "testRoom2", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "TestExisting", + imageId: "12", + userCount: 0, + infoUpdates: 12, + displayPictureFilename: "TestFilename" + ) + .insert(db) + } + + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + expect(mockJobRunner) + .toNot(call { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }) + } + + // MARK: -- updates the cache with the default rooms + it("does not schedule a display picture download if the imageId matches and the image has already been downloaded") { + RetrieveDefaultOpenGroupRoomsJob.run( + job, + queue: .main, + success: { _, _ in }, + failure: { _, _, _ in }, + deferred: { _ in }, + using: dependencies + ) + + expect(mockOGMCache) + .toNot(call(matchingParameters: .all) { + $0.setDefaultRoomInfo([ + ( + room: OpenGroupAPI.Room.mock.with( + token: "testRoom", + name: "TestRoomName" + ), + openGroup: OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "testRoom", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "TestRoomName", + userCount: 0, + infoUpdates: 0 + ) + ), + ( + room: OpenGroupAPI.Room.mock.with( + token: "testRoom2", + name: "TestRoomName2", + infoUpdates: 12, + imageId: "12" + ), + openGroup: OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "testRoom2", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "TestRoomName2", + imageId: "12", + userCount: 0, + infoUpdates: 12, + displayPictureFilename: nil + ) + ) + ]) + }) + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index aab6d09dd..595dbcde2 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -567,13 +567,20 @@ class OpenGroupAPISpec: QuickSpec { } // MARK: ---- and given an invalid response - context("and given an invalid response") { // MARK: ------ errors when not given a room response it("errors when not given a room response") { mockNetwork .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(Network.BatchResponse.mockCapabilitiesAndBanResponse) + .thenReturn( + MockNetwork.batchResponseData(with: [ + (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), + ( + OpenGroupAPI.Endpoint.userBan(""), + OpenGroupAPI.DirectMessage.mockBatchSubResponse() + ) + ]) + ) var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomsResponse)? diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 1e12894ba..44a833c78 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -131,7 +131,13 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn([:]) } ) - @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork() + @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( + initialSetup: { network in + network + .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .thenReturn(MockNetwork.errorResponse()) + } + ) @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( initialSetup: { crypto in crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn([]) @@ -170,6 +176,11 @@ class OpenGroupManagerSpec: QuickSpec { defaults.when { $0.integer(forKey: .any) }.thenReturn(0) } ) + @TestState(defaults: .appGroup, in: dependencies) var mockAppGroupDefaults: MockUserDefaults! = MockUserDefaults( + initialSetup: { defaults in + defaults.when { $0.bool(forKey: .any) }.thenReturn(false) + } + ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( initialSetup: { cache in cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) @@ -177,12 +188,10 @@ class OpenGroupManagerSpec: QuickSpec { ) @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( initialSetup: { cache in - cache - .when { $0.defaultRoomsPublisher = .any(type: [OpenGroupManager.DefaultRoomInfo].self) } - .thenReturn(()) cache.when { $0.pendingChanges }.thenReturn([]) cache.when { $0.pendingChanges = .any }.thenReturn(()) cache.when { $0.getTimeSinceLastOpen(using: .any) }.thenReturn(0) + cache.when { $0.setDefaultRoomInfo(.any) }.thenReturn(()) } ) @TestState var mockPoller: MockCommunityPoller! = MockCommunityPoller( @@ -202,7 +211,7 @@ class OpenGroupManagerSpec: QuickSpec { ) @TestState var disposables: [AnyCancellable]! = [] - @TestState var cache: OpenGroupManager.Cache! = OpenGroupManager.Cache() + @TestState var cache: OpenGroupManager.Cache! = OpenGroupManager.Cache(using: dependencies) @TestState var openGroupManager: OpenGroupManager! = OpenGroupManager(using: dependencies) // MARK: - an OpenGroupManager @@ -2738,206 +2747,51 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: -- when getting the default rooms if needed - context("when getting the default rooms if needed") { - beforeEach { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomsResponse) - + // MARK: -- when accessing the default rooms publisher + context("when accessing the default rooms publisher") { + // MARK: ---- starts a job to retrieve the default rooms if we have none + it("starts a job to retrieve the default rooms if we have none") { + mockAppGroupDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) mockStorage.write { db in - try OpenGroup.deleteAll(db) - - // This is done in the 'RetrieveDefaultOpenGroupRoomsJob' - _ = try OpenGroup( + try OpenGroup( server: OpenGroupAPI.defaultServer, roomToken: "", publicKey: OpenGroupAPI.defaultServerPublicKey, isActive: false, - name: "", + name: "TestExisting", userCount: 0, infoUpdates: 0 ) .insert(db) } - - mockOGMCache.when { $0.defaultRoomsPublisher }.thenReturn(nil) - mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.object(forKey: .any) - }.thenReturn(nil) - mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in - defaults.set(anyAny(), forKey: .any) - }.thenReturn(()) - } - - // MARK: ---- caches the publisher if there is no cached publisher - it("caches the publisher if there is no cached publisher") { - let publisher = openGroupManager.getDefaultRoomsIfNeeded() - - expect(mockOGMCache) - .to(call(matchingParameters: .all) { - $0.defaultRoomsPublisher = publisher - }) - } - - // MARK: ---- returns the cached publisher if there is one - it("returns the cached publisher if there is one") { - let uniqueRoomInstance: OpenGroupAPI.Room = OpenGroupAPI.Room.mock.with( - token: "UniqueId", - name: "" - ) - let group: OpenGroup = OpenGroup( - server: "testServer", - roomToken: "UniqueId", - publicKey: "", - isActive: true, - name: "", - userCount: 0, - infoUpdates: 0 - ) - let publisher = Future<[OpenGroupManager.DefaultRoomInfo], Error> { resolver in - resolver(Result.success([(uniqueRoomInstance, group)])) + let expectedRequest: Network.PreparedRequest<OpenGroupAPI.CapabilitiesAndRoomsResponse>! = mockStorage.read { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( + db, + on: OpenGroupAPI.defaultServer, + using: dependencies + ) } - .shareReplay(1) - .eraseToAnyPublisher() - mockOGMCache.when { $0.defaultRoomsPublisher }.thenReturn(publisher) - let publisher2 = openGroupManager.getDefaultRoomsIfNeeded() - - expect(publisher2.firstValue()?.map { $0.room }) - .to(equal(publisher.firstValue()?.map { $0.room })) - } - - // MARK: ---- stores the open group information - it("stores the open group information") { - openGroupManager.getDefaultRoomsIfNeeded() - - // 1 for the value returned from the API and 1 for the default added - // by the 'RetrieveDefaultOpenGroupRoomsJob' logic - expect(mockStorage.read { db -> Int in try OpenGroup.fetchCount(db) }).to(equal(2)) - expect( - mockStorage.read { db -> String? in - try OpenGroup - .select(.server) - .asRequest(of: String.self) - .fetchOne(db) - } - ).to(equal("https://open.getsession.org")) - expect( - mockStorage.read { db -> String? in - try OpenGroup - .select(.publicKey) - .asRequest(of: String.self) - .fetchOne(db) - } - ).to(equal("a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238")) - expect( - mockStorage.read { db -> Bool? in - try OpenGroup - .select(.isActive) - .asRequest(of: Bool.self) - .fetchOne(db) - } - ).to(beFalse()) - } - - // MARK: ---- fetches rooms for the server - it("fetches rooms for the server") { - var response: [OpenGroupManager.DefaultRoomInfo]? - - openGroupManager.getDefaultRoomsIfNeeded() - .handleEvents(receiveOutput: { response = $0 }) - .sinkAndStore(in: &disposables) - - expect(response?.map { $0.room }) - .to(equal([OpenGroupAPI.Room.mock])) - } - - // MARK: ---- will retry fetching rooms 8 times before it fails - it("will retry fetching rooms 8 times before it fails") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.nullResponse()) - - var error: Error? - - openGroupManager.getDefaultRoomsIfNeeded() - .mapError { result -> Error in error.setting(to: result) } - .sinkAndStore(in: &disposables) + cache.defaultRoomsPublisher.sinkUntilComplete() - expect(error).to(matchError(NetworkError.parsingFailed)) - expect(mockNetwork) // First attempt + 8 retries - .to(call(.exactly(times: 9)) { network in - network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) + expect(mockNetwork) + .to(call { network in + network.send( + expectedRequest.body, + to: expectedRequest.destination, + requestTimeout: expectedRequest.requestTimeout, + requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + ) }) } - // MARK: ---- removes the cache publisher if all retries fail - it("removes the cache publisher if all retries fail") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.nullResponse()) - - var error: Error? - - openGroupManager.getDefaultRoomsIfNeeded() - .mapError { result -> Error in error.setting(to: result) } - .sinkAndStore(in: &disposables) + // MARK: ---- does not start a job to retrieve the default rooms if we already have rooms + it("does not start a job to retrieve the default rooms if we already have rooms") { + mockAppGroupDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) + cache.setDefaultRoomInfo([(room: OpenGroupAPI.Room.mock, openGroup: OpenGroup.mock)]) + cache.defaultRoomsPublisher.sinkUntilComplete() - expect(error) - .to(matchError(NetworkError.parsingFailed)) - expect(mockOGMCache) - .to(call(matchingParameters: .all) { - $0.defaultRoomsPublisher = nil - }) - } - - // MARK: ---- schedules jobs to download any room images - it("schedules jobs to download any room images") { - mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn( - MockNetwork.batchResponseData( - with: [ - (OpenGroupAPI.Endpoint.capabilities, OpenGroupAPI.Capabilities.mockBatchSubResponse()), - ( - OpenGroupAPI.Endpoint.rooms, - [ - OpenGroupAPI.Room.mock.with( - token: "test2", - name: "test2", - infoUpdates: 11, - imageId: "12" - ) - ].batchSubResponse() - ) - ] - ) - ) - - openGroupManager - .getDefaultRoomsIfNeeded() - .sinkAndStore(in: &disposables) - - expect(mockJobRunner) - .to(call(matchingParameters: .all) { - $0.add( - .any, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: "12", - roomToken: "test2", - server: OpenGroupAPI.defaultServer - ), - timestamp: 1234567890 - ) - ), - dependantJob: nil, - canStartJob: true - ) - }) + expect(mockNetwork) + .toNot(call { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) }) } } } @@ -3014,6 +2868,18 @@ extension OpenGroupAPI.RoomPollInfo { // MARK: - Mock Types +extension OpenGroup: Mocked { + static var mock: OpenGroup = OpenGroup( + server: "testserver", + roomToken: "testRoom", + publicKey: TestConstants.serverPublicKey, + isActive: true, + name: "testRoom", + userCount: 0, + infoUpdates: 0 + ) +} + extension OpenGroupAPI.Capabilities: Mocked { static var mock: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [], missing: nil) } diff --git a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift index 2dea6c6be..d66a07ad8 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift @@ -77,9 +77,6 @@ class CommunityPollerSpec: QuickSpec { ) @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( initialSetup: { cache in - cache - .when { $0.defaultRoomsPublisher = .any(type: [OpenGroupManager.DefaultRoomInfo].self) } - .thenReturn(()) cache.when { $0.pendingChanges }.thenReturn([]) cache.when { $0.getTimeSinceLastOpen(using: .any) }.thenReturn(0) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift index 657a2fdda..103b644ad 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift @@ -7,9 +7,8 @@ import SessionUtilitiesKit @testable import SessionMessagingKit class MockOGMCache: Mock<OGMCacheType>, OGMCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { - get { return mock() } - set { mockNoReturn(args: [newValue]) } + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { + mock() } var pendingChanges: [OpenGroupAPI.PendingChange] { @@ -20,4 +19,8 @@ class MockOGMCache: Mock<OGMCacheType>, OGMCacheType { func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval { return mock(args: [dependencies]) } + + func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) { + mockNoReturn(args: [info]) + } } diff --git a/SessionSnodeKit/Types/BatchResponse.swift b/SessionSnodeKit/Types/BatchResponse.swift index a3f3a2da7..5e5f8eac6 100644 --- a/SessionSnodeKit/Types/BatchResponse.swift +++ b/SessionSnodeKit/Types/BatchResponse.swift @@ -107,7 +107,7 @@ public extension Network { code: Int, headers: [String: String] = [:], body: T? = nil, - failedToParseBody: Bool = false + failedToParseBody: Bool ) { self.code = code self.headers = headers @@ -151,6 +151,7 @@ extension Network.BatchSubResponse: Decodable { protocol ErasedBatchSubResponse: ResponseInfoType { var erasedBody: Any? { get } + var failedToParseBody: Bool { get } } // MARK: - Convenience diff --git a/SessionSnodeKit/Types/PreparedRequest.swift b/SessionSnodeKit/Types/PreparedRequest.swift index 027d81859..c6cf77171 100644 --- a/SessionSnodeKit/Types/PreparedRequest.swift +++ b/SessionSnodeKit/Types/PreparedRequest.swift @@ -362,7 +362,8 @@ extension Network.PreparedRequest: ErasedPreparedRequest { return Network.BatchSubResponse( code: subResponse.code, headers: subResponse.headers, - body: try originalType.from(subResponse.erasedBody).map { try converter(info, $0) } + body: try originalType.from(subResponse.erasedBody).map { try converter(info, $0) }, + failedToParseBody: subResponse.failedToParseBody ) default: return try originalType.from(data).map { try converter(info, $0) } as Any