From 204fd96f86f95fe5f9645f0227bff8924c06d5ae Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Wed, 6 Nov 2024 17:10:29 +0100 Subject: [PATCH 01/14] Conversation - add UTs for local store and repository --- .../project.pbxproj | 12 + .../ConversationLocalStoreTests.swift | 588 ++++++++++++ .../ConversationRepositoryTests.swift | 847 +++++------------- 3 files changed, 815 insertions(+), 632 deletions(-) create mode 100644 WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift diff --git a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj index 97d9511d0bd..85cc176f24b 100644 --- a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj +++ b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ C96B75432CDB8E03003A85EB /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75422CDB8E03003A85EB /* NSManagedObjectContext.swift */; }; C96B75452CDBA0A9003A85EB /* ConversationDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75442CDBA0A9003A85EB /* ConversationDeleteEventProcessorTests.swift */; }; C96B75482CDBA10F003A85EB /* SystemMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75462CDBA10F003A85EB /* SystemMessage.swift */; }; + C96B755D2CDBB176003A85EB /* ConversationLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */; }; C97BCCAA2C98704B004F2D0D /* WireDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 01D0DCA62C1C8C870076CB1C /* WireDomain.framework */; }; C97C013D2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */; }; C97C014B2CB00F92000683C5 /* OneOnOneResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */; }; @@ -181,6 +182,7 @@ C96B75422CDB8E03003A85EB /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; C96B75442CDBA0A9003A85EB /* ConversationDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDeleteEventProcessorTests.swift; sourceTree = ""; }; C96B75462CDBA10F003A85EB /* SystemMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMessage.swift; sourceTree = ""; }; + C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLocalStoreTests.swift; sourceTree = ""; }; C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPropertiesDeleteEventProcessorTests.swift; sourceTree = ""; }; C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OneOnOneResolver.swift; path = ../../OneOnOneResolver.swift; sourceTree = ""; }; C97C014F2CB01BDF000683C5 /* OneOnOneResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneResolverTests.swift; sourceTree = ""; }; @@ -341,6 +343,7 @@ 017F679A2C20801800B6E02D /* WireDomain */ = { isa = PBXGroup; children = ( + C96B755B2CDBB149003A85EB /* LocalStores */, C97C015D2CB40EBE000683C5 /* UseCases */, C9C8FDCD2C9DBE0E00702B91 /* Event Processing */, EEC410252C60D48900E89394 /* Synchronization */, @@ -481,6 +484,14 @@ path = Models; sourceTree = ""; }; + C96B755B2CDBB149003A85EB /* LocalStores */ = { + isa = PBXGroup; + children = ( + C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */, + ); + path = LocalStores; + sourceTree = ""; + }; C97C01582CB40010000683C5 /* FederationEventProcessor */ = { isa = PBXGroup; children = ( @@ -1104,6 +1115,7 @@ C97C01AC2CB92D47000683C5 /* UserLegalholdEnableEventProcessorTests.swift in Sources */, C97C015A2CB40010000683C5 /* FederationDeleteEventProcessorTests.swift in Sources */, C9C8FDD42C9DBE0E00702B91 /* UserLegalholdRequestEventProcessorTests.swift in Sources */, + C96B755D2CDBB176003A85EB /* ConversationLocalStoreTests.swift in Sources */, 017F679C2C20801800B6E02D /* TeamRepositoryTests.swift in Sources */, C9E8A3B72C749F2A0093DD5C /* ConversationLabelsRepositoryTests.swift in Sources */, ); diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift new file mode 100644 index 00000000000..cd18026d741 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift @@ -0,0 +1,588 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +@testable import WireAPI +import WireAPISupport +import WireDataModel +import WireDataModelSupport +@testable import WireDomain +import WireDomainSupport +import XCTest + +final class ConversationLocalStoreTests: XCTestCase { + + private var sut: ConversationLocalStore! + private var userLocalStore: MockUserLocalStoreProtocol! + private var mlsService: MockMLSServiceInterface! + + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + mlsService = MockMLSServiceInterface() + coreDataStackHelper = CoreDataStackHelper() + userLocalStore = MockUserLocalStoreProtocol() + modelHelper = ModelHelper() + stack = try await coreDataStackHelper.createStack() + sut = ConversationLocalStore( + context: context, + mlsService: mlsService, + userLocalStore: userLocalStore + ) + } + + override func tearDown() async throws { + mlsService = nil + stack = nil + sut = nil + try coreDataStackHelper.cleanupDirectory() + coreDataStackHelper = nil + modelHelper = nil + userLocalStore = nil + } + + // MARK: - Tests + + func testStoreConversation_It_Stores_Conversation_Locally() async throws { + + // Mock + + let groupConversation = Scaffolding.groupConversation + let qualifiedID = try XCTUnwrap(groupConversation.qualifiedID) + let id = qualifiedID.uuid + let domain = qualifiedID.domain + + // When + + await sut.storeConversation( + groupConversation, + timestamp: .distantPast, + isFederationEnabled: false + ) + + // Then + + let localConversation = await sut.fetchConversation( + id: id, + domain: domain + ) + + await context.perform { + XCTAssertNotNil(localConversation) + XCTAssertEqual(localConversation?.remoteIdentifier, id) + } + } + + func testStoreFailedConversation_It_Sets_Pending_Metadata_Refresh_And_Backend_Update_Flags_To_True() async throws { + // Given + + let groupConversation = Scaffolding.groupConversation + let qualifiedID = try XCTUnwrap(groupConversation.qualifiedID) + let id = qualifiedID.uuid + let domain = qualifiedID.domain + + // When + + await sut.storeFailedConversation( + withQualifiedId: qualifiedID + ) + + // Then + + let localConversation = await sut.fetchConversation( + id: id, + domain: domain + ) + + await context.perform { + XCTAssertEqual(localConversation?.isPendingMetadataRefresh, true) + XCTAssertEqual(localConversation?.needsToBeUpdatedFromBackend, true) + } + } + + func testStoreConversation_It_Needs_Backend_Update() async throws { + + // Mock + + let groupConversation = Scaffolding.groupConversation + let qualifiedID = try XCTUnwrap(groupConversation.qualifiedID) + let id = qualifiedID.uuid + let domain = qualifiedID.domain + + await context.perform { [self] in + let conversation = modelHelper.createGroupConversation(id: id, in: context) + XCTAssertEqual(conversation.needsToBeUpdatedFromBackend, false) + } + + // When + + await sut.storeConversation( + needsBackendUpdate: true, + qualifiedId: qualifiedID + ) + + // Then + + let localConversation = await sut.fetchConversation( + id: id, + domain: domain + ) + + await context.perform { + XCTAssertEqual(localConversation?.needsToBeUpdatedFromBackend, true) + } + } + + func testFetchMLSConversation_It_Retrieves_Conversation_Locally() async throws { + // Mock + + let mlsGroupID = try XCTUnwrap( + MLSGroupID(base64Encoded: Scaffolding.base64EncodedString) + ) + + let mlsConversation = await context.perform { [self] in + modelHelper.createMLSConversation( + mlsGroupID: mlsGroupID, + in: context + ) + } + + // When + + let localConversation = await sut.fetchMLSConversation( + groupID: mlsGroupID + ) + + // Then + + await context.perform { + XCTAssertEqual(localConversation, mlsConversation) + } + } + + func testRemoveParticipantFromAllGroupConversation_It_Appends_A_System_Message_To_All_Team_Conversations_When_A_Member_Leave() async throws { + + // Mock + + let user = try await context.perform { [self] in + let (team, users, _) = modelHelper.createTeam( + id: Scaffolding.teamID, + withMembers: [Scaffolding.userID], + inGroupConversation: Scaffolding.teamConversationID, + context: context + ) + + modelHelper.createGroupConversation( + id: Scaffolding.anotherTeamConversationID, + with: users, + team: team, + domain: nil, + in: context + ) + + modelHelper.createGroupConversation( + id: Scaffolding.conversationID, + with: Set(users), + domain: nil, + in: context + ) + + let user = try XCTUnwrap(users.first) + let member = try XCTUnwrap(team.members.first) + XCTAssertEqual(user.membership, member) + + return user + } + + let timestamp = Scaffolding.date(from: Scaffolding.time) + + // When + + await sut.removeParticipantFromAllGroupConversations( + user: user, + date: timestamp + ) + + // Then + + try await context.perform { [self] in + + XCTAssertNotNil(Team.fetch(with: Scaffolding.teamID, in: context)) + + let teamConversation = try XCTUnwrap(ZMConversation.fetch(with: Scaffolding.teamConversationID, in: context), "No Team Conversation") + + let teamAnotherConversation = try XCTUnwrap(ZMConversation.fetch(with: Scaffolding.anotherTeamConversationID, in: context), "No Team Conversation") + + let conversation = try XCTUnwrap(ZMConversation.fetch(with: Scaffolding.conversationID, in: context), "No Conversation") + + try internalTest_checkLastMessage( + in: teamConversation, + messageType: .teamMemberLeave, + at: timestamp + ) + + try internalTest_checkLastMessage( + in: teamAnotherConversation, + messageType: .teamMemberLeave, + at: timestamp + ) + + let lastMessage = try XCTUnwrap(conversation.lastMessage as? ZMSystemMessage) + XCTAssertNotEqual(lastMessage.systemMessageType, .teamMemberLeave, "Should not append leave message to regular conversation") + } + } + + func testRemoveParticipantFromConversation_It_Removes_Participant() async throws { + + // Mock + + let (removedUser, remainingUsers, conversation) = await context.perform { [self] in + let user1 = modelHelper.createUser(in: context) + let user2 = modelHelper.createUser(in: context) + let user3 = modelHelper.createUser(in: context) + let removedUser = modelHelper.createUser(id: Scaffolding.userID, in: context) + + let conversation = modelHelper.createGroupConversation( + id: Scaffolding.conversationID, + with: [removedUser, user1, user2, user3], + in: context + ) + + return (removedUser, [user1, user2, user3], conversation) + } + + // When + + await sut.removeParticipantFromAllGroupConversations( + user: removedUser, + date: .now + ) + + // Then + + await context.perform { + XCTAssertEqual(conversation.localParticipants, Set(remainingUsers)) + XCTAssertEqual(conversation.localParticipants.contains(removedUser), false) + } + } + + func testAddOrUpdateParticipant_It_Adds_Participant_To_Conversation() async { + + // Mock + + let (addedUser, conversation) = await context.perform { [self] in + let user1 = modelHelper.createUser(in: context) + let user2 = modelHelper.createUser(in: context) + let user3 = modelHelper.createUser(in: context) + let addedUser = modelHelper.createUser(id: Scaffolding.userID, in: context) + + let conversation = modelHelper.createGroupConversation( + id: Scaffolding.conversationID, + with: [user1, user2, user3], + in: context + ) + + return (addedUser, conversation) + } + + // When + + await sut.addOrUpdateParticipant( + addedUser, + withRole: ZMConversation.defaultMemberRoleName, + in: conversation + ) + + // Then + + await context.perform { + XCTAssertEqual(conversation.localParticipants.contains(addedUser), true) + } + } + + func testFetchConversation_It_Retrieves_Conversation_Locally() async { + + // Mock + + let conversation = await context.perform { [self] in + modelHelper.createGroupConversation( + id: Scaffolding.conversationID, + domain: Scaffolding.domain, + in: context + ) + } + + // When + + let localConversation = await sut.fetchConversation( + id: Scaffolding.conversationID, + domain: Scaffolding.domain + ) + + // Then + + XCTAssertEqual(conversation, localConversation) + } + + func testDeleteConversation_It_Marks_Conversation_As_Deleted_Locally() async throws { + + // Mock + + let conversation = await context.perform { [self] in + modelHelper.createGroupConversation(in: context) + } + + // When + + await sut.deleteConversation(conversation) + + // Then + + await context.perform { + XCTAssertEqual(conversation.isDeletedRemotely, true) + } + } + + func testRemoveParticipants_It_Removes_Participant_From_A_Given_Conversation() async throws { + + // Mock + + let (conversation, selfUser, senderUser, removedUser) = await context.perform { [self] in + let selfUser = modelHelper.createSelfUser(id: Scaffolding.selfUserId, in: context) + let senderUser = modelHelper.createUser(id: Scaffolding.userID, in: context) + let removedUser = modelHelper.createUser(id: Scaffolding.otherUserID, in: context) + let mlsGroupID = MLSGroupID(base64Encoded: Scaffolding.base64EncodedString) + + let mlsConversation = modelHelper.createMLSConversation( + id: Scaffolding.conversationID, + mlsGroupID: mlsGroupID, + with: [senderUser, selfUser, removedUser], + in: context + ) + + return (mlsConversation, selfUser, senderUser, removedUser) + } + + // When + + await sut.removeParticipantsAndUpdateConversationState( + conversation: conversation, + users: [removedUser], + initiatingUser: senderUser + ) + + // Then + + await context.perform { + let newParticipants = conversation.localParticipants + XCTAssertEqual(newParticipants, [selfUser, senderUser]) + XCTAssertFalse(newParticipants.contains(removedUser)) // user was successfuly removed from conversation + } + } + + func testAddOrUpdateParticipant_It_Updates_Participant_Role_In_Conversation() async throws { + + // Mock + + let (updatedUser, conversation) = await context.perform { [self] in + let updatedUser = modelHelper.createUser(id: Scaffolding.userID, in: context) + + let conversation = modelHelper.createGroupConversation( + id: Scaffolding.conversationID, + with: [updatedUser], + in: context + ) + + return (updatedUser, conversation) + } + + // When + + await sut.addOrUpdateParticipant( + updatedUser, + withRole: ZMConversation.defaultAdminRoleName, + in: conversation + ) + + // Then + + try await context.perform { + let role = try XCTUnwrap(updatedUser.role(in: conversation)) + XCTAssertEqual(role.name, ZMConversation.defaultAdminRoleName) + } + } + + func testAddParticipants_It_Adds_Participants_To_Conversation() async throws { + + // Mock + + let (conversation, sender, addedUser) = await context.perform { [self] in + let conversation = modelHelper.createGroupConversation( + id: Scaffolding.conversationID, + domain: Scaffolding.domain, + in: context + ) + + let addedUser = modelHelper.createUser( + qualifiedID: .init(uuid: Scaffolding.otherUserID, domain: Scaffolding.domain), + in: context + ) + + let sender = modelHelper.createUser( + qualifiedID: .init(uuid: Scaffolding.userID, domain: Scaffolding.domain), + in: context + ) + + return (conversation, sender, addedUser) + } + + userLocalStore.fetchOrCreateUserIdDomain_MockValue = addedUser + userLocalStore.fetchUserIdDomain_MockValue = sender + + // When + + try await sut.addParticipants( + [(Scaffolding.otherUserID, + Scaffolding.domain, + ZMConversation.defaultMemberRoleName)], + addedBy: (Scaffolding.userID, Scaffolding.domain), + atDate: .distantPast, + to: conversation + ) + + // Then + + XCTAssertEqual(userLocalStore.fetchOrCreateUserIdDomain_Invocations.count, 1) + XCTAssertEqual(userLocalStore.fetchUserIdDomain_Invocations.count, 1) + + await context.perform { + XCTAssertTrue(conversation.localParticipants.contains(addedUser)) + } + } + + func testAddSystemMessage_It_Adds_System_Message_To_Conversation() async throws { + + // Mock + + let (conversation, user) = await context.perform { [self] in + let conversation = modelHelper.createGroupConversation( + id: Scaffolding.conversationID, + domain: Scaffolding.domain, + in: context + ) + + let user = modelHelper.createUser(in: context) + + return (conversation, user) + } + + let timestamp = Scaffolding.date(from: Scaffolding.time) + + let systemMessage = SystemMessage( + type: .participantsAdded, + sender: user, + timestamp: timestamp + ) + + // When + + await sut.addSystemMessage(systemMessage, to: conversation) + + // Then + + try await context.perform { [self] in + try internalTest_checkLastMessage( + in: conversation, + messageType: .participantsAdded, + at: timestamp + ) + } + } + + private func internalTest_checkLastMessage( + in conversation: ZMConversation, + messageType: ZMSystemMessageType, + at timestamp: Date + ) throws { + let lastMessage = try XCTUnwrap( + conversation.lastMessage as? ZMSystemMessage, + "Last message is not system message" + ) + + XCTAssertEqual( + lastMessage.systemMessageType, + messageType, "System message is not \(messageType.rawValue): but '\(lastMessage.systemMessageType.rawValue)" + ) + + let serverTimeStamp = try XCTUnwrap( + lastMessage.serverTimestamp, "System message should have timestamp" + ) + + XCTAssertEqual( + serverTimeStamp.timeIntervalSince1970, + timestamp.timeIntervalSince1970, + accuracy: 0.1 + ) + } + + private enum Scaffolding { + static let selfUserId = UUID() + static let domain = "domain.com" + static let teamID = UUID() + static let userID = UUID() + static let otherUserID = UUID() + static let time = "2021-05-12T10:52:02.671Z" + static let teamConversationID = UUID() + static let anotherTeamConversationID = UUID() + static let conversationID = UUID() + static let base64EncodedString = "pQABARn//wKhAFggHsa0CszLXYLFcOzg8AA//E1+Dl1rDHQ5iuk44X0/PNYDoQChAFgg309rkhG6SglemG6kWae81P1HtQPx9lyb6wExTovhU4cE9g==" + + static func date(from string: String) -> Date { + ISO8601DateFormatter.fractionalInternetDateTime.date(from: string)! + } + + static let groupConversation = Conversation( + id: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, + qualifiedID: .init(uuid: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, domain: "example.com"), + teamID: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, + type: .group, + messageProtocol: .proteus, + mlsGroupID: "", + cipherSuite: .MLS_128_DHKEMP256_AES128GCM_SHA256_P256, + epoch: 0, + epochTimestamp: nil, + creator: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, + members: nil, + name: nil, + messageTimer: 0, + readReceiptMode: 0, + access: [.invite], + accessRoles: [.teamMember], + legacyAccessRole: .team, + lastEvent: "", + lastEventTime: nil + ) + + } + +} diff --git a/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift index e2e6d429f6c..7b285778d89 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift @@ -28,7 +28,7 @@ final class ConversationRepositoryTests: XCTestCase { private var sut: ConversationRepository! private var conversationsAPI: MockConversationsAPI! - private var conversationsLocalStore: ConversationLocalStoreProtocol! + private var conversationsLocalStore: MockConversationLocalStoreProtocol! private var userRepository: MockUserRepositoryProtocol! private let backendInfo: ConversationRepository.BackendInfo = .init( domain: "example.com", @@ -38,32 +38,27 @@ final class ConversationRepositoryTests: XCTestCase { private var teamRepository: MockTeamRepositoryProtocol! private var mlsService: MockMLSServiceInterface! private var mlsProvider: MLSProvider! + private var modelHelper: ModelHelper! + private var stack: CoreDataStack! private var coreDataStackHelper: CoreDataStackHelper! - private var modelHelper: ModelHelper! - private var userLocalStore: MockUserLocalStoreProtocol! private var context: NSManagedObjectContext { stack.syncContext } override func setUp() async throws { - try await super.setUp() mlsService = MockMLSServiceInterface() mlsProvider = MLSProvider(service: mlsService, isMLSEnabled: true) userRepository = MockUserRepositoryProtocol() teamRepository = MockTeamRepositoryProtocol() - coreDataStackHelper = CoreDataStackHelper() - userLocalStore = MockUserLocalStoreProtocol() modelHelper = ModelHelper() - stack = try await coreDataStackHelper.createStack() - conversationsLocalStore = ConversationLocalStore( - context: context, - mlsService: mlsService, - userLocalStore: userLocalStore - ) + conversationsLocalStore = MockConversationLocalStoreProtocol() conversationsAPI = MockConversationsAPI() userRepository = MockUserRepositoryProtocol() + + coreDataStackHelper = CoreDataStackHelper() + stack = try await coreDataStackHelper.createStack() sut = ConversationRepository( conversationsAPI: conversationsAPI, @@ -76,345 +71,189 @@ final class ConversationRepositoryTests: XCTestCase { } override func tearDown() async throws { - try await super.tearDown() userRepository = nil teamRepository = nil mlsProvider = nil mlsService = nil conversationsLocalStore = nil - stack = nil conversationsAPI = nil sut = nil + modelHelper = nil + stack = nil try coreDataStackHelper.cleanupDirectory() coreDataStackHelper = nil - modelHelper = nil - userLocalStore = nil } // MARK: - Tests - func testPullConversations_Found_And_Failed_Conversations_Are_Stored_Locally() async throws { - // Given - let uuids = Scaffolding.conversationList.found.compactMap(\.id) + Scaffolding.conversationList.failed.map(\.uuid) - - await context.perform { [context] in - // There are no conversations in the database. - - let conversations = ZMConversation.fetchObjects( - withRemoteIdentifiers: Set(uuids), - in: context - ) as! Set - - XCTAssertEqual(conversations.count, 0) - } - + func testPullFoundConversations_It_Invokes_Local_Store_And_Conversation_API_Methods() async throws { + // Mock + + conversationsAPI.getLegacyConversationIdentifiers_MockValue = .init(fetchPage: { _ in + .init( + element: [Scaffolding.id], + hasMore: false, + nextStart: .init() + ) + }) - mockConversationsAPI() + conversationsAPI.getConversationsFor_MockValue = .init( + found: [Scaffolding.conversation], + notFound: [], + failed: [] + ) + + conversationsLocalStore.storeConversationTimestampIsFederationEnabled_MockMethod = { _, _, _ in} // When + try await sut.pullConversations() // Then - await context.perform { [context] in - - let conversations = ZMConversation.fetchObjects( - withRemoteIdentifiers: Set(uuids), - in: context - ) as! Set - - XCTAssertEqual(conversations.count, uuids.count) - for conversation in conversations { - XCTAssert(uuids.contains(conversation.remoteIdentifier)) - } - } + XCTAssertEqual(conversationsAPI.getLegacyConversationIdentifiers_Invocations.count, 1) + XCTAssertEqual(conversationsAPI.getConversationsFor_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.storeConversationTimestampIsFederationEnabled_Invocations.count, 1) } - func testPullConversations_Found_Conversations_Pending_MetadataRefresh_And_Initial_Fetch_Are_False() async throws { - // Given - let uuids = Scaffolding.conversationList.found.compactMap(\.id) - - await context.perform { [context] in - // There are no conversations in the database. - - let conversations = ZMConversation.fetchObjects( - withRemoteIdentifiers: Set(uuids), - in: context - ) as! Set - - XCTAssertEqual(conversations.count, 0) - } - + func testPullNotFoundConversations_It_Invokes_Local_Store_And_Conversation_API_Methods() async throws { + // Mock - mockConversationsAPI() - - // When - try await sut.pullConversations() - - // Then - await context.perform { [context] in - let conversations = ZMConversation.fetchObjects( - withRemoteIdentifiers: Set(uuids), - in: context - ) as! Set - - XCTAssertEqual(conversations.count, uuids.count) - - for conversation in conversations { - XCTAssertEqual(conversation.isPendingMetadataRefresh, false) - XCTAssertEqual(conversation.isPendingInitialFetch, false) - } - } - } - - func testPullConversations_Failed_Conversations_Needs_To_Be_Updated_From_Backend_And_Pending_MetataRefresh_Are_True() async throws { - // Given - let failedUuids = Scaffolding.conversationList.failed.map(\.uuid) - - await context.perform { - // There are no conversations in the database. - let uuids = Scaffolding.conversationList.found.compactMap(\.id) + failedUuids - - let conversations = self.fetchConversations(withIds: uuids) - - XCTAssertEqual(conversations.count, 0) - - for conversation in conversations { - XCTAssertEqual(conversation.isPendingMetadataRefresh, false) - XCTAssertEqual(conversation.needsToBeUpdatedFromBackend, false) - } - } - - // Mock + conversationsAPI.getLegacyConversationIdentifiers_MockValue = .init(fetchPage: { _ in + .init( + element: [Scaffolding.id], + hasMore: false, + nextStart: .init() + ) + }) - mockConversationsAPI() + conversationsAPI.getConversationsFor_MockValue = .init( + found: [], + notFound: [QualifiedID(uuid: Scaffolding.id, domain: Scaffolding.domain)], + failed: [] + ) + + conversationsLocalStore.storeConversationNeedsBackendUpdateQualifiedId_MockMethod = { _, _ in} // When + try await sut.pullConversations() // Then - await context.perform { - let conversations = self.fetchConversations(withIds: failedUuids) - XCTAssertEqual(conversations.count, failedUuids.count) - - for conversation in conversations { - XCTAssertEqual(conversation.isPendingMetadataRefresh, true) - XCTAssertEqual(conversation.needsToBeUpdatedFromBackend, true) - } - } + + XCTAssertEqual(conversationsAPI.getLegacyConversationIdentifiers_Invocations.count, 1) + XCTAssertEqual(conversationsAPI.getConversationsFor_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.storeConversationNeedsBackendUpdateQualifiedId_Invocations.count, 1) } - func testPullConversations_Not_Found_Conversations_Needs_To_Be_Updated_From_Backend_Is_True() async throws { - // Given - - let uuids = Scaffolding.conversationList.notFound.map(\.uuid) - - await context.perform { [context] in - // We already have conversations in the database. - - for uuid in uuids { - _ = ZMConversation.fetchOrCreate( - with: uuid, - domain: self.backendInfo.domain, - in: context - ) - } - - let conversations = self.fetchConversations(withIds: uuids) - - for conversation in conversations { - XCTAssertEqual(conversation.needsToBeUpdatedFromBackend, false) - } - } - + func testPullFailedConversations_It_Invokes_Local_Store_And_Conversation_API_Methods() async throws { + // Mock - mockConversationsAPI() + conversationsAPI.getLegacyConversationIdentifiers_MockValue = .init(fetchPage: { _ in + .init( + element: [Scaffolding.id], + hasMore: false, + nextStart: .init() + ) + }) + + conversationsAPI.getConversationsFor_MockValue = .init( + found: [], + notFound: [], + failed: [QualifiedID(uuid: Scaffolding.id, domain: Scaffolding.domain)] + ) + + conversationsLocalStore.storeFailedConversationWithQualifiedId_MockMethod = { _ in} // When + try await sut.pullConversations() // Then - await context.perform { [self] in - let conversations = fetchConversations(withIds: uuids) - - for conversation in conversations { - XCTAssertEqual(conversation.needsToBeUpdatedFromBackend, true) - } - } + + XCTAssertEqual(conversationsAPI.getLegacyConversationIdentifiers_Invocations.count, 1) + XCTAssertEqual(conversationsAPI.getConversationsFor_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.storeFailedConversationWithQualifiedId_Invocations.count, 1) } - func testGetMLSOneToOneConversation() async throws { + func testPullMLSOneToOneConversation_It_Invokes_Local_Store_And_API_Methods() async throws { + // Mock - mockConversationsAPI() + conversationsAPI.getMLSOneToOneConversationUserIDIn_MockValue = Scaffolding.conversation + conversationsLocalStore.storeConversationTimestampIsFederationEnabled_MockMethod = { _, _, _ in } // When let mlsGroupID = try await sut.pullMLSOneToOneConversation( - userID: Scaffolding.userID.uuidString, + userID: Scaffolding.id.uuidString, userDomain: Scaffolding.domain ) - let mlsConversation = await sut.fetchMLSConversation(groupID: mlsGroupID) - // Then - - await context.perform { - XCTAssertEqual(mlsConversation?.remoteIdentifier, Scaffolding.conversationOneOnOneType.id) - } + + XCTAssertEqual(conversationsAPI.getMLSOneToOneConversationUserIDIn_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.storeConversationTimestampIsFederationEnabled_Invocations.count, 1) } - func testRemoveParticipantFromConversation_It_Appends_A_System_Message_To_All_Team_Conversations_When_A_Member_Leave() async throws { + func testRemoveParticipantFromAllGroupConversations_It_Invokes_Local_Store_And_User_Repo_Methods() async throws { + // Mock - let user = try await context.perform { [self] in - let (team, users, _) = modelHelper.createTeam( - id: Scaffolding.teamID, - withMembers: [Scaffolding.userID], - inGroupConversation: Scaffolding.teamConversationID, - context: context - ) - - modelHelper.createGroupConversation( - id: Scaffolding.anotherTeamConversationID, - with: users, - team: team, - domain: nil, - in: context - ) - - modelHelper.createGroupConversation( - id: Scaffolding.conversationID, - with: Set(users), - domain: nil, - in: context - ) - - let user = try XCTUnwrap(users.first) - let member = try XCTUnwrap(team.members.first) - XCTAssertEqual(user.membership, member) - - return user + let user = await context.perform { [self] in + modelHelper.createUser(in: context) } - let timestamp = Scaffolding.date(from: Scaffolding.time) - userRepository.fetchUserIdDomain_MockValue = user + conversationsLocalStore.removeParticipantFromAllGroupConversationsUserDate_MockMethod = { _, _ in } // When try await sut.removeParticipantFromAllGroupConversations( - participantID: Scaffolding.userID, - participantDomain: nil, - removedAt: timestamp + participantID: Scaffolding.id, + participantDomain: Scaffolding.domain, + removedAt: .distantPast ) // Then - try await context.perform { [self] in - - let user = try XCTUnwrap(ZMUser.fetch(with: Scaffolding.userID, in: context), "No User") - XCTAssertNotNil(Team.fetch(with: Scaffolding.teamID, in: context)) - - let teamConversation = try XCTUnwrap(ZMConversation.fetch(with: Scaffolding.teamConversationID, in: context), "No Team Conversation") - - let teamAnotherConversation = try XCTUnwrap(ZMConversation.fetch(with: Scaffolding.anotherTeamConversationID, in: context), "No Team Conversation") - - let conversation = try XCTUnwrap(ZMConversation.fetch(with: Scaffolding.conversationID, in: context), "No Conversation") - - try internalTest_checkLastMessage( - in: teamConversation, - messageType: .teamMemberLeave, - at: timestamp - ) - - try internalTest_checkLastMessage( - in: teamAnotherConversation, - messageType: .teamMemberLeave, - at: timestamp - ) - - let lastMessage = try XCTUnwrap(conversation.lastMessage as? ZMSystemMessage) - XCTAssertNotEqual(lastMessage.systemMessageType, .teamMemberLeave, "Should not append leave message to regular conversation") - } - } - - func testRemoveParticipantFromConversation_It_Removes_Participant() async throws { - // Mock - - let (removedUser, remainingUsers, conversation) = await context.perform { [self] in - let user1 = modelHelper.createUser(in: context) - let user2 = modelHelper.createUser(in: context) - let user3 = modelHelper.createUser(in: context) - let removedUser = modelHelper.createUser(id: Scaffolding.userID, in: context) - - let conversation = modelHelper.createGroupConversation( - id: Scaffolding.conversationID, - with: [removedUser, user1, user2, user3], - in: context - ) - - return (removedUser, [user1, user2, user3], conversation) - } - - userRepository.fetchUserIdDomain_MockValue = removedUser - - // When - - try await sut.removeParticipantFromAllGroupConversations( - participantID: Scaffolding.userID, - participantDomain: nil, - removedAt: .now - ) - - // Then - - await context.perform { - XCTAssertEqual(conversation.localParticipants, Set(remainingUsers)) - XCTAssertEqual(conversation.localParticipants.contains(removedUser), false) - } + XCTAssertEqual(userRepository.fetchUserIdDomain_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.removeParticipantFromAllGroupConversationsUserDate_Invocations.count, 1) + } - func testPullConversation_It_Retrieves_Conversation_Locally() async throws { + func testPullConversation_It_Invokes_Local_Store_And_Conversation_API_Methods() async throws { + // Mock - let conversationID = try XCTUnwrap(Scaffolding.conversationGroupType.qualifiedID) - conversationsAPI.getConversationsFor_MockValue = ConversationList( - found: [Scaffolding.conversationGroupType], + found: [Scaffolding.conversation], notFound: [], failed: [] ) + + conversationsLocalStore.storeConversationTimestampIsFederationEnabled_MockMethod = { _, _, _ in } // When try await sut.pullConversation( - id: conversationID.uuid, - domain: conversationID.domain + id: Scaffolding.id, + domain: Scaffolding.domain ) // Then - await context.perform { [context] in - let localConversation = ZMConversation.fetch( - with: conversationID.uuid, - domain: conversationID.domain, - in: context - ) - - XCTAssertNotNil(localConversation) - XCTAssertEqual(localConversation?.remoteIdentifier, conversationID.uuid) - } + XCTAssertEqual(conversationsAPI.getConversationsFor_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.storeConversationTimestampIsFederationEnabled_Invocations.count, 1) } func testPullConversation_It_Throws_Error() async throws { + // Mock - let conversationID = try XCTUnwrap(Scaffolding.conversationGroupType.qualifiedID) - conversationsAPI.getConversationsFor_MockValue = ConversationList( found: [], notFound: [], @@ -424,83 +263,50 @@ final class ConversationRepositoryTests: XCTestCase { do { // When try await sut.pullConversation( - id: conversationID.uuid, - domain: conversationID.domain + id: Scaffolding.id, + domain: Scaffolding.domain ) - XCTFail("it should have failed") + XCTFail("It should have failed") } catch { // Then XCTAssertTrue(error is ConversationRepositoryError) } } - func testAddOrUpdateParticipant_It_Adds_Participant_To_Conversation() async { + func testFetchConversation_It_Invokes_Local_Store_Method() async { + // Mock - let (addedUser, conversation) = await context.perform { [self] in - let user1 = modelHelper.createUser(in: context) - let user2 = modelHelper.createUser(in: context) - let user3 = modelHelper.createUser(in: context) - let addedUser = modelHelper.createUser(id: Scaffolding.userID, in: context) - - let conversation = modelHelper.createGroupConversation( - id: Scaffolding.conversationID, - with: [user1, user2, user3], - in: context - ) - - return (addedUser, conversation) - } - - userRepository.fetchOrCreateUserIdDomain_MockValue = addedUser - - // When - - await sut.addOrUpdateParticipant( - participantID: UUID(), - participantDomain: nil, - participantRole: "", - conversationID: Scaffolding.conversationID, - conversationDomain: nil - ) - - // Then - - await context.perform { - XCTAssertEqual(conversation.localParticipants.contains(addedUser), true) - } - } - - func testFetchConversation_It_Retrieves_Conversation_Locally() async { - // Given - let conversation = await context.perform { [self] in modelHelper.createGroupConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, domain: Scaffolding.domain, in: context ) } + + conversationsLocalStore.fetchConversationIdDomain_MockValue = conversation // When let localConversation = await sut.fetchConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, domain: Scaffolding.domain ) // Then - XCTAssertEqual(conversation, localConversation) + XCTAssertEqual(conversationsLocalStore.fetchConversationIdDomain_Invocations.count, 1) } - func testDeleteMLSConversation_It_Wipes_MLS_Group_And_Marks_MLS_Conversation_As_Deleted_Locally() async throws { + func testDeleteMLSConversation_It_Invokes_Local_Store_Methods() async throws { + // Mock let conversation = await context.perform { [self] in modelHelper.createMLSConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, domain: Scaffolding.domain, mlsGroupID: MLSGroupID(base64Encoded: Scaffolding.base64EncodedString), mlsStatus: .ready, @@ -510,96 +316,87 @@ final class ConversationRepositoryTests: XCTestCase { ) } - mlsService.wipeGroup_MockMethod = { _ in } + conversationsLocalStore.isMLSConversation_MockValue = true + conversationsLocalStore.fetchConversationIdDomain_MockValue = conversation + conversationsLocalStore.mlsGroupIDFor_MockValue = MLSGroupID(base64Encoded: Scaffolding.base64EncodedString) + conversationsLocalStore.wipeMLSGroupGroupID_MockMethod = { _ in } + conversationsLocalStore.deleteConversation_MockMethod = { _ in } // When try await sut.deleteConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, domain: Scaffolding.domain ) // Then - let isDeletedRemotely = await context.perform { - conversation.isDeletedRemotely - } - - XCTAssertEqual(mlsService.wipeGroup_Invocations.count, 1) - XCTAssertEqual(isDeletedRemotely, true) + XCTAssertEqual(conversationsLocalStore.isMLSConversation_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.fetchConversationIdDomain_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.mlsGroupIDFor_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.wipeMLSGroupGroupID_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.deleteConversation_Invocations.count, 1) } - func testDeleteProteusConversation_It_Marks_Conversation_As_Deleted_Remotely() async throws { + func testDeleteProteusConversation_It_Invokes_Local_Store_Methods() async throws { + // Mock let conversation = await context.perform { [self] in modelHelper.createGroupConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, in: context ) } + conversationsLocalStore.fetchConversationIdDomain_MockValue = conversation + conversationsLocalStore.isMLSConversation_MockValue = false + conversationsLocalStore.deleteConversation_MockMethod = { _ in } + // When try await sut.deleteConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, domain: Scaffolding.domain ) - + // Then - - let isDeletedRemotely = await context.perform { - conversation.isDeletedRemotely - } - - XCTAssertEqual(isDeletedRemotely, true) + + XCTAssertEqual(conversationsLocalStore.fetchConversationIdDomain_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.isMLSConversation_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.deleteConversation_Invocations.count, 1) } - func testStoreConversation_It_Stores_Conversation_Locally() async throws { - // Given - - let groupConversation = Scaffolding.conversationGroupType - let id = try XCTUnwrap(groupConversation.qualifiedID?.uuid) - let domain = try XCTUnwrap(groupConversation.qualifiedID?.domain) + func testStoreConversation_It_Invokes_Local_Store_Method() async throws { + + // Mock + + conversationsLocalStore.storeConversationTimestampIsFederationEnabled_MockMethod = { _, _, _ in } // When - await sut.storeConversation(Scaffolding.conversationGroupType, timestamp: .now) + await sut.storeConversation( + Scaffolding.conversation, + timestamp: .now + ) // Then - let localConversation = await sut.fetchConversation( - id: id, - domain: domain - ) - - await context.perform { - XCTAssertEqual(localConversation?.remoteIdentifier, id) - XCTAssertEqual(localConversation?.teamRemoteIdentifier, groupConversation.teamID) - XCTAssertEqual(localConversation?.conversationType, .group) - XCTAssertEqual(localConversation?.messageProtocol, .proteus) - XCTAssertEqual(localConversation?.epoch, 0) - XCTAssertEqual(localConversation?.hasReadReceiptsEnabled, false) - XCTAssertEqual(localConversation?.accessMode, [.invite]) - XCTAssertEqual(localConversation?.accessRoles, [.teamMember]) - } + XCTAssertEqual(conversationsLocalStore.storeConversationTimestampIsFederationEnabled_Invocations.count, 1) } - func testRemoveMembers() async throws { + func testRemoveMembers_It_Invokes_Local_Store_User_Repo_Team_Repo_And_MLS_Service_Methods() async throws { + // Mock - let removedMembersIDs = [UserID(uuid: Scaffolding.otherUserID, domain: Scaffolding.domain)] - let conversationID = ConversationID(uuid: Scaffolding.conversationID, domain: Scaffolding.domain) - let sender = UserID(uuid: Scaffolding.userID, domain: Scaffolding.domain) - let (conversation, selfUser, senderUser, removedUser) = await context.perform { [self] in - let selfUser = modelHelper.createSelfUser(id: Scaffolding.selfUserId, in: context) - let senderUser = modelHelper.createUser(id: Scaffolding.userID, in: context) - let removedUser = modelHelper.createUser(id: Scaffolding.otherUserID, in: context) + let selfUser = modelHelper.createSelfUser(id: Scaffolding.id, in: context) + let senderUser = modelHelper.createUser(id: Scaffolding.id, in: context) + let removedUser = modelHelper.createUser(id: Scaffolding.id, in: context) let mlsGroupID = MLSGroupID(base64Encoded: Scaffolding.base64EncodedString) let mlsConversation = modelHelper.createMLSConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, mlsGroupID: mlsGroupID, with: [senderUser, selfUser, removedUser], in: context @@ -608,6 +405,12 @@ final class ConversationRepositoryTests: XCTestCase { return (mlsConversation, selfUser, senderUser, removedUser) } + conversationsLocalStore.messageProtocolFor_MockValue = .mls + conversationsLocalStore.addSystemMessageTo_MockMethod = { _, _ in } + conversationsLocalStore.fetchOrCreateConversationIdDomain_MockValue = conversation + conversationsLocalStore.localParticipantsIn_MockValue = [selfUser, senderUser, removedUser] + conversationsLocalStore.removeParticipantsAndUpdateConversationStateConversationUsersInitiatingUser_MockMethod = { _, _, _ in } + conversationsLocalStore.mlsGroupIDFor_MockValue = MLSGroupID(base64Encoded: Scaffolding.base64EncodedString) userRepository.fetchOrCreateUserIdDomain_MockValue = removedUser userRepository.fetchUserIdDomain_MockValue = senderUser userRepository.isSelfUserIdDomain_MockValue = true @@ -617,39 +420,37 @@ final class ConversationRepositoryTests: XCTestCase { // When try await sut.removeMembers( - Set(removedMembersIDs), - from: conversationID, - initiatedBy: sender, + Set([.init(uuid: Scaffolding.id, domain: Scaffolding.domain)]), + from: .init(uuid: Scaffolding.id, domain: Scaffolding.domain), + initiatedBy: .init(uuid: Scaffolding.id, domain: Scaffolding.domain), at: .now, reason: .userDeleted ) // Then + XCTAssertEqual(conversationsLocalStore.messageProtocolFor_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.addSystemMessageTo_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.fetchOrCreateConversationIdDomain_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.localParticipantsIn_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.removeParticipantsAndUpdateConversationStateConversationUsersInitiatingUser_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.mlsGroupIDFor_Invocations.count, 1) XCTAssertEqual(mlsService.wipeGroup_Invocations.count, 1) XCTAssertEqual(userRepository.fetchOrCreateUserIdDomain_Invocations.count, 1) XCTAssertEqual(userRepository.fetchUserIdDomain_Invocations.count, 1) XCTAssertEqual(userRepository.isSelfUserIdDomain_Invocations.count, 1) XCTAssertEqual(teamRepository.deleteMembershipForDomainAt_Invocations.count, 1) - - let newParticipants = await context.perform { - conversation.localParticipants - } - - await context.perform { - XCTAssertEqual(newParticipants, [selfUser, senderUser]) - XCTAssertFalse(newParticipants.contains(removedUser)) // user was successfuly removed from conversation - } } - func testAddOrUpdateParticipant_It_Updates_Participant_Role_In_Conversation() async throws { + func testAddOrUpdateParticipant_It_Invokes_Local_Store_And_User_Repo_Methods() async throws { + // Mock let (updatedUser, conversation) = await context.perform { [self] in - let updatedUser = modelHelper.createUser(id: Scaffolding.userID, in: context) + let updatedUser = modelHelper.createUser(id: Scaffolding.id, in: context) let conversation = modelHelper.createGroupConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, with: [updatedUser], in: context ) @@ -657,6 +458,8 @@ final class ConversationRepositoryTests: XCTestCase { return (updatedUser, conversation) } + conversationsLocalStore.fetchOrCreateConversationIdDomain_MockValue = conversation + conversationsLocalStore.addOrUpdateParticipantWithRoleIn_MockMethod = { _, _, _ in } userRepository.fetchOrCreateUserIdDomain_MockValue = updatedUser // When @@ -665,260 +468,104 @@ final class ConversationRepositoryTests: XCTestCase { participantID: UUID(), participantDomain: nil, participantRole: ZMConversation.defaultAdminRoleName, - conversationID: Scaffolding.conversationID, + conversationID: Scaffolding.id, conversationDomain: nil ) // Then - try await context.perform { - let role = try XCTUnwrap(updatedUser.role(in: conversation)) - XCTAssertEqual(role.name, ZMConversation.defaultAdminRoleName) - } + XCTAssertEqual(conversationsLocalStore.fetchOrCreateConversationIdDomain_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.addOrUpdateParticipantWithRoleIn_Invocations.count, 1) + XCTAssertEqual(userRepository.fetchOrCreateUserIdDomain_Invocations.count, 1) } - func testAddParticipants_It_Adds_Participants_To_Conversation() async throws { + func testAddParticipants_It_Invokes_Local_Store_Methods() async throws { + // Mock - let (conversation, sender, addedUser) = await context.perform { [self] in + let (conversation) = await context.perform { [self] in let conversation = modelHelper.createGroupConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, domain: Scaffolding.domain, in: context ) - - let addedUser = modelHelper.createUser( - qualifiedID: .init(uuid: Scaffolding.otherUserID, domain: Scaffolding.domain), - in: context - ) - - let sender = modelHelper.createUser( - qualifiedID: .init(uuid: Scaffolding.userID, domain: Scaffolding.domain), - in: context - ) - - return (conversation, sender, addedUser) + return conversation } - userLocalStore.fetchOrCreateUserIdDomain_MockValue = addedUser - userLocalStore.fetchUserIdDomain_MockValue = sender + + conversationsLocalStore.fetchConversationIdDomain_MockValue = conversation + conversationsLocalStore.addParticipantsAddedByAtDateTo_MockMethod = { _, _, _, _ in } + // When try await sut.addParticipants( - [(Scaffolding.otherUserID, + [(Scaffolding.id, Scaffolding.domain, ZMConversation.defaultMemberRoleName)], - sender: (Scaffolding.userID, Scaffolding.domain), + sender: (Scaffolding.id, Scaffolding.domain), date: .distantPast, - conversationID: Scaffolding.conversationID, + conversationID: Scaffolding.id, conversationDomain: Scaffolding.domain ) // Then - XCTAssertEqual(userLocalStore.fetchOrCreateUserIdDomain_Invocations.count, 1) - XCTAssertEqual(userLocalStore.fetchUserIdDomain_Invocations.count, 1) - - await context.perform { - XCTAssertTrue(conversation.localParticipants.contains(addedUser)) - } + XCTAssertEqual(conversationsLocalStore.fetchConversationIdDomain_Invocations.count, 1) + XCTAssertEqual(conversationsLocalStore.addParticipantsAddedByAtDateTo_Invocations.count, 1) } - func testAddSystemMessage_It_Adds_System_Message_To_Conversation() async throws { + func testAddSystemMessage_It_Invokes_Local_Store_Method() async throws { + // Mock let (conversation, user) = await context.perform { [self] in let conversation = modelHelper.createGroupConversation( - id: Scaffolding.conversationID, + id: Scaffolding.id, domain: Scaffolding.domain, in: context ) - + let user = modelHelper.createUser(in: context) - + return (conversation, user) } - let timestamp = Scaffolding.date(from: Scaffolding.time) - let systemMessage = SystemMessage( type: .participantsAdded, sender: user, - timestamp: timestamp + timestamp: .distantPast ) + + conversationsLocalStore.addSystemMessageTo_MockMethod = { _, _ in } // When - await sut.addSystemMessage(systemMessage, to: conversation) - - // Then - - try await context.perform { [self] in - try internalTest_checkLastMessage( - in: conversation, - messageType: .participantsAdded, - at: timestamp - ) - } - } - - private func internalTest_checkLastMessage( - in conversation: ZMConversation, - messageType: ZMSystemMessageType, - at timestamp: Date - ) throws { - let lastMessage = try XCTUnwrap( - conversation.lastMessage as? ZMSystemMessage, - "Last message is not system message" - ) - - XCTAssertEqual( - lastMessage.systemMessageType, - messageType, "System message is not \(messageType.rawValue): but '\(lastMessage.systemMessageType.rawValue)" + await sut.addSystemMessage( + systemMessage, + to: conversation ) - let serverTimeStamp = try XCTUnwrap( - lastMessage.serverTimestamp, "System message should have timestamp" - ) + // Then - XCTAssertEqual( - serverTimeStamp.timeIntervalSince1970, - timestamp.timeIntervalSince1970, - accuracy: 0.1 - ) + XCTAssertEqual(conversationsLocalStore.addSystemMessageTo_Invocations.count, 1) } private enum Scaffolding { - static let teamID = UUID() - static let userID = UUID() - static let otherUserID = UUID() - static let time = "2021-05-12T10:52:02.671Z" - static let teamConversationID = UUID() - static let anotherTeamConversationID = UUID() - static let conversationID = UUID() - - static func date(from string: String) -> Date { - ISO8601DateFormatter.fractionalInternetDateTime.date(from: string)! - } - - static let conversationList = ConversationList( - found: [conversationSelfType, - conversationGroupType, - conversationConnectionType, - conversationOneOnOneType], - notFound: [conversationNotFound], - failed: [conversationFailed] - ) - - static let conversationListError = ConversationList( - found: [conversationSelfTypeMissingId, - conversationGroupType, - conversationConnectionType, - conversationOneOnOneType], - notFound: [conversationNotFound], - failed: [conversationFailed] - ) - - static let conversationSelfType = Conversation( - id: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ab")!, - qualifiedID: .init(uuid: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ab")!, domain: "example.com"), - teamID: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ab")!, - type: .`self`, - messageProtocol: .proteus, - mlsGroupID: "", - cipherSuite: .MLS_128_DHKEMP256_AES128GCM_SHA256_P256, - epoch: 0, - epochTimestamp: nil, - creator: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ab")!, - members: nil, - name: "Test", - messageTimer: 0, - readReceiptMode: 0, - access: [.invite], - accessRoles: [.teamMember], - legacyAccessRole: .team, - lastEvent: "", - lastEventTime: nil - ) - - static let conversationSelfTypeMissingId = Conversation( - id: nil, - qualifiedID: nil, - teamID: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ab")!, - type: .`self`, - messageProtocol: .proteus, - mlsGroupID: "", - cipherSuite: .MLS_128_DHKEMP256_AES128GCM_SHA256_P256, - epoch: 0, - epochTimestamp: nil, - creator: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ab")!, - members: nil, - name: nil, - messageTimer: 0, - readReceiptMode: 0, - access: [.invite], - accessRoles: [.teamMember], - legacyAccessRole: .team, - lastEvent: "", - lastEventTime: nil - ) - - static let conversationConnectionType = Conversation( - id: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ac")!, - qualifiedID: .init(uuid: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ac")!, domain: "example.com"), - teamID: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ac")!, - type: .connection, - messageProtocol: .proteus, - mlsGroupID: "", - cipherSuite: .MLS_128_DHKEMP256_AES128GCM_SHA256_P256, - epoch: 0, - epochTimestamp: nil, - creator: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ac")!, - members: nil, - name: nil, - messageTimer: 0, - readReceiptMode: 0, - access: [.invite], - accessRoles: [.teamMember], - legacyAccessRole: .team, - lastEvent: "", - lastEventTime: nil - ) - - static let conversationGroupType = Conversation( - id: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, - qualifiedID: .init(uuid: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, domain: "example.com"), - teamID: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, + static let id = UUID() + static let domain = "domain.com" + + static let conversation = Conversation( + id: id, + qualifiedID: .init(uuid: id, domain: domain), + teamID: id, type: .group, messageProtocol: .proteus, mlsGroupID: "", cipherSuite: .MLS_128_DHKEMP256_AES128GCM_SHA256_P256, epoch: 0, epochTimestamp: nil, - creator: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, - members: nil, - name: nil, - messageTimer: 0, - readReceiptMode: 0, - access: [.invite], - accessRoles: [.teamMember], - legacyAccessRole: .team, - lastEvent: "", - lastEventTime: nil - ) - - static let conversationOneOnOneType = Conversation( - id: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ae")!, - qualifiedID: .init(uuid: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ae")!, domain: "example.com"), - teamID: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ae")!, - type: .oneOnOne, - messageProtocol: .proteus, - mlsGroupID: base64EncodedString, - cipherSuite: .MLS_128_DHKEMP256_AES128GCM_SHA256_P256, - epoch: 0, - epochTimestamp: nil, - creator: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ae")!, + creator: id, members: nil, name: nil, messageTimer: 0, @@ -931,70 +578,6 @@ final class ConversationRepositoryTests: XCTestCase { ) static let base64EncodedString = "pQABARn//wKhAFggHsa0CszLXYLFcOzg8AA//E1+Dl1rDHQ5iuk44X0/PNYDoQChAFgg309rkhG6SglemG6kWae81P1HtQPx9lyb6wExTovhU4cE9g==" - - static let conversationNotFound = WireAPI.QualifiedID( - uuid: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4aa")!, - domain: "example.com" - ) - - static let conversationFailed = WireAPI.QualifiedID( - uuid: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4af")!, - domain: "example.com" - ) - - static let selfUserId = UUID() - - static let domain = "domain.com" - } - -} - -extension ConversationRepositoryTests { - - private func fetchConversations(withIds ids: [UUID]) -> Set { - ZMConversation.fetchObjects( - withRemoteIdentifiers: Set(ids), - in: context - ) as! Set - } - - private func mockSelfUser() -> ZMUser { - let selfUser = ZMUser.selfUser(in: context) - selfUser.remoteIdentifier = Scaffolding.selfUserId - selfUser.domain = backendInfo.domain - - let client = UserClient.insertNewObject(in: context) - client.remoteIdentifier = UUID().uuidString - client.user = selfUser - context.saveOrRollback() - - return selfUser - } - - private func mockConversationsAPI(conversationList: WireAPI.ConversationList = Scaffolding.conversationList) { - conversationsAPI.getLegacyConversationIdentifiers_MockValue = .init(fetchPage: { _ in - .init( - element: [Scaffolding.conversationSelfType.id!], - hasMore: false, - nextStart: .init() - ) - }) - - conversationsAPI.getConversationIdentifiers_MockValue = .init(fetchPage: { _ in - .init( - element: [Scaffolding.conversationSelfType.qualifiedID!], - hasMore: false, - nextStart: .init() - ) - }) - - conversationsAPI.getConversationsFor_MockValue = .init( - found: conversationList.found, - notFound: conversationList.notFound, - failed: conversationList.failed - ) - - conversationsAPI.getMLSOneToOneConversationUserIDIn_MockValue = Scaffolding.conversationOneOnOneType } } From 07e8c0106d29ac6ab49012214eefadbfea259693 Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Wed, 6 Nov 2024 18:14:06 +0100 Subject: [PATCH 02/14] User - add UTs for local store and repository --- .../project.pbxproj | 4 + .../ConversationLocalStoreTests.swift | 26 +- .../LocalStores/UserLocalStoreTests.swift | 408 ++++++++++++++++++ .../ConversationRepositoryTests.swift | 10 +- .../Repositories/UserRepositoryTests.swift | 349 ++++++--------- 5 files changed, 577 insertions(+), 220 deletions(-) create mode 100644 WireDomain/Tests/WireDomainTests/LocalStores/UserLocalStoreTests.swift diff --git a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj index 85cc176f24b..b41474e2166 100644 --- a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj +++ b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ C96B75452CDBA0A9003A85EB /* ConversationDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75442CDBA0A9003A85EB /* ConversationDeleteEventProcessorTests.swift */; }; C96B75482CDBA10F003A85EB /* SystemMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75462CDBA10F003A85EB /* SystemMessage.swift */; }; C96B755D2CDBB176003A85EB /* ConversationLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */; }; + C96B755F2CDBCD24003A85EB /* UserLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */; }; C97BCCAA2C98704B004F2D0D /* WireDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 01D0DCA62C1C8C870076CB1C /* WireDomain.framework */; }; C97C013D2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */; }; C97C014B2CB00F92000683C5 /* OneOnOneResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */; }; @@ -183,6 +184,7 @@ C96B75442CDBA0A9003A85EB /* ConversationDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDeleteEventProcessorTests.swift; sourceTree = ""; }; C96B75462CDBA10F003A85EB /* SystemMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMessage.swift; sourceTree = ""; }; C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLocalStoreTests.swift; sourceTree = ""; }; + C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLocalStoreTests.swift; sourceTree = ""; }; C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPropertiesDeleteEventProcessorTests.swift; sourceTree = ""; }; C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OneOnOneResolver.swift; path = ../../OneOnOneResolver.swift; sourceTree = ""; }; C97C014F2CB01BDF000683C5 /* OneOnOneResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneResolverTests.swift; sourceTree = ""; }; @@ -488,6 +490,7 @@ isa = PBXGroup; children = ( C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */, + C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */, ); path = LocalStores; sourceTree = ""; @@ -1083,6 +1086,7 @@ C96B752A2CDB7688003A85EB /* ConversationMemberJoinEventTests.swift in Sources */, C96B752B2CDB7688003A85EB /* ConversationProtocolUpdateEventProcessorTests.swift in Sources */, C96B752C2CDB7688003A85EB /* ConversationAccessUpdateEventProcessorTests.swift in Sources */, + C96B755F2CDBCD24003A85EB /* UserLocalStoreTests.swift in Sources */, C96B752D2CDB7688003A85EB /* ConversationCreateEventProcessorTests.swift in Sources */, C96B752E2CDB7688003A85EB /* ConversationReceiptModeUpdateEventProcessorTests.swift in Sources */, C96B752F2CDB7688003A85EB /* ConversationMemberLeaveEventTests.swift in Sources */, diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift index cd18026d741..36e3c6a4bde 100644 --- a/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift +++ b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift @@ -22,6 +22,7 @@ import WireDataModel import WireDataModelSupport @testable import WireDomain import WireDomainSupport +import WireTestingPackage import XCTest final class ConversationLocalStoreTests: XCTestCase { @@ -154,6 +155,7 @@ final class ConversationLocalStoreTests: XCTestCase { } func testFetchMLSConversation_It_Retrieves_Conversation_Locally() async throws { + // Mock let mlsGroupID = try XCTUnwrap( @@ -252,7 +254,7 @@ final class ConversationLocalStoreTests: XCTestCase { } } - func testRemoveParticipantFromConversation_It_Removes_Participant() async throws { + func testRemoveParticipantFromConversation_It_Removes_Participant() async { // Mock @@ -344,7 +346,7 @@ final class ConversationLocalStoreTests: XCTestCase { XCTAssertEqual(conversation, localConversation) } - func testDeleteConversation_It_Marks_Conversation_As_Deleted_Locally() async throws { + func testDeleteConversation_It_Marks_Conversation_As_Deleted_Locally() async { // Mock @@ -363,7 +365,7 @@ final class ConversationLocalStoreTests: XCTestCase { } } - func testRemoveParticipants_It_Removes_Participant_From_A_Given_Conversation() async throws { + func testRemoveParticipants_It_Removes_Participant_From_A_Given_Conversation() async { // Mock @@ -546,15 +548,25 @@ final class ConversationLocalStoreTests: XCTestCase { } private enum Scaffolding { + static let selfUserId = UUID() + static let domain = "domain.com" + static let teamID = UUID() + static let userID = UUID() + static let otherUserID = UUID() + static let time = "2021-05-12T10:52:02.671Z" + static let teamConversationID = UUID() + static let anotherTeamConversationID = UUID() + static let conversationID = UUID() + static let base64EncodedString = "pQABARn//wKhAFggHsa0CszLXYLFcOzg8AA//E1+Dl1rDHQ5iuk44X0/PNYDoQChAFgg309rkhG6SglemG6kWae81P1HtQPx9lyb6wExTovhU4cE9g==" static func date(from string: String) -> Date { @@ -562,16 +574,16 @@ final class ConversationLocalStoreTests: XCTestCase { } static let groupConversation = Conversation( - id: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, - qualifiedID: .init(uuid: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, domain: "example.com"), - teamID: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, + id: .mockID1, + qualifiedID: .init(uuid: .mockID1, domain: domain), + teamID: .mockID2, type: .group, messageProtocol: .proteus, mlsGroupID: "", cipherSuite: .MLS_128_DHKEMP256_AES128GCM_SHA256_P256, epoch: 0, epochTimestamp: nil, - creator: UUID(uuidString: "99db9768-04e3-4b5d-9268-831b6a25c4ad")!, + creator: .mockID3, members: nil, name: nil, messageTimer: 0, diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/UserLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/UserLocalStoreTests.swift new file mode 100644 index 00000000000..4f85ea9dc1e --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/LocalStores/UserLocalStoreTests.swift @@ -0,0 +1,408 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +@testable import WireAPI +import WireAPISupport +import WireDataModel +import WireDataModelSupport +@testable import WireDomain +import WireDomainSupport +import XCTest + +final class UserLocalStoreTests: XCTestCase { + + private var sut: UserLocalStore! + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + private var mockUserDefaults: UserDefaults! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + coreDataStackHelper = CoreDataStackHelper() + modelHelper = ModelHelper() + stack = try await coreDataStackHelper.createStack() + + mockUserDefaults = UserDefaults( + suiteName: Scaffolding.defaultsTestSuiteName + ) + + sut = UserLocalStore( + context: context, + userDefaults: mockUserDefaults + ) + } + + override func tearDown() async throws { + stack = nil + sut = nil + mockUserDefaults.removePersistentDomain( + forName: Scaffolding.defaultsTestSuiteName + ) + mockUserDefaults = nil + try coreDataStackHelper.cleanupDirectory() + coreDataStackHelper = nil + modelHelper = nil + } + + // MARK: - Tests + + func testPersistUser_It_Stores_User_Locally() async throws { + + // Mock + + await context.perform { [context] in + // There is no user in the database. + XCTAssertNil(ZMUser.fetch(with: Scaffolding.user1.id.uuid, domain: Scaffolding.user1.id.domain, in: context)) + } + + // When + + await sut.persistUser(from: Scaffolding.user1) + + // Then + try await context.perform { [context] in + // There is a user in the database. + let user = try XCTUnwrap( + ZMUser.fetch( + with: Scaffolding.user1.id.uuid, + domain: Scaffolding.user1.id.domain, + in: context + ) + ) + XCTAssertEqual(user.remoteIdentifier, Scaffolding.user1.id.uuid) + XCTAssertEqual(user.name, Scaffolding.user1.name) + XCTAssertEqual(user.handle, Scaffolding.user1.handle) + XCTAssertEqual(user.teamIdentifier, Scaffolding.user1.teamID) + XCTAssertEqual(user.accentColorValue, Int16(Scaffolding.user1.accentID)) + XCTAssertEqual(user.isAccountDeleted, Scaffolding.user1.deleted) + XCTAssertEqual(user.emailAddress, Scaffolding.user1.email) + XCTAssertEqual(user.supportedProtocols, Scaffolding.user1.supportedProtocols?.toDomainModel()) + XCTAssertFalse(user.needsToBeUpdatedFromBackend) + } + } + + func testDeletePushToken_It_Removes_Token_From_Defaults() async throws { + + // Mock + + let key = "PushToken" + let data = try JSONEncoder().encode(Scaffolding.pushToken) + mockUserDefaults.set(data, forKey: key) + XCTAssertNotNil(mockUserDefaults.object(forKey: key)) + + // When + + sut.deletePushToken() + + // Then + + let pushToken = mockUserDefaults.object(forKey: key) + XCTAssertNil(pushToken) + } + + func testFetchSelfUser_It_Retrieves_Self_User_Locally() async { + + // Mock + + let selfUser = await context.perform { [self] in + modelHelper.createSelfUser( + id: Scaffolding.userID, + domain: nil, + in: context + ) + } + + // When + + let localSelfUser = await sut.fetchSelfUser() + + // Then + + await context.perform { + XCTAssertEqual(selfUser, localSelfUser) + } + } + + func testFetchUser_It_Retrieves_User_Locally() async throws { + + // Mock + + let user = await context.perform { [self] in + modelHelper.createUser( + id: Scaffolding.userID, + domain: nil, + in: context + ) + } + + // When + + let localUser = try await sut.fetchUser( + id: Scaffolding.userID, + domain: nil + ) + + // Then + + await context.perform { + XCTAssertEqual(user, localUser) + } + } + + func testAddSelfLegalholdRequest_It_Sets_Status_To_Pending_With_Legal_Hold_Request() async throws { + + // Mock + + _ = await context.perform { [self] in + modelHelper.createSelfUser( + id: Scaffolding.userID, + domain: nil, + in: context + ) + } + + // When + + await sut.addSelfLegalHoldRequest( + userID: Scaffolding.userID, + clientID: Scaffolding.userClientID, + lastPrekey: .init( + id: Scaffolding.lastPrekeyId, + key: try XCTUnwrap(Data(base64Encoded: Scaffolding.base64encodedString)) + ) + ) + + // Then + + try await context.perform { [context] in + let selfUser = try XCTUnwrap(ZMUser.fetch(with: Scaffolding.userID, in: context)) + + XCTAssertEqual(selfUser.legalHoldStatus, .pending(Scaffolding.legalHoldRequest)) + } + } + + func testPostAccountDeletedNotification_It_Posts_Account_Deleted_Notification() async { + + // Given + + let expectation = XCTestExpectation() + let notificationName = AccountDeletedNotification.notificationName + + NotificationCenter.default.addObserver( + forName: notificationName, + object: nil, + queue: nil + ) { notification in + + // Then + XCTAssertNotNil(notification.userInfo?[notificationName] as? AccountDeletedNotification) + + expectation.fulfill() + } + + // When + + sut.postAccountDeletedNotification() + + // Then + + await fulfillment(of: [expectation], timeout: 1) + } + + func testMarkAccountAsDeleted_It_Sets_Is_Account_Deleted_Flag_To_True() async { + + // Mock + + let user = await context.perform { [self] in + modelHelper.createUser( + id: Scaffolding.userID, + domain: nil, + in: context + ) + } + + // When + + await sut.markAccountAsDeleted(for: user) + + // Then + + await context.perform { + XCTAssertEqual(user.isAccountDeleted, true) + } + } + + func testUpdateSelfUserReadReceipts_It_Enables_Read_Receipts_Property() async { + + // Mock + + let selfUser = await context.perform { [self] in + let selfUser = modelHelper.createSelfUser( + id: Scaffolding.userID, + domain: nil, + in: context + ) + + selfUser.readReceiptsEnabled = false + selfUser.readReceiptsEnabledChangedRemotely = false + + return selfUser + } + + // When + + await sut.updateSelfUserReadReceipts( + isReadReceiptsEnabled: true, + isReadReceiptsEnabledChangedRemotely: true + ) + + // Then + + await context.perform { + XCTAssertEqual(selfUser.readReceiptsEnabled, true) + XCTAssertEqual(selfUser.readReceiptsEnabledChangedRemotely, true) + } + } + + func testUpdateUser_It_Updates_User_Locally() async throws { + // Given + + _ = await context.perform { [self] in + modelHelper.createUser( + id: Scaffolding.userID, + handle: Scaffolding.existingHandle, + email: Scaffolding.existingEmail, + supportedProtocols: [.mls], + in: context + ) + } + + // When + + await sut.updateUser(from: Scaffolding.event) + + // Then + + try await context.perform { [context] in + let updatedUser = try XCTUnwrap(ZMUser.fetch(with: Scaffolding.userID, in: context)) + + XCTAssertEqual(updatedUser.remoteIdentifier, Scaffolding.userID) + XCTAssertEqual(updatedUser.name, Scaffolding.event.name) + XCTAssertEqual(updatedUser.handle, Scaffolding.existingHandle) /// ensuring handle is not updated to nil + XCTAssertEqual(updatedUser.emailAddress, Scaffolding.existingEmail) /// ensuring email is not updated to nil + XCTAssertEqual(updatedUser.supportedProtocols, [.proteus, .mls]) + } + } + + func testIsSelfUser_It_Returns_Correct_Flag() async throws { + // Mock + + let (selfUser, notSelfUser) = await context.perform { [self] in + let selfUser = modelHelper.createSelfUser(id: Scaffolding.selfUserID, in: context) + let notSelfUser = modelHelper.createUser(id: Scaffolding.userID, in: context) + + return (selfUser, notSelfUser) + } + + // When / Then isSelfUser == true + + let (user, isSelfUser) = try await sut.isSelfUser( + id: Scaffolding.selfUserID, + domain: nil + ) + + XCTAssertEqual(user, selfUser) + XCTAssertEqual(isSelfUser, true) + + // When / Then isSelfUser2 == false + + let (user2, isSelfUser2) = try await sut.isSelfUser( + id: Scaffolding.userID, + domain: nil + ) + + XCTAssertEqual(user2, notSelfUser) + XCTAssertEqual(isSelfUser2, false) + } + + private enum Scaffolding { + + static let selfUserID = UUID() + static let userID = UUID() + static let domain = "domain.com" + static let existingHandle = "handle" + static let existingEmail = "test@wire.com" + static let userClientID = UUID().uuidString + static let lastPrekeyId = 65_535 + static let base64encodedString = "pQABAQoCoQBYIPEFMBhOtG0dl6gZrh3kgopEK4i62t9sqyqCBckq3IJgA6EAoQBYIC9gPmCdKyqwj9RiAaeSsUI7zPKDZS+CjoN+sfihk/5VBPY=" + + nonisolated(unsafe) static let legalHoldRequest = LegalHoldRequest( + target: userID, + requester: nil, + clientIdentifier: userClientID, + lastPrekey: .init( + id: lastPrekeyId, + key: Data(base64Encoded: base64encodedString)! + ) + ) + + static let user1 = User( + id: QualifiedID(uuid: userID, domain: domain), + name: "user1", + handle: "handle1", + teamID: nil, + accentID: 1, + assets: [], + deleted: false, + email: "john.doe@example.com", + expiresAt: nil, + service: nil, + supportedProtocols: [.mls], + legalholdStatus: .disabled + ) + + static let event = UserUpdateEvent( + userID: userID, + accentColorID: nil, + name: "username", + handle: nil, + email: nil, + isSSOIDDeleted: nil, + assets: nil, + supportedProtocols: [.proteus, .mls] + ) + + static let deviceToken = Data(repeating: 0x41, count: 10) + + nonisolated(unsafe) static let pushToken = PushToken( + deviceToken: deviceToken, + appIdentifier: "com.wire", + transportType: "APNS_VOIP", + tokenType: .voip + ) + + static let defaultsTestSuiteName = UUID().uuidString + + } + +} + diff --git a/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift index 7b285778d89..e211a01663d 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift @@ -195,6 +195,7 @@ final class ConversationRepositoryTests: XCTestCase { // Then + XCTAssertEqual(mlsGroupID, Scaffolding.conversation.mlsGroupID) XCTAssertEqual(conversationsAPI.getMLSOneToOneConversationUserIDIn_Invocations.count, 1) XCTAssertEqual(conversationsLocalStore.storeConversationTimestampIsFederationEnabled_Invocations.count, 1) } @@ -297,6 +298,7 @@ final class ConversationRepositoryTests: XCTestCase { // Then + XCTAssertEqual(conversation, localConversation) XCTAssertEqual(conversationsLocalStore.fetchConversationIdDomain_Invocations.count, 1) } @@ -367,7 +369,7 @@ final class ConversationRepositoryTests: XCTestCase { XCTAssertEqual(conversationsLocalStore.deleteConversation_Invocations.count, 1) } - func testStoreConversation_It_Invokes_Local_Store_Method() async throws { + func testStoreConversation_It_Invokes_Local_Store_Method() async { // Mock @@ -442,7 +444,7 @@ final class ConversationRepositoryTests: XCTestCase { XCTAssertEqual(teamRepository.deleteMembershipForDomainAt_Invocations.count, 1) } - func testAddOrUpdateParticipant_It_Invokes_Local_Store_And_User_Repo_Methods() async throws { + func testAddOrUpdateParticipant_It_Invokes_Local_Store_And_User_Repo_Methods() async { // Mock @@ -515,7 +517,7 @@ final class ConversationRepositoryTests: XCTestCase { XCTAssertEqual(conversationsLocalStore.addParticipantsAddedByAtDateTo_Invocations.count, 1) } - func testAddSystemMessage_It_Invokes_Local_Store_Method() async throws { + func testAddSystemMessage_It_Invokes_Local_Store_Method() async { // Mock @@ -552,7 +554,9 @@ final class ConversationRepositoryTests: XCTestCase { } private enum Scaffolding { + static let id = UUID() + static let domain = "domain.com" static let conversation = Conversation( diff --git a/WireDomain/Tests/WireDomainTests/Repositories/UserRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/UserRepositoryTests.swift index e4b9ce5eb87..1463de1f6b6 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/UserRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/UserRepositoryTests.swift @@ -21,6 +21,7 @@ import WireAPISupport import WireDataModel import WireDataModelSupport @testable import WireDomain +import WireTestingPackage import WireDomainSupport import XCTest @@ -29,20 +30,18 @@ final class UserRepositoryTests: XCTestCase { private var sut: UserRepository! private var usersAPI: MockUsersAPI! private var selfUsersAPI: MockSelfUserAPI! - private var userLocalStore: UserLocalStoreProtocol! + private var userLocalStore: MockUserLocalStoreProtocol! private var conversationLabelsRepository: MockConversationLabelsRepositoryProtocol! private var conversationsRepository: MockConversationRepositoryProtocol! private var stack: CoreDataStack! private var coreDataStackHelper: CoreDataStackHelper! private var modelHelper: ModelHelper! - private var mockUserDefaults: UserDefaults! private var context: NSManagedObjectContext { stack.syncContext } override func setUp() async throws { - try await super.setUp() coreDataStackHelper = CoreDataStackHelper() modelHelper = ModelHelper() stack = try await coreDataStackHelper.createStack() @@ -50,10 +49,8 @@ final class UserRepositoryTests: XCTestCase { selfUsersAPI = MockSelfUserAPI() conversationLabelsRepository = MockConversationLabelsRepositoryProtocol() conversationsRepository = MockConversationRepositoryProtocol() - mockUserDefaults = UserDefaults( - suiteName: Scaffolding.defaultsTestSuiteName - ) - userLocalStore = UserLocalStore(context: context, userDefaults: mockUserDefaults) + userLocalStore = MockUserLocalStoreProtocol() + sut = UserRepository( usersAPI: usersAPI, selfUserAPI: selfUsersAPI, @@ -64,17 +61,12 @@ final class UserRepositoryTests: XCTestCase { } override func tearDown() async throws { - try await super.tearDown() stack = nil usersAPI = nil selfUsersAPI = nil userLocalStore = nil conversationLabelsRepository = nil sut = nil - mockUserDefaults.removePersistentDomain( - forName: Scaffolding.defaultsTestSuiteName - ) - mockUserDefaults = nil conversationsRepository = nil try coreDataStackHelper.cleanupDirectory() coreDataStackHelper = nil @@ -83,110 +75,91 @@ final class UserRepositoryTests: XCTestCase { // MARK: - Tests - func testPullUsers() async throws { + func testPullUsers_It_Invokes_Local_Store_Method() async throws { + // Given + await context.perform { [context] in // There is no user in the database. XCTAssertNil(ZMUser.fetch(with: Scaffolding.user1.id.uuid, domain: Scaffolding.user1.id.domain, in: context)) } // Mock + usersAPI.getUsersUserIDs_MockValue = WireAPI.UserList( found: [Scaffolding.user1], failed: [] ) + + userLocalStore.persistUserFrom_MockMethod = { _ in } // When + try await sut.pullUsers(userIDs: [Scaffolding.user1.id.toDomainModel()]) // Then - try await context.perform { [context] in - // There is a user in the database. - let user = try XCTUnwrap( - ZMUser.fetch( - with: Scaffolding.user1.id.uuid, - domain: Scaffolding.user1.id.domain, - in: context - ) - ) - XCTAssertEqual(user.remoteIdentifier, Scaffolding.user1.id.uuid) - XCTAssertEqual(user.name, Scaffolding.user1.name) - XCTAssertEqual(user.handle, Scaffolding.user1.handle) - XCTAssertEqual(user.teamIdentifier, Scaffolding.user1.teamID) - XCTAssertEqual(user.accentColorValue, Int16(Scaffolding.user1.accentID)) - XCTAssertEqual(user.isAccountDeleted, Scaffolding.user1.deleted) - XCTAssertEqual(user.emailAddress, Scaffolding.user1.email) - XCTAssertEqual(user.supportedProtocols, Scaffolding.user1.supportedProtocols?.toDomainModel()) - XCTAssertFalse(user.needsToBeUpdatedFromBackend) - } + + XCTAssertEqual(userLocalStore.persistUserFrom_Invocations.count, 1) } - func testPullKnownUsers() async throws { + func testPullKnownUsers_It_Invokes_Local_Store_Methods() async throws { + // Given + _ = await context.perform { [context] in // Insert incomplete user in the database. ZMUser.fetchOrCreate(with: Scaffolding.user1.id.uuid, domain: Scaffolding.user1.id.domain, in: context) } // Mock + usersAPI.getUsersUserIDs_MockValue = WireAPI.UserList( found: [Scaffolding.user1], failed: [] ) + + userLocalStore.fetchUsersQualifiedIDs_MockValue = [Scaffolding.user1.id.toDomainModel()] + userLocalStore.persistUserFrom_MockMethod = { _ in } // When + try await sut.pullKnownUsers() // Then - try await context.perform { [context] in - // The complete user in the database. - let user = try XCTUnwrap( - ZMUser.fetch( - with: Scaffolding.user1.id.uuid, - domain: Scaffolding.user1.id.domain, - in: context - ) - ) - XCTAssertEqual(user.remoteIdentifier, Scaffolding.user1.id.uuid) - XCTAssertEqual(user.name, Scaffolding.user1.name) - XCTAssertEqual(user.handle, Scaffolding.user1.handle) - XCTAssertEqual(user.teamIdentifier, Scaffolding.user1.teamID) - XCTAssertEqual(user.accentColorValue, Int16(Scaffolding.user1.accentID)) - XCTAssertEqual(user.isAccountDeleted, Scaffolding.user1.deleted) - XCTAssertEqual(user.emailAddress, Scaffolding.user1.email) - XCTAssertEqual(user.supportedProtocols, Scaffolding.user1.supportedProtocols?.toDomainModel()) - XCTAssertFalse(user.needsToBeUpdatedFromBackend) - } - - func testRemovesPushToken() async throws { - // Given - - let key = "PushToken" - let data = try JSONEncoder().encode(Scaffolding.pushToken) - mockUserDefaults.set(data, forKey: key) - XCTAssertNotNil(mockUserDefaults.object(forKey: key)) + + XCTAssertEqual(userLocalStore.fetchUsersQualifiedIDs_Invocations.count, 1) + XCTAssertEqual(userLocalStore.persistUserFrom_Invocations.count, 1) + } + + func testRemovesPushToken_It_Invokes_Local_Store_Method() { + + // Mock + + userLocalStore.deletePushToken_MockMethod = {} + - // When + // When - sut.removePushToken() + sut.removePushToken() - // Then + // Then - let pushToken = mockUserDefaults.object(forKey: key) - XCTAssertNil(pushToken) - } + XCTAssertEqual(userLocalStore.deletePushToken_Invocations.count, 1) } - func testFetchSelfUser() async { - // Given + func testFetchSelfUser_It_Invokes_Local_Store_Method() async { + + // Mock let selfUser = await context.perform { [self] in modelHelper.createSelfUser( - id: Scaffolding.userID, + id: .mockID1, domain: nil, in: context ) } + + userLocalStore.fetchSelfUser_MockValue = selfUser // When @@ -194,49 +167,48 @@ final class UserRepositoryTests: XCTestCase { // Then - await context.perform { - XCTAssertEqual(selfUser, localSelfUser) - } + XCTAssertEqual(localSelfUser, selfUser) + XCTAssertEqual(userLocalStore.fetchSelfUser_Invocations.count, 1) } - func testFetchUser() async throws { - // Given + func testFetchUser_It_Invokes_Local_Store_Method() async throws { + + // Mock let user = await context.perform { [self] in modelHelper.createUser( - id: Scaffolding.userID, + id: .mockID1, domain: nil, in: context ) } + + userLocalStore.fetchUserIdDomain_MockValue = user // When - let localUser = try await sut.fetchUser(id: Scaffolding.userID, domain: nil) + let localUser = try await sut.fetchUser( + id: .mockID1, + domain: nil + ) // Then - await context.perform { - XCTAssertEqual(user, localUser) - } + XCTAssertEqual(localUser, user) + XCTAssertEqual(userLocalStore.fetchUserIdDomain_Invocations.count, 1) } - func testAddLegalholdRequest() async throws { - // Given + func testAddLegalholdRequest_It_Invokes_Local_Store_Method() async { + + // Mock - _ = await context.perform { [self] in - modelHelper.createSelfUser( - id: Scaffolding.userID, - domain: nil, - in: context - ) - } + userLocalStore.addSelfLegalHoldRequestUserIDClientIDLastPrekey_MockMethod = { _, _, _ in } // When await sut.addLegalHoldRequest( - userID: Scaffolding.userID, - clientID: Scaffolding.userClientID, + userID: .mockID1, + clientID: UUID().uuidString, lastPrekey: Prekey( id: Scaffolding.lastPrekeyId, base64EncodedKey: Scaffolding.base64encodedString @@ -245,14 +217,10 @@ final class UserRepositoryTests: XCTestCase { // Then - try await context.perform { [context] in - let selfUser = try XCTUnwrap(ZMUser.fetch(with: Scaffolding.userID, in: context)) - - XCTAssertEqual(selfUser.legalHoldStatus, .pending(Scaffolding.legalHoldRequest)) - } + XCTAssertEqual(userLocalStore.addSelfLegalHoldRequestUserIDClientIDLastPrekey_Invocations.count, 1) } - func testPushSelfSupportedProtocols() async throws { + func testPushSelfSupportedProtocols_It_Invokes_Self_Users_API_Method() async throws { // Given selfUsersAPI.pushSupportedProtocols_MockMethod = { _ in () } XCTAssertEqual(selfUsersAPI.pushSupportedProtocols_Invocations, []) @@ -266,86 +234,73 @@ final class UserRepositoryTests: XCTestCase { XCTAssertEqual(selfUsersAPI.pushSupportedProtocols_Invocations, [expectedProtocols]) } - func testDeleteUserAccountForSelfUser() async throws { - _ = await context.perform { [self] in - modelHelper.createSelfUser( - id: Scaffolding.userID, - domain: nil, + func testDeleteUserAccountForSelfUser_It_Invokes_Local_Store_Methods() async throws { + + // Mock + + let selfUser = await context.perform { [self] in + let selfUser = modelHelper.createSelfUser( + id: .mockID1, in: context ) - } - - let expectation = XCTestExpectation() - let notificationName = AccountDeletedNotification.notificationName - - NotificationCenter.default.addObserver( - forName: notificationName, - object: nil, - queue: nil - ) { notification in - XCTAssertNotNil(notification.userInfo?[notificationName] as? AccountDeletedNotification) - - expectation.fulfill() + return selfUser } + + userLocalStore.isSelfUserIdDomain_MockValue = (selfUser, true) + userLocalStore.postAccountDeletedNotification_MockMethod = {} // When try await sut.deleteUserAccount( - id: Scaffolding.userID, + id: .mockID1, domain: nil, at: .now ) // Then - await fulfillment(of: [expectation], timeout: 1) + XCTAssertEqual(userLocalStore.isSelfUserIdDomain_Invocations.count, 1) + XCTAssertEqual(userLocalStore.postAccountDeletedNotification_Invocations.count, 1) } - func testDeleteUserAccountForNotSelfUser() async throws { - // Given + func testDeleteUserAccountForNotSelfUser_It_Invokes_Local_Store_And_Conversation_Repo_Methods() async throws { + // Mock + let user = await context.perform { [self] in - modelHelper.createUser( - id: Scaffolding.userID, - domain: nil, + let user = modelHelper.createUser( + id: .mockID1, in: context ) - } - // Mock + return user + } + + userLocalStore.isSelfUserIdDomain_MockValue = (user, false) + userLocalStore.markAccountAsDeletedFor_MockMethod = { _ in } conversationsRepository.removeParticipantFromAllGroupConversationsParticipantIDParticipantDomainRemovedAt_MockMethod = { _, _, _ in } // When try await sut.deleteUserAccount( - id: Scaffolding.userID, + id: .mockID1, domain: nil, at: .now ) // Then + XCTAssertEqual(userLocalStore.isSelfUserIdDomain_Invocations.count, 1) + XCTAssertEqual(userLocalStore.markAccountAsDeletedFor_Invocations.count, 1) XCTAssertEqual(conversationsRepository.removeParticipantFromAllGroupConversationsParticipantIDParticipantDomainRemovedAt_Invocations.count, 1) - - await context.perform { - XCTAssertEqual(user.isAccountDeleted, true) - } } - func testUpdateUserProperty_It_Enables_Read_Receipts_Property() async throws { - // Given - - await context.perform { [self] in - let selfUser = modelHelper.createSelfUser( - id: Scaffolding.userID, - domain: nil, - in: context - ) - - selfUser.readReceiptsEnabled = false - selfUser.readReceiptsEnabledChangedRemotely = false - } + func testUpdateUserProperty_It_Enables_Read_Receipts_Property_It_Invokes_Local_Store_Method() async throws { + + // Mock + + userLocalStore.updateSelfUserReadReceiptsIsReadReceiptsEnabledIsReadReceiptsEnabledChangedRemotely_MockMethod = { _, _ in } // When @@ -353,15 +308,11 @@ final class UserRepositoryTests: XCTestCase { // Then - let selfUser = await sut.fetchSelfUser() - - await context.perform { - XCTAssertEqual(selfUser.readReceiptsEnabled, true) - XCTAssertEqual(selfUser.readReceiptsEnabledChangedRemotely, true) - } + XCTAssertEqual(userLocalStore.updateSelfUserReadReceiptsIsReadReceiptsEnabledIsReadReceiptsEnabledChangedRemotely_Invocations.count, 1) } - func testUpdateUserProperty_Update_Conversation_Labels_Is_Invocated() async throws { + func testUpdateUserProperty_It_Invokes_Conversation_Labels_Repo_Method() async throws { + // Mock conversationLabelsRepository.updateConversationLabels_MockMethod = { _ in } @@ -389,7 +340,9 @@ final class UserRepositoryTests: XCTestCase { // Then - await XCTAssertThrowsError(ConversationLabelsRepositoryError.failedToDeleteStoredLabels) { [self] in + await XCTAssertThrowsError( + ConversationLabelsRepositoryError.failedToDeleteStoredLabels + ) { [self] in // When @@ -399,18 +352,11 @@ final class UserRepositoryTests: XCTestCase { } } - func testUpdateUser_It_Updates_User_Locally() async throws { - // Given - - _ = await context.perform { [self] in - modelHelper.createUser( - id: Scaffolding.userID, - handle: Scaffolding.existingHandle, - email: Scaffolding.existingEmail, - supportedProtocols: [.mls], - in: context - ) - } + func testUpdateUser_It_Updates_User_Locally_It_Invokes_Local_Store_Method() async { + + // Mock + + userLocalStore.updateUserFrom_MockMethod = { _ in } // When @@ -418,81 +364,68 @@ final class UserRepositoryTests: XCTestCase { // Then - try await context.perform { [context] in - let updatedUser = try XCTUnwrap(ZMUser.fetch(with: Scaffolding.userID, in: context)) - - XCTAssertEqual(updatedUser.remoteIdentifier, Scaffolding.userID) - XCTAssertEqual(updatedUser.name, Scaffolding.event.name) - XCTAssertEqual(updatedUser.handle, Scaffolding.existingHandle) /// ensuring handle is not updated to nil - XCTAssertEqual(updatedUser.emailAddress, Scaffolding.existingEmail) /// ensuring email is not updated to nil - XCTAssertEqual(updatedUser.supportedProtocols, [.proteus, .mls]) - } + XCTAssertEqual(userLocalStore.updateUserFrom_Invocations.count, 1) } - func testIsSelfUser_It_Returns_Correct_Flag() async throws { + func testIsSelfUser_It_Returns_True() async throws { + // Mock - let (selfUser, notSelfUser) = await context.perform { [self] in - let selfUser = modelHelper.createSelfUser(id: Scaffolding.selfUserID, in: context) - let notSelfUser = modelHelper.createUser(id: Scaffolding.userID, in: context) + let user = await context.perform { [self] in + let user = modelHelper.createSelfUser( + id: .mockID1, + in: context + ) - return (selfUser, notSelfUser) + return user } + + userLocalStore.isSelfUserIdDomain_MockValue = (user, true) - // When / Then isSelfUser == true - + // When + let isSelfUser = try await sut.isSelfUser( - id: Scaffolding.selfUserID, - domain: nil + id: .mockID1, + domain: Scaffolding.domain ) - + + // Then + XCTAssertEqual(isSelfUser, true) - - // When / Then isSelfUser == false - - let isNotSelfUser = try await sut.isSelfUser( - id: Scaffolding.userID, - domain: nil - ) - - XCTAssertEqual(isNotSelfUser, false) } private enum Scaffolding { - static let selfUserID = UUID() - static let userID = UUID() + static let domain = "domain.com" - static let existingHandle = "handle" - static let existingEmail = "test@wire.com" - static let userPropertyKey = UserProperty.Key.wireReceiptMode - static let userClientID = UUID().uuidString + static let lastPrekeyId = 65_535 + static let base64encodedString = "pQABAQoCoQBYIPEFMBhOtG0dl6gZrh3kgopEK4i62t9sqyqCBckq3IJgA6EAoQBYIC9gPmCdKyqwj9RiAaeSsUI7zPKDZS+CjoN+sfihk/5VBPY=" static let conversationLabel1 = ConversationLabel( - id: UUID(uuidString: "f3d302fb-3fd5-43b2-927b-6336f9e787b0")!, + id: .mockID1, name: "ConversationLabel1", type: 0, conversationIDs: [ - UUID(uuidString: "ffd0a9af-c0d0-4748-be9b-ab309c640dde")!, - UUID(uuidString: "03fe0d05-f0d5-4ee4-a8ff-8d4b4dcf89d8")! + .mockID2, + .mockID3 ] ) static let conversationLabel2 = ConversationLabel( - id: UUID(uuidString: "2AA27182-AA54-4D79-973E-8974A3BBE375")!, + id: .mockID1, name: "ConversationLabel2", type: 0, conversationIDs: [ - UUID(uuidString: "ceb3f577-3b22-4fe9-8ffd-757f29c47ffc")!, - UUID(uuidString: "eca55fdb-8f81-4112-9175-4ffca7691bf8")! + .mockID2, + .mockID3 ] ) nonisolated(unsafe) static let legalHoldRequest = LegalHoldRequest( - target: userID, + target: .mockID1, requester: nil, - clientIdentifier: userClientID, + clientIdentifier: UUID().uuidString, lastPrekey: .init( id: lastPrekeyId, key: Data(base64Encoded: base64encodedString)! @@ -500,7 +433,7 @@ final class UserRepositoryTests: XCTestCase { ) static let user1 = User( - id: QualifiedID(uuid: userID, domain: domain), + id: QualifiedID(uuid: .mockID1, domain: domain), name: "user1", handle: "handle1", teamID: nil, @@ -515,7 +448,7 @@ final class UserRepositoryTests: XCTestCase { ) static let event = UserUpdateEvent( - userID: userID, + userID: .mockID1, accentColorID: nil, name: "username", handle: nil, @@ -525,17 +458,13 @@ final class UserRepositoryTests: XCTestCase { supportedProtocols: [.proteus, .mls] ) - static let deviceToken = Data(repeating: 0x41, count: 10) - nonisolated(unsafe) static let pushToken = PushToken( - deviceToken: deviceToken, + deviceToken: Data(repeating: 0x41, count: 10), appIdentifier: "com.wire", transportType: "APNS_VOIP", tokenType: .voip ) - static let defaultsTestSuiteName = UUID().uuidString - } } From ee3927caf71676c85a0f6460fb62cf7f00395b2a Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Thu, 7 Nov 2024 10:03:09 +0100 Subject: [PATCH 03/14] Connection - add UTs for local store and repository --- .../project.pbxproj | 4 + .../Connections/ConnectionsLocalStore.swift | 3 +- .../generated/AutoMockable.generated.swift | 29 ++++ .../ConnectionsLocalStoreTests.swift | 160 ++++++++++++++++++ .../ConnectionsRepositoryTests.swift | 135 +++------------ 5 files changed, 218 insertions(+), 113 deletions(-) create mode 100644 WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift diff --git a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj index b41474e2166..fc2edd389ce 100644 --- a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj +++ b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ C96B75482CDBA10F003A85EB /* SystemMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75462CDBA10F003A85EB /* SystemMessage.swift */; }; C96B755D2CDBB176003A85EB /* ConversationLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */; }; C96B755F2CDBCD24003A85EB /* UserLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */; }; + C96B75612CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */; }; C97BCCAA2C98704B004F2D0D /* WireDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 01D0DCA62C1C8C870076CB1C /* WireDomain.framework */; }; C97C013D2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */; }; C97C014B2CB00F92000683C5 /* OneOnOneResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */; }; @@ -185,6 +186,7 @@ C96B75462CDBA10F003A85EB /* SystemMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMessage.swift; sourceTree = ""; }; C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLocalStoreTests.swift; sourceTree = ""; }; C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLocalStoreTests.swift; sourceTree = ""; }; + C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionsLocalStoreTests.swift; sourceTree = ""; }; C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPropertiesDeleteEventProcessorTests.swift; sourceTree = ""; }; C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OneOnOneResolver.swift; path = ../../OneOnOneResolver.swift; sourceTree = ""; }; C97C014F2CB01BDF000683C5 /* OneOnOneResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneResolverTests.swift; sourceTree = ""; }; @@ -491,6 +493,7 @@ children = ( C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */, C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */, + C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */, ); path = LocalStores; sourceTree = ""; @@ -1110,6 +1113,7 @@ C97C01BB2CBE5E65000683C5 /* UserUpdateEventProcessorTests.swift in Sources */, C97C013D2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift in Sources */, C97C01502CB01BDF000683C5 /* OneOnOneResolverTests.swift in Sources */, + C96B75612CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift in Sources */, C9C8FDD22C9DBE0E00702B91 /* UserClientAddEventProcessorTests.swift in Sources */, C9C8FDD12C9DBE0E00702B91 /* TeamMemberUpdateEventProcessorTests.swift in Sources */, C97C01542CB04626000683C5 /* UserDeleteEventProcessorTests.swift in Sources */, diff --git a/WireDomain/Sources/WireDomain/Repositories/Connections/ConnectionsLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/Connections/ConnectionsLocalStore.swift index 3bd51753af2..f6a73638ef8 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Connections/ConnectionsLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Connections/ConnectionsLocalStore.swift @@ -21,7 +21,8 @@ import Foundation import WireAPI import WireDataModel -protocol ConnectionsLocalStoreProtocol { +// sourcery: AutoMockable +public protocol ConnectionsLocalStoreProtocol { func storeConnection( _ connectionPayload: Connection diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index 8cdbf103570..a19fd7976e1 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -55,6 +55,35 @@ import WireDataModel + +public class MockConnectionsLocalStoreProtocol: ConnectionsLocalStoreProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - storeConnection + + public var storeConnection_Invocations: [Connection] = [] + public var storeConnection_MockError: Error? + public var storeConnection_MockMethod: ((Connection) async throws -> Void)? + + public func storeConnection(_ connectionPayload: Connection) async throws { + storeConnection_Invocations.append(connectionPayload) + + if let error = storeConnection_MockError { + throw error + } + + guard let mock = storeConnection_MockMethod else { + fatalError("no mock for `storeConnection`") + } + + try await mock(connectionPayload) + } + +} public class MockConnectionsRepositoryProtocol: ConnectionsRepositoryProtocol { diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift new file mode 100644 index 00000000000..959b8189534 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift @@ -0,0 +1,160 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +@testable import WireAPI +import WireDataModel +import WireDataModelSupport +@testable import WireDomain +import XCTest + +final class ConnectionsLocalStoreTests: XCTestCase { + + private var sut: ConnectionsLocalStore! + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + BackendInfo.isFederationEnabled = false + + modelHelper = ModelHelper() + coreDataStackHelper = CoreDataStackHelper() + stack = try await coreDataStackHelper.createStack() + sut = ConnectionsLocalStore(context: context) + } + + override func tearDown() async throws { + stack = nil + sut = nil + try coreDataStackHelper.cleanupDirectory() + modelHelper = nil + coreDataStackHelper = nil + } + + // MARK: - Tests + + func testPullConnections_GivenConnectionDoesNotExist_FederationDisabled() async throws { + try await internalTestPullConnections_GivenConnectionDoesNotExist( + federationEnabled: false + ) + } + + func testPullConnections_GivenConnectionDoesNotExist_FederationEnabled() async throws { + BackendInfo.isFederationEnabled = true + try await internalTestPullConnections_GivenConnectionDoesNotExist( + federationEnabled: true + ) + } + + // MARK: Private + + func internalTestPullConnections_GivenConnectionDoesNotExist( + federationEnabled: Bool, + file: StaticString = #file, + line: UInt = #line + ) async throws { + + // Mock + + let connection = Scaffolding.connection + + // When + + try await sut.storeConnection(connection) + + // Then + + try await context.perform { [context] in + // There is a connection in the database. + let storedConnection = try XCTUnwrap(ZMConnection.fetch(userID: Scaffolding.member2ID.uuid, domain: Scaffolding.member2ID.domain, in: context)) + + XCTAssertEqual(storedConnection.lastUpdateDateInGMT, connection.lastUpdate) + + XCTAssertEqual(storedConnection.to.remoteIdentifier, connection.receiverID) + if federationEnabled { + XCTAssertEqual(storedConnection.to.domain, connection.receiverQualifiedID?.domain) + } else { + XCTAssertNil(storedConnection.to.domain) + } + XCTAssertEqual(storedConnection.status, ZMConnectionStatus.accepted) + + let relatedConversation = try XCTUnwrap(storedConnection.to.oneOnOneConversation) + XCTAssertEqual(relatedConversation.remoteIdentifier, connection.qualifiedConversationID?.uuid) + + if federationEnabled { + XCTAssertEqual(relatedConversation.domain, connection.qualifiedConversationID?.domain) + } else { + XCTAssertNil(relatedConversation.domain) + } + + XCTAssertTrue(relatedConversation.needsToBeUpdatedFromBackend) + } + } + + func testUpdateConnection_It_Successfully_Updates_Connection_Locally() async throws { + + // Given + + let connection = Scaffolding.connection + + // When + + try await sut.storeConnection(connection) + + // Then + + try await context.perform { [context] in + let storedConnection = try XCTUnwrap(ZMConnection.fetch(userID: Scaffolding.member2ID.uuid, domain: Scaffolding.member2ID.domain, in: context)) + + XCTAssertEqual(storedConnection.lastUpdateDateInGMT, connection.lastUpdate) + + XCTAssertEqual(storedConnection.to.remoteIdentifier, connection.receiverID) + XCTAssertNil(storedConnection.to.domain) + XCTAssertEqual(storedConnection.status, ZMConnectionStatus.accepted) + + let relatedConversation = try XCTUnwrap(storedConnection.to.oneOnOneConversation) + XCTAssertEqual(relatedConversation.remoteIdentifier, connection.qualifiedConversationID?.uuid) + + XCTAssertNil(relatedConversation.domain) + + XCTAssertTrue(relatedConversation.needsToBeUpdatedFromBackend) + } + } + + private enum Scaffolding { + static let member1ID = WireAPI.QualifiedID(uuid: UUID(), domain: String.randomDomain()) + static let conversationID = WireAPI.QualifiedID(uuid: UUID(), domain: String.randomDomain()) + static let member2ID = WireAPI.QualifiedID(uuid: UUID(), domain: String.randomDomain()) + static let lastUpdate = Date() + static let connectionStatus = ConnectionStatus.accepted + + static let connection = WireAPI.Connection(senderID: Scaffolding.member1ID.uuid, + receiverID: Scaffolding.member2ID.uuid, + receiverQualifiedID: Scaffolding.member2ID, + conversationID: Scaffolding.conversationID.uuid, + qualifiedConversationID: Scaffolding.conversationID, + lastUpdate: Scaffolding.lastUpdate, + status: Scaffolding.connectionStatus) + } + +} + diff --git a/WireDomain/Tests/WireDomainTests/Repositories/ConnectionsRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/ConnectionsRepositoryTests.swift index 22e24f4e935..c1707412b92 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/ConnectionsRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/ConnectionsRepositoryTests.swift @@ -20,6 +20,7 @@ import WireAPISupport import WireDataModel import WireDataModelSupport +import WireDomainSupport @testable import WireDomain import XCTest @@ -27,148 +28,66 @@ final class ConnectionsRepositoryTests: XCTestCase { private var sut: ConnectionsRepository! private var connectionsAPI: MockConnectionsAPI! - private var stack: CoreDataStack! - private var coreDataStackHelper: CoreDataStackHelper! - private var modelHelper: ModelHelper! - - private var context: NSManagedObjectContext { - stack.syncContext - } + private var connectionsLocalStore: MockConnectionsLocalStoreProtocol! override func setUp() async throws { - try await super.setUp() - BackendInfo.isFederationEnabled = false - - modelHelper = ModelHelper() - coreDataStackHelper = CoreDataStackHelper() - stack = try await coreDataStackHelper.createStack() connectionsAPI = MockConnectionsAPI() + connectionsLocalStore = MockConnectionsLocalStoreProtocol() + sut = ConnectionsRepository( connectionsAPI: connectionsAPI, - connectionsLocalStore: ConnectionsLocalStore(context: context) + connectionsLocalStore: connectionsLocalStore ) } override func tearDown() async throws { - try await super.tearDown() - stack = nil connectionsAPI = nil + connectionsLocalStore = nil sut = nil - try coreDataStackHelper.cleanupDirectory() - modelHelper = nil - coreDataStackHelper = nil } // MARK: - Tests - func testPullConnections_GivenOneConnectionFails_OtherConnectionsAreStored() async throws { + func testPullConnections_It_Invokes_Local_Store_Method() async throws { + // Mock + let connection = Scaffolding.connection - let brokenConnection = Scaffolding.brokenConnection connectionsAPI.getConnections_MockValue = .init(fetchPage: { _ in WireAPI.PayloadPager.Page( - element: [ - brokenConnection, - connection - ], + element: [connection], hasMore: false, nextStart: "first" ) }) + + connectionsLocalStore.storeConnection_MockMethod = { _ in } // When + try await sut.pullConnections() // Then - try await context.perform { [context] in - // There is a connection in the database. - let fetchRequest = NSFetchRequest(entityName: ZMConnection.entityName()) - let a = try context.fetch(fetchRequest) - XCTAssertEqual(a.count, 1) - } - } - - func testPullConnections_GivenConnectionDoesNotExist_FederationDisabled() async throws { - try await internalTestPullConnections_GivenConnectionDoesNotExist(federationEnabled: false) - } - - func testPullConnections_GivenConnectionDoesNotExist_FederationEnabled() async throws { - BackendInfo.isFederationEnabled = true - try await internalTestPullConnections_GivenConnectionDoesNotExist(federationEnabled: true) + + XCTAssertEqual(connectionsLocalStore.storeConnection_Invocations.count, 1) } - - // MARK: Private - - func internalTestPullConnections_GivenConnectionDoesNotExist( - federationEnabled: Bool, - file: StaticString = #file, - line: UInt = #line - ) async throws { + + func testUpdateConnection_It_Invokes_Local_Store_Method() async throws { + // Mock + let connection = Scaffolding.connection - connectionsAPI.getConnections_MockValue = .init(fetchPage: { _ in - WireAPI.PayloadPager.Page(element: [connection], hasMore: false, nextStart: "first") - }) + connectionsLocalStore.storeConnection_MockMethod = { _ in } // When - try await sut.pullConnections() - - // Then - try await context.perform { [context] in - // There is a connection in the database. - let storedConnection = try XCTUnwrap(ZMConnection.fetch(userID: Scaffolding.member2ID.uuid, domain: Scaffolding.member2ID.domain, in: context)) - - XCTAssertEqual(storedConnection.lastUpdateDateInGMT, connection.lastUpdate) - - XCTAssertEqual(storedConnection.to.remoteIdentifier, connection.receiverID) - if federationEnabled { - XCTAssertEqual(storedConnection.to.domain, connection.receiverQualifiedID?.domain) - } else { - XCTAssertNil(storedConnection.to.domain) - } - XCTAssertEqual(storedConnection.status, ZMConnectionStatus.accepted) - - let relatedConversation = try XCTUnwrap(storedConnection.to.oneOnOneConversation) - XCTAssertEqual(relatedConversation.remoteIdentifier, connection.qualifiedConversationID?.uuid) - - if federationEnabled { - XCTAssertEqual(relatedConversation.domain, connection.qualifiedConversationID?.domain) - } else { - XCTAssertNil(relatedConversation.domain) - } - - XCTAssertTrue(relatedConversation.needsToBeUpdatedFromBackend) - } - } - - func testUpdateConnection_It_Successfully_Updates_Connection_Locally() async throws { - // Given - - let connection = Scaffolding.connection - - // When - + try await sut.updateConnection(connection) // Then - try await context.perform { [context] in - let storedConnection = try XCTUnwrap(ZMConnection.fetch(userID: Scaffolding.member2ID.uuid, domain: Scaffolding.member2ID.domain, in: context)) - - XCTAssertEqual(storedConnection.lastUpdateDateInGMT, connection.lastUpdate) - - XCTAssertEqual(storedConnection.to.remoteIdentifier, connection.receiverID) - XCTAssertNil(storedConnection.to.domain) - XCTAssertEqual(storedConnection.status, ZMConnectionStatus.accepted) - - let relatedConversation = try XCTUnwrap(storedConnection.to.oneOnOneConversation) - XCTAssertEqual(relatedConversation.remoteIdentifier, connection.qualifiedConversationID?.uuid) - - XCTAssertNil(relatedConversation.domain) - - XCTAssertTrue(relatedConversation.needsToBeUpdatedFromBackend) - } + + XCTAssertEqual(connectionsLocalStore.storeConnection_Invocations.count, 1) } private enum Scaffolding { @@ -185,14 +104,6 @@ final class ConnectionsRepositoryTests: XCTestCase { qualifiedConversationID: Scaffolding.conversationID, lastUpdate: Scaffolding.lastUpdate, status: Scaffolding.connectionStatus) - - static let brokenConnection = WireAPI.Connection(senderID: nil, - receiverID: nil, - receiverQualifiedID: nil, - conversationID: nil, - qualifiedConversationID: nil, - lastUpdate: Date(), - status: .pending) } } From 3a3234ba2dae68d4a725d0870a4f5d423ea7e37f Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Thu, 7 Nov 2024 11:52:37 +0100 Subject: [PATCH 04/14] Team - add UTs for local store and repository --- .../project.pbxproj | 8 + .../Repositories/Team/TeamLocalStore.swift | 318 +++++++++++++++++ .../Repositories/Team/TeamRepository.swift | 201 +++-------- .../Team/TeamRepositoryError.swift | 4 - .../generated/AutoMockable.generated.swift | 166 +++++++++ .../ConnectionsLocalStoreTests.swift | 11 +- .../ConversationLocalStoreTests.swift | 64 ++-- .../LocalStores/TeamLocalStoreTests.swift | 330 ++++++++++++++++++ .../LocalStores/UserLocalStoreTests.swift | 21 +- .../ConnectionsRepositoryTests.swift | 22 +- .../ConversationRepositoryTests.swift | 72 ++-- .../Repositories/TeamRepositoryTests.swift | 230 ++++-------- .../Repositories/UserRepositoryTests.swift | 69 ++-- 13 files changed, 1048 insertions(+), 468 deletions(-) create mode 100644 WireDomain/Sources/WireDomain/Repositories/Team/TeamLocalStore.swift create mode 100644 WireDomain/Tests/WireDomainTests/LocalStores/TeamLocalStoreTests.swift diff --git a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj index fc2edd389ce..aff1b70e34b 100644 --- a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj +++ b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj @@ -37,6 +37,8 @@ C96B755D2CDBB176003A85EB /* ConversationLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */; }; C96B755F2CDBCD24003A85EB /* UserLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */; }; C96B75612CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */; }; + C96B75632CDCBA10003A85EB /* TeamLocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75622CDCBA10003A85EB /* TeamLocalStore.swift */; }; + C96B75652CDCC85B003A85EB /* TeamLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */; }; C97BCCAA2C98704B004F2D0D /* WireDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 01D0DCA62C1C8C870076CB1C /* WireDomain.framework */; }; C97C013D2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */; }; C97C014B2CB00F92000683C5 /* OneOnOneResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */; }; @@ -187,6 +189,8 @@ C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLocalStoreTests.swift; sourceTree = ""; }; C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLocalStoreTests.swift; sourceTree = ""; }; C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionsLocalStoreTests.swift; sourceTree = ""; }; + C96B75622CDCBA10003A85EB /* TeamLocalStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLocalStore.swift; sourceTree = ""; }; + C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLocalStoreTests.swift; sourceTree = ""; }; C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPropertiesDeleteEventProcessorTests.swift; sourceTree = ""; }; C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OneOnOneResolver.swift; path = ../../OneOnOneResolver.swift; sourceTree = ""; }; C97C014F2CB01BDF000683C5 /* OneOnOneResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneResolverTests.swift; sourceTree = ""; }; @@ -494,6 +498,7 @@ C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */, C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */, C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */, + C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */, ); path = LocalStores; sourceTree = ""; @@ -551,6 +556,7 @@ isa = PBXGroup; children = ( C99322B22C986E3A0065E10F /* TeamRepository.swift */, + C96B75622CDCBA10003A85EB /* TeamLocalStore.swift */, C99322B32C986E3A0065E10F /* TeamRepositoryError.swift */, ); path = Team; @@ -1010,6 +1016,7 @@ EE368CD02C2DAA87009DBAB0 /* FederationEventProcessor.swift in Sources */, EEAD09FA2C46773300CC8658 /* ConversationMemberJoinEventProcessor.swift in Sources */, C99322E12C986E3A0065E10F /* ConversationLocalStore+MLS.swift in Sources */, + C96B75632CDCBA10003A85EB /* TeamLocalStore.swift in Sources */, C97C014B2CB00F92000683C5 /* OneOnOneResolver.swift in Sources */, C99322D52C986E3A0065E10F /* UserModelMappings.swift in Sources */, C91F111D2C9C4B2B00BA5BE2 /* ConnectionsLocalStore.swift in Sources */, @@ -1121,6 +1128,7 @@ C9C8FDCF2C9DBE0E00702B91 /* TeamDeleteEventProcessorTests.swift in Sources */, C97C01592CB40010000683C5 /* FederationConnectionRemovedEventProcessorTests.swift in Sources */, C97C01AC2CB92D47000683C5 /* UserLegalholdEnableEventProcessorTests.swift in Sources */, + C96B75652CDCC85B003A85EB /* TeamLocalStoreTests.swift in Sources */, C97C015A2CB40010000683C5 /* FederationDeleteEventProcessorTests.swift in Sources */, C9C8FDD42C9DBE0E00702B91 /* UserLegalholdRequestEventProcessorTests.swift in Sources */, C96B755D2CDBB176003A85EB /* ConversationLocalStoreTests.swift in Sources */, diff --git a/WireDomain/Sources/WireDomain/Repositories/Team/TeamLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/Team/TeamLocalStore.swift new file mode 100644 index 00000000000..bb39705c8fe --- /dev/null +++ b/WireDomain/Sources/WireDomain/Repositories/Team/TeamLocalStore.swift @@ -0,0 +1,318 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import CoreData +import WireDataModel + +// sourcery: AutoMockable +public protocol TeamLocalStoreProtocol { + + func fetchMember( + id: UUID + ) async -> Member? + + /// Fetches the self user ID. + /// - returns: The self user ID. + + func selfUserID() async -> UUID + + /// Fetches the user membership. + /// - parameter user: A given user. + /// - returns: The user membership. + + func userMembership( + user: ZMUser + ) async -> Member? + + /// Fetches the user domain. + /// - parameter user: A given user. + /// - returns: The user domain. + + func userDomain( + user: ZMUser + ) async -> String? + + /// Deletes the member locally. + /// - parameter member: A given member. + + func deleteMember( + _ member: Member + ) async + + /// Stores a flag whether the member needs backend update. + /// - parameters: + /// - needsBackendUpdate: The flag to update. + /// - member: A given member. + + func storeMember( + needsBackendUpdate: Bool, + member: Member + ) async + + /// Stores a team locally. + /// - Parameters: + /// - id: The team ID. + /// - name: The team name. + /// - creatorID: The team creator ID. + /// - logoID: The team logo ID. + /// - logoKey: The team logo key. + + func storeTeam( + id: UUID, + name: String, + creatorID: UUID, + logoID: String?, + logoKey: String? + ) async + + /// Stores team roles locally. + /// - parameters: + /// - selfTeamID: The self team ID. + /// - teamRolesInfo: A list of role and actions. + + func storeTeamRoles( + selfTeamID: UUID, + teamRolesInfo: [TeamLocalStore.TeamRoleInfo] + ) async throws + + /// Stores team members locally. + /// - parameters: + /// - selfTeamID: The self team ID. + /// - teamMembersInfo: A list of member info (id, permission, creator id, date) + + func storeTeamMembers( + selfTeamID: UUID, + teamMembersInfo: [TeamLocalStore.TeamMemberInfo] + ) async throws +} + +public final class TeamLocalStore: TeamLocalStoreProtocol { + + // MARK: - Error + + enum Error: Swift.Error { + /// The local team instance was not found in the database. + + case teamNotFoundLocally + } + + // MARK: - Models + + public struct TeamRoleInfo: Sendable { + let role: String + let actions: [String] + } + + public struct TeamMemberInfo: Sendable { + let id: UUID + let selfPermission: Int64? + let creatorID: UUID? + let creationDate: Date? + } + + // MARK: - Properties + + private let context: NSManagedObjectContext + private let userLocalStore: any UserLocalStoreProtocol + + // MARK: - Object lifecycle + + public init( + context: NSManagedObjectContext, + userLocalStore: any UserLocalStoreProtocol + ) { + self.context = context + self.userLocalStore = userLocalStore + } + + // MARK: - Public + + public func fetchMember( + id: UUID + ) async -> Member? { + await context.perform { [context] in + Member.fetch( + with: id, + in: context + ) + } + } + + public func selfUserID() async -> UUID { + let selfUser = await userLocalStore.fetchSelfUser() + + return await context.perform { + selfUser.remoteIdentifier + } + } + + public func userMembership( + user: ZMUser + ) async -> Member? { + await context.perform { + user.membership + } + } + + public func userDomain( + user: ZMUser + ) async -> String? { + await context.perform { + user.domain + } + } + + public func deleteMember( + _ member: Member + ) async { + await context.perform { [context] in + context.delete(member) + } + } + + public func storeMember( + needsBackendUpdate: Bool, + member: Member + ) async { + await context.perform { [context] in + member.needsToBeUpdatedFromBackend = true + context.saveOrRollback() + } + } + + public func storeTeam( + id: UUID, + name: String, + creatorID: UUID, + logoID: String?, + logoKey: String? + ) async { + let selfUser = await userLocalStore.fetchSelfUser() + + await context.perform { [context] in + let team = WireDataModel.Team.fetchOrCreate( + with: id, + in: context + ) + + _ = WireDataModel.Member.getOrUpdateMember( + for: selfUser, + in: team, + context: context + ) + + team.name = name + team.creator = ZMUser.fetchOrCreate( + with: creatorID, + domain: nil, + in: context + ) + team.pictureAssetId = logoID + team.pictureAssetKey = logoKey + team.needsToBeUpdatedFromBackend = false + } + } + + public func storeTeamRoles( + selfTeamID: UUID, + teamRolesInfo: [TeamRoleInfo] + ) async throws { + try await context.perform { [context, selfTeamID] in + guard let team = WireDataModel.Team.fetch( + with: selfTeamID, + in: context + ) else { + throw Error.teamNotFoundLocally + } + + let existingRoles = team.roles + + let localRoles = teamRolesInfo.map { teamRoleInfo in + let localRole = WireDataModel.Role.fetchOrCreate( + name: teamRoleInfo.role, + teamOrConversation: .team(team), + context: context + ) + + localRole.name = teamRoleInfo.role + localRole.team = team + + for action in teamRoleInfo.actions { + let action = Action.fetchOrCreate( + name: action, + in: context + ) + + localRole.actions.insert(action) + } + + return localRole + } + + for roleToDelete in existingRoles.subtracting(localRoles) { + context.delete(roleToDelete) + } + + team.needsToDownloadRoles = false + } + } + + public func storeTeamMembers( + selfTeamID: UUID, + teamMembersInfo: [TeamMemberInfo] + ) async throws { + try await context.perform { [context, selfTeamID] in + guard let team = WireDataModel.Team.fetch( + with: selfTeamID, + in: context + ) else { + throw Error.teamNotFoundLocally + } + + for teamMemberInfo in teamMembersInfo { + let user = ZMUser.fetchOrCreate( + with: teamMemberInfo.id, + domain: nil, + in: context + ) + + let membership = Member.getOrUpdateMember( + for: user, + in: team, + context: context + ) + + if let selfPermission = teamMemberInfo.selfPermission { + membership.permissions = Permissions(rawValue: selfPermission) + } + + if let creatorID = teamMemberInfo.creatorID { + membership.createdBy = ZMUser.fetchOrCreate( + with: creatorID, + domain: nil, + in: context + ) + } + + membership.createdAt = teamMemberInfo.creationDate + membership.needsToBeUpdatedFromBackend = false + } + } + } + +} diff --git a/WireDomain/Sources/WireDomain/Repositories/Team/TeamRepository.swift b/WireDomain/Sources/WireDomain/Repositories/Team/TeamRepository.swift index a124b5406c8..77f80945faa 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Team/TeamRepository.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Team/TeamRepository.swift @@ -58,7 +58,9 @@ public protocol TeamRepositoryProtocol { /// Sets the team member `needsToBeUpdatedFromBackend` flag to true. /// - Parameter membershipID: The id of the team member. - func storeTeamMemberNeedsBackendUpdate(membershipID: UUID) async throws + func storeTeamMemberNeedsBackendUpdate( + membershipID: UUID + ) async throws } @@ -69,47 +71,69 @@ public class TeamRepository: TeamRepositoryProtocol { private let selfTeamID: UUID private let userRepository: any UserRepositoryProtocol private let teamsAPI: any TeamsAPI - // swiftlint:disable:next todo_requires_jira_link - // TODO: create TeamLocalStore - private let context: NSManagedObjectContext + private let teamLocalStore: any TeamLocalStoreProtocol // MARK: - Object lifecycle public init( selfTeamID: UUID, userRepository: any UserRepositoryProtocol, - teamsAPI: any TeamsAPI, - context: NSManagedObjectContext + teamLocalStore: any TeamLocalStoreProtocol, + teamsAPI: any TeamsAPI ) { self.selfTeamID = selfTeamID self.userRepository = userRepository + self.teamLocalStore = teamLocalStore self.teamsAPI = teamsAPI - self.context = context } // MARK: - Public public func pullSelfTeam() async throws { let team = try await fetchSelfTeamRemotely() - await storeTeamLocally(team) + + await teamLocalStore.storeTeam( + id: team.id, + name: team.name, + creatorID: team.creatorID, + logoID: team.logoID, + logoKey: team.logoKey + ) } public func pullSelfTeamRoles() async throws { let teamRoles = try await fetchSelfTeamRolesRemotely() - try await storeTeamRolesLocally(teamRoles) + + let teamRolesInfo = teamRoles.map { + TeamLocalStore.TeamRoleInfo(role: $0.name, actions: $0.actions.map(\.name)) + } + + try await teamLocalStore.storeTeamRoles( + selfTeamID: selfTeamID, + teamRolesInfo: teamRolesInfo + ) } public func pullSelfTeamMembers() async throws { let teamMembers = try await fetchSelfTeamMembersRemotely() - try await storeTeamMembersLocally(teamMembers) + + let teamMembersInfo = teamMembers.map { + TeamLocalStore.TeamMemberInfo( + id: $0.userID, + selfPermission: $0.permissions?.selfPermissions, + creatorID: $0.creatorID, + creationDate: $0.creationDate + ) + } + + try await teamLocalStore.storeTeamMembers( + selfTeamID: selfTeamID, + teamMembersInfo: teamMembersInfo + ) } public func fetchSelfLegalholdStatus() async throws -> LegalholdStatus { - let selfUser = await userRepository.fetchSelfUser() - - let selfUserID: UUID = await context.perform { - selfUser.remoteIdentifier - } + let selfUserID = await teamLocalStore.selfUserID() return try await teamsAPI.getLegalholdStatus( for: selfTeamID, @@ -127,17 +151,13 @@ public class TeamRepository: TeamRepositoryProtocol { domain: domain ) - let member = try await context.perform { - guard let member = user.membership else { - throw TeamRepositoryError.userNotAMemberInTeam(user: userID) - } - - return member + guard let member = await teamLocalStore.userMembership( + user: user + ) else { + throw TeamRepositoryError.userNotAMemberInTeam(user: userID) } - let domain = await context.perform { - user.domain - } + let domain = await teamLocalStore.userDomain(user: user) try await userRepository.deleteUserAccount( id: userID, @@ -145,25 +165,20 @@ public class TeamRepository: TeamRepositoryProtocol { at: time ) - await context.perform { [context] in - context.delete(member) - } + await teamLocalStore.deleteMember(member) } public func storeTeamMemberNeedsBackendUpdate(membershipID: UUID) async throws { - try await context.perform { [context] in - - guard let member = Member.fetch( - with: membershipID, - in: context - ) else { - throw TeamRepositoryError.failedToFindTeamMember(membershipID) - } - - member.needsToBeUpdatedFromBackend = true - - try context.save() + guard let member = await teamLocalStore.fetchMember( + id: membershipID + ) else { + throw TeamRepositoryError.failedToFindTeamMember(membershipID) } + + await teamLocalStore.storeMember( + needsBackendUpdate: true, + member: member + ) } // MARK: - Private @@ -176,33 +191,6 @@ public class TeamRepository: TeamRepositoryProtocol { } } - private func storeTeamLocally(_ teamAPIModel: WireAPI.Team) async { - let selfUser = await userRepository.fetchSelfUser() - - await context.perform { [context] in - let team = WireDataModel.Team.fetchOrCreate( - with: teamAPIModel.id, - in: context - ) - - _ = WireDataModel.Member.getOrUpdateMember( - for: selfUser, - in: team, - context: context - ) - - team.name = teamAPIModel.name - team.creator = ZMUser.fetchOrCreate( - with: teamAPIModel.creatorID, - domain: nil, - in: context - ) - team.pictureAssetId = teamAPIModel.logoID - team.pictureAssetKey = teamAPIModel.logoKey - team.needsToBeUpdatedFromBackend = false - } - } - private func fetchSelfTeamRolesRemotely() async throws -> [WireAPI.ConversationRole] { do { return try await teamsAPI.getTeamRoles(for: selfTeamID) @@ -211,47 +199,6 @@ public class TeamRepository: TeamRepositoryProtocol { } } - private func storeTeamRolesLocally(_ roles: [WireAPI.ConversationRole]) async throws { - try await context.perform { [context, selfTeamID] in - guard let team = WireDataModel.Team.fetch( - with: selfTeamID, - in: context - ) else { - throw TeamRepositoryError.teamNotFoundLocally - } - - let existingRoles = team.roles - - let localRoles = roles.map { role in - let localRole = Role.fetchOrCreate( - name: role.name, - teamOrConversation: .team(team), - context: context - ) - - localRole.name = role.name - localRole.team = team - - for action in role.actions { - let action = Action.fetchOrCreate( - name: action.name, - in: context - ) - - localRole.actions.insert(action) - } - - return localRole - } - - for roleToDelete in existingRoles.subtracting(localRoles) { - context.delete(roleToDelete) - } - - team.needsToDownloadRoles = false - } - } - private func fetchSelfTeamMembersRemotely() async throws -> [WireAPI.TeamMember] { do { return try await teamsAPI.getTeamMembers( @@ -263,46 +210,6 @@ public class TeamRepository: TeamRepositoryProtocol { } } - private func storeTeamMembersLocally(_ teamMembers: [WireAPI.TeamMember]) async throws { - try await context.perform { [context, selfTeamID] in - guard let team = WireDataModel.Team.fetch( - with: selfTeamID, - in: context - ) else { - throw TeamRepositoryError.teamNotFoundLocally - } - - for member in teamMembers { - let user = ZMUser.fetchOrCreate( - with: member.userID, - domain: nil, - in: context - ) - - let membership = Member.getOrUpdateMember( - for: user, - in: team, - context: context - ) - - if let permissions = member.permissions { - membership.permissions = Permissions(rawValue: permissions.selfPermissions) - } - - if let creatorID = member.creatorID { - membership.createdBy = ZMUser.fetchOrCreate( - with: creatorID, - domain: nil, - in: context - ) - } - - membership.createdAt = member.creationDate - membership.needsToBeUpdatedFromBackend = false - } - } - } - } private extension ConversationAction { diff --git a/WireDomain/Sources/WireDomain/Repositories/Team/TeamRepositoryError.swift b/WireDomain/Sources/WireDomain/Repositories/Team/TeamRepositoryError.swift index 6b119bb7f20..48591d6f90a 100644 --- a/WireDomain/Sources/WireDomain/Repositories/Team/TeamRepositoryError.swift +++ b/WireDomain/Sources/WireDomain/Repositories/Team/TeamRepositoryError.swift @@ -26,10 +26,6 @@ enum TeamRepositoryError: Error { case failedToFetchRemotely(Error) - /// The local team instance was not found in the database. - - case teamNotFoundLocally - /// User is not a member of the team. case userNotAMemberInTeam(user: UUID) diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index a19fd7976e1..a4526fb1dd2 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -963,6 +963,172 @@ public class MockSelfUserProviderProtocol: SelfUserProviderProtocol { } +public class MockTeamLocalStoreProtocol: TeamLocalStoreProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - fetchMember + + public var fetchMemberId_Invocations: [UUID] = [] + public var fetchMemberId_MockMethod: ((UUID) async -> Member?)? + public var fetchMemberId_MockValue: Member?? + + public func fetchMember(id: UUID) async -> Member? { + fetchMemberId_Invocations.append(id) + + if let mock = fetchMemberId_MockMethod { + return await mock(id) + } else if let mock = fetchMemberId_MockValue { + return mock + } else { + fatalError("no mock for `fetchMemberId`") + } + } + + // MARK: - selfUserID + + public var selfUserID_Invocations: [Void] = [] + public var selfUserID_MockMethod: (() async -> UUID)? + public var selfUserID_MockValue: UUID? + + public func selfUserID() async -> UUID { + selfUserID_Invocations.append(()) + + if let mock = selfUserID_MockMethod { + return await mock() + } else if let mock = selfUserID_MockValue { + return mock + } else { + fatalError("no mock for `selfUserID`") + } + } + + // MARK: - userMembership + + public var userMembershipUser_Invocations: [ZMUser] = [] + public var userMembershipUser_MockMethod: ((ZMUser) async -> Member?)? + public var userMembershipUser_MockValue: Member?? + + public func userMembership(user: ZMUser) async -> Member? { + userMembershipUser_Invocations.append(user) + + if let mock = userMembershipUser_MockMethod { + return await mock(user) + } else if let mock = userMembershipUser_MockValue { + return mock + } else { + fatalError("no mock for `userMembershipUser`") + } + } + + // MARK: - userDomain + + public var userDomainUser_Invocations: [ZMUser] = [] + public var userDomainUser_MockMethod: ((ZMUser) async -> String?)? + public var userDomainUser_MockValue: String?? + + public func userDomain(user: ZMUser) async -> String? { + userDomainUser_Invocations.append(user) + + if let mock = userDomainUser_MockMethod { + return await mock(user) + } else if let mock = userDomainUser_MockValue { + return mock + } else { + fatalError("no mock for `userDomainUser`") + } + } + + // MARK: - deleteMember + + public var deleteMember_Invocations: [Member] = [] + public var deleteMember_MockMethod: ((Member) async -> Void)? + + public func deleteMember(_ member: Member) async { + deleteMember_Invocations.append(member) + + guard let mock = deleteMember_MockMethod else { + fatalError("no mock for `deleteMember`") + } + + await mock(member) + } + + // MARK: - storeMember + + public var storeMemberNeedsBackendUpdateMember_Invocations: [(needsBackendUpdate: Bool, member: Member)] = [] + public var storeMemberNeedsBackendUpdateMember_MockMethod: ((Bool, Member) async -> Void)? + + public func storeMember(needsBackendUpdate: Bool, member: Member) async { + storeMemberNeedsBackendUpdateMember_Invocations.append((needsBackendUpdate: needsBackendUpdate, member: member)) + + guard let mock = storeMemberNeedsBackendUpdateMember_MockMethod else { + fatalError("no mock for `storeMemberNeedsBackendUpdateMember`") + } + + await mock(needsBackendUpdate, member) + } + + // MARK: - storeTeam + + public var storeTeamIdNameCreatorIDLogoIDLogoKey_Invocations: [(id: UUID, name: String, creatorID: UUID, logoID: String?, logoKey: String?)] = [] + public var storeTeamIdNameCreatorIDLogoIDLogoKey_MockMethod: ((UUID, String, UUID, String?, String?) async -> Void)? + + public func storeTeam(id: UUID, name: String, creatorID: UUID, logoID: String?, logoKey: String?) async { + storeTeamIdNameCreatorIDLogoIDLogoKey_Invocations.append((id: id, name: name, creatorID: creatorID, logoID: logoID, logoKey: logoKey)) + + guard let mock = storeTeamIdNameCreatorIDLogoIDLogoKey_MockMethod else { + fatalError("no mock for `storeTeamIdNameCreatorIDLogoIDLogoKey`") + } + + await mock(id, name, creatorID, logoID, logoKey) + } + + // MARK: - storeTeamRoles + + public var storeTeamRolesSelfTeamIDTeamRolesInfo_Invocations: [(selfTeamID: UUID, teamRolesInfo: [TeamLocalStore.TeamRoleInfo])] = [] + public var storeTeamRolesSelfTeamIDTeamRolesInfo_MockError: Error? + public var storeTeamRolesSelfTeamIDTeamRolesInfo_MockMethod: ((UUID, [TeamLocalStore.TeamRoleInfo]) async throws -> Void)? + + public func storeTeamRoles(selfTeamID: UUID, teamRolesInfo: [TeamLocalStore.TeamRoleInfo]) async throws { + storeTeamRolesSelfTeamIDTeamRolesInfo_Invocations.append((selfTeamID: selfTeamID, teamRolesInfo: teamRolesInfo)) + + if let error = storeTeamRolesSelfTeamIDTeamRolesInfo_MockError { + throw error + } + + guard let mock = storeTeamRolesSelfTeamIDTeamRolesInfo_MockMethod else { + fatalError("no mock for `storeTeamRolesSelfTeamIDTeamRolesInfo`") + } + + try await mock(selfTeamID, teamRolesInfo) + } + + // MARK: - storeTeamMembers + + public var storeTeamMembersSelfTeamIDTeamMembersInfo_Invocations: [(selfTeamID: UUID, teamMembersInfo: [TeamLocalStore.TeamMemberInfo])] = [] + public var storeTeamMembersSelfTeamIDTeamMembersInfo_MockError: Error? + public var storeTeamMembersSelfTeamIDTeamMembersInfo_MockMethod: ((UUID, [TeamLocalStore.TeamMemberInfo]) async throws -> Void)? + + public func storeTeamMembers(selfTeamID: UUID, teamMembersInfo: [TeamLocalStore.TeamMemberInfo]) async throws { + storeTeamMembersSelfTeamIDTeamMembersInfo_Invocations.append((selfTeamID: selfTeamID, teamMembersInfo: teamMembersInfo)) + + if let error = storeTeamMembersSelfTeamIDTeamMembersInfo_MockError { + throw error + } + + guard let mock = storeTeamMembersSelfTeamIDTeamMembersInfo_MockMethod else { + fatalError("no mock for `storeTeamMembersSelfTeamIDTeamMembersInfo`") + } + + try await mock(selfTeamID, teamMembersInfo) + } + +} + public class MockTeamRepositoryProtocol: TeamRepositoryProtocol { // MARK: - Life cycle diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift index 959b8189534..f0930d6dade 100644 --- a/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift +++ b/WireDomain/Tests/WireDomainTests/LocalStores/ConnectionsLocalStoreTests.swift @@ -72,17 +72,16 @@ final class ConnectionsLocalStoreTests: XCTestCase { file: StaticString = #file, line: UInt = #line ) async throws { - // Mock - + let connection = Scaffolding.connection // When - + try await sut.storeConnection(connection) // Then - + try await context.perform { [context] in // There is a connection in the database. let storedConnection = try XCTUnwrap(ZMConnection.fetch(userID: Scaffolding.member2ID.uuid, domain: Scaffolding.member2ID.domain, in: context)) @@ -111,7 +110,6 @@ final class ConnectionsLocalStoreTests: XCTestCase { } func testUpdateConnection_It_Successfully_Updates_Connection_Locally() async throws { - // Given let connection = Scaffolding.connection @@ -121,7 +119,7 @@ final class ConnectionsLocalStoreTests: XCTestCase { try await sut.storeConnection(connection) // Then - + try await context.perform { [context] in let storedConnection = try XCTUnwrap(ZMConnection.fetch(userID: Scaffolding.member2ID.uuid, domain: Scaffolding.member2ID.domain, in: context)) @@ -157,4 +155,3 @@ final class ConnectionsLocalStoreTests: XCTestCase { } } - diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift index 36e3c6a4bde..c1d536b0152 100644 --- a/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift +++ b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLocalStoreTests.swift @@ -30,7 +30,7 @@ final class ConversationLocalStoreTests: XCTestCase { private var sut: ConversationLocalStore! private var userLocalStore: MockUserLocalStoreProtocol! private var mlsService: MockMLSServiceInterface! - + private var stack: CoreDataStack! private var coreDataStackHelper: CoreDataStackHelper! private var modelHelper: ModelHelper! @@ -65,16 +65,15 @@ final class ConversationLocalStoreTests: XCTestCase { // MARK: - Tests func testStoreConversation_It_Stores_Conversation_Locally() async throws { - // Mock - + let groupConversation = Scaffolding.groupConversation let qualifiedID = try XCTUnwrap(groupConversation.qualifiedID) let id = qualifiedID.uuid let domain = qualifiedID.domain // When - + await sut.storeConversation( groupConversation, timestamp: .distantPast, @@ -82,12 +81,12 @@ final class ConversationLocalStoreTests: XCTestCase { ) // Then - + let localConversation = await sut.fetchConversation( id: id, domain: domain ) - + await context.perform { XCTAssertNotNil(localConversation) XCTAssertEqual(localConversation?.remoteIdentifier, id) @@ -96,25 +95,25 @@ final class ConversationLocalStoreTests: XCTestCase { func testStoreFailedConversation_It_Sets_Pending_Metadata_Refresh_And_Backend_Update_Flags_To_True() async throws { // Given - + let groupConversation = Scaffolding.groupConversation let qualifiedID = try XCTUnwrap(groupConversation.qualifiedID) let id = qualifiedID.uuid let domain = qualifiedID.domain // When - + await sut.storeFailedConversation( withQualifiedId: qualifiedID ) // Then - + let localConversation = await sut.fetchConversation( id: id, domain: domain ) - + await context.perform { XCTAssertEqual(localConversation?.isPendingMetadataRefresh, true) XCTAssertEqual(localConversation?.needsToBeUpdatedFromBackend, true) @@ -122,42 +121,40 @@ final class ConversationLocalStoreTests: XCTestCase { } func testStoreConversation_It_Needs_Backend_Update() async throws { - // Mock - + let groupConversation = Scaffolding.groupConversation let qualifiedID = try XCTUnwrap(groupConversation.qualifiedID) let id = qualifiedID.uuid let domain = qualifiedID.domain - + await context.perform { [self] in let conversation = modelHelper.createGroupConversation(id: id, in: context) XCTAssertEqual(conversation.needsToBeUpdatedFromBackend, false) } // When - + await sut.storeConversation( needsBackendUpdate: true, qualifiedId: qualifiedID ) // Then - + let localConversation = await sut.fetchConversation( id: id, domain: domain ) - + await context.perform { XCTAssertEqual(localConversation?.needsToBeUpdatedFromBackend, true) } } func testFetchMLSConversation_It_Retrieves_Conversation_Locally() async throws { - // Mock - + let mlsGroupID = try XCTUnwrap( MLSGroupID(base64Encoded: Scaffolding.base64EncodedString) ) @@ -183,7 +180,6 @@ final class ConversationLocalStoreTests: XCTestCase { } func testRemoveParticipantFromAllGroupConversation_It_Appends_A_System_Message_To_All_Team_Conversations_When_A_Member_Leave() async throws { - // Mock let user = try await context.perform { [self] in @@ -255,7 +251,6 @@ final class ConversationLocalStoreTests: XCTestCase { } func testRemoveParticipantFromConversation_It_Removes_Participant() async { - // Mock let (removedUser, remainingUsers, conversation) = await context.perform { [self] in @@ -289,7 +284,6 @@ final class ConversationLocalStoreTests: XCTestCase { } func testAddOrUpdateParticipant_It_Adds_Participant_To_Conversation() async { - // Mock let (addedUser, conversation) = await context.perform { [self] in @@ -323,7 +317,6 @@ final class ConversationLocalStoreTests: XCTestCase { } func testFetchConversation_It_Retrieves_Conversation_Locally() async { - // Mock let conversation = await context.perform { [self] in @@ -347,7 +340,6 @@ final class ConversationLocalStoreTests: XCTestCase { } func testDeleteConversation_It_Marks_Conversation_As_Deleted_Locally() async { - // Mock let conversation = await context.perform { [self] in @@ -366,7 +358,6 @@ final class ConversationLocalStoreTests: XCTestCase { } func testRemoveParticipants_It_Removes_Participant_From_A_Given_Conversation() async { - // Mock let (conversation, selfUser, senderUser, removedUser) = await context.perform { [self] in @@ -386,7 +377,7 @@ final class ConversationLocalStoreTests: XCTestCase { } // When - + await sut.removeParticipantsAndUpdateConversationState( conversation: conversation, users: [removedUser], @@ -403,7 +394,6 @@ final class ConversationLocalStoreTests: XCTestCase { } func testAddOrUpdateParticipant_It_Updates_Participant_Role_In_Conversation() async throws { - // Mock let (updatedUser, conversation) = await context.perform { [self] in @@ -435,7 +425,6 @@ final class ConversationLocalStoreTests: XCTestCase { } func testAddParticipants_It_Adds_Participants_To_Conversation() async throws { - // Mock let (conversation, sender, addedUser) = await context.perform { [self] in @@ -483,7 +472,6 @@ final class ConversationLocalStoreTests: XCTestCase { } func testAddSystemMessage_It_Adds_System_Message_To_Conversation() async throws { - // Mock let (conversation, user) = await context.perform { [self] in @@ -548,25 +536,25 @@ final class ConversationLocalStoreTests: XCTestCase { } private enum Scaffolding { - + static let selfUserId = UUID() - + static let domain = "domain.com" - + static let teamID = UUID() - + static let userID = UUID() - + static let otherUserID = UUID() - + static let time = "2021-05-12T10:52:02.671Z" - + static let teamConversationID = UUID() - + static let anotherTeamConversationID = UUID() - + static let conversationID = UUID() - + static let base64EncodedString = "pQABARn//wKhAFggHsa0CszLXYLFcOzg8AA//E1+Dl1rDHQ5iuk44X0/PNYDoQChAFgg309rkhG6SglemG6kWae81P1HtQPx9lyb6wExTovhU4cE9g==" static func date(from string: String) -> Date { diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/TeamLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/TeamLocalStoreTests.swift new file mode 100644 index 00000000000..b794563329a --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/LocalStores/TeamLocalStoreTests.swift @@ -0,0 +1,330 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireDataModel +import WireDataModelSupport +@testable import WireDomain +@testable import WireDomainSupport +import XCTest + +final class TeamLocalStoreTests: XCTestCase { + + private var sut: TeamLocalStore! + private var userLocalStore: MockUserLocalStoreProtocol! + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + modelHelper = ModelHelper() + coreDataStackHelper = CoreDataStackHelper() + stack = try await coreDataStackHelper.createStack() + userLocalStore = MockUserLocalStoreProtocol() + + sut = TeamLocalStore( + context: context, + userLocalStore: userLocalStore + ) + } + + override func tearDown() async throws { + stack = nil + modelHelper = nil + userLocalStore = nil + sut = nil + try coreDataStackHelper.cleanupDirectory() + coreDataStackHelper = nil + } + + // MARK: - Tests + + func testStoreTeam_It_Stores_Team_Locally() async throws { + // Mock + + let user = await context.perform { [self] in + let user = modelHelper.createUser(in: context) + + // There is no team in the database. + XCTAssertNil(Team.fetch(with: Scaffolding.teamID, in: context)) + + return user + } + + userLocalStore.fetchSelfUser_MockValue = user + + // When + + await sut.storeTeam( + id: Scaffolding.teamID, + name: Scaffolding.teamName, + creatorID: Scaffolding.teamCreatorID, + logoID: Scaffolding.logoID, + logoKey: Scaffolding.logoKey + ) + + // Then + + try await context.perform { [context] in + // There is a team in the database. + let team = try XCTUnwrap(Team.fetch(with: Scaffolding.teamID, in: context)) + XCTAssertEqual(team.remoteIdentifier, Scaffolding.teamID) + XCTAssertEqual(team.name, Scaffolding.teamName) + XCTAssertEqual(team.creator?.remoteIdentifier, Scaffolding.teamCreatorID) + XCTAssertEqual(team.pictureAssetId, Scaffolding.logoID) + XCTAssertEqual(team.pictureAssetKey, Scaffolding.logoKey) + XCTAssertFalse(team.needsToBeUpdatedFromBackend) + } + } + + func testStoreTeamRoles_It_Store_Team_Roles_Locally() async throws { + // Mock + + let team = try await context.perform { [context, modelHelper] in + // Make sure we have no roles to begin with. + let request = Role.fetchRequest() + let roles = try context.fetch(request) + XCTAssertTrue(roles.isEmpty) + + // A team is needed to store new roles. + return modelHelper!.createTeam( + id: Scaffolding.selfTeamID, + in: context + ) + } + + // When + + try await sut.storeTeamRoles( + selfTeamID: Scaffolding.selfTeamID, + teamRolesInfo: Scaffolding.teamRolesInfo + ) + + // Then + + try await context.perform { [context] in + XCTAssertFalse(team.needsToDownloadRoles) + + // There are two roles. + let request = NSFetchRequest(entityName: Role.entityName()) + request.sortDescriptors = [NSSortDescriptor(key: Role.nameKey, ascending: true)] + let roles = try context.fetch(request) + guard roles.count == 2 else { return XCTFail("roles.count != 2") } + + // One is for the admin. + let firstRole = try XCTUnwrap(roles[0]) + XCTAssertEqual(firstRole.name, "admin") + XCTAssertEqual(firstRole.team?.remoteIdentifier, Scaffolding.selfTeamID) + XCTAssertNil(firstRole.conversation) + XCTAssertEqual( + Set(firstRole.actions.map(\.name)), + [ + "add_conversation_member", + "delete_conversation" + ] + ) + + // One is for the member. + let secondRole = try XCTUnwrap(roles[1]) + XCTAssertEqual(secondRole.name, "member") + XCTAssertEqual(secondRole.team?.remoteIdentifier, Scaffolding.selfTeamID) + XCTAssertNil(secondRole.conversation) + XCTAssertEqual(Set(secondRole.actions.map(\.name)), ["add_conversation_member"]) + } + } + + func testStoreTeamMembers_It_Stores_Team_Members_Locally() async throws { + // Mock + + let team = await context.perform { [context, modelHelper] in + let team = modelHelper!.createTeam( + id: Scaffolding.selfTeamID, + in: context + ) + + XCTAssertTrue(team.members.isEmpty) + return team + } + + // When + + try await sut.storeTeamMembers( + selfTeamID: Scaffolding.selfTeamID, + teamMembersInfo: Scaffolding.teamMembersInfo + ) + + // Then + + try await context.perform { + XCTAssertEqual(team.members.count, 2) + + let member1 = try XCTUnwrap(team.members.first(where: { + $0.remoteIdentifier == Scaffolding.member1ID + })) + + XCTAssertEqual(member1.createdAt, Scaffolding.member1CreationDate) + XCTAssertEqual(member1.createdBy?.remoteIdentifier, Scaffolding.member1CreatorID) + XCTAssertEqual(member1.permissions.rawValue, Scaffolding.member1Permissions) + XCTAssertFalse(member1.needsToBeUpdatedFromBackend) + + let member2 = try XCTUnwrap(team.members.first(where: { + $0.remoteIdentifier == Scaffolding.member2ID + })) + + XCTAssertEqual(member2.createdAt, Scaffolding.member2CreationDate) + XCTAssertEqual(member2.createdBy?.remoteIdentifier, Scaffolding.member2CreatorID) + XCTAssertEqual(member2.permissions.rawValue, Scaffolding.member2Permissions) + XCTAssertFalse(member2.needsToBeUpdatedFromBackend) + } + } + + func testDeleteTeamMembership_It_Deletes_Member_From_Team_Locally() async throws { + // Mock + + let member = try await context.perform { [self] in + let (team, users, _) = modelHelper.createTeam( + id: Scaffolding.teamID, + withMembers: [Scaffolding.userID], + context: context + ) + + let user = try XCTUnwrap(users.first) + let member = try XCTUnwrap(team.members.first) + XCTAssertEqual(user.membership, member) + + return try XCTUnwrap(user.membership) + } + + // When + + await sut.deleteMember(member) + + // Then + + try await context.perform { [context] in + /// users won't be deleted as we might be in other (non-team) conversations with them + XCTAssertNotNil(ZMUser.fetch(with: Scaffolding.userID, in: context)) + + let team = try XCTUnwrap(Team.fetch(with: Scaffolding.teamID, in: context), "No team") + + XCTAssertEqual(team.members, []) + } + } + + func testStoreTeamMemberNeedsBackendUpdate_It_Updates_Flag_Locally() async throws { + // Mock + + let member = await context.perform { [context, modelHelper] in + + let team = modelHelper!.createTeam( + id: Scaffolding.teamID, + in: context + ) + + let user = modelHelper!.createUser( + id: Scaffolding.membershipID, + domain: Scaffolding.domain, + in: context + ) + + let member = modelHelper!.addUser( + user, + to: team, + in: context + ) + + XCTAssertEqual(member.needsToBeUpdatedFromBackend, false) + + return member + } + + // When + + await sut.storeMember( + needsBackendUpdate: true, + member: member + ) + + await context.perform { [context] in + let user = ZMUser.fetch(with: Scaffolding.membershipID, in: context) + let team = Team.fetch(with: Scaffolding.teamID, in: context) + + guard let user, let team, let member = user.membership else { + return XCTFail() + } + + // Then + + XCTAssertEqual(member.needsToBeUpdatedFromBackend, true) + XCTAssertEqual(member.team, team) + } + } + + private enum Scaffolding { + static let userID = UUID() + static let selfUserID = UUID() + static let teamID = UUID() + static let selfTeamID = UUID() + static let domain = "example.com" + static let membershipID = UUID() + static let teamCreatorID = UUID() + static let teamName = "Team Foo" + static let logoID = UUID().uuidString + static let logoKey = UUID().uuidString + static let conversationID = UUID() + + static let member1ID = UUID() + static let member1CreationDate = Date() + static let member1CreatorID = UUID() + static let member1Permissions = Permissions.admin.rawValue + + static let member2ID = UUID() + static let member2CreationDate = Date() + static let member2CreatorID = UUID() + static let member2Permissions = Permissions.member.rawValue + + static let teamRolesInfo: [TeamLocalStore.TeamRoleInfo] = [ + .init( + role: "admin", + actions: ["add_conversation_member", "delete_conversation"] + ), + .init( + role: "member", + actions: ["add_conversation_member"] + ) + ] + + static let teamMembersInfo: [TeamLocalStore.TeamMemberInfo] = [ + .init( + id: member1ID, + selfPermission: Scaffolding.member1Permissions, + creatorID: Scaffolding.member1CreatorID, + creationDate: Scaffolding.member1CreationDate + ), + .init( + id: member2ID, + selfPermission: Scaffolding.member2Permissions, + creatorID: Scaffolding.member2CreatorID, + creationDate: Scaffolding.member2CreationDate + ) + ] + } +} diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/UserLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/UserLocalStoreTests.swift index 4f85ea9dc1e..394b7d38a70 100644 --- a/WireDomain/Tests/WireDomainTests/LocalStores/UserLocalStoreTests.swift +++ b/WireDomain/Tests/WireDomainTests/LocalStores/UserLocalStoreTests.swift @@ -66,16 +66,15 @@ final class UserLocalStoreTests: XCTestCase { // MARK: - Tests func testPersistUser_It_Stores_User_Locally() async throws { - // Mock - + await context.perform { [context] in // There is no user in the database. XCTAssertNil(ZMUser.fetch(with: Scaffolding.user1.id.uuid, domain: Scaffolding.user1.id.domain, in: context)) } // When - + await sut.persistUser(from: Scaffolding.user1) // Then @@ -101,7 +100,6 @@ final class UserLocalStoreTests: XCTestCase { } func testDeletePushToken_It_Removes_Token_From_Defaults() async throws { - // Mock let key = "PushToken" @@ -118,9 +116,8 @@ final class UserLocalStoreTests: XCTestCase { let pushToken = mockUserDefaults.object(forKey: key) XCTAssertNil(pushToken) } - + func testFetchSelfUser_It_Retrieves_Self_User_Locally() async { - // Mock let selfUser = await context.perform { [self] in @@ -143,7 +140,6 @@ final class UserLocalStoreTests: XCTestCase { } func testFetchUser_It_Retrieves_User_Locally() async throws { - // Mock let user = await context.perform { [self] in @@ -169,7 +165,6 @@ final class UserLocalStoreTests: XCTestCase { } func testAddSelfLegalholdRequest_It_Sets_Status_To_Pending_With_Legal_Hold_Request() async throws { - // Mock _ = await context.perform { [self] in @@ -201,9 +196,8 @@ final class UserLocalStoreTests: XCTestCase { } func testPostAccountDeletedNotification_It_Posts_Account_Deleted_Notification() async { - // Given - + let expectation = XCTestExpectation() let notificationName = AccountDeletedNotification.notificationName @@ -229,7 +223,6 @@ final class UserLocalStoreTests: XCTestCase { } func testMarkAccountAsDeleted_It_Sets_Is_Account_Deleted_Flag_To_True() async { - // Mock let user = await context.perform { [self] in @@ -252,7 +245,6 @@ final class UserLocalStoreTests: XCTestCase { } func testUpdateSelfUserReadReceipts_It_Enables_Read_Receipts_Property() async { - // Mock let selfUser = await context.perform { [self] in @@ -264,7 +256,7 @@ final class UserLocalStoreTests: XCTestCase { selfUser.readReceiptsEnabled = false selfUser.readReceiptsEnabledChangedRemotely = false - + return selfUser } @@ -345,7 +337,7 @@ final class UserLocalStoreTests: XCTestCase { } private enum Scaffolding { - + static let selfUserID = UUID() static let userID = UUID() static let domain = "domain.com" @@ -405,4 +397,3 @@ final class UserLocalStoreTests: XCTestCase { } } - diff --git a/WireDomain/Tests/WireDomainTests/Repositories/ConnectionsRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/ConnectionsRepositoryTests.swift index c1707412b92..4c0532f0e3b 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/ConnectionsRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/ConnectionsRepositoryTests.swift @@ -20,8 +20,8 @@ import WireAPISupport import WireDataModel import WireDataModelSupport -import WireDomainSupport @testable import WireDomain +import WireDomainSupport import XCTest final class ConnectionsRepositoryTests: XCTestCase { @@ -33,7 +33,7 @@ final class ConnectionsRepositoryTests: XCTestCase { override func setUp() async throws { connectionsAPI = MockConnectionsAPI() connectionsLocalStore = MockConnectionsLocalStoreProtocol() - + sut = ConnectionsRepository( connectionsAPI: connectionsAPI, connectionsLocalStore: connectionsLocalStore @@ -49,9 +49,8 @@ final class ConnectionsRepositoryTests: XCTestCase { // MARK: - Tests func testPullConnections_It_Invokes_Local_Store_Method() async throws { - // Mock - + let connection = Scaffolding.connection connectionsAPI.getConnections_MockValue = .init(fetchPage: { _ in @@ -62,31 +61,30 @@ final class ConnectionsRepositoryTests: XCTestCase { nextStart: "first" ) }) - + connectionsLocalStore.storeConnection_MockMethod = { _ in } // When - + try await sut.pullConnections() // Then - + XCTAssertEqual(connectionsLocalStore.storeConnection_Invocations.count, 1) } - + func testUpdateConnection_It_Invokes_Local_Store_Method() async throws { - // Mock - + let connection = Scaffolding.connection connectionsLocalStore.storeConnection_MockMethod = { _ in } // When - + try await sut.updateConnection(connection) // Then - + XCTAssertEqual(connectionsLocalStore.storeConnection_Invocations.count, 1) } diff --git a/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift index e211a01663d..95f56b7851e 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/ConversationRepositoryTests.swift @@ -39,7 +39,7 @@ final class ConversationRepositoryTests: XCTestCase { private var mlsService: MockMLSServiceInterface! private var mlsProvider: MLSProvider! private var modelHelper: ModelHelper! - + private var stack: CoreDataStack! private var coreDataStackHelper: CoreDataStackHelper! @@ -56,7 +56,7 @@ final class ConversationRepositoryTests: XCTestCase { conversationsLocalStore = MockConversationLocalStoreProtocol() conversationsAPI = MockConversationsAPI() userRepository = MockUserRepositoryProtocol() - + coreDataStackHelper = CoreDataStackHelper() stack = try await coreDataStackHelper.createStack() @@ -87,9 +87,8 @@ final class ConversationRepositoryTests: XCTestCase { // MARK: - Tests func testPullFoundConversations_It_Invokes_Local_Store_And_Conversation_API_Methods() async throws { - // Mock - + conversationsAPI.getLegacyConversationIdentifiers_MockValue = .init(fetchPage: { _ in .init( element: [Scaffolding.id], @@ -103,11 +102,11 @@ final class ConversationRepositoryTests: XCTestCase { notFound: [], failed: [] ) - - conversationsLocalStore.storeConversationTimestampIsFederationEnabled_MockMethod = { _, _, _ in} + + conversationsLocalStore.storeConversationTimestampIsFederationEnabled_MockMethod = { _, _, _ in } // When - + try await sut.pullConversations() // Then @@ -118,7 +117,6 @@ final class ConversationRepositoryTests: XCTestCase { } func testPullNotFoundConversations_It_Invokes_Local_Store_And_Conversation_API_Methods() async throws { - // Mock conversationsAPI.getLegacyConversationIdentifiers_MockValue = .init(fetchPage: { _ in @@ -134,22 +132,21 @@ final class ConversationRepositoryTests: XCTestCase { notFound: [QualifiedID(uuid: Scaffolding.id, domain: Scaffolding.domain)], failed: [] ) - - conversationsLocalStore.storeConversationNeedsBackendUpdateQualifiedId_MockMethod = { _, _ in} + + conversationsLocalStore.storeConversationNeedsBackendUpdateQualifiedId_MockMethod = { _, _ in } // When - + try await sut.pullConversations() // Then - + XCTAssertEqual(conversationsAPI.getLegacyConversationIdentifiers_Invocations.count, 1) XCTAssertEqual(conversationsAPI.getConversationsFor_Invocations.count, 1) XCTAssertEqual(conversationsLocalStore.storeConversationNeedsBackendUpdateQualifiedId_Invocations.count, 1) } func testPullFailedConversations_It_Invokes_Local_Store_And_Conversation_API_Methods() async throws { - // Mock conversationsAPI.getLegacyConversationIdentifiers_MockValue = .init(fetchPage: { _ in @@ -165,22 +162,21 @@ final class ConversationRepositoryTests: XCTestCase { notFound: [], failed: [QualifiedID(uuid: Scaffolding.id, domain: Scaffolding.domain)] ) - - conversationsLocalStore.storeFailedConversationWithQualifiedId_MockMethod = { _ in} + + conversationsLocalStore.storeFailedConversationWithQualifiedId_MockMethod = { _ in } // When - + try await sut.pullConversations() // Then - + XCTAssertEqual(conversationsAPI.getLegacyConversationIdentifiers_Invocations.count, 1) XCTAssertEqual(conversationsAPI.getConversationsFor_Invocations.count, 1) XCTAssertEqual(conversationsLocalStore.storeFailedConversationWithQualifiedId_Invocations.count, 1) } func testPullMLSOneToOneConversation_It_Invokes_Local_Store_And_API_Methods() async throws { - // Mock conversationsAPI.getMLSOneToOneConversationUserIDIn_MockValue = Scaffolding.conversation @@ -194,14 +190,13 @@ final class ConversationRepositoryTests: XCTestCase { ) // Then - + XCTAssertEqual(mlsGroupID, Scaffolding.conversation.mlsGroupID) XCTAssertEqual(conversationsAPI.getMLSOneToOneConversationUserIDIn_Invocations.count, 1) XCTAssertEqual(conversationsLocalStore.storeConversationTimestampIsFederationEnabled_Invocations.count, 1) } func testRemoveParticipantFromAllGroupConversations_It_Invokes_Local_Store_And_User_Repo_Methods() async throws { - // Mock let user = await context.perform { [self] in @@ -223,11 +218,9 @@ final class ConversationRepositoryTests: XCTestCase { XCTAssertEqual(userRepository.fetchUserIdDomain_Invocations.count, 1) XCTAssertEqual(conversationsLocalStore.removeParticipantFromAllGroupConversationsUserDate_Invocations.count, 1) - } func testPullConversation_It_Invokes_Local_Store_And_Conversation_API_Methods() async throws { - // Mock conversationsAPI.getConversationsFor_MockValue = ConversationList( @@ -235,7 +228,7 @@ final class ConversationRepositoryTests: XCTestCase { notFound: [], failed: [] ) - + conversationsLocalStore.storeConversationTimestampIsFederationEnabled_MockMethod = { _, _, _ in } // When @@ -252,7 +245,6 @@ final class ConversationRepositoryTests: XCTestCase { } func testPullConversation_It_Throws_Error() async throws { - // Mock conversationsAPI.getConversationsFor_MockValue = ConversationList( @@ -276,7 +268,6 @@ final class ConversationRepositoryTests: XCTestCase { } func testFetchConversation_It_Invokes_Local_Store_Method() async { - // Mock let conversation = await context.perform { [self] in @@ -286,7 +277,7 @@ final class ConversationRepositoryTests: XCTestCase { in: context ) } - + conversationsLocalStore.fetchConversationIdDomain_MockValue = conversation // When @@ -303,7 +294,6 @@ final class ConversationRepositoryTests: XCTestCase { } func testDeleteMLSConversation_It_Invokes_Local_Store_Methods() async throws { - // Mock let conversation = await context.perform { [self] in @@ -341,7 +331,6 @@ final class ConversationRepositoryTests: XCTestCase { } func testDeleteProteusConversation_It_Invokes_Local_Store_Methods() async throws { - // Mock let conversation = await context.perform { [self] in @@ -361,18 +350,17 @@ final class ConversationRepositoryTests: XCTestCase { id: Scaffolding.id, domain: Scaffolding.domain ) - + // Then - + XCTAssertEqual(conversationsLocalStore.fetchConversationIdDomain_Invocations.count, 1) XCTAssertEqual(conversationsLocalStore.isMLSConversation_Invocations.count, 1) XCTAssertEqual(conversationsLocalStore.deleteConversation_Invocations.count, 1) } func testStoreConversation_It_Invokes_Local_Store_Method() async { - // Mock - + conversationsLocalStore.storeConversationTimestampIsFederationEnabled_MockMethod = { _, _, _ in } // When @@ -388,7 +376,6 @@ final class ConversationRepositoryTests: XCTestCase { } func testRemoveMembers_It_Invokes_Local_Store_User_Repo_Team_Repo_And_MLS_Service_Methods() async throws { - // Mock let (conversation, selfUser, senderUser, removedUser) = await context.perform { [self] in @@ -445,7 +432,6 @@ final class ConversationRepositoryTests: XCTestCase { } func testAddOrUpdateParticipant_It_Invokes_Local_Store_And_User_Repo_Methods() async { - // Mock let (updatedUser, conversation) = await context.perform { [self] in @@ -482,10 +468,9 @@ final class ConversationRepositoryTests: XCTestCase { } func testAddParticipants_It_Invokes_Local_Store_Methods() async throws { - // Mock - let (conversation) = await context.perform { [self] in + let conversation = await context.perform { [self] in let conversation = modelHelper.createGroupConversation( id: Scaffolding.id, domain: Scaffolding.domain, @@ -494,10 +479,8 @@ final class ConversationRepositoryTests: XCTestCase { return conversation } - conversationsLocalStore.fetchConversationIdDomain_MockValue = conversation conversationsLocalStore.addParticipantsAddedByAtDateTo_MockMethod = { _, _, _, _ in } - // When @@ -518,7 +501,6 @@ final class ConversationRepositoryTests: XCTestCase { } func testAddSystemMessage_It_Invokes_Local_Store_Method() async { - // Mock let (conversation, user) = await context.perform { [self] in @@ -527,9 +509,9 @@ final class ConversationRepositoryTests: XCTestCase { domain: Scaffolding.domain, in: context ) - + let user = modelHelper.createUser(in: context) - + return (conversation, user) } @@ -538,7 +520,7 @@ final class ConversationRepositoryTests: XCTestCase { sender: user, timestamp: .distantPast ) - + conversationsLocalStore.addSystemMessageTo_MockMethod = { _, _ in } // When @@ -554,11 +536,11 @@ final class ConversationRepositoryTests: XCTestCase { } private enum Scaffolding { - + static let id = UUID() - + static let domain = "domain.com" - + static let conversation = Conversation( id: id, qualifiedID: .init(uuid: id, domain: domain), diff --git a/WireDomain/Tests/WireDomainTests/Repositories/TeamRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/TeamRepositoryTests.swift index 9e6a6fdee08..7d9ab12e9ff 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/TeamRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/TeamRepositoryTests.swift @@ -29,6 +29,7 @@ final class TeamRepositoryTests: XCTestCase { private var sut: TeamRepository! private var userRespository: MockUserRepositoryProtocol! private var teamsAPI: MockTeamsAPI! + private var teamLocalStore: MockTeamLocalStoreProtocol! private var stack: CoreDataStack! private var coreDataStackHelper: CoreDataStackHelper! private var modelHelper: ModelHelper! @@ -38,31 +39,22 @@ final class TeamRepositoryTests: XCTestCase { } override func setUp() async throws { - try await super.setUp() modelHelper = ModelHelper() coreDataStackHelper = CoreDataStackHelper() stack = try await coreDataStackHelper.createStack() userRespository = MockUserRepositoryProtocol() teamsAPI = MockTeamsAPI() + teamLocalStore = MockTeamLocalStoreProtocol() + sut = TeamRepository( selfTeamID: Scaffolding.selfTeamID, userRepository: userRespository, - teamsAPI: teamsAPI, - context: context + teamLocalStore: teamLocalStore, + teamsAPI: teamsAPI ) - - let selfUser = await context.perform { [context, modelHelper] in - modelHelper?.createSelfUser( - id: Scaffolding.selfUserID, - in: context - ) - } - - userRespository.fetchSelfUser_MockValue = selfUser } override func tearDown() async throws { - try await super.tearDown() stack = nil modelHelper = nil userRespository = nil @@ -74,14 +66,9 @@ final class TeamRepositoryTests: XCTestCase { // MARK: - Tests - func testPullSelfTeam() async throws { - // Given - await context.perform { [context] in - // There is no team in the database. - XCTAssertNil(Team.fetch(with: Scaffolding.selfTeamID, in: context)) - } - + func testPullSelfTeam_It_Invokes_Local_Store_And_Team_API_Methods() async throws { // Mock + teamsAPI.getTeamFor_MockValue = WireAPI.Team( id: Scaffolding.selfTeamID, name: Scaffolding.teamName, @@ -91,38 +78,21 @@ final class TeamRepositoryTests: XCTestCase { splashScreenID: Scaffolding.splashScreenID ) + teamLocalStore.storeTeamIdNameCreatorIDLogoIDLogoKey_MockMethod = { _, _, _, _, _ in } + // When + try await sut.pullSelfTeam() // Then - try await context.perform { [context] in - // There is a team in the database. - let team = try XCTUnwrap(Team.fetch(with: Scaffolding.selfTeamID, in: context)) - XCTAssertEqual(team.remoteIdentifier, Scaffolding.selfTeamID) - XCTAssertEqual(team.name, Scaffolding.teamName) - XCTAssertEqual(team.creator?.remoteIdentifier, Scaffolding.teamCreatorID) - XCTAssertEqual(team.pictureAssetId, Scaffolding.logoID) - XCTAssertEqual(team.pictureAssetKey, Scaffolding.logoKey) - XCTAssertFalse(team.needsToBeUpdatedFromBackend) - } - } - func testPullSelfTeamRoles() async throws { - // Given - let team = try await context.perform { [context, modelHelper] in - // Make sure we have no roles to begin with. - let request = Role.fetchRequest() - let roles = try context.fetch(request) - XCTAssertTrue(roles.isEmpty) - - // A team is needed to store new roles. - return modelHelper!.createTeam( - id: Scaffolding.selfTeamID, - in: context - ) - } + XCTAssertEqual(teamsAPI.getTeamFor_Invocations.count, 1) + XCTAssertEqual(teamLocalStore.storeTeamIdNameCreatorIDLogoIDLogoKey_Invocations.count, 1) + } + func testPullSelfTeamRoles_It_Invokes_Local_Store_And_Team_API_Methods() async throws { // Mock + teamsAPI.getTeamRolesFor_MockValue = [ ConversationRole( name: "admin", @@ -139,54 +109,21 @@ final class TeamRepositoryTests: XCTestCase { ) ] + teamLocalStore.storeTeamRolesSelfTeamIDTeamRolesInfo_MockMethod = { _, _ in } + // When + try await sut.pullSelfTeamRoles() // Then - try await context.perform { [context] in - XCTAssertFalse(team.needsToDownloadRoles) - - // There are two roles. - let request = NSFetchRequest(entityName: Role.entityName()) - request.sortDescriptors = [NSSortDescriptor(key: Role.nameKey, ascending: true)] - let roles = try context.fetch(request) - guard roles.count == 2 else { return XCTFail("roles.count != 2") } - - // One is for the admin. - let firstRole = try XCTUnwrap(roles[0]) - XCTAssertEqual(firstRole.name, "admin") - XCTAssertEqual(firstRole.team?.remoteIdentifier, Scaffolding.selfTeamID) - XCTAssertNil(firstRole.conversation) - XCTAssertEqual( - Set(firstRole.actions.map(\.name)), - [ - "add_conversation_member", - "delete_conversation" - ] - ) - // One is for the member. - let secondRole = try XCTUnwrap(roles[1]) - XCTAssertEqual(secondRole.name, "member") - XCTAssertEqual(secondRole.team?.remoteIdentifier, Scaffolding.selfTeamID) - XCTAssertNil(secondRole.conversation) - XCTAssertEqual(Set(secondRole.actions.map(\.name)), ["add_conversation_member"]) - } + XCTAssertEqual(teamsAPI.getTeamRolesFor_Invocations.count, 1) + XCTAssertEqual(teamLocalStore.storeTeamRolesSelfTeamIDTeamRolesInfo_Invocations.count, 1) } - func testPullSelfTeamMembers() async throws { - // Given - let team = await context.perform { [context, modelHelper] in - let team = modelHelper!.createTeam( - id: Scaffolding.selfTeamID, - in: context - ) - - XCTAssertTrue(team.members.isEmpty) - return team - } - + func testPullSelfTeamMembers_It_Invokes_Local_Store_And_Team_API_Methods() async throws { // Mock + teamsAPI.getTeamMembersForMaxResults_MockValue = [ TeamMember( userID: Scaffolding.member1ID, @@ -210,48 +147,39 @@ final class TeamRepositoryTests: XCTestCase { ) ] + teamLocalStore.storeTeamMembersSelfTeamIDTeamMembersInfo_MockMethod = { _, _ in } + // When + try await sut.pullSelfTeamMembers() // Then - try await context.perform { - XCTAssertEqual(team.members.count, 2) - - let member1 = try XCTUnwrap(team.members.first(where: { - $0.remoteIdentifier == Scaffolding.member1ID - })) - - XCTAssertEqual(member1.createdAt, Scaffolding.member1CreationDate) - XCTAssertEqual(member1.createdBy?.remoteIdentifier, Scaffolding.member1CreatorID) - XCTAssertEqual(member1.permissions.rawValue, Scaffolding.member1Permissions) - XCTAssertFalse(member1.needsToBeUpdatedFromBackend) - - let member2 = try XCTUnwrap(team.members.first(where: { - $0.remoteIdentifier == Scaffolding.member2ID - })) - - XCTAssertEqual(member2.createdAt, Scaffolding.member2CreationDate) - XCTAssertEqual(member2.createdBy?.remoteIdentifier, Scaffolding.member2CreatorID) - XCTAssertEqual(member2.permissions.rawValue, Scaffolding.member2Permissions) - XCTAssertFalse(member2.needsToBeUpdatedFromBackend) - } + + XCTAssertEqual(teamsAPI.getTeamMembersForMaxResults_Invocations.count, 1) + XCTAssertEqual(teamLocalStore.storeTeamMembersSelfTeamIDTeamMembersInfo_Invocations.count, 1) } - func testFetchSelfLegalholdStatus() async throws { + func testFetchSelfLegalholdStatus_It_Invokes_Local_Store_And_Teams_API_Methods_And_Legal_Hold_Status_Is_Pending() async throws { // Mock + teamsAPI.getLegalholdStatusForUserID_MockValue = .pending + teamLocalStore.selfUserID_MockValue = UUID() // When + let result = try await sut.fetchSelfLegalholdStatus() // Then + + XCTAssertEqual(teamLocalStore.selfUserID_Invocations.count, 1) + XCTAssertEqual(teamsAPI.getLegalholdStatusForUserID_Invocations.count, 1) XCTAssertEqual(result, .pending) } - func testDeleteTeamMembership_It_Deletes_Member_From_Team() async throws { - // Given + func testDeleteTeamMembership_It_Invokes_Local_Store_And_User_Repo_Methods() async throws { + // Mock - let user = try await context.perform { [self] in + let (user, member) = try await context.perform { [self] in let (team, users, _) = modelHelper.createTeam( id: Scaffolding.teamID, withMembers: [Scaffolding.userID], @@ -262,87 +190,86 @@ final class TeamRepositoryTests: XCTestCase { let member = try XCTUnwrap(team.members.first) XCTAssertEqual(user.membership, member) - return user + return (user, member) } - // Mock - userRespository.deleteUserAccountIdDomainAt_MockMethod = { _, _, _ in } userRespository.fetchUserIdDomain_MockValue = user + teamLocalStore.userMembershipUser_MockValue = member + teamLocalStore.userDomainUser_MockValue = Scaffolding.domain + teamLocalStore.deleteMember_MockMethod = { _ in } // When try await sut.deleteMembership( for: Scaffolding.userID, domain: nil, - at: Scaffolding.date(from: Scaffolding.time) + at: .distantPast ) // Then - try await context.perform { [context] in - /// users won't be deleted as we might be in other (non-team) conversations with them - XCTAssertNotNil(ZMUser.fetch(with: Scaffolding.userID, in: context)) - - let team = try XCTUnwrap(Team.fetch(with: Scaffolding.teamID, in: context), "No team") - - XCTAssertEqual(team.members, []) - } + XCTAssertEqual(userRespository.deleteUserAccountIdDomainAt_Invocations.count, 1) + XCTAssertEqual(userRespository.fetchUserIdDomain_Invocations.count, 1) + XCTAssertEqual(teamLocalStore.userMembershipUser_Invocations.count, 1) + XCTAssertEqual(teamLocalStore.userDomainUser_Invocations.count, 1) + XCTAssertEqual(teamLocalStore.deleteMember_Invocations.count, 1) } - func testStoreTeamMemberNeedsBackendUpdate_It_Updates_Flag() async throws { - // Given + func testStoreTeamMemberNeedsBackendUpdate_It_Invokes_Local_Store_Methods() async throws { + // Mock - try await context.perform { [context, modelHelper] in + let member = await context.perform { [self] in - let team = modelHelper!.createTeam( + let team = modelHelper.createTeam( id: Scaffolding.teamID, in: context ) - let user = modelHelper!.createUser( + let user = modelHelper.createUser( id: Scaffolding.membershipID, domain: Scaffolding.domain, in: context ) - let member = modelHelper!.addUser( + let member = modelHelper.addUser( user, to: team, in: context ) - XCTAssertEqual(member.needsToBeUpdatedFromBackend, false) - - try context.save() + return member } + teamLocalStore.fetchMemberId_MockValue = member + teamLocalStore.storeMemberNeedsBackendUpdateMember_MockMethod = { _, _ in } + // When try await sut.storeTeamMemberNeedsBackendUpdate( membershipID: Scaffolding.membershipID ) - await context.perform { [context] in - let user = ZMUser.fetch(with: Scaffolding.membershipID, in: context) - let team = Team.fetch(with: Scaffolding.teamID, in: context) - - guard let user, let team, let member = user.membership else { - return XCTFail() - } - - // Then + // Then - XCTAssertEqual(member.needsToBeUpdatedFromBackend, true) - XCTAssertEqual(member.team, team) - } + XCTAssertEqual(teamLocalStore.fetchMemberId_Invocations.count, 1) + XCTAssertEqual(teamLocalStore.storeMemberNeedsBackendUpdateMember_Invocations.count, 1) } func testStoreTeamMemberNeedsBackendUpdate_It_Throws_Error_When_Member_Was_Not_Found() async throws { + // Mock + + teamLocalStore.fetchMemberId_MockMethod = { _ in nil } + // Then + await XCTAssertThrowsError { [self] in + // When - try await sut.storeTeamMemberNeedsBackendUpdate(membershipID: Scaffolding.membershipID) + + try await sut.storeTeamMemberNeedsBackendUpdate( + membershipID: Scaffolding.membershipID + ) } } @@ -358,15 +285,8 @@ final class TeamRepositoryTests: XCTestCase { static let logoID = UUID().uuidString static let logoKey = UUID().uuidString static let splashScreenID = UUID().uuidString - static let time = "2021-05-12T10:52:02.671Z" - static let teamConversationID = UUID() - static let anotherTeamConversationID = UUID() static let conversationID = UUID() - static func date(from string: String) -> Date { - ISO8601DateFormatter.fractionalInternetDateTime.date(from: string)! - } - static let member1ID = UUID() static let member1CreationDate = Date() static let member1CreatorID = UUID() @@ -380,11 +300,3 @@ final class TeamRepositoryTests: XCTestCase { static let member2Permissions = Permissions.member.rawValue } } - -private extension ISO8601DateFormatter { - nonisolated(unsafe) static let fractionalInternetDateTime = { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return dateFormatter - }() -} diff --git a/WireDomain/Tests/WireDomainTests/Repositories/UserRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/UserRepositoryTests.swift index 1463de1f6b6..51cdf4c2165 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/UserRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/UserRepositoryTests.swift @@ -21,8 +21,8 @@ import WireAPISupport import WireDataModel import WireDataModelSupport @testable import WireDomain -import WireTestingPackage import WireDomainSupport +import WireTestingPackage import XCTest final class UserRepositoryTests: XCTestCase { @@ -50,7 +50,7 @@ final class UserRepositoryTests: XCTestCase { conversationLabelsRepository = MockConversationLabelsRepositoryProtocol() conversationsRepository = MockConversationRepositoryProtocol() userLocalStore = MockUserLocalStoreProtocol() - + sut = UserRepository( usersAPI: usersAPI, selfUserAPI: selfUsersAPI, @@ -76,25 +76,24 @@ final class UserRepositoryTests: XCTestCase { // MARK: - Tests func testPullUsers_It_Invokes_Local_Store_Method() async throws { - // Given - + await context.perform { [context] in // There is no user in the database. XCTAssertNil(ZMUser.fetch(with: Scaffolding.user1.id.uuid, domain: Scaffolding.user1.id.domain, in: context)) } // Mock - + usersAPI.getUsersUserIDs_MockValue = WireAPI.UserList( found: [Scaffolding.user1], failed: [] ) - + userLocalStore.persistUserFrom_MockMethod = { _ in } // When - + try await sut.pullUsers(userIDs: [Scaffolding.user1.id.toDomainModel()]) // Then @@ -103,40 +102,37 @@ final class UserRepositoryTests: XCTestCase { } func testPullKnownUsers_It_Invokes_Local_Store_Methods() async throws { - // Given - + _ = await context.perform { [context] in // Insert incomplete user in the database. ZMUser.fetchOrCreate(with: Scaffolding.user1.id.uuid, domain: Scaffolding.user1.id.domain, in: context) } // Mock - + usersAPI.getUsersUserIDs_MockValue = WireAPI.UserList( found: [Scaffolding.user1], failed: [] ) - + userLocalStore.fetchUsersQualifiedIDs_MockValue = [Scaffolding.user1.id.toDomainModel()] userLocalStore.persistUserFrom_MockMethod = { _ in } // When - + try await sut.pullKnownUsers() // Then - + XCTAssertEqual(userLocalStore.fetchUsersQualifiedIDs_Invocations.count, 1) XCTAssertEqual(userLocalStore.persistUserFrom_Invocations.count, 1) } - + func testRemovesPushToken_It_Invokes_Local_Store_Method() { - // Mock - + userLocalStore.deletePushToken_MockMethod = {} - // When @@ -148,7 +144,6 @@ final class UserRepositoryTests: XCTestCase { } func testFetchSelfUser_It_Invokes_Local_Store_Method() async { - // Mock let selfUser = await context.perform { [self] in @@ -158,7 +153,7 @@ final class UserRepositoryTests: XCTestCase { in: context ) } - + userLocalStore.fetchSelfUser_MockValue = selfUser // When @@ -172,7 +167,6 @@ final class UserRepositoryTests: XCTestCase { } func testFetchUser_It_Invokes_Local_Store_Method() async throws { - // Mock let user = await context.perform { [self] in @@ -182,7 +176,7 @@ final class UserRepositoryTests: XCTestCase { in: context ) } - + userLocalStore.fetchUserIdDomain_MockValue = user // When @@ -199,7 +193,6 @@ final class UserRepositoryTests: XCTestCase { } func testAddLegalholdRequest_It_Invokes_Local_Store_Method() async { - // Mock userLocalStore.addSelfLegalHoldRequestUserIDClientIDLastPrekey_MockMethod = { _, _, _ in } @@ -235,9 +228,8 @@ final class UserRepositoryTests: XCTestCase { } func testDeleteUserAccountForSelfUser_It_Invokes_Local_Store_Methods() async throws { - // Mock - + let selfUser = await context.perform { [self] in let selfUser = modelHelper.createSelfUser( id: .mockID1, @@ -246,7 +238,7 @@ final class UserRepositoryTests: XCTestCase { return selfUser } - + userLocalStore.isSelfUserIdDomain_MockValue = (selfUser, true) userLocalStore.postAccountDeletedNotification_MockMethod = {} @@ -265,9 +257,8 @@ final class UserRepositoryTests: XCTestCase { } func testDeleteUserAccountForNotSelfUser_It_Invokes_Local_Store_And_Conversation_Repo_Methods() async throws { - // Mock - + let user = await context.perform { [self] in let user = modelHelper.createUser( id: .mockID1, @@ -276,7 +267,7 @@ final class UserRepositoryTests: XCTestCase { return user } - + userLocalStore.isSelfUserIdDomain_MockValue = (user, false) userLocalStore.markAccountAsDeletedFor_MockMethod = { _ in } conversationsRepository.removeParticipantFromAllGroupConversationsParticipantIDParticipantDomainRemovedAt_MockMethod = { _, _, _ in } @@ -297,9 +288,8 @@ final class UserRepositoryTests: XCTestCase { } func testUpdateUserProperty_It_Enables_Read_Receipts_Property_It_Invokes_Local_Store_Method() async throws { - // Mock - + userLocalStore.updateSelfUserReadReceiptsIsReadReceiptsEnabledIsReadReceiptsEnabledChangedRemotely_MockMethod = { _, _ in } // When @@ -312,7 +302,6 @@ final class UserRepositoryTests: XCTestCase { } func testUpdateUserProperty_It_Invokes_Conversation_Labels_Repo_Method() async throws { - // Mock conversationLabelsRepository.updateConversationLabels_MockMethod = { _ in } @@ -353,9 +342,8 @@ final class UserRepositoryTests: XCTestCase { } func testUpdateUser_It_Updates_User_Locally_It_Invokes_Local_Store_Method() async { - // Mock - + userLocalStore.updateUserFrom_MockMethod = { _ in } // When @@ -368,7 +356,6 @@ final class UserRepositoryTests: XCTestCase { } func testIsSelfUser_It_Returns_True() async throws { - // Mock let user = await context.perform { [self] in @@ -379,27 +366,27 @@ final class UserRepositoryTests: XCTestCase { return user } - + userLocalStore.isSelfUserIdDomain_MockValue = (user, true) // When - + let isSelfUser = try await sut.isSelfUser( id: .mockID1, domain: Scaffolding.domain ) - + // Then - + XCTAssertEqual(isSelfUser, true) } private enum Scaffolding { - + static let domain = "domain.com" - + static let lastPrekeyId = 65_535 - + static let base64encodedString = "pQABAQoCoQBYIPEFMBhOtG0dl6gZrh3kgopEK4i62t9sqyqCBckq3IJgA6EAoQBYIC9gPmCdKyqwj9RiAaeSsUI7zPKDZS+CjoN+sfihk/5VBPY=" static let conversationLabel1 = ConversationLabel( From 8d7285734b4ebe9607361c8b07778f1462775ae0 Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Thu, 7 Nov 2024 12:52:55 +0100 Subject: [PATCH 05/14] UserClients - add UTs for local store and repo --- .../project.pbxproj | 8 + .../UserClientAddEventProcessor.swift | 4 +- .../UserClients/UserClientsLocalStore.swift | 254 ++++++++++++++++++ .../UserClients/UserClientsRepository.swift | 193 ++++--------- .../generated/AutoMockable.generated.swift | 141 ++++++++-- .../UserClientAddEventProcessorTests.swift | 8 +- .../UserClientsLocalStoreTests.swift | 167 ++++++++++++ .../UserClientsRepositoryTests.swift | 139 ++++------ 8 files changed, 652 insertions(+), 262 deletions(-) create mode 100644 WireDomain/Sources/WireDomain/Repositories/UserClients/UserClientsLocalStore.swift create mode 100644 WireDomain/Tests/WireDomainTests/LocalStores/UserClientsLocalStoreTests.swift diff --git a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj index aff1b70e34b..ff995675f5f 100644 --- a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj +++ b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj @@ -39,6 +39,8 @@ C96B75612CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */; }; C96B75632CDCBA10003A85EB /* TeamLocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75622CDCBA10003A85EB /* TeamLocalStore.swift */; }; C96B75652CDCC85B003A85EB /* TeamLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */; }; + C96B75672CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75662CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift */; }; + C96B75692CDCD488003A85EB /* UserClientsLocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75682CDCD488003A85EB /* UserClientsLocalStore.swift */; }; C97BCCAA2C98704B004F2D0D /* WireDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 01D0DCA62C1C8C870076CB1C /* WireDomain.framework */; }; C97C013D2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */; }; C97C014B2CB00F92000683C5 /* OneOnOneResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */; }; @@ -191,6 +193,8 @@ C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionsLocalStoreTests.swift; sourceTree = ""; }; C96B75622CDCBA10003A85EB /* TeamLocalStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLocalStore.swift; sourceTree = ""; }; C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLocalStoreTests.swift; sourceTree = ""; }; + C96B75662CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserClientsLocalStoreTests.swift; sourceTree = ""; }; + C96B75682CDCD488003A85EB /* UserClientsLocalStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserClientsLocalStore.swift; sourceTree = ""; }; C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPropertiesDeleteEventProcessorTests.swift; sourceTree = ""; }; C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OneOnOneResolver.swift; path = ../../OneOnOneResolver.swift; sourceTree = ""; }; C97C014F2CB01BDF000683C5 /* OneOnOneResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneResolverTests.swift; sourceTree = ""; }; @@ -499,6 +503,7 @@ C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */, C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */, C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */, + C96B75662CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift */, ); path = LocalStores; sourceTree = ""; @@ -524,6 +529,7 @@ isa = PBXGroup; children = ( C97C01A42CB80F4E000683C5 /* UserClientsRepository.swift */, + C96B75682CDCD488003A85EB /* UserClientsLocalStore.swift */, ); path = UserClients; sourceTree = ""; @@ -1011,6 +1017,7 @@ C99322DD2C986E3A0065E10F /* FeatureConfigRepositoryError.swift in Sources */, C99322E72C986E3A0065E10F /* ConnectionsRepositoryError.swift in Sources */, C98433D02CC26A1D009723D4 /* MLSProvider.swift in Sources */, + C96B75692CDCD488003A85EB /* UserClientsLocalStore.swift in Sources */, EEAD0A362C46BBA600CC8658 /* FeatureConfigUpdateEventProcessor.swift in Sources */, EE57A7002C298F630096F242 /* ProteusMessageDecryptor.swift in Sources */, EE368CD02C2DAA87009DBAB0 /* FederationEventProcessor.swift in Sources */, @@ -1124,6 +1131,7 @@ C9C8FDD22C9DBE0E00702B91 /* UserClientAddEventProcessorTests.swift in Sources */, C9C8FDD12C9DBE0E00702B91 /* TeamMemberUpdateEventProcessorTests.swift in Sources */, C97C01542CB04626000683C5 /* UserDeleteEventProcessorTests.swift in Sources */, + C96B75672CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift in Sources */, C9C8FDCE2C9DBE0E00702B91 /* FeatureConfigUpdateEventProcessorTests.swift in Sources */, C9C8FDCF2C9DBE0E00702B91 /* TeamDeleteEventProcessorTests.swift in Sources */, C97C01592CB40010000683C5 /* FederationConnectionRemovedEventProcessorTests.swift in Sources */, diff --git a/WireDomain/Sources/WireDomain/Event Processing/UserEventProcessor/UserClientAddEventProcessor.swift b/WireDomain/Sources/WireDomain/Event Processing/UserEventProcessor/UserClientAddEventProcessor.swift index 90e0f675c18..15e6b6b95ae 100644 --- a/WireDomain/Sources/WireDomain/Event Processing/UserEventProcessor/UserClientAddEventProcessor.swift +++ b/WireDomain/Sources/WireDomain/Event Processing/UserEventProcessor/UserClientAddEventProcessor.swift @@ -41,11 +41,11 @@ struct UserClientAddEventProcessor: UserClientAddEventProcessorProtocol { func processEvent(_ event: UserClientAddEvent) async throws { do { let localUserClient = try await repository.fetchOrCreateClient( - with: event.client.id + id: event.client.id ) try await repository.updateClient( - with: event.client.id, + id: event.client.id, from: event.client, isNewClient: localUserClient.isNew ) diff --git a/WireDomain/Sources/WireDomain/Repositories/UserClients/UserClientsLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/UserClients/UserClientsLocalStore.swift new file mode 100644 index 00000000000..c56c4a132be --- /dev/null +++ b/WireDomain/Sources/WireDomain/Repositories/UserClients/UserClientsLocalStore.swift @@ -0,0 +1,254 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import CoreData +import WireDataModel + +// sourcery: AutoMockable +public protocol UserClientsLocalStoreProtocol { + + /// Fetches or creates a client locally. + /// + /// - parameters: + /// - id: The user client id to find or create locally. + /// - returns: The user client found or created locally and a flag indicating whether or not the user client is new. + + func fetchOrCreateClient( + id: String + ) async -> (client: WireDataModel.UserClient, isNew: Bool) + + /// Retrieves deleted self clients locally based on new self clients. + /// - parameter newClients: The new self user clients. + /// - returns: A list of deleted self clients. + + func deletedSelfClients( + newClients: [String] + ) async -> [String] + + /// Deletes client locally. + /// - parameter id: The client id. + + func deleteClient( + id: String + ) async + + /// Updates the user client informations locally. + /// + /// - parameters: + /// - id: The user client id. + /// - isNewClient: A flag indicating whether the user client is new. + /// - remoteClient: The up-to-date user client info object. + + func updateClient( + id: String, + isNewClient: Bool, + userClientInfo: UserClientsLocalStore.UserClientInfo + ) async + + /// Indicates whether self user clients are active MLS clients. + /// - returns: A flag indicating whether all self user clients are active MLS clients. + + func allSelfUserClientsAreActiveMLSClients() async -> Bool +} + +public final class UserClientsLocalStore: UserClientsLocalStoreProtocol { + + // MARK: - Models + + public struct UserClientInfo: Sendable { + let id: String + let label: String? + let type: WireDataModel.DeviceType + let activationDate: Date + let model: String? + let deviceClass: WireDataModel.DeviceClass? + let lastActiveDate: Date? + let mlsPublicKeys: UserClientInfo.MLSPublicKeys? + + struct MLSPublicKeys { + let ed25519: String? + let ed448: String? + let p256: String? + let p384: String? + let p512: String? + } + } + + // MARK: - Properties + + private let context: NSManagedObjectContext + private let userLocalStore: any UserLocalStoreProtocol + + // MARK: - Object lifecycle + + init( + context: NSManagedObjectContext, + userLocalStore: any UserLocalStoreProtocol + ) { + self.context = context + self.userLocalStore = userLocalStore + } + + public func fetchOrCreateClient( + id: String + ) async -> (client: WireDataModel.UserClient, isNew: Bool) { + let localUserClient = await context.perform { [context] in + if let existingClient = UserClient.fetchExistingUserClient( + with: id, + in: context + ) { + return (existingClient, false) + } else { + let newClient = UserClient.insertNewObject(in: context) + newClient.remoteIdentifier = id + return (newClient, true) + } + } + + return localUserClient + } + + public func deletedSelfClients( + newClients: [String] + ) async -> [String] { + let selfUser = await userLocalStore.fetchSelfUser() + + return await context.perform { + selfUser.clients + .compactMap(\.remoteIdentifier) + .filter { + !newClients.contains($0) + } + } + } + + public func deleteClient( + id: String + ) async { + let localClient = await context.perform { [context] in + let localClient = UserClient.fetchExistingUserClient( + with: id, + in: context + ) + + return localClient + } + + guard let localClient else { + return WireLogger.userClient.error( + "Failed to find existing client with id: \(id.redactedAndTruncated())" + ) + } + + await localClient.deleteClientAndEndSession() + } + + public func updateClient( + id: String, + isNewClient: Bool, + userClientInfo: UserClientInfo + ) async { + await context.perform { [context] in + + guard let localClient = UserClient.fetchExistingUserClient( + with: id, + in: context + ) else { + return WireLogger.userClient.error( + "Failed to find existing client with id: \(id.redactedAndTruncated())" + ) + } + + localClient.label = userClientInfo.label + localClient.type = userClientInfo.type + localClient.model = userClientInfo.model + localClient.deviceClass = userClientInfo.deviceClass + localClient.activationDate = userClientInfo.activationDate + localClient.lastActiveDate = userClientInfo.lastActiveDate + localClient.remoteIdentifier = userClientInfo.id + + let selfUser = ZMUser.selfUser(in: context) + localClient.user = localClient.user ?? selfUser + + if isNewClient { + localClient.needsSessionMigration = selfUser.domain == nil + } + + if localClient.isLegalHoldDevice, isNewClient { + selfUser.legalHoldRequest = nil + selfUser.needsToAcknowledgeLegalHoldStatus = true + } + + if !localClient.isSelfClient() { + localClient.mlsPublicKeys = .init( + ed25519: userClientInfo.mlsPublicKeys?.ed25519, + ed448: userClientInfo.mlsPublicKeys?.ed448, + p256: userClientInfo.mlsPublicKeys?.p256, + p384: userClientInfo.mlsPublicKeys?.p384, + p521: userClientInfo.mlsPublicKeys?.p512 + ) + } + + let selfClient = selfUser.selfClient() + let isNotSameId = localClient.remoteIdentifier != selfClient?.remoteIdentifier + let localClientActivationDate = localClient.activationDate + let selfClientActivationDate = selfClient?.activationDate + + if selfClient != nil, isNotSameId, let localClientActivationDate, let selfClientActivationDate { + let comparisonResult = localClientActivationDate + .compare(selfClientActivationDate) + + if comparisonResult == .orderedDescending { + localClient.needsToNotifyUser = true + } + } + + selfUser.selfClient()?.addNewClientToIgnored(localClient) + selfUser.selfClient()?.updateSecurityLevelAfterDiscovering(Set([localClient])) + } + } + + public func allSelfUserClientsAreActiveMLSClients() async -> Bool { + let selfUser = await userLocalStore.fetchSelfUser() + + return await context.perform { + selfUser.clients.all { userClient in + let hasMLSIdentity = !userClient.mlsPublicKeys.isEmpty + + let isRecentlyActive: Bool = { + if userClient.isSelfClient() { + return true + } + + guard let lastActiveDate = userClient.lastActiveDate else { + return false + } + + guard lastActiveDate <= Date() else { + return true + } + + return lastActiveDate.timeIntervalSinceNow.magnitude < .fourWeeks + }() + + return hasMLSIdentity && isRecentlyActive + } + } + } + +} diff --git a/WireDomain/Sources/WireDomain/Repositories/UserClients/UserClientsRepository.swift b/WireDomain/Sources/WireDomain/Repositories/UserClients/UserClientsRepository.swift index faea71b59ad..4e1b35909a3 100644 --- a/WireDomain/Sources/WireDomain/Repositories/UserClients/UserClientsRepository.swift +++ b/WireDomain/Sources/WireDomain/Repositories/UserClients/UserClientsRepository.swift @@ -16,7 +16,6 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import CoreData import Foundation import WireAPI import WireDataModel @@ -42,7 +41,7 @@ public protocol UserClientsRepositoryProtocol { /// - returns: The user client found or created locally and a flag indicating whether or not the user client is new. func fetchOrCreateClient( - with id: String + id: String ) async throws -> (client: WireDataModel.UserClient, isNew: Bool) /// Updates the user client informations locally. @@ -53,7 +52,7 @@ public protocol UserClientsRepositoryProtocol { /// - isNewClient: A flag indicating whether the user client is new. func updateClient( - with id: String, + id: String, from remoteClient: WireAPI.SelfUserClient, isNewClient: Bool ) async throws @@ -61,7 +60,7 @@ public protocol UserClientsRepositoryProtocol { /// Deletes client locally. /// - parameter id: The client id. - func deleteClient(with id: String) async + func deleteClient(id: String) async /// Indicates whether self user clients are active MLS clients. /// - returns: A flag indicating whether all self user clients are active MLS clients. @@ -75,178 +74,92 @@ public struct UserClientsRepository: UserClientsRepositoryProtocol { private let userClientsAPI: any UserClientsAPI private let userRepository: any UserRepositoryProtocol - private let context: NSManagedObjectContext + private let userClientsLocalStore: any UserClientsLocalStoreProtocol // MARK: - Object lifecycle init( userClientsAPI: any UserClientsAPI, userRepository: any UserRepositoryProtocol, - context: NSManagedObjectContext + userClientsLocalStore: any UserClientsLocalStoreProtocol ) { self.userClientsAPI = userClientsAPI self.userRepository = userRepository - self.context = context + self.userClientsLocalStore = userClientsLocalStore } // MARK: - Public + public func fetchOrCreateClient( + id: String + ) async throws -> (client: WireDataModel.UserClient, isNew: Bool) { + await userClientsLocalStore.fetchOrCreateClient( + id: id + ) + } + + public func deleteClient( + id: String + ) async { + await userClientsLocalStore.deleteClient(id: id) + } + public func pullSelfClients() async throws { let remoteSelfClients = try await userClientsAPI.getSelfClients() - let selfUser = await userRepository.fetchSelfUser() - let localSelfClients = await context.perform { - selfUser.clients - } for remoteSelfClient in remoteSelfClients { - let localUserClient = try await fetchOrCreateClient(with: remoteSelfClient.id) + let localUserClient = await userClientsLocalStore.fetchOrCreateClient( + id: remoteSelfClient.id + ) + try await updateClient( - with: remoteSelfClient.id, + id: remoteSelfClient.id, from: remoteSelfClient, isNewClient: localUserClient.isNew ) } - let deletedSelfClientsIDs = await context.perform { - localSelfClients - .compactMap(\.remoteIdentifier) - .filter { - !remoteSelfClients.map(\.id).contains($0) - } - } + let deletedSelfClientsIDs = await userClientsLocalStore.deletedSelfClients( + newClients: remoteSelfClients.map(\.id) + ) for deletedSelfClientID in deletedSelfClientsIDs { - await deleteClient(with: deletedSelfClientID) - } - } - - public func fetchOrCreateClient( - with id: String - ) async throws -> (client: WireDataModel.UserClient, isNew: Bool) { - let localUserClient = await context.perform { [context] in - if let existingClient = UserClient.fetchExistingUserClient( - with: id, - in: context - ) { - return (existingClient, false) - } else { - let newClient = UserClient.insertNewObject(in: context) - newClient.remoteIdentifier = id - return (newClient, true) - } + await userClientsLocalStore.deleteClient(id: deletedSelfClientID) } - - return localUserClient } public func updateClient( - with id: String, + id: String, from remoteClient: WireAPI.SelfUserClient, isNewClient: Bool ) async throws { - await context.perform { [context] in - - guard let localClient = UserClient.fetchExistingUserClient( - with: id, - in: context - ) else { - return WireLogger.userClient.error( - "Failed to find existing client with id: \(id.redactedAndTruncated())" - ) - } - - localClient.label = remoteClient.label - localClient.type = remoteClient.type.toDomainModel() - localClient.model = remoteClient.model - localClient.deviceClass = remoteClient.deviceClass?.toDomainModel() - localClient.activationDate = remoteClient.activationDate - localClient.lastActiveDate = remoteClient.lastActiveDate - localClient.remoteIdentifier = remoteClient.id - - let selfUser = ZMUser.selfUser(in: context) - localClient.user = localClient.user ?? selfUser - - if isNewClient { - localClient.needsSessionMigration = selfUser.domain == nil - } - - if localClient.isLegalHoldDevice, isNewClient { - selfUser.legalHoldRequest = nil - selfUser.needsToAcknowledgeLegalHoldStatus = true - } - - if !localClient.isSelfClient() { - localClient.mlsPublicKeys = .init( - ed25519: remoteClient.mlsPublicKeys?.ed25519, - ed448: remoteClient.mlsPublicKeys?.ed448, - p256: remoteClient.mlsPublicKeys?.p256, - p384: remoteClient.mlsPublicKeys?.p384, - p521: remoteClient.mlsPublicKeys?.p512 - ) - } - - let selfClient = selfUser.selfClient() - let isNotSameId = localClient.remoteIdentifier != selfClient?.remoteIdentifier - let localClientActivationDate = localClient.activationDate - let selfClientActivationDate = selfClient?.activationDate - - if selfClient != nil, isNotSameId, let localClientActivationDate, let selfClientActivationDate { - let comparisonResult = localClientActivationDate - .compare(selfClientActivationDate) - - if comparisonResult == .orderedDescending { - localClient.needsToNotifyUser = true - } - } - - selfUser.selfClient()?.addNewClientToIgnored(localClient) - selfUser.selfClient()?.updateSecurityLevelAfterDiscovering(Set([localClient])) - } - } - - public func deleteClient(with id: String) async { - let localClient = await context.perform { - let localClient = UserClient.fetchExistingUserClient( - with: id, - in: context + // prepare data for local store + + let userClientInfo = UserClientsLocalStore.UserClientInfo( + id: remoteClient.id, + label: remoteClient.label, + type: remoteClient.type.toDomainModel(), + activationDate: remoteClient.activationDate, + model: remoteClient.model, + deviceClass: remoteClient.deviceClass?.toDomainModel(), + lastActiveDate: remoteClient.lastActiveDate, + mlsPublicKeys: .init( + ed25519: remoteClient.mlsPublicKeys?.ed25519, + ed448: remoteClient.mlsPublicKeys?.ed448, + p256: remoteClient.mlsPublicKeys?.p256, + p384: remoteClient.mlsPublicKeys?.p384, + p512: remoteClient.mlsPublicKeys?.p512 ) - return localClient - } - - guard let localClient else { - return WireLogger.userClient.error( - "Failed to find existing client with id: \(id.redactedAndTruncated())" - ) - } + ) - await localClient.deleteClientAndEndSession() + await userClientsLocalStore.updateClient( + id: id, + isNewClient: isNewClient, + userClientInfo: userClientInfo + ) } public func allSelfUserClientsAreActiveMLSClients() async -> Bool { - let selfUser = await userRepository.fetchSelfUser() - - return await context.perform { - selfUser.clients.all { userClient in - let hasMLSIdentity = !userClient.mlsPublicKeys.isEmpty - - let isRecentlyActive: Bool = { - if userClient.isSelfClient() { - return true - } - - guard let lastActiveDate = userClient.lastActiveDate else { - return false - } - - guard lastActiveDate <= Date() else { - return true - } - - return lastActiveDate.timeIntervalSinceNow.magnitude < .fourWeeks - }() - - return hasMLSIdentity && isRecentlyActive - } - } + await userClientsLocalStore.allSelfUserClientsAreActiveMLSClients() } } diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index a4526fb1dd2..2f9c416752e 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -1464,6 +1464,99 @@ class MockUpdateEventsRepositoryProtocol: UpdateEventsRepositoryProtocol { } +public class MockUserClientsLocalStoreProtocol: UserClientsLocalStoreProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - fetchOrCreateClient + + public var fetchOrCreateClientId_Invocations: [String] = [] + public var fetchOrCreateClientId_MockMethod: ((String) async -> (client: WireDataModel.UserClient, isNew: Bool))? + public var fetchOrCreateClientId_MockValue: (client: WireDataModel.UserClient, isNew: Bool)? + + public func fetchOrCreateClient(id: String) async -> (client: WireDataModel.UserClient, isNew: Bool) { + fetchOrCreateClientId_Invocations.append(id) + + if let mock = fetchOrCreateClientId_MockMethod { + return await mock(id) + } else if let mock = fetchOrCreateClientId_MockValue { + return mock + } else { + fatalError("no mock for `fetchOrCreateClientId`") + } + } + + // MARK: - deletedSelfClients + + public var deletedSelfClientsNewClients_Invocations: [[String]] = [] + public var deletedSelfClientsNewClients_MockMethod: (([String]) async -> [String])? + public var deletedSelfClientsNewClients_MockValue: [String]? + + public func deletedSelfClients(newClients: [String]) async -> [String] { + deletedSelfClientsNewClients_Invocations.append(newClients) + + if let mock = deletedSelfClientsNewClients_MockMethod { + return await mock(newClients) + } else if let mock = deletedSelfClientsNewClients_MockValue { + return mock + } else { + fatalError("no mock for `deletedSelfClientsNewClients`") + } + } + + // MARK: - deleteClient + + public var deleteClientId_Invocations: [String] = [] + public var deleteClientId_MockMethod: ((String) async -> Void)? + + public func deleteClient(id: String) async { + deleteClientId_Invocations.append(id) + + guard let mock = deleteClientId_MockMethod else { + fatalError("no mock for `deleteClientId`") + } + + await mock(id) + } + + // MARK: - updateClient + + public var updateClientIdIsNewClientUserClientInfo_Invocations: [(id: String, isNewClient: Bool, userClientInfo: UserClientsLocalStore.UserClientInfo)] = [] + public var updateClientIdIsNewClientUserClientInfo_MockMethod: ((String, Bool, UserClientsLocalStore.UserClientInfo) async -> Void)? + + public func updateClient(id: String, isNewClient: Bool, userClientInfo: UserClientsLocalStore.UserClientInfo) async { + updateClientIdIsNewClientUserClientInfo_Invocations.append((id: id, isNewClient: isNewClient, userClientInfo: userClientInfo)) + + guard let mock = updateClientIdIsNewClientUserClientInfo_MockMethod else { + fatalError("no mock for `updateClientIdIsNewClientUserClientInfo`") + } + + await mock(id, isNewClient, userClientInfo) + } + + // MARK: - allSelfUserClientsAreActiveMLSClients + + public var allSelfUserClientsAreActiveMLSClients_Invocations: [Void] = [] + public var allSelfUserClientsAreActiveMLSClients_MockMethod: (() async -> Bool)? + public var allSelfUserClientsAreActiveMLSClients_MockValue: Bool? + + public func allSelfUserClientsAreActiveMLSClients() async -> Bool { + allSelfUserClientsAreActiveMLSClients_Invocations.append(()) + + if let mock = allSelfUserClientsAreActiveMLSClients_MockMethod { + return await mock() + } else if let mock = allSelfUserClientsAreActiveMLSClients_MockValue { + return mock + } else { + fatalError("no mock for `allSelfUserClientsAreActiveMLSClients`") + } + } + +} + public class MockUserClientsRepositoryProtocol: UserClientsRepositoryProtocol { // MARK: - Life cycle @@ -1493,42 +1586,42 @@ public class MockUserClientsRepositoryProtocol: UserClientsRepositoryProtocol { // MARK: - fetchOrCreateClient - public var fetchOrCreateClientWith_Invocations: [String] = [] - public var fetchOrCreateClientWith_MockError: Error? - public var fetchOrCreateClientWith_MockMethod: ((String) async throws -> (client: WireDataModel.UserClient, isNew: Bool))? - public var fetchOrCreateClientWith_MockValue: (client: WireDataModel.UserClient, isNew: Bool)? + public var fetchOrCreateClientId_Invocations: [String] = [] + public var fetchOrCreateClientId_MockError: Error? + public var fetchOrCreateClientId_MockMethod: ((String) async throws -> (client: WireDataModel.UserClient, isNew: Bool))? + public var fetchOrCreateClientId_MockValue: (client: WireDataModel.UserClient, isNew: Bool)? - public func fetchOrCreateClient(with id: String) async throws -> (client: WireDataModel.UserClient, isNew: Bool) { - fetchOrCreateClientWith_Invocations.append(id) + public func fetchOrCreateClient(id: String) async throws -> (client: WireDataModel.UserClient, isNew: Bool) { + fetchOrCreateClientId_Invocations.append(id) - if let error = fetchOrCreateClientWith_MockError { + if let error = fetchOrCreateClientId_MockError { throw error } - if let mock = fetchOrCreateClientWith_MockMethod { + if let mock = fetchOrCreateClientId_MockMethod { return try await mock(id) - } else if let mock = fetchOrCreateClientWith_MockValue { + } else if let mock = fetchOrCreateClientId_MockValue { return mock } else { - fatalError("no mock for `fetchOrCreateClientWith`") + fatalError("no mock for `fetchOrCreateClientId`") } } // MARK: - updateClient - public var updateClientWithFromIsNewClient_Invocations: [(id: String, remoteClient: WireAPI.SelfUserClient, isNewClient: Bool)] = [] - public var updateClientWithFromIsNewClient_MockError: Error? - public var updateClientWithFromIsNewClient_MockMethod: ((String, WireAPI.SelfUserClient, Bool) async throws -> Void)? + public var updateClientIdFromIsNewClient_Invocations: [(id: String, remoteClient: WireAPI.SelfUserClient, isNewClient: Bool)] = [] + public var updateClientIdFromIsNewClient_MockError: Error? + public var updateClientIdFromIsNewClient_MockMethod: ((String, WireAPI.SelfUserClient, Bool) async throws -> Void)? - public func updateClient(with id: String, from remoteClient: WireAPI.SelfUserClient, isNewClient: Bool) async throws { - updateClientWithFromIsNewClient_Invocations.append((id: id, remoteClient: remoteClient, isNewClient: isNewClient)) + public func updateClient(id: String, from remoteClient: WireAPI.SelfUserClient, isNewClient: Bool) async throws { + updateClientIdFromIsNewClient_Invocations.append((id: id, remoteClient: remoteClient, isNewClient: isNewClient)) - if let error = updateClientWithFromIsNewClient_MockError { + if let error = updateClientIdFromIsNewClient_MockError { throw error } - guard let mock = updateClientWithFromIsNewClient_MockMethod else { - fatalError("no mock for `updateClientWithFromIsNewClient`") + guard let mock = updateClientIdFromIsNewClient_MockMethod else { + fatalError("no mock for `updateClientIdFromIsNewClient`") } try await mock(id, remoteClient, isNewClient) @@ -1536,14 +1629,14 @@ public class MockUserClientsRepositoryProtocol: UserClientsRepositoryProtocol { // MARK: - deleteClient - public var deleteClientWith_Invocations: [String] = [] - public var deleteClientWith_MockMethod: ((String) async -> Void)? + public var deleteClientId_Invocations: [String] = [] + public var deleteClientId_MockMethod: ((String) async -> Void)? - public func deleteClient(with id: String) async { - deleteClientWith_Invocations.append(id) + public func deleteClient(id: String) async { + deleteClientId_Invocations.append(id) - guard let mock = deleteClientWith_MockMethod else { - fatalError("no mock for `deleteClientWith`") + guard let mock = deleteClientId_MockMethod else { + fatalError("no mock for `deleteClientId`") } await mock(id) diff --git a/WireDomain/Tests/WireDomainTests/Event Processing/UserEventProcessor/UserClientAddEventProcessorTests.swift b/WireDomain/Tests/WireDomainTests/Event Processing/UserEventProcessor/UserClientAddEventProcessorTests.swift index a82eb09788d..51d4a6cf656 100644 --- a/WireDomain/Tests/WireDomainTests/Event Processing/UserEventProcessor/UserClientAddEventProcessorTests.swift +++ b/WireDomain/Tests/WireDomainTests/Event Processing/UserEventProcessor/UserClientAddEventProcessorTests.swift @@ -66,11 +66,11 @@ final class UserClientAddEventProcessorTests: XCTestCase { modelHelper.createSelfClient(in: context) } - userClientsRepository.fetchOrCreateClientWith_MockMethod = { _ in + userClientsRepository.fetchOrCreateClientId_MockMethod = { _ in (userClient, true) } - userClientsRepository.updateClientWithFromIsNewClient_MockMethod = { _, _, _ in } + userClientsRepository.updateClientIdFromIsNewClient_MockMethod = { _, _, _ in } // When @@ -78,8 +78,8 @@ final class UserClientAddEventProcessorTests: XCTestCase { // Then - XCTAssertEqual(userClientsRepository.fetchOrCreateClientWith_Invocations.count, 1) - XCTAssertEqual(userClientsRepository.updateClientWithFromIsNewClient_Invocations.count, 1) + XCTAssertEqual(userClientsRepository.fetchOrCreateClientId_Invocations.count, 1) + XCTAssertEqual(userClientsRepository.updateClientIdFromIsNewClient_Invocations.count, 1) } private enum Scaffolding { diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/UserClientsLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/UserClientsLocalStoreTests.swift new file mode 100644 index 00000000000..550ffbe5db0 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/LocalStores/UserClientsLocalStoreTests.swift @@ -0,0 +1,167 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireDataModel +import WireDataModelSupport +@testable import WireDomain +import WireDomainSupport +import XCTest + +final class UserClientsLocalStoreTests: XCTestCase { + + private var sut: UserClientsLocalStore! + private var userLocalStore: MockUserLocalStoreProtocol! + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + coreDataStackHelper = CoreDataStackHelper() + modelHelper = ModelHelper() + stack = try await coreDataStackHelper.createStack() + userLocalStore = MockUserLocalStoreProtocol() + + sut = UserClientsLocalStore( + context: context, + userLocalStore: userLocalStore + ) + } + + override func tearDown() async throws { + stack = nil + userLocalStore = nil + sut = nil + try coreDataStackHelper.cleanupDirectory() + coreDataStackHelper = nil + modelHelper = nil + } + + // MARK: - Tests + + func testFetchOrCreateClient_It_Retrieves_Client_Locally() async { + // Given + + await context.perform { [self] in + let userClient = modelHelper.createSelfClient( + id: Scaffolding.userClientID, + in: context + ) + + XCTAssertEqual(userClient.remoteIdentifier, Scaffolding.userClientID) + } + + // When + + let userClient = await sut.fetchOrCreateClient( + id: Scaffolding.userClientID + ) + + // Then + + await context.perform { + XCTAssertNotNil(userClient) + } + } + + func testUpdateClient_It_Updates_Client_Info() async throws { + // Given + + let createdClient = await sut.fetchOrCreateClient( + id: Scaffolding.userClientID + ) + + let clientID = await context.perform { + createdClient.client.remoteIdentifier! + } + + // When + + await sut.updateClient( + id: clientID, + isNewClient: createdClient.isNew, + userClientInfo: Scaffolding.selfUserClientInfo + ) + + // Then + + try await context.perform { [context] in + let updatedClient = try XCTUnwrap(UserClient.fetchExistingUserClient( + with: Scaffolding.userClientID, + in: context + )) + + XCTAssertEqual(updatedClient.remoteIdentifier, Scaffolding.userClientID) + XCTAssertEqual(updatedClient.type, .permanent) + XCTAssertEqual(updatedClient.label, Scaffolding.selfUserClientInfo.label) + XCTAssertEqual(updatedClient.model, Scaffolding.selfUserClientInfo.model) + XCTAssertEqual(updatedClient.deviceClass, .phone) + } + } + + func testDeleteClient_It_Deletes_Client_Locally() async throws { + // Given + + let (newClient, _) = await sut.fetchOrCreateClient(id: Scaffolding.userClientID) + + let localClient = await context.perform { [context] in + WireDataModel.UserClient.fetchExistingUserClient( + with: Scaffolding.userClientID, + in: context + ) + } + + XCTAssertEqual(localClient, newClient) + + // When + + await sut.deleteClient(id: Scaffolding.userClientID) + + // Then + + let deletedClient = await context.perform { [context] in + WireDataModel.UserClient.fetchExistingUserClient( + with: Scaffolding.userClientID, + in: context + ) + } + + XCTAssertEqual(deletedClient, nil) + } + + private enum Scaffolding { + static let userClientID = UUID().uuidString + static let otherUserClientID = UUID().uuidString + + static let selfUserClientInfo = UserClientsLocalStore.UserClientInfo( + id: userClientID, + label: "test", + type: .permanent, + activationDate: .now, + model: "test", + deviceClass: .phone, + lastActiveDate: nil, + mlsPublicKeys: nil + ) + + } + +} diff --git a/WireDomain/Tests/WireDomainTests/Repositories/UserClientsRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/UserClientsRepositoryTests.swift index 8af760a2e1e..339056ba985 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/UserClientsRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/UserClientsRepositoryTests.swift @@ -29,6 +29,7 @@ final class UserClientsRepositoryTests: XCTestCase { private var sut: UserClientsRepository! private var userClientsAPI: MockUserClientsAPI! private var userRepository: MockUserRepositoryProtocol! + private var userClientsLocalStore: MockUserClientsLocalStoreProtocol! private var stack: CoreDataStack! private var coreDataStackHelper: CoreDataStackHelper! private var modelHelper: ModelHelper! @@ -38,21 +39,21 @@ final class UserClientsRepositoryTests: XCTestCase { } override func setUp() async throws { - try await super.setUp() coreDataStackHelper = CoreDataStackHelper() modelHelper = ModelHelper() stack = try await coreDataStackHelper.createStack() userClientsAPI = MockUserClientsAPI() userRepository = MockUserRepositoryProtocol() + userClientsLocalStore = MockUserClientsLocalStoreProtocol() + sut = UserClientsRepository( userClientsAPI: userClientsAPI, userRepository: userRepository, - context: context + userClientsLocalStore: userClientsLocalStore ) } override func tearDown() async throws { - try await super.tearDown() stack = nil userClientsAPI = nil sut = nil @@ -63,95 +64,69 @@ final class UserClientsRepositoryTests: XCTestCase { // MARK: - Tests - func testFetchOrCreateClient() async throws { - // Given + func testFetchOrCreateClient_It_Invokes_Local_Store_Method() async throws { + // Mock - await context.perform { [self] in + let userClient = await context.perform { [self] in let userClient = modelHelper.createSelfClient( id: Scaffolding.userClientID, in: context ) - XCTAssertEqual(userClient.remoteIdentifier, Scaffolding.userClientID) + return userClient } + userClientsLocalStore.fetchOrCreateClientId_MockValue = (userClient, false) + // When - let userClient = try await sut.fetchOrCreateClient( - with: Scaffolding.userClientID + _ = try await sut.fetchOrCreateClient( + id: Scaffolding.userClientID ) // Then - await context.perform { - XCTAssertNotNil(userClient) - } + XCTAssertEqual(userClientsLocalStore.fetchOrCreateClientId_Invocations.count, 1) } - func testUpdatesClient() async throws { - // Given - - let createdClient = try await sut.fetchOrCreateClient( - with: Scaffolding.userClientID - ) + func testUpdateClient_It_Invokes_Local_Store_Method() async throws { + // Mock - let clientID = await context.perform { - createdClient.client.remoteIdentifier! - } + userClientsLocalStore.updateClientIdIsNewClientUserClientInfo_MockMethod = { _, _, _ in } // When try await sut.updateClient( - with: clientID, + id: Scaffolding.userClientID, from: Scaffolding.selfUserClient, - isNewClient: createdClient.isNew + isNewClient: false ) // Then - try await context.perform { [context] in - let updatedClient = try XCTUnwrap(UserClient.fetchExistingUserClient( - with: Scaffolding.userClientID, - in: context - )) - - XCTAssertEqual(updatedClient.remoteIdentifier, Scaffolding.userClientID) - XCTAssertEqual(updatedClient.type, .permanent) - XCTAssertEqual(updatedClient.label, Scaffolding.selfUserClient.label) - XCTAssertEqual(updatedClient.model, Scaffolding.selfUserClient.model) - XCTAssertEqual(updatedClient.deviceClass, .phone) - } + XCTAssertEqual(userClientsLocalStore.updateClientIdIsNewClientUserClientInfo_Invocations.count, 1) } - func testPullSelfClients() async throws { + func testPullSelfClients_It_Invokes_Local_Store_And_User_Repo_Methods() async throws { // Mock - let selfUser = await context.perform { [self] in - let selfUser = modelHelper.createSelfUser(id: UUID(), in: context) - modelHelper.createSelfClient( - id: Scaffolding.userClientID, - in: context - ) - - modelHelper.createSelfClient( + let selfUserClient = await context.perform { [self] in + let selfUserClient = modelHelper.createSelfClient( id: Scaffolding.otherUserClientID, in: context ) - return selfUser - } - - await context.perform { - let selfUserClientsIDs = selfUser.clients.map(\.remoteIdentifier) - XCTAssertTrue(selfUserClientsIDs.contains(Scaffolding.userClientID)) - XCTAssertTrue(selfUserClientsIDs.contains(Scaffolding.otherUserClientID)) + return selfUserClient } userClientsAPI.getSelfClients_MockValue = [ Scaffolding.selfUserClient ] - userRepository.fetchSelfUser_MockValue = selfUser + userClientsLocalStore.fetchOrCreateClientId_MockValue = (selfUserClient, false) + userClientsLocalStore.updateClientIdIsNewClientUserClientInfo_MockMethod = { _, _, _ in } + userClientsLocalStore.deletedSelfClientsNewClients_MockValue = [Scaffolding.userClientID] + userClientsLocalStore.deleteClientId_MockMethod = { _ in } // When @@ -159,47 +134,38 @@ final class UserClientsRepositoryTests: XCTestCase { // Then - try await context.perform { - let selfUserClientsIDs = selfUser.clients.map(\.remoteIdentifier) - XCTAssertTrue(selfUserClientsIDs.contains(Scaffolding.userClientID)) - XCTAssertFalse(selfUserClientsIDs.contains(Scaffolding.otherUserClientID)) // should be deleted + XCTAssertEqual(userClientsLocalStore.fetchOrCreateClientId_Invocations.count, 1) + XCTAssertEqual(userClientsLocalStore.updateClientIdIsNewClientUserClientInfo_Invocations.count, 1) + XCTAssertEqual(userClientsLocalStore.deletedSelfClientsNewClients_Invocations.count, 1) + XCTAssertEqual(userClientsLocalStore.deleteClientId_Invocations.count, 1) + } - let updatedClient = try XCTUnwrap(selfUser.clients.first(where: { $0.remoteIdentifier == Scaffolding.userClientID })) // should be updated + func testDeleteClient_It_Invokes_Local_Store_Method() async throws { + // Mock - XCTAssertEqual(updatedClient.type.rawValue, Scaffolding.selfUserClient.type.rawValue) - XCTAssertEqual(updatedClient.label, Scaffolding.selfUserClient.label) - XCTAssertEqual(updatedClient.model, Scaffolding.selfUserClient.model) - } - } + userClientsLocalStore.deleteClientId_MockMethod = { _ in } - func testDeleteClients() async throws { - // Given + // When - let (newClient, _) = try await sut.fetchOrCreateClient(with: Scaffolding.userClientID) + await sut.deleteClient(id: Scaffolding.userClientID) - let localClient = await context.perform { [context] in - WireDataModel.UserClient.fetchExistingUserClient( - with: Scaffolding.userClientID, - in: context - ) - } + // Then - XCTAssertEqual(localClient, newClient) + XCTAssertEqual(userClientsLocalStore.deleteClientId_Invocations.count, 1) + } + + func testAllSelfUserClientsAreActiveMLSClients_It_Invokes_Local_Store_Method() async { + // Mock + + userClientsLocalStore.allSelfUserClientsAreActiveMLSClients_MockValue = true // When - await sut.deleteClient(with: Scaffolding.userClientID) + _ = await sut.allSelfUserClientsAreActiveMLSClients() // Then - let deletedClient = await context.perform { [context] in - WireDataModel.UserClient.fetchExistingUserClient( - with: Scaffolding.userClientID, - in: context - ) - } - - XCTAssertEqual(deletedClient, nil) + XCTAssertEqual(userClientsLocalStore.allSelfUserClientsAreActiveMLSClients_Invocations.count, 1) } private enum Scaffolding { @@ -215,17 +181,6 @@ final class UserClientsRepositoryTests: XCTestCase { deviceClass: .phone, capabilities: [] ) - - static let selfUserOtherClient = WireAPI.SelfUserClient( - id: otherUserClientID, - type: .permanent, - activationDate: .now, - label: "test", - model: "test", - deviceClass: .phone, - capabilities: [] - ) - } } From 30796bfd4dc50fff15a071079fb9151b1ea9208e Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Thu, 7 Nov 2024 16:30:46 +0100 Subject: [PATCH 06/14] FeatureConfig - add UTS for local store and repo --- .../project.pbxproj | 40 ++- .../FeatureConfigLocalStore.swift | 148 ++++++++ .../FeatureConfigRepository.swift | 332 +++++++++--------- .../FeatureState.swift} | 14 +- .../FeatureConfig/Models/LocalFeature.swift | 25 ++ .../PushSupportedProtocolsUseCase.swift | 4 +- .../FeatureConfigLocalStoreTests.swift | 135 +++++++ .../MockFeatureConfigLocalStoreProtocol.swift | 112 ++++++ .../FeatureConfigRepositoryTests.swift | 168 ++++++--- .../MockFeatureConfigRepositoryProtocol.swift | 36 +- .../PushSupportedProtocolsUseCaseTests.swift | 2 +- 11 files changed, 765 insertions(+), 251 deletions(-) create mode 100644 WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift rename WireDomain/Sources/WireDomain/Repositories/FeatureConfig/{FeatureConfigRepositoryError.swift => Models/FeatureState.swift} (78%) create mode 100644 WireDomain/Sources/WireDomain/Repositories/FeatureConfig/Models/LocalFeature.swift create mode 100644 WireDomain/Tests/WireDomainTests/LocalStores/FeatureConfigLocalStoreTests.swift create mode 100644 WireDomain/Tests/WireDomainTests/LocalStores/Mock/MockFeatureConfigLocalStoreProtocol.swift diff --git a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj index ff995675f5f..21d192e71c8 100644 --- a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj +++ b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj @@ -41,6 +41,11 @@ C96B75652CDCC85B003A85EB /* TeamLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */; }; C96B75672CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75662CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift */; }; C96B75692CDCD488003A85EB /* UserClientsLocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75682CDCD488003A85EB /* UserClientsLocalStore.swift */; }; + C96B756B2CDCF1C8003A85EB /* FeatureConfigLocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B756A2CDCF1C8003A85EB /* FeatureConfigLocalStore.swift */; }; + C96B756D2CDCFA36003A85EB /* FeatureConfigLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B756C2CDCFA36003A85EB /* FeatureConfigLocalStoreTests.swift */; }; + C96B75702CDD0000003A85EB /* MockFeatureConfigLocalStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B756F2CDD0000003A85EB /* MockFeatureConfigLocalStoreProtocol.swift */; }; + C96B75732CDD1333003A85EB /* LocalFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75722CDD1333003A85EB /* LocalFeature.swift */; }; + C96B75752CDD1357003A85EB /* FeatureState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75742CDD1357003A85EB /* FeatureState.swift */; }; C97BCCAA2C98704B004F2D0D /* WireDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 01D0DCA62C1C8C870076CB1C /* WireDomain.framework */; }; C97C013D2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */; }; C97C014B2CB00F92000683C5 /* OneOnOneResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */; }; @@ -68,7 +73,6 @@ C99322D92C986E3A0065E10F /* UpdateEventsRepositoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99322BB2C986E3A0065E10F /* UpdateEventsRepositoryError.swift */; }; C99322DB2C986E3A0065E10F /* FeatureConfigModelMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99322BF2C986E3A0065E10F /* FeatureConfigModelMappings.swift */; }; C99322DC2C986E3A0065E10F /* FeatureConfigRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99322C02C986E3A0065E10F /* FeatureConfigRepository.swift */; }; - C99322DD2C986E3A0065E10F /* FeatureConfigRepositoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99322C12C986E3A0065E10F /* FeatureConfigRepositoryError.swift */; }; C99322DE2C986E3A0065E10F /* ConversationLocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99322C32C986E3A0065E10F /* ConversationLocalStore.swift */; }; C99322DF2C986E3A0065E10F /* ConversationLocalStore+Group.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99322C42C986E3A0065E10F /* ConversationLocalStore+Group.swift */; }; C99322E02C986E3A0065E10F /* ConversationLocalStore+Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99322C52C986E3A0065E10F /* ConversationLocalStore+Metadata.swift */; }; @@ -195,6 +199,11 @@ C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLocalStoreTests.swift; sourceTree = ""; }; C96B75662CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserClientsLocalStoreTests.swift; sourceTree = ""; }; C96B75682CDCD488003A85EB /* UserClientsLocalStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserClientsLocalStore.swift; sourceTree = ""; }; + C96B756A2CDCF1C8003A85EB /* FeatureConfigLocalStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureConfigLocalStore.swift; sourceTree = ""; }; + C96B756C2CDCFA36003A85EB /* FeatureConfigLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureConfigLocalStoreTests.swift; sourceTree = ""; }; + C96B756F2CDD0000003A85EB /* MockFeatureConfigLocalStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFeatureConfigLocalStoreProtocol.swift; sourceTree = ""; }; + C96B75722CDD1333003A85EB /* LocalFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeature.swift; sourceTree = ""; }; + C96B75742CDD1357003A85EB /* FeatureState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureState.swift; sourceTree = ""; }; C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPropertiesDeleteEventProcessorTests.swift; sourceTree = ""; }; C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OneOnOneResolver.swift; path = ../../OneOnOneResolver.swift; sourceTree = ""; }; C97C014F2CB01BDF000683C5 /* OneOnOneResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneResolverTests.swift; sourceTree = ""; }; @@ -221,7 +230,6 @@ C99322BB2C986E3A0065E10F /* UpdateEventsRepositoryError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateEventsRepositoryError.swift; sourceTree = ""; }; C99322BF2C986E3A0065E10F /* FeatureConfigModelMappings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureConfigModelMappings.swift; sourceTree = ""; }; C99322C02C986E3A0065E10F /* FeatureConfigRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureConfigRepository.swift; sourceTree = ""; }; - C99322C12C986E3A0065E10F /* FeatureConfigRepositoryError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureConfigRepositoryError.swift; sourceTree = ""; }; C99322C32C986E3A0065E10F /* ConversationLocalStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationLocalStore.swift; sourceTree = ""; }; C99322C42C986E3A0065E10F /* ConversationLocalStore+Group.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConversationLocalStore+Group.swift"; sourceTree = ""; }; C99322C52C986E3A0065E10F /* ConversationLocalStore+Metadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConversationLocalStore+Metadata.swift"; sourceTree = ""; }; @@ -499,15 +507,34 @@ C96B755B2CDBB149003A85EB /* LocalStores */ = { isa = PBXGroup; children = ( + C96B756E2CDCFFEB003A85EB /* Mock */, C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */, C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */, C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */, C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */, C96B75662CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift */, + C96B756C2CDCFA36003A85EB /* FeatureConfigLocalStoreTests.swift */, ); path = LocalStores; sourceTree = ""; }; + C96B756E2CDCFFEB003A85EB /* Mock */ = { + isa = PBXGroup; + children = ( + C96B756F2CDD0000003A85EB /* MockFeatureConfigLocalStoreProtocol.swift */, + ); + path = Mock; + sourceTree = ""; + }; + C96B75712CDD1326003A85EB /* Models */ = { + isa = PBXGroup; + children = ( + C96B75722CDD1333003A85EB /* LocalFeature.swift */, + C96B75742CDD1357003A85EB /* FeatureState.swift */, + ); + path = Models; + sourceTree = ""; + }; C97C01582CB40010000683C5 /* FederationEventProcessor */ = { isa = PBXGroup; children = ( @@ -592,9 +619,10 @@ C99322C22C986E3A0065E10F /* FeatureConfig */ = { isa = PBXGroup; children = ( + C96B75712CDD1326003A85EB /* Models */, C99322BF2C986E3A0065E10F /* FeatureConfigModelMappings.swift */, C99322C02C986E3A0065E10F /* FeatureConfigRepository.swift */, - C99322C12C986E3A0065E10F /* FeatureConfigRepositoryError.swift */, + C96B756A2CDCF1C8003A85EB /* FeatureConfigLocalStore.swift */, ); path = FeatureConfig; sourceTree = ""; @@ -1014,7 +1042,6 @@ files = ( C99322D22C986E3A0065E10F /* TeamRepository.swift in Sources */, EEAD0A042C46775700CC8658 /* ConversationMLSWelcomeEventProcessor.swift in Sources */, - C99322DD2C986E3A0065E10F /* FeatureConfigRepositoryError.swift in Sources */, C99322E72C986E3A0065E10F /* ConnectionsRepositoryError.swift in Sources */, C98433D02CC26A1D009723D4 /* MLSProvider.swift in Sources */, C96B75692CDCD488003A85EB /* UserClientsLocalStore.swift in Sources */, @@ -1047,8 +1074,10 @@ EEAD09FC2C46773900CC8658 /* ConversationMemberLeaveEventProcessor.swift in Sources */, EE57A7082C2A8B740096F242 /* ProteusMessageDecryptorError.swift in Sources */, EEAD0A1A2C46A92000CC8658 /* UserClientRemoveEventProcessor.swift in Sources */, + C96B756B2CDCF1C8003A85EB /* FeatureConfigLocalStore.swift in Sources */, EEAD0A0A2C46776B00CC8658 /* ConversationReceiptModeUpdateEventProcessor.swift in Sources */, C99322D62C986E3A0065E10F /* UserRepository.swift in Sources */, + C96B75732CDD1333003A85EB /* LocalFeature.swift in Sources */, EEAD0A002C46774900CC8658 /* ConversationMessageTimerUpdateEventProcessor.swift in Sources */, EEAD0A182C46A88D00CC8658 /* UserClientAddEventProcessor.swift in Sources */, C99322DC2C986E3A0065E10F /* FeatureConfigRepository.swift in Sources */, @@ -1085,6 +1114,7 @@ C96B75482CDBA10F003A85EB /* SystemMessage.swift in Sources */, EE57A6FE2C298F380096F242 /* UpdateEventDecryptor.swift in Sources */, C99322E92C986E3A0065E10F /* ConversationLabelsRepositoryError.swift in Sources */, + C96B75752CDD1357003A85EB /* FeatureState.swift in Sources */, EEAD0A0C2C46777200CC8658 /* ConversationRenameEventProcessor.swift in Sources */, C99322DB2C986E3A0065E10F /* FeatureConfigModelMappings.swift in Sources */, C97C01A52CB80F54000683C5 /* UserClientsRepository.swift in Sources */, @@ -1129,6 +1159,7 @@ C97C01502CB01BDF000683C5 /* OneOnOneResolverTests.swift in Sources */, C96B75612CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift in Sources */, C9C8FDD22C9DBE0E00702B91 /* UserClientAddEventProcessorTests.swift in Sources */, + C96B756D2CDCFA36003A85EB /* FeatureConfigLocalStoreTests.swift in Sources */, C9C8FDD12C9DBE0E00702B91 /* TeamMemberUpdateEventProcessorTests.swift in Sources */, C97C01542CB04626000683C5 /* UserDeleteEventProcessorTests.swift in Sources */, C96B75672CDCD3BC003A85EB /* UserClientsLocalStoreTests.swift in Sources */, @@ -1137,6 +1168,7 @@ C97C01592CB40010000683C5 /* FederationConnectionRemovedEventProcessorTests.swift in Sources */, C97C01AC2CB92D47000683C5 /* UserLegalholdEnableEventProcessorTests.swift in Sources */, C96B75652CDCC85B003A85EB /* TeamLocalStoreTests.swift in Sources */, + C96B75702CDD0000003A85EB /* MockFeatureConfigLocalStoreProtocol.swift in Sources */, C97C015A2CB40010000683C5 /* FederationDeleteEventProcessorTests.swift in Sources */, C9C8FDD42C9DBE0E00702B91 /* UserLegalholdRequestEventProcessorTests.swift in Sources */, C96B755D2CDBB176003A85EB /* ConversationLocalStoreTests.swift in Sources */, diff --git a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift new file mode 100644 index 00000000000..b8c12abe4cc --- /dev/null +++ b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift @@ -0,0 +1,148 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import CoreData +import WireDataModel + +protocol FeatureConfigLocalStoreProtocol { + + func fetchFeature( + name: Feature.Name + ) async throws -> Feature + + func storeFeature( + needsNotifyUser: Bool, + feature: Feature + ) async + + func featureNeedsNotifyUser( + feature: Feature + ) async -> Bool + + func storeFeature( + name: Feature.Name, + isEnabled: Bool, + config: (any Codable)? + ) async + + func featureConfig( + feature: Feature + ) async -> (status: Feature.Status, config: Data?) + +} + +final class FeatureConfigLocalStore: FeatureConfigLocalStoreProtocol { + + // MARK: - Error + + enum Error: Swift.Error { + case failedToFetchFeatureLocally + } + + // MARK: - Properties + + private let context: NSManagedObjectContext + + // MARK: - Object lifecycle + + init( + context: NSManagedObjectContext + ) { + self.context = context + } + + // MARK: - Public + + public func fetchFeature( + name: Feature.Name + ) async throws -> Feature { + try await context.perform { [context] in + guard let feature = Feature.fetch( + name: name, + context: context + ) else { + throw Error.failedToFetchFeatureLocally + } + + return feature + } + } + + public func storeFeature( + needsNotifyUser: Bool, + feature: Feature + ) async { + await context.perform { + feature.needsToNotifyUser = needsNotifyUser + } + } + + public func featureConfig( + feature: Feature + ) async -> (status: Feature.Status, config: Data?) { + await context.perform { + (feature.status, feature.config) + } + } + + func featureNeedsNotifyUser( + feature: Feature + ) async -> Bool { + await context.perform { + feature.needsToNotifyUser + } + } + + public func storeFeature( + name: Feature.Name, + isEnabled: Bool, + config: (any Codable)? = nil + ) async { + await context.perform { [context] in + if let config { + let encoder = JSONEncoder() + + do { + let data = try encoder.encode(config) + + Feature.updateOrCreate( + havingName: name, + in: context + ) { + $0.status = isEnabled ? .enabled : .disabled + $0.config = data + } + + } catch { + WireLogger.featureConfigs.error( + "Failed to encode \(String(describing: config.self)) : \(error)" + ) + } + + } else { + Feature.updateOrCreate( + havingName: name, + in: context + ) { + $0.status = isEnabled ? .enabled : .disabled + } + } + } + } + +} diff --git a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigRepository.swift b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigRepository.swift index cee3f0a8693..21f7ed9f4ff 100644 --- a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigRepository.swift +++ b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigRepository.swift @@ -16,8 +16,6 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import Foundation - import Combine import WireAPI import WireDataModel @@ -48,25 +46,35 @@ protocol FeatureConfigRepositoryProtocol { /// - Parameter type: The type of config to retrieve. /// - Returns: A `LocalFeature` object with a status and a config (if any). - func fetchFeatureConfig(with name: Feature.Name, type: T.Type) async throws -> LocalFeature + func fetchFeatureConfig( + name: Feature.Name, + type: T.Type + ) async throws -> LocalFeature /// Updates a feature config locally. /// /// - Parameter featureConfig: The feature config to update. - func updateFeatureConfig(_ featureConfig: FeatureConfig) async throws + func updateFeatureConfig( + _ featureConfig: FeatureConfig + ) async throws /// Fetches a flag indicating whether the user should be notified of a given feature. /// - Parameter name: The feature name. /// - Returns: `true` if user should be notified. - func fetchNeedsToNotifyUser(for name: Feature.Name) async throws -> Bool + func needsToNotifyUser( + name: Feature.Name + ) async throws -> Bool /// Stores a flag indicating whether the user should be notified of a given feature. /// - Parameter notifyUser: Whether the user should be notified for a given feature. /// - Parameter name: The name of the feature to set the flag for. - func storeNeedsToNotifyUser(_ notifyUser: Bool, forFeatureName name: Feature.Name) async throws + func storeFeatureNeedsToNotifyUser( + _ notifyUser: Bool, + name: Feature.Name + ) async throws } @@ -75,9 +83,7 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { // MARK: - Properties private let featureConfigsAPI: any FeatureConfigsAPI - // swiftlint:disable:next todo_requires_jira_link - // TODO: create FeatureConfigLocalStore - private let context: NSManagedObjectContext + private let featureConfigLocalStore: any FeatureConfigLocalStoreProtocol private let logger = WireLogger.featureConfigs private let featureStateSubject = PassthroughSubject() @@ -85,10 +91,10 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { init( featureConfigsAPI: any FeatureConfigsAPI, - context: NSManagedObjectContext + featureConfigLocalStore: any FeatureConfigLocalStoreProtocol ) { self.featureConfigsAPI = featureConfigsAPI - self.context = context + self.featureConfigLocalStore = featureConfigLocalStore } // MARK: - Public @@ -97,8 +103,15 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { let featureConfigs = try await featureConfigsAPI.getFeatureConfigs() for featureConfig in featureConfigs { - await storeFeatureConfig(featureConfig) - await sendFeatureState(for: featureConfig) + if let featureConfigInfo = getFeatureConfigInfo(featureConfig) { + await featureConfigLocalStore.storeFeature( + name: featureConfigInfo.name, + isEnabled: featureConfigInfo.isEnabled, + config: featureConfigInfo.config + ) + + await sendFeatureState(for: featureConfig) + } } } @@ -106,37 +119,67 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { featureStateSubject.eraseToAnyPublisher() } - func fetchFeatureConfig(with name: Feature.Name, type: T.Type) async throws -> LocalFeature { - try await context.perform { [self] in - let feature = try fetchFeature(withName: name) + func fetchFeatureConfig( + name: Feature.Name, + type: T.Type + ) async throws -> LocalFeature { + let feature = try await featureConfigLocalStore.fetchFeature( + name: name + ) - if let config = feature.config { - let decoder = JSONDecoder() - let config = try decoder.decode(type, from: config) + let featureConfig = await featureConfigLocalStore.featureConfig(feature: feature) - return LocalFeature(status: feature.status, config: config) - } + if let config = featureConfig.config { + let decoder = JSONDecoder() + let config = try decoder.decode(type, from: config) - return LocalFeature(status: feature.status, config: nil) + return LocalFeature(status: featureConfig.status, config: config) } + + return LocalFeature(status: featureConfig.status, config: nil) } - func fetchNeedsToNotifyUser(for name: Feature.Name) async throws -> Bool { - try await context.perform { [self] in - let feature = try fetchFeature(withName: name) - return feature.needsToNotifyUser - } + func needsToNotifyUser( + name: Feature.Name + ) async throws -> Bool { + let feature = try await featureConfigLocalStore.fetchFeature( + name: name + ) + + return await featureConfigLocalStore.featureNeedsNotifyUser( + feature: feature + ) } - func storeNeedsToNotifyUser(_ notifyUser: Bool, forFeatureName name: Feature.Name) async throws { - try await context.perform { [self] in - let feature = try fetchFeature(withName: name) - feature.needsToNotifyUser = notifyUser - } + func storeFeatureNeedsToNotifyUser( + _ notifyUser: Bool, + name: Feature.Name + ) async throws { + let feature = try await featureConfigLocalStore.fetchFeature( + name: name + ) + + await featureConfigLocalStore.storeFeature( + needsNotifyUser: notifyUser, + feature: feature + ) } - func updateFeatureConfig(_ featureConfig: FeatureConfig) async throws { - await storeFeatureConfig(featureConfig) + func updateFeatureConfig( + _ featureConfig: FeatureConfig + ) async throws { + guard let featureConfigInfo = getFeatureConfigInfo( + featureConfig + ) else { + return + } + + await featureConfigLocalStore.storeFeature( + name: featureConfigInfo.name, + isEnabled: featureConfigInfo.isEnabled, + config: featureConfigInfo.config + ) + await sendFeatureState(for: featureConfig) } @@ -150,21 +193,13 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { featureStateSubject.send(featureState) } - private func fetchFeature(withName name: Feature.Name) throws -> Feature { - guard let feature = Feature.fetch(name: name, context: context) else { - throw FeatureConfigRepositoryError.failedToFetchFeatureLocally - } - - return feature - } - private func getFeatureState(forFeatureConfig config: FeatureConfig) async throws -> FeatureState? { switch config { case .appLock(let appLockFeatureConfig): return FeatureState( name: .appLock, - status: appLockFeatureConfig.status, + isEnabled: appLockFeatureConfig.status == .enabled, shouldNotifyUser: false ) @@ -172,25 +207,25 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { return FeatureState( name: .classifiedDomains, - status: classifiedDomainsFeatureConfig.status, + isEnabled: classifiedDomainsFeatureConfig.status == .enabled, shouldNotifyUser: false ) case .conferenceCalling(let conferenceCallingFeatureConfig): - let needsToNotifyUser = try await fetchNeedsToNotifyUser(for: .conferenceCalling) + let needsToNotifyUser = try await needsToNotifyUser(name: .conferenceCalling) return FeatureState( name: .conferenceCalling, - status: conferenceCallingFeatureConfig.status, + isEnabled: conferenceCallingFeatureConfig.status == .enabled, shouldNotifyUser: needsToNotifyUser ) case .conversationGuestLinks(let conversationGuestLinksFeatureConfig): - let needsToNotifyUser = try await fetchNeedsToNotifyUser(for: .conversationGuestLinks) + let needsToNotifyUser = try await needsToNotifyUser(name: .conversationGuestLinks) return FeatureState( name: .conversationGuestLinks, - status: conversationGuestLinksFeatureConfig.status, + isEnabled: conversationGuestLinksFeatureConfig.status == .enabled, shouldNotifyUser: needsToNotifyUser ) @@ -198,7 +233,7 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { return FeatureState( name: .digitalSignature, - status: digitalSignatureFeatureConfig.status, + isEnabled: digitalSignatureFeatureConfig.status == .enabled, shouldNotifyUser: false ) @@ -206,16 +241,16 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { return FeatureState( name: .e2ei, - status: endToEndIdentityFeatureConfig.status, + isEnabled: endToEndIdentityFeatureConfig.status == .enabled, shouldNotifyUser: false ) case .fileSharing(let fileSharingFeatureConfig): - let needsToNotifyUser = try await fetchNeedsToNotifyUser(for: .fileSharing) + let needsToNotifyUser = try await needsToNotifyUser(name: .fileSharing) return FeatureState( name: .fileSharing, - status: fileSharingFeatureConfig.status, + isEnabled: fileSharingFeatureConfig.status == .enabled, shouldNotifyUser: needsToNotifyUser ) @@ -223,7 +258,7 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { return FeatureState( name: .mls, - status: mlsFeatureConfig.status, + isEnabled: mlsFeatureConfig.status == .enabled, shouldNotifyUser: false ) @@ -231,16 +266,16 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { return FeatureState( name: .mlsMigration, - status: mLSMigrationFeatureConfig.status, + isEnabled: mLSMigrationFeatureConfig.status == .enabled, shouldNotifyUser: false ) case .selfDeletingMessages(let selfDeletingMessagesFeatureConfig): - let needsToNotifyUser = try await fetchNeedsToNotifyUser(for: .selfDeletingMessages) + let needsToNotifyUser = try await needsToNotifyUser(name: .selfDeletingMessages) return FeatureState( name: .selfDeletingMessages, - status: selfDeletingMessagesFeatureConfig.status, + isEnabled: selfDeletingMessagesFeatureConfig.status == .enabled, shouldNotifyUser: needsToNotifyUser ) @@ -253,144 +288,97 @@ final class FeatureConfigRepository: FeatureConfigRepositoryProtocol { } } - private func storeFeatureConfig(_ featureConfig: FeatureConfig) async { - await context.perform { [self] in - - switch featureConfig { - case .appLock(let appLockFeatureConfig): - - updateOrCreate( - featureName: .appLock, - isEnabled: appLockFeatureConfig.status == .enabled, - config: appLockFeatureConfig.toDomainModel() - ) - - case .classifiedDomains(let classifiedDomainsFeatureConfig): + private func getFeatureConfigInfo( + _ featureConfig: FeatureConfig + ) -> (name: Feature.Name, isEnabled: Bool, config: (any Codable)?)? { + switch featureConfig { + case .appLock(let appLockFeatureConfig): - updateOrCreate( - featureName: .classifiedDomains, - isEnabled: classifiedDomainsFeatureConfig.status == .enabled, - config: classifiedDomainsFeatureConfig.toDomainModel() - ) + return ( + .appLock, + appLockFeatureConfig.status == .enabled, + appLockFeatureConfig.toDomainModel() + ) - case .conferenceCalling(let conferenceCallingFeatureConfig): + case .classifiedDomains(let classifiedDomainsFeatureConfig): - updateOrCreate( - featureName: .conferenceCalling, - isEnabled: conferenceCallingFeatureConfig.status == .enabled, - config: conferenceCallingFeatureConfig.toDomainModel() /// always nil for api < v6 - ) + return ( + .classifiedDomains, + classifiedDomainsFeatureConfig.status == .enabled, + classifiedDomainsFeatureConfig.toDomainModel() + ) - case .conversationGuestLinks(let conversationGuestLinksFeatureConfig): + case .conferenceCalling(let conferenceCallingFeatureConfig): - updateOrCreate( - featureName: .conversationGuestLinks, - isEnabled: conversationGuestLinksFeatureConfig.status == .enabled - ) + return ( + .conferenceCalling, + conferenceCallingFeatureConfig.status == .enabled, + conferenceCallingFeatureConfig.toDomainModel() + ) - case .digitalSignature(let digitalSignatureFeatureConfig): + case .conversationGuestLinks(let conversationGuestLinksFeatureConfig): - updateOrCreate( - featureName: .digitalSignature, - isEnabled: digitalSignatureFeatureConfig.status == .enabled - ) + return ( + .conversationGuestLinks, + conversationGuestLinksFeatureConfig.status == .enabled, + nil + ) - case .endToEndIdentity(let endToEndIdentityFeatureConfig): + case .digitalSignature(let digitalSignatureFeatureConfig): - updateOrCreate( - featureName: .e2ei, - isEnabled: endToEndIdentityFeatureConfig.status == .enabled, - config: endToEndIdentityFeatureConfig.toDomainModel() - ) + return ( + .digitalSignature, + digitalSignatureFeatureConfig.status == .enabled, + nil + ) - case .fileSharing(let fileSharingFeatureConfig): + case .endToEndIdentity(let endToEndIdentityFeatureConfig): - updateOrCreate( - featureName: .fileSharing, - isEnabled: fileSharingFeatureConfig.status == .enabled - ) + return ( + .e2ei, + endToEndIdentityFeatureConfig.status == .enabled, + nil + ) - case .mls(let mlsFeatureConfig): + case .fileSharing(let fileSharingFeatureConfig): - updateOrCreate( - featureName: .mls, - isEnabled: mlsFeatureConfig.status == .enabled, - config: mlsFeatureConfig.toDomainModel() - ) + return ( + .fileSharing, + fileSharingFeatureConfig.status == .enabled, + nil + ) - case .mlsMigration(let mLSMigrationFeatureConfig): + case .mls(let mlsFeatureConfig): - updateOrCreate( - featureName: .mlsMigration, - isEnabled: mLSMigrationFeatureConfig.status == .enabled, - config: mLSMigrationFeatureConfig.toDomainModel() - ) + return ( + .mls, + mlsFeatureConfig.status == .enabled, + mlsFeatureConfig.toDomainModel() + ) - case .selfDeletingMessages(let selfDeletingMessagesFeatureConfig): + case .mlsMigration(let mLSMigrationFeatureConfig): - updateOrCreate( - featureName: .selfDeletingMessages, - isEnabled: selfDeletingMessagesFeatureConfig.status == .enabled, - config: selfDeletingMessagesFeatureConfig.toDomainModel() - ) + return ( + .mlsMigration, + mLSMigrationFeatureConfig.status == .enabled, + mLSMigrationFeatureConfig.toDomainModel() + ) - case .unknown(let featureName): + case .selfDeletingMessages(let selfDeletingMessagesFeatureConfig): - logger.warn( - "Unknown feature name: \(featureName)" - ) - } - } - } + return ( + .selfDeletingMessages, + selfDeletingMessagesFeatureConfig.status == .enabled, + selfDeletingMessagesFeatureConfig.toDomainModel() + ) - private func updateOrCreate( - featureName: Feature.Name, - isEnabled: Bool, - config: (any Codable)? = nil - ) { - if let config { - let encoder = JSONEncoder() - - do { - let data = try encoder.encode(config) - - Feature.updateOrCreate( - havingName: featureName, - in: context - ) { - $0.status = isEnabled ? .enabled : .disabled - $0.config = data - } - - } catch { - logger.error( - "Failed to encode \(String(describing: config.self)) : \(error)" - ) - } + case .unknown(let featureName): + logger.warn( + "Unknown feature name: \(featureName)" + ) - } else { - Feature.updateOrCreate( - havingName: featureName, - in: context - ) { - $0.status = isEnabled ? .enabled : .disabled - } + return nil } } } - -/// A feature fetched locally - -struct LocalFeature { - let status: Feature.Status - let config: T? -} - -/// The state of the feature - -struct FeatureState { - let name: Feature.Name - let status: FeatureConfigStatus - let shouldNotifyUser: Bool -} diff --git a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigRepositoryError.swift b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/Models/FeatureState.swift similarity index 78% rename from WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigRepositoryError.swift rename to WireDomain/Sources/WireDomain/Repositories/FeatureConfig/Models/FeatureState.swift index 0ea8390e769..8a50b6ce260 100644 --- a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigRepositoryError.swift +++ b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/Models/FeatureState.swift @@ -16,14 +16,12 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import Foundation +import WireDataModel -/// Errors originating from `FeatureConfigRepository`. - -enum FeatureConfigRepositoryError: Error { - - /// Unable to fetch feature locally - - case failedToFetchFeatureLocally +/// The state of the feature +struct FeatureState { + let name: Feature.Name + let isEnabled: Bool + let shouldNotifyUser: Bool } diff --git a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/Models/LocalFeature.swift b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/Models/LocalFeature.swift new file mode 100644 index 00000000000..a090534cf91 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/Models/LocalFeature.swift @@ -0,0 +1,25 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireDataModel + +/// A feature fetched locally +struct LocalFeature { + let status: Feature.Status + let config: T? +} diff --git a/WireDomain/Sources/WireDomain/UseCases/PushSupportedProtocolsUseCase.swift b/WireDomain/Sources/WireDomain/UseCases/PushSupportedProtocolsUseCase.swift index 2e012b55442..0f0d206f8a3 100644 --- a/WireDomain/Sources/WireDomain/UseCases/PushSupportedProtocolsUseCase.swift +++ b/WireDomain/Sources/WireDomain/UseCases/PushSupportedProtocolsUseCase.swift @@ -91,7 +91,7 @@ public struct PushSupportedProtocolsUseCase { private func remotelySupportedProtocols() async -> Set { let mlsFeature = try? await featureConfigRepository.fetchFeatureConfig( - with: .mls, + name: .mls, type: Feature.MLS.Config.self ) @@ -118,7 +118,7 @@ public struct PushSupportedProtocolsUseCase { private func currentMigrationState() async -> ProteusToMLSMigrationState { let mlsMigrationFeature = try? await featureConfigRepository.fetchFeatureConfig( - with: .mlsMigration, + name: .mlsMigration, type: Feature.MLSMigration.Config.self ) diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/FeatureConfigLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/FeatureConfigLocalStoreTests.swift new file mode 100644 index 00000000000..adc427439e2 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/LocalStores/FeatureConfigLocalStoreTests.swift @@ -0,0 +1,135 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireDataModel +import WireDataModelSupport +@testable import WireDomain +import XCTest + +final class FeatureConfigLocalStoreTests: XCTestCase { + + private var sut: FeatureConfigLocalStore! + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + coreDataStackHelper = CoreDataStackHelper() + modelHelper = ModelHelper() + stack = try await coreDataStackHelper.createStack() + sut = FeatureConfigLocalStore(context: context) + } + + override func tearDown() async throws { + stack = nil + sut = nil + try coreDataStackHelper.cleanupDirectory() + coreDataStackHelper = nil + modelHelper = nil + } + + // MARK: - Tests + + func testStoreFeature_It_Stores_Feature_Locally() async throws { + // When + + await sut.storeFeature( + name: .appLock, + isEnabled: true, + config: Scaffolding.featureConfig + ) + + // Then + + await context.perform { [context] in + let feature = Feature.fetch(name: .appLock, context: context) + XCTAssertNotNil(feature) + } + } + + func testFeatureNeedsNotifyUser_It_Returns_True() async throws { + // Given + + let feature = try await context.perform { [context] in + Feature.updateOrCreate( + havingName: .conversationGuestLinks, + in: context + ) { $0.status = .enabled } + + let feature = Feature.fetch(name: .conversationGuestLinks, context: context) + + return try XCTUnwrap(feature) + } + + // When + + await sut.storeFeature( + needsNotifyUser: true, + feature: feature + ) + + // Then + + let result = await sut.featureNeedsNotifyUser(feature: feature) + XCTAssertEqual(result, true) + } + + func testFetchFeature_It_Retrieves_Feature_With_Correct_Config() async throws { + // Given + + try await context.perform { [context] in + let config = try JSONEncoder().encode(Scaffolding.featureConfig) + Feature.updateOrCreate( + havingName: .appLock, + in: context + ) { + $0.status = .enabled + $0.config = config + } + } + + // When + + let feature = try await sut.fetchFeature( + name: .appLock + ) + + // Then + + try await context.perform { + XCTAssertEqual(feature.name, .appLock) + let appLockConfig = try JSONDecoder().decode( + Feature.AppLock.Config.self, + from: try XCTUnwrap(feature.config) + ) + XCTAssertEqual(Scaffolding.featureConfig, appLockConfig) + } + } + + private enum Scaffolding { + nonisolated(unsafe) static let featureConfig = Feature.AppLock.Config( + enforceAppLock: true, + inactivityTimeoutSecs: .min + ) + } + +} diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/Mock/MockFeatureConfigLocalStoreProtocol.swift b/WireDomain/Tests/WireDomainTests/LocalStores/Mock/MockFeatureConfigLocalStoreProtocol.swift new file mode 100644 index 00000000000..bc40e240b55 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/LocalStores/Mock/MockFeatureConfigLocalStoreProtocol.swift @@ -0,0 +1,112 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireDataModel +@testable import WireDomain + +class MockFeatureConfigLocalStoreProtocol: FeatureConfigLocalStoreProtocol { + + // MARK: - Life cycle + + // MARK: - fetchFeature + + var fetchFeatureName_Invocations: [Feature.Name] = [] + var fetchFeatureName_MockError: Error? + var fetchFeatureName_MockMethod: ((Feature.Name) async throws -> Feature)? + var fetchFeatureName_MockValue: Feature? + + func fetchFeature(name: Feature.Name) async throws -> Feature { + fetchFeatureName_Invocations.append(name) + + if let error = fetchFeatureName_MockError { + throw error + } + + if let mock = fetchFeatureName_MockMethod { + return try await mock(name) + } else if let mock = fetchFeatureName_MockValue { + return mock + } else { + fatalError("no mock for `fetchFeatureName`") + } + } + + // MARK: - storeFeature + + var storeFeatureNeedsNotifyUserFeature_Invocations: [(needsNotifyUser: Bool, feature: Feature)] = [] + var storeFeatureNeedsNotifyUserFeature_MockMethod: ((Bool, Feature) async -> Void)? + + func storeFeature(needsNotifyUser: Bool, feature: Feature) async { + storeFeatureNeedsNotifyUserFeature_Invocations.append((needsNotifyUser: needsNotifyUser, feature: feature)) + + guard let mock = storeFeatureNeedsNotifyUserFeature_MockMethod else { + fatalError("no mock for `storeFeatureNeedsNotifyUserFeature`") + } + + await mock(needsNotifyUser, feature) + } + + // MARK: - featureNeedsNotifyUser + + var featureNeedsNotifyUserFeature_Invocations: [Feature] = [] + var featureNeedsNotifyUserFeature_MockMethod: ((Feature) async -> Bool)? + var featureNeedsNotifyUserFeature_MockValue: Bool? + + func featureNeedsNotifyUser(feature: Feature) async -> Bool { + featureNeedsNotifyUserFeature_Invocations.append(feature) + + if let mock = featureNeedsNotifyUserFeature_MockMethod { + return await mock(feature) + } else if let mock = featureNeedsNotifyUserFeature_MockValue { + return mock + } else { + fatalError("no mock for `featureNeedsNotifyUserFeature`") + } + } + + // MARK: - storeFeature + + var storeFeatureNameIsEnabledConfig_Invocations: [(name: Feature.Name, isEnabled: Bool, config: (any Codable)?)] = [] + var storeFeatureNameIsEnabledConfig_MockMethod: ((Feature.Name, Bool, (any Codable)?) async -> Void)? + + func storeFeature(name: Feature.Name, isEnabled: Bool, config: (any Codable)?) async { + storeFeatureNameIsEnabledConfig_Invocations.append((name: name, isEnabled: isEnabled, config: config)) + + guard let mock = storeFeatureNameIsEnabledConfig_MockMethod else { + fatalError("no mock for `storeFeatureNameIsEnabledConfig`") + } + + await mock(name, isEnabled, config) + } + + // MARK: - featureConfig + + var featureConfigFeature_Invocations: [Feature] = [] + var featureConfigFeature_MockMethod: ((Feature) async -> (status: Feature.Status, config: Data?))? + + func featureConfig(feature: Feature) async -> (status: Feature.Status, config: Data?) { + featureConfigFeature_Invocations.append(feature) + + guard let mock = featureConfigFeature_MockMethod else { + fatal("no mock for `featureConfigFeature`") + } + + return await mock(feature) + } + +} diff --git a/WireDomain/Tests/WireDomainTests/Repositories/FeatureConfigRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/FeatureConfigRepositoryTests.swift index 03b898d5b8b..b479283ed9a 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/FeatureConfigRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/FeatureConfigRepositoryTests.swift @@ -22,16 +22,17 @@ import WireAPISupport import WireDataModel import WireDataModelSupport @testable import WireDomain +import WireDomainSupport import XCTest final class FeatureConfigRepositoryTests: XCTestCase { private var sut: FeatureConfigRepository! private var featureConfigsAPI: MockFeatureConfigsAPI! + private var featureConfigLocalStore: MockFeatureConfigLocalStoreProtocol! private var stack: CoreDataStack! private var coreDataStackHelper: CoreDataStackHelper! private var modelHelper: ModelHelper! - private var subscription: AnyCancellable? private var context: NSManagedObjectContext { @@ -39,17 +40,19 @@ final class FeatureConfigRepositoryTests: XCTestCase { } override func setUp() async throws { - try await super.setUp() coreDataStackHelper = CoreDataStackHelper() modelHelper = ModelHelper() stack = try await coreDataStackHelper.createStack() featureConfigsAPI = MockFeatureConfigsAPI() - sut = FeatureConfigRepository(featureConfigsAPI: featureConfigsAPI, - context: context) + featureConfigLocalStore = MockFeatureConfigLocalStoreProtocol() + + sut = FeatureConfigRepository( + featureConfigsAPI: featureConfigsAPI, + featureConfigLocalStore: featureConfigLocalStore + ) } override func tearDown() async throws { - try await super.tearDown() stack = nil featureConfigsAPI = nil sut = nil @@ -60,10 +63,27 @@ final class FeatureConfigRepositoryTests: XCTestCase { // MARK: - Tests - func testPullFeatureConfigs_When_Configs_Are_Pulled_Configs_Then_Exists_Locally() async throws { - // Given + func testPullFeatureConfigs_It_Invokes_Local_Store_Methods() async throws { + // Mock + + let feature = await context.perform { [context] in + Feature.updateOrCreate( + havingName: .conversationGuestLinks, + in: context + ) { $0.status = .enabled } + + let feature = Feature.fetch( + name: .conversationGuestLinks, + context: context + ) + + return feature + } featureConfigsAPI.getFeatureConfigs_MockValue = Scaffolding.featureConfigs + featureConfigLocalStore.storeFeatureNameIsEnabledConfig_MockMethod = { _, _, _ in } + featureConfigLocalStore.fetchFeatureName_MockValue = feature + featureConfigLocalStore.featureNeedsNotifyUserFeature_MockValue = true // When @@ -71,59 +91,106 @@ final class FeatureConfigRepositoryTests: XCTestCase { // Then - let features = await context.perform { [self] in - let allFeatures = Feature.Name.allCases.compactMap { - Feature.fetch(name: $0, context: context) - } - - return allFeatures - } - - XCTAssertEqual(features.count, Scaffolding.featureConfigs.count) + XCTAssertEqual(featureConfigLocalStore.fetchFeatureName_Invocations.count, 4) + XCTAssertEqual(featureConfigLocalStore.featureNeedsNotifyUserFeature_Invocations.count, 4) + XCTAssertEqual(featureConfigLocalStore.storeFeatureNameIsEnabledConfig_Invocations.count, Scaffolding.featureConfigs.count) } - func testNeedsToNotifyUser_When_Flag_Set_To_True_Stored_Value_Returns_True() async throws { - // Given + func testStoreNeedsToNotifyUser_It_Invokes_Local_Store_Methods() async throws { + // Mock - featureConfigsAPI.getFeatureConfigs_MockValue = Scaffolding.featureConfigs + let feature = await context.perform { [context] in + Feature.updateOrCreate( + havingName: .conversationGuestLinks, + in: context + ) { $0.status = .enabled } - await context.perform { [context] in - Feature.updateOrCreate(havingName: .conversationGuestLinks, in: context) { - $0.status = .enabled - } + let feature = Feature.fetch( + name: .conversationGuestLinks, + context: context + ) + + return feature } + featureConfigLocalStore.fetchFeatureName_MockValue = feature + featureConfigLocalStore.storeFeatureNeedsNotifyUserFeature_MockMethod = { _, _ in } + // When - try await sut.storeNeedsToNotifyUser(true, forFeatureName: .conversationGuestLinks) + try await sut.storeFeatureNeedsToNotifyUser( + true, + name: .conversationGuestLinks + ) // Then - let result = try await sut.fetchNeedsToNotifyUser(for: .conversationGuestLinks) - XCTAssertEqual(result, true) + XCTAssertEqual(featureConfigLocalStore.fetchFeatureName_Invocations.count, 1) + XCTAssertEqual(featureConfigLocalStore.storeFeatureNeedsNotifyUserFeature_Invocations.count, 1) } - func testFetchFeatureConfig_When_Feature_Is_Stored_Locally_Feature_Is_Successfully_Retrieved() async throws { - // Given + func testFetchFeatureConfig_It_Invokes_Local_Store_Methods_And_Retrieves_Correct_Config() async throws { + // Mock - featureConfigsAPI.getFeatureConfigs_MockValue = Scaffolding.featureConfigs + let (feature, config) = try await context.perform { [context] in + let config = try JSONEncoder().encode(Scaffolding.featureConfig) + + Feature.updateOrCreate( + havingName: .appLock, + in: context + ) { + $0.status = .enabled + $0.config = config + } + + let feature = Feature.fetch( + name: .appLock, + context: context + ) + + return (feature, config) + } + + featureConfigLocalStore.fetchFeatureName_MockValue = feature + featureConfigLocalStore.featureConfigFeature_MockMethod = { _ in (.enabled, config) } // When - try await sut.pullFeatureConfigs() + let localFeature = try await sut.fetchFeatureConfig( + name: .appLock, + type: Feature.AppLock.Config.self + ) // Then - let feature = try await sut.fetchFeatureConfig(with: .appLock, type: Feature.AppLock.Config.self) - XCTAssertEqual(feature.status == .enabled, true) - XCTAssertEqual(feature.config?.enforceAppLock, true) - XCTAssertEqual(feature.config?.inactivityTimeoutSecs, 2_147_483_647) + XCTAssertEqual(featureConfigLocalStore.featureConfigFeature_Invocations.count, 1) + XCTAssertEqual(featureConfigLocalStore.fetchFeatureName_Invocations.count, 1) + XCTAssertEqual(localFeature.status, .enabled) + XCTAssertEqual(localFeature.config, Scaffolding.featureConfig) } - func testObserveFeatureChanges() async throws { + func testObserveFeatureChanges_It_Invokes_Local_Store_Methods_And_Values_Are_Correctly_Emitted() async throws { // Given - featureConfigsAPI.getFeatureConfigs_MockValue = Scaffolding.featureConfigs + let feature = try await context.perform { [context] in + let config = try JSONEncoder().encode(Scaffolding.featureConfig) + + Feature.updateOrCreate( + havingName: .appLock, + in: context + ) { + $0.status = .enabled + $0.config = config + } + + let feature = Feature.fetch( + name: .appLock, + context: context + ) + + return feature + } + let expectation = XCTestExpectation() var featureStates: [FeatureState] = [] @@ -137,25 +204,34 @@ final class FeatureConfigRepositoryTests: XCTestCase { } } + // Mock + + featureConfigsAPI.getFeatureConfigs_MockValue = Scaffolding.featureConfigs + featureConfigLocalStore.storeFeatureNameIsEnabledConfig_MockMethod = { _, _, _ in } + featureConfigLocalStore.fetchFeatureName_MockValue = feature + featureConfigLocalStore.featureNeedsNotifyUserFeature_MockValue = true + // When + _ = try await sut.pullFeatureConfigs() await fulfillment(of: [expectation], timeout: 5.0) // Then - XCTAssertEqual(featureStates.count, Scaffolding.featureConfigs.count) - - for featureState in featureStates { - await context.perform { - let feature = Feature.fetch(name: featureState.name, context: self.context) - XCTAssertNotNil(feature) - XCTAssertEqual(featureState.status, .enabled) - XCTAssertEqual(featureState.shouldNotifyUser, false) - } - } + + XCTAssertEqual(featureConfigsAPI.getFeatureConfigs_Invocations.count, 1) + XCTAssertEqual(featureConfigLocalStore.fetchFeatureName_Invocations.count, 4) + XCTAssertEqual(featureConfigLocalStore.featureNeedsNotifyUserFeature_Invocations.count, 4) + XCTAssertEqual(featureConfigLocalStore.storeFeatureNameIsEnabledConfig_Invocations.count, Scaffolding.featureConfigs.count) } private enum Scaffolding { + + nonisolated(unsafe) static let featureConfig = Feature.AppLock.Config( + enforceAppLock: true, + inactivityTimeoutSecs: .min + ) + static let featureConfigs: [FeatureConfig] = [ .appLock(.init( status: .enabled, diff --git a/WireDomain/Tests/WireDomainTests/Repositories/Mock/MockFeatureConfigRepositoryProtocol.swift b/WireDomain/Tests/WireDomainTests/Repositories/Mock/MockFeatureConfigRepositoryProtocol.swift index 1893730837e..269679e6247 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/Mock/MockFeatureConfigRepositoryProtocol.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/Mock/MockFeatureConfigRepositoryProtocol.swift @@ -67,7 +67,7 @@ class MockFeatureConfigRepositoryProtocol: FeatureConfigRepositoryProtocol { // MARK: - fetchFeatureConfig - func fetchFeatureConfig(with name: Feature.Name, type: T.Type) async throws -> LocalFeature { + func fetchFeatureConfig(name: Feature.Name, type: T.Type) async throws -> LocalFeature { fatalError("to implement using generics") } @@ -93,41 +93,41 @@ class MockFeatureConfigRepositoryProtocol: FeatureConfigRepositoryProtocol { // MARK: - fetchNeedsToNotifyUser - var fetchNeedsToNotifyUserFor_Invocations: [Feature.Name] = [] - var fetchNeedsToNotifyUserFor_MockError: Error? - var fetchNeedsToNotifyUserFor_MockMethod: ((Feature.Name) async throws -> Bool)? - var fetchNeedsToNotifyUserFor_MockValue: Bool? + var needsToNotifyUserName_Invocations: [Feature.Name] = [] + var needsToNotifyUserName_MockError: Error? + var needsToNotifyUserName_MockMethod: ((Feature.Name) async throws -> Bool)? + var needsToNotifyUserName_MockValue: Bool? - func fetchNeedsToNotifyUser(for name: Feature.Name) async throws -> Bool { - fetchNeedsToNotifyUserFor_Invocations.append(name) + func needsToNotifyUser(name: Feature.Name) async throws -> Bool { + needsToNotifyUserName_Invocations.append(name) - if let error = fetchNeedsToNotifyUserFor_MockError { + if let error = needsToNotifyUserName_MockError { throw error } - if let mock = fetchNeedsToNotifyUserFor_MockMethod { + if let mock = needsToNotifyUserName_MockMethod { return try await mock(name) - } else if let mock = fetchNeedsToNotifyUserFor_MockValue { + } else if let mock = needsToNotifyUserName_MockValue { return mock } else { - fatalError("no mock for `fetchNeedsToNotifyUserFor`") + fatalError("no mock for `featureNeedsToNotifyUser`") } } // MARK: - storeNeedsToNotifyUser - var storeNeedsToNotifyUserForFeatureName_Invocations: [(notifyUser: Bool, name: Feature.Name)] = [] - var storeNeedsToNotifyUserForFeatureName_MockError: Error? - var storeNeedsToNotifyUserForFeatureName_MockMethod: ((Bool, Feature.Name) async throws -> Void)? + var storeFeatureNeedsToNotifyUserName_Invocations: [(notifyUser: Bool, name: Feature.Name)] = [] + var storeFeatureNeedsToNotifyUserName_MockError: Error? + var storeFeatureNeedsToNotifyUserName_MockMethod: ((Bool, Feature.Name) async throws -> Void)? - func storeNeedsToNotifyUser(_ notifyUser: Bool, forFeatureName name: Feature.Name) async throws { - storeNeedsToNotifyUserForFeatureName_Invocations.append((notifyUser: notifyUser, name: name)) + func storeFeatureNeedsToNotifyUser(_ notifyUser: Bool, name: Feature.Name) async throws { + storeFeatureNeedsToNotifyUserName_Invocations.append((notifyUser: notifyUser, name: name)) - if let error = storeNeedsToNotifyUserForFeatureName_MockError { + if let error = storeFeatureNeedsToNotifyUserName_MockError { throw error } - guard let mock = storeNeedsToNotifyUserForFeatureName_MockMethod else { + guard let mock = storeFeatureNeedsToNotifyUserName_MockMethod else { fatalError("no mock for `storeNeedsToNotifyUserForFeatureName`") } diff --git a/WireDomain/Tests/WireDomainTests/UseCases/PushSupportedProtocolsUseCaseTests.swift b/WireDomain/Tests/WireDomainTests/UseCases/PushSupportedProtocolsUseCaseTests.swift index c25803bc057..00290655196 100644 --- a/WireDomain/Tests/WireDomainTests/UseCases/PushSupportedProtocolsUseCaseTests.swift +++ b/WireDomain/Tests/WireDomainTests/UseCases/PushSupportedProtocolsUseCaseTests.swift @@ -52,7 +52,7 @@ final class PushSupportedProtocolsUseCaseTests: XCTestCase { sut = PushSupportedProtocolsUseCase( featureConfigRepository: FeatureConfigRepository( featureConfigsAPI: MockFeatureConfigsAPI(), - context: context + featureConfigLocalStore: FeatureConfigLocalStore(context: context) ), userRepository: UserRepository( usersAPI: MockUsersAPI(), From c47a3e269174e1ae63c4f0671d7e30031eb6f4b6 Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:26:07 +0100 Subject: [PATCH 07/14] UpdateEvents - add UTs for local store and repository --- .../project.pbxproj | 8 + .../UpdateEvents/UpdateEventsLocalStore.swift | 163 ++++++++++ .../UpdateEvents/UpdateEventsRepository.swift | 82 +---- .../UpdateEventsRepositoryError.swift | 2 - .../generated/AutoMockable.generated.swift | 127 ++++++++ .../UpdateEventsLocalStoreTests.swift | 303 ++++++++++++++++++ .../UpdateEventsRepositoryTests.swift | 221 +++---------- 7 files changed, 654 insertions(+), 252 deletions(-) create mode 100644 WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift create mode 100644 WireDomain/Tests/WireDomainTests/LocalStores/UpdateEventsLocalStoreTests.swift diff --git a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj index 21d192e71c8..8ad877bc2e7 100644 --- a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj +++ b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj @@ -46,6 +46,8 @@ C96B75702CDD0000003A85EB /* MockFeatureConfigLocalStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B756F2CDD0000003A85EB /* MockFeatureConfigLocalStoreProtocol.swift */; }; C96B75732CDD1333003A85EB /* LocalFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75722CDD1333003A85EB /* LocalFeature.swift */; }; C96B75752CDD1357003A85EB /* FeatureState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75742CDD1357003A85EB /* FeatureState.swift */; }; + C96B75772CDD154C003A85EB /* UpdateEventsLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75762CDD154C003A85EB /* UpdateEventsLocalStoreTests.swift */; }; + C96B75792CDD159A003A85EB /* UpdateEventsLocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75782CDD159A003A85EB /* UpdateEventsLocalStore.swift */; }; C97BCCAA2C98704B004F2D0D /* WireDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 01D0DCA62C1C8C870076CB1C /* WireDomain.framework */; }; C97C013D2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */; }; C97C014B2CB00F92000683C5 /* OneOnOneResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */; }; @@ -204,6 +206,8 @@ C96B756F2CDD0000003A85EB /* MockFeatureConfigLocalStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFeatureConfigLocalStoreProtocol.swift; sourceTree = ""; }; C96B75722CDD1333003A85EB /* LocalFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeature.swift; sourceTree = ""; }; C96B75742CDD1357003A85EB /* FeatureState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureState.swift; sourceTree = ""; }; + C96B75762CDD154C003A85EB /* UpdateEventsLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateEventsLocalStoreTests.swift; sourceTree = ""; }; + C96B75782CDD159A003A85EB /* UpdateEventsLocalStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateEventsLocalStore.swift; sourceTree = ""; }; C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPropertiesDeleteEventProcessorTests.swift; sourceTree = ""; }; C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OneOnOneResolver.swift; path = ../../OneOnOneResolver.swift; sourceTree = ""; }; C97C014F2CB01BDF000683C5 /* OneOnOneResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneResolverTests.swift; sourceTree = ""; }; @@ -509,6 +513,7 @@ children = ( C96B756E2CDCFFEB003A85EB /* Mock */, C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */, + C96B75762CDD154C003A85EB /* UpdateEventsLocalStoreTests.swift */, C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */, C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */, C96B75642CDCC85B003A85EB /* TeamLocalStoreTests.swift */, @@ -611,6 +616,7 @@ isa = PBXGroup; children = ( C99322BA2C986E3A0065E10F /* UpdateEventsRepository.swift */, + C96B75782CDD159A003A85EB /* UpdateEventsLocalStore.swift */, C99322BB2C986E3A0065E10F /* UpdateEventsRepositoryError.swift */, ); path = UpdateEvents; @@ -1115,6 +1121,7 @@ EE57A6FE2C298F380096F242 /* UpdateEventDecryptor.swift in Sources */, C99322E92C986E3A0065E10F /* ConversationLabelsRepositoryError.swift in Sources */, C96B75752CDD1357003A85EB /* FeatureState.swift in Sources */, + C96B75792CDD159A003A85EB /* UpdateEventsLocalStore.swift in Sources */, EEAD0A0C2C46777200CC8658 /* ConversationRenameEventProcessor.swift in Sources */, C99322DB2C986E3A0065E10F /* FeatureConfigModelMappings.swift in Sources */, C97C01A52CB80F54000683C5 /* UserClientsRepository.swift in Sources */, @@ -1172,6 +1179,7 @@ C97C015A2CB40010000683C5 /* FederationDeleteEventProcessorTests.swift in Sources */, C9C8FDD42C9DBE0E00702B91 /* UserLegalholdRequestEventProcessorTests.swift in Sources */, C96B755D2CDBB176003A85EB /* ConversationLocalStoreTests.swift in Sources */, + C96B75772CDD154C003A85EB /* UpdateEventsLocalStoreTests.swift in Sources */, 017F679C2C20801800B6E02D /* TeamRepositoryTests.swift in Sources */, C9E8A3B72C749F2A0093DD5C /* ConversationLabelsRepositoryTests.swift in Sources */, ); diff --git a/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift new file mode 100644 index 00000000000..fc76c74a5d5 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift @@ -0,0 +1,163 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import CoreData +import WireDataModel +import WireFoundation + +// sourcery: AutoMockable +protocol UpdateEventsLocalStoreProtocol { + + /// Get last event ID. + /// - returns: The last event ID. + + func lastEventID() -> UUID? + + /// Stores last event ID. + /// - parameter id: The last event ID to store. + + func storeLastEventID(id: UUID) + + /// Retrieves the index of the last event envelope. + /// - returns: The last index event envelope. + + func indexOfLastEventEnvelope() async throws -> Int64 + + /// Persists an event envelope locally. + /// - Parameters: + /// - data: The event envelope payload data. + /// - index: The event envelope index. + + func persistEventEnvelope( + _ data: Data, + index: Int64 + ) async throws + + /// Fetches stored event envelope payloads. + /// - parameter limit: A fetch limit. + /// - returns: A list of event payloads. + + func fetchStoredEventEnvelopePayloads( + limit: UInt + ) async throws -> [Data] + + /// Deletes next pending events locally. + /// - parameter limit: A fetch limit. + + func deleteNextPendingEvents( + limit: UInt + ) async throws +} + +final class UpdateEventsLocalStore: UpdateEventsLocalStoreProtocol { + + enum Key: String, DefaultsKey { + case lastEventID + } + + enum Error: Swift.Error { + case failedToFetchStoredEvents(Swift.Error) + case failedToDeleteStoredEvents(Swift.Error) + } + + // MARK: - Properties + + private let context: NSManagedObjectContext + private let storage: PrivateUserDefaults + + // MARK: - Object lifecycle + + init( + context: NSManagedObjectContext, + userID: UUID, + sharedUserDefaults: UserDefaults + ) { + self.context = context + storage = PrivateUserDefaults( + userID: userID, + storage: sharedUserDefaults + ) + } + + // MARK: - Public + + public func lastEventID() -> UUID? { + storage.getUUID( + forKey: .lastEventID + ) + } + + public func storeLastEventID(id: UUID) { + storage.setUUID(id, forKey: .lastEventID) + } + + public func indexOfLastEventEnvelope() async throws -> Int64 { + try await context.perform { [context] in + let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: false) + request.fetchBatchSize = 1 + let lastEnvelope = try context.fetch(request).first + return lastEnvelope?.sortIndex ?? 0 + } + } + + public func persistEventEnvelope( + _ data: Data, + index: Int64 + ) async throws { + try await context.perform { [context] in + let storedEventEnvelope = StoredUpdateEventEnvelope(context: context) + storedEventEnvelope.data = data + storedEventEnvelope.sortIndex = index + try context.save() + } + } + + public func fetchStoredEventEnvelopePayloads( + limit: UInt + ) async throws -> [Data] { + try await context.perform { [context] in + do { + let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: true) + request.fetchLimit = Int(limit) + request.returnsObjectsAsFaults = false + let storedEventEnvelopes = try context.fetch(request) + return storedEventEnvelopes.map(\.data) + } catch { + throw Error.failedToFetchStoredEvents(error) + } + } + } + + public func deleteNextPendingEvents( + limit: UInt + ) async throws { + try await context.perform { [context] in + do { + let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: true) + request.fetchLimit = Int(limit) + let storedEventEnvelopes = try context.fetch(request) + WireLogger.sync.debug("deleting \(storedEventEnvelopes.count) stored envelopes") + storedEventEnvelopes.forEach(context.delete) + try context.save() + } catch { + throw Error.failedToDeleteStoredEvents(error) + } + } + } + +} diff --git a/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsRepository.swift b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsRepository.swift index a281afa776a..3628a798277 100644 --- a/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsRepository.swift +++ b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsRepository.swift @@ -85,10 +85,6 @@ protocol UpdateEventsRepositoryProtocol { final class UpdateEventsRepository: UpdateEventsRepositoryProtocol { - enum Key: String, DefaultsKey { - case lastEventID - } - // MARK: - Properties private let userID: UUID @@ -96,11 +92,7 @@ final class UpdateEventsRepository: UpdateEventsRepositoryProtocol { private let updateEventsAPI: any UpdateEventsAPI private let pushChannel: any PushChannelProtocol private let updateEventDecryptor: any UpdateEventDecryptorProtocol - // swiftlint:disable:next todo_requires_jira_link - // TODO: create UpdateEventsLocalStore - private let eventContext: NSManagedObjectContext - private let storage: PrivateUserDefaults - + private let updateEventsLocalStore: any UpdateEventsLocalStoreProtocol private let encoder = JSONEncoder() private let decoder = JSONDecoder() @@ -112,19 +104,14 @@ final class UpdateEventsRepository: UpdateEventsRepositoryProtocol { updateEventsAPI: any UpdateEventsAPI, pushChannel: any PushChannelProtocol, updateEventDecryptor: any UpdateEventDecryptorProtocol, - eventContext: NSManagedObjectContext, - sharedUserDefaults: UserDefaults + updateEventsLocalStore: any UpdateEventsLocalStoreProtocol ) { self.userID = userID self.selfClientID = selfClientID self.updateEventsAPI = updateEventsAPI self.pushChannel = pushChannel self.updateEventDecryptor = updateEventDecryptor - self.eventContext = eventContext - storage = PrivateUserDefaults( - userID: userID, - storage: sharedUserDefaults - ) + self.updateEventsLocalStore = updateEventsLocalStore } // MARK: - Pull pending events @@ -132,12 +119,12 @@ final class UpdateEventsRepository: UpdateEventsRepositoryProtocol { func pullPendingEvents() async throws { WireLogger.sync.debug("pulling pending events") // We want all events since this event. - guard let lastEventID = storage.getUUID(forKey: .lastEventID) else { + guard let lastEventID = updateEventsLocalStore.lastEventID() else { throw UpdateEventsRepositoryError.lastEventIDMissing } // We'll insert new events from this index. - var currentIndex = try await indexOfLastEventEnvelope() + 1 + var currentIndex = try await updateEventsLocalStore.indexOfLastEventEnvelope() + 1 // Events are fetched in batches. for try await envelopes in updateEventsAPI.getUpdateEvents( @@ -168,8 +155,10 @@ final class UpdateEventsRepository: UpdateEventsRepositoryProtocol { attributes: [.eventEnvelopeID: envelope.id] ) - try await persistEventEnvelope( - decryptedEnvelope, + let decryptedEnvelopeData = try encoder.encode(decryptedEnvelope) + + try await updateEventsLocalStore.persistEventEnvelope( + decryptedEnvelopeData, index: currentIndex ) @@ -192,49 +181,13 @@ final class UpdateEventsRepository: UpdateEventsRepositoryProtocol { storeLastEventEnvelopeID(lastEvent.id) } - private func indexOfLastEventEnvelope() async throws -> Int64 { - try await eventContext.perform { [eventContext] in - let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: false) - request.fetchBatchSize = 1 - let lastEnvelope = try eventContext.fetch(request).first - return lastEnvelope?.sortIndex ?? 0 - } - } - - private func persistEventEnvelope( - _ eventEnvelope: UpdateEventEnvelope, - index: Int64 - ) async throws { - try await eventContext.perform { [eventContext, encoder] in - let data = try encoder.encode(eventEnvelope) - let storedEventEnvelope = StoredUpdateEventEnvelope(context: eventContext) - storedEventEnvelope.data = data - storedEventEnvelope.sortIndex = index - try eventContext.save() - } - } - // MARK: - Fetch pending events func fetchNextPendingEvents(limit: UInt) async throws -> [UpdateEventEnvelope] { - let payloads = try await fetchStoredEventEnvelopePayloads(limit: limit) + let payloads = try await updateEventsLocalStore.fetchStoredEventEnvelopePayloads(limit: limit) return try decodeEventEnvelopes(payloads) } - private func fetchStoredEventEnvelopePayloads(limit: UInt) async throws -> [Data] { - try await eventContext.perform { [eventContext] in - do { - let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: true) - request.fetchLimit = Int(limit) - request.returnsObjectsAsFaults = false - let storedEventEnvelopes = try eventContext.fetch(request) - return storedEventEnvelopes.map(\.data) - } catch { - throw UpdateEventsRepositoryError.failedToFetchStoredEvents(error) - } - } - } - private func decodeEventEnvelopes(_ payloads: [Data]) throws -> [UpdateEventEnvelope] { try payloads.map { do { @@ -248,18 +201,7 @@ final class UpdateEventsRepository: UpdateEventsRepositoryProtocol { // MARK: - Delete pending events func deleteNextPendingEvents(limit: UInt) async throws { - try await eventContext.perform { [eventContext] in - do { - let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: true) - request.fetchLimit = Int(limit) - let storedEventEnvelopes = try eventContext.fetch(request) - WireLogger.sync.debug("deleting \(storedEventEnvelopes.count) stored envelopes") - storedEventEnvelopes.forEach(eventContext.delete) - try eventContext.save() - } catch { - throw UpdateEventsRepositoryError.failedToDeleteStoredEvents(error) - } - } + try await updateEventsLocalStore.deleteNextPendingEvents(limit: limit) } // MARK: - Live events @@ -294,7 +236,7 @@ final class UpdateEventsRepository: UpdateEventsRepositoryProtocol { attributes: [.eventEnvelopeID: id] ) - storage.setUUID(id, forKey: .lastEventID) + updateEventsLocalStore.storeLastEventID(id: id) } } diff --git a/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsRepositoryError.swift b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsRepositoryError.swift index 4d8703798e6..68828db00f3 100644 --- a/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsRepositoryError.swift +++ b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsRepositoryError.swift @@ -21,8 +21,6 @@ import Foundation enum UpdateEventsRepositoryError: Error { case lastEventIDMissing - case failedToFetchStoredEvents(Error) case failedToDecodeStoredEvent(Error) - case failedToDeleteStoredEvents(Error) } diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index 2f9c416752e..ef53ad07fbc 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -1320,6 +1320,133 @@ class MockUpdateEventProcessorProtocol: UpdateEventProcessorProtocol { } +class MockUpdateEventsLocalStoreProtocol: UpdateEventsLocalStoreProtocol { + + // MARK: - Life cycle + + + + // MARK: - lastEventID + + var lastEventID_Invocations: [Void] = [] + var lastEventID_MockMethod: (() -> UUID?)? + var lastEventID_MockValue: UUID?? + + func lastEventID() -> UUID? { + lastEventID_Invocations.append(()) + + if let mock = lastEventID_MockMethod { + return mock() + } else if let mock = lastEventID_MockValue { + return mock + } else { + fatalError("no mock for `lastEventID`") + } + } + + // MARK: - storeLastEventID + + var storeLastEventIDId_Invocations: [UUID] = [] + var storeLastEventIDId_MockMethod: ((UUID) -> Void)? + + func storeLastEventID(id: UUID) { + storeLastEventIDId_Invocations.append(id) + + guard let mock = storeLastEventIDId_MockMethod else { + fatalError("no mock for `storeLastEventIDId`") + } + + mock(id) + } + + // MARK: - indexOfLastEventEnvelope + + var indexOfLastEventEnvelope_Invocations: [Void] = [] + var indexOfLastEventEnvelope_MockError: Error? + var indexOfLastEventEnvelope_MockMethod: (() async throws -> Int64)? + var indexOfLastEventEnvelope_MockValue: Int64? + + func indexOfLastEventEnvelope() async throws -> Int64 { + indexOfLastEventEnvelope_Invocations.append(()) + + if let error = indexOfLastEventEnvelope_MockError { + throw error + } + + if let mock = indexOfLastEventEnvelope_MockMethod { + return try await mock() + } else if let mock = indexOfLastEventEnvelope_MockValue { + return mock + } else { + fatalError("no mock for `indexOfLastEventEnvelope`") + } + } + + // MARK: - persistEventEnvelope + + var persistEventEnvelopeIndex_Invocations: [(data: Data, index: Int64)] = [] + var persistEventEnvelopeIndex_MockError: Error? + var persistEventEnvelopeIndex_MockMethod: ((Data, Int64) async throws -> Void)? + + func persistEventEnvelope(_ data: Data, index: Int64) async throws { + persistEventEnvelopeIndex_Invocations.append((data: data, index: index)) + + if let error = persistEventEnvelopeIndex_MockError { + throw error + } + + guard let mock = persistEventEnvelopeIndex_MockMethod else { + fatalError("no mock for `persistEventEnvelopeIndex`") + } + + try await mock(data, index) + } + + // MARK: - fetchStoredEventEnvelopePayloads + + var fetchStoredEventEnvelopePayloadsLimit_Invocations: [UInt] = [] + var fetchStoredEventEnvelopePayloadsLimit_MockError: Error? + var fetchStoredEventEnvelopePayloadsLimit_MockMethod: ((UInt) async throws -> [Data])? + var fetchStoredEventEnvelopePayloadsLimit_MockValue: [Data]? + + func fetchStoredEventEnvelopePayloads(limit: UInt) async throws -> [Data] { + fetchStoredEventEnvelopePayloadsLimit_Invocations.append(limit) + + if let error = fetchStoredEventEnvelopePayloadsLimit_MockError { + throw error + } + + if let mock = fetchStoredEventEnvelopePayloadsLimit_MockMethod { + return try await mock(limit) + } else if let mock = fetchStoredEventEnvelopePayloadsLimit_MockValue { + return mock + } else { + fatalError("no mock for `fetchStoredEventEnvelopePayloadsLimit`") + } + } + + // MARK: - deleteNextPendingEvents + + var deleteNextPendingEventsLimit_Invocations: [UInt] = [] + var deleteNextPendingEventsLimit_MockError: Error? + var deleteNextPendingEventsLimit_MockMethod: ((UInt) async throws -> Void)? + + func deleteNextPendingEvents(limit: UInt) async throws { + deleteNextPendingEventsLimit_Invocations.append(limit) + + if let error = deleteNextPendingEventsLimit_MockError { + throw error + } + + guard let mock = deleteNextPendingEventsLimit_MockMethod else { + fatalError("no mock for `deleteNextPendingEventsLimit`") + } + + try await mock(limit) + } + +} + class MockUpdateEventsRepositoryProtocol: UpdateEventsRepositoryProtocol { // MARK: - Life cycle diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/UpdateEventsLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/UpdateEventsLocalStoreTests.swift new file mode 100644 index 00000000000..d57f6883af7 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/LocalStores/UpdateEventsLocalStoreTests.swift @@ -0,0 +1,303 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireAPI +import WireDataModel +import WireDataModelSupport +@testable import WireDomain +@testable import WireDomainSupport +import WireTestingPackage +import XCTest + +final class UpdateEventsLocalStoreTests: XCTestCase { + + private var sut: UpdateEventsLocalStore! + private var mockUserDefaults: UserDefaults! + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + + private var context: NSManagedObjectContext { + stack.eventContext + } + + override func setUp() async throws { + coreDataStackHelper = CoreDataStackHelper() + stack = try await coreDataStackHelper.createStack() + mockUserDefaults = UserDefaults( + suiteName: Scaffolding.defaultsTestSuiteName + ) + sut = UpdateEventsLocalStore( + context: context, + userID: Scaffolding.selfUserID.uuid, + sharedUserDefaults: mockUserDefaults + ) + } + + override func tearDown() async throws { + coreDataStackHelper = CoreDataStackHelper() + stack = nil + sut = nil + mockUserDefaults.removePersistentDomain( + forName: Scaffolding.defaultsTestSuiteName + ) + mockUserDefaults = nil + try coreDataStackHelper.cleanupDirectory() + } + + // MARK: - Tests + + func testPersistEventEnvelope_It_Stores_Envelope_Locally() async throws { + // Given + + let envelopeData = try JSONEncoder().encode(Scaffolding.envelope1) + + // When + + try await sut.persistEventEnvelope( + envelopeData, + index: 1 + ) + + // Then + + try await context.perform { [context] in + let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: true) + let storedEventEnvelope = try XCTUnwrap(context.fetch(request).first) + let decodedEnvelope = try JSONDecoder().decode(UpdateEventEnvelope.self, from: storedEventEnvelope.data) + + XCTAssertEqual(decodedEnvelope.id, Scaffolding.envelope1.id) + XCTAssertEqual(decodedEnvelope.events, Scaffolding.envelope1.events) + } + } + + func testFetchStoredEventEnvelopePayloads_It_Fetches_No_Envelopes_If_There_Are_None() async throws { + // Given no stored events. + + // When + + let fetchedEnvelopes = try await sut.fetchStoredEventEnvelopePayloads(limit: 3) + + // Then it returns no envelopes. + + XCTAssertTrue(fetchedEnvelopes.isEmpty) + } + + func testFetchStoredEventEnvelopePayloads_It_Fetches_Less_Than_The_Limit_If_There_Are_Not_Enough_Envelopes() async throws { + // Given there are stored envelopes. + + try await insertStoredEventEnvelopes([Scaffolding.envelope3]) + + // When + + let fetchedEnvelopes = try await sut.fetchStoredEventEnvelopePayloads(limit: 3) + + // Then it returns the one and only envelope. + + let fetchedEnvelope1 = try JSONDecoder().decode(UpdateEventEnvelope.self, from: fetchedEnvelopes[0]) + + XCTAssertEqual(fetchedEnvelope1, Scaffolding.envelope3) + } + + func testFetchStoredEventEnvelopePayloads_It_Does_Not_Fetch_More_Than_The_Limit() async throws { + // Given there are stored envelopes. + + try await insertStoredEventEnvelopes([ + Scaffolding.envelope3, + Scaffolding.envelope4, + Scaffolding.envelope1, + Scaffolding.envelope5, + Scaffolding.envelope2 + ]) + + // When + + let fetchedEnvelopes = try await sut.fetchStoredEventEnvelopePayloads(limit: 3) + + // Then the first 3 envelopes were returned. + + guard fetchedEnvelopes.count == 3 else { + XCTFail("expected 3 envelopes, got \(fetchedEnvelopes.count)") + return + } + + let fetchedEnvelope1 = try JSONDecoder().decode(UpdateEventEnvelope.self, from: fetchedEnvelopes[0]) + let fetchedEnvelope2 = try JSONDecoder().decode(UpdateEventEnvelope.self, from: fetchedEnvelopes[1]) + let fetchedEnvelope3 = try JSONDecoder().decode(UpdateEventEnvelope.self, from: fetchedEnvelopes[2]) + + XCTAssertEqual(fetchedEnvelope1, Scaffolding.envelope3) + XCTAssertEqual(fetchedEnvelope2, Scaffolding.envelope4) + XCTAssertEqual(fetchedEnvelope3, Scaffolding.envelope1) + } + + func testDeleteNextPendingEvents_It_Deletes_All_Stored_Envelopes_If_Limit_Exceeds_Total_Number_Of_Envelopes() async throws { + // Given there are stored envelopes. + + try await insertStoredEventEnvelopes([ + Scaffolding.envelope1, + Scaffolding.envelope2, + Scaffolding.envelope3 + ]) + + // When it deletes more than 3. + + try await sut.deleteNextPendingEvents(limit: 10) + + // Then all stored events were deleted. + + try await context.perform { [context] in + let request = StoredUpdateEventEnvelope.fetchRequest() + let result = try context.fetch(request) + XCTAssertTrue(result.isEmpty) + } + } + + func testDeleteNextPendingEvents_It_Deletes_Stored_Envelopes_Only_Up_To_The_Limit() async throws { + // Given there are stored envelopes. + + try await insertStoredEventEnvelopes([ + Scaffolding.envelope1, + Scaffolding.envelope2, + Scaffolding.envelope3 + ]) + + // When it deletes 2 envelopes. + + try await sut.deleteNextPendingEvents(limit: 2) + + // Then the first 2 envelopes were deleted. + + try await context.perform { [context] in + let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: true) + let result = try context.fetch(request) + + XCTAssertEqual(result.count, 1) + + let envelope = try XCTUnwrap(result.first) + XCTAssertEqual(envelope.sortIndex, 2) + + let decoder = JSONDecoder() + let decodedEnvelope = try decoder.decode(UpdateEventEnvelope.self, from: envelope.data) + XCTAssertEqual(decodedEnvelope, Scaffolding.envelope3) + } + } + + func testStoreLastEventID_It_Stores_Last_Event_Envelope_ID() throws { + // Given + + let id = UUID() + + // When + + sut.storeLastEventID(id: id) + + // Then + + let lastEventId = try XCTUnwrap(mockUserDefaults.string(forKey: Scaffolding.lastEventIDUserDefaultsKey)) + XCTAssertEqual(UUID(uuidString: lastEventId), id) + } + + private func insertStoredEventEnvelopes(_ envelopes: [UpdateEventEnvelope]) async throws { + try await context.perform { [context] in + let encoder = JSONEncoder() + + for (index, envelope) in envelopes.enumerated() { + let storedEventEnvelope = StoredUpdateEventEnvelope(context: context) + storedEventEnvelope.data = try encoder.encode(envelope) + storedEventEnvelope.sortIndex = Int64(index) + } + + try context.save() + } + } + + private enum Scaffolding { + + static let localDomain = "local.com" + static let selfUserID = UserID(uuid: UUID(), domain: localDomain) + static let selfClientID = "abcd1234" + static let conversationID = ConversationID(uuid: UUID(), domain: localDomain) + static let lastEventID = UUID(uuidString: "571d22a5-026c-48b4-90bf-78d00354f121")! + static let otherDomain = "other.com" + static let aliceID = UserID(uuid: UUID(), domain: otherDomain) + static let aliceClientID = "efgh5678" + static let defaultsTestSuiteName = UUID().uuidString + + static let id1 = UUID(uuidString: "d92f875d-9599-4469-886e-39addaffdad7")! + static let id2 = UUID(uuidString: "a826994f-082b-4d1e-9655-df8e1c7dccbf")! + static let id3 = UUID(uuidString: "000e7674-6fbe-4099-b081-10c5757c37f2")! + static let id4 = UUID(uuidString: "94d2dbb9-7a81-411d-b009-41a58cdae13b")! + static let id5 = UUID(uuidString: "9ec9d043-150b-4b4e-b916-33bf04e8c74f")! + + static let time30SecondsAgo = Date(timeIntervalSinceNow: -30) + static let time20SecondsAgo = Date(timeIntervalSinceNow: -20) + + static let lastEventIDUserDefaultsKey = "\(selfUserID.uuid.uuidString)_lastEventID" + + nonisolated(unsafe) static let envelope1 = UpdateEventEnvelope( + id: id1, + events: [.user(.pushRemove)], + isTransient: false + ) + + nonisolated(unsafe) static let envelope2 = UpdateEventEnvelope( + id: id2, + events: [.user(.pushRemove)], + isTransient: false + ) + + nonisolated(unsafe) static let envelope3 = UpdateEventEnvelope( + id: id3, + events: [.conversation(.proteusMessageAdd(proteusMessage1))], + isTransient: false + ) + + nonisolated(unsafe) static let envelope4 = UpdateEventEnvelope( + id: id4, + events: [.user(.pushRemove)], + isTransient: true + ) + + nonisolated(unsafe) static let envelope5 = UpdateEventEnvelope( + id: id5, + events: [.conversation(.proteusMessageAdd(proteusMessage2))], + isTransient: false + ) + + nonisolated(unsafe) static let proteusMessage1 = ConversationProteusMessageAddEvent( + conversationID: conversationID, + senderID: aliceID, + timestamp: time30SecondsAgo, + message: .ciphertext("xxxxx"), + externalData: nil, + messageSenderClientID: aliceClientID, + messageRecipientClientID: selfClientID + ) + + nonisolated(unsafe) static let proteusMessage2 = ConversationProteusMessageAddEvent( + conversationID: conversationID, + senderID: aliceID, + timestamp: time20SecondsAgo, + message: .ciphertext("yyyyy"), + externalData: nil, + messageSenderClientID: aliceClientID, + messageRecipientClientID: selfClientID + ) + + } + +} diff --git a/WireDomain/Tests/WireDomainTests/Repositories/UpdateEventsRepositoryTests.swift b/WireDomain/Tests/WireDomainTests/Repositories/UpdateEventsRepositoryTests.swift index 7516b9a9e36..e36d9b8fec6 100644 --- a/WireDomain/Tests/WireDomainTests/Repositories/UpdateEventsRepositoryTests.swift +++ b/WireDomain/Tests/WireDomainTests/Repositories/UpdateEventsRepositoryTests.swift @@ -31,7 +31,7 @@ final class UpdateEventsRepositoryTests: XCTestCase { private var updateEventsAPI: MockUpdateEventsAPI! private var pushChannel: MockPushChannelProtocol! private var updateEventDecryptor: MockUpdateEventDecryptorProtocol! - private var mockUserDefaults: UserDefaults! + private var updateEventsLocalStore: MockUpdateEventsLocalStoreProtocol! private var stack: CoreDataStack! private var coreDataStackHelper: CoreDataStackHelper! @@ -40,23 +40,20 @@ final class UpdateEventsRepositoryTests: XCTestCase { } override func setUp() async throws { - try await super.setUp() coreDataStackHelper = CoreDataStackHelper() stack = try await coreDataStackHelper.createStack() updateEventsAPI = MockUpdateEventsAPI() pushChannel = MockPushChannelProtocol() updateEventDecryptor = MockUpdateEventDecryptorProtocol() - mockUserDefaults = UserDefaults( - suiteName: Scaffolding.defaultsTestSuiteName - ) + updateEventsLocalStore = MockUpdateEventsLocalStoreProtocol() + sut = UpdateEventsRepository( userID: Scaffolding.selfUserID.uuid, selfClientID: Scaffolding.selfClientID, updateEventsAPI: updateEventsAPI, pushChannel: pushChannel, updateEventDecryptor: updateEventDecryptor, - eventContext: context, - sharedUserDefaults: mockUserDefaults + updateEventsLocalStore: updateEventsLocalStore ) // Base mocks @@ -64,17 +61,13 @@ final class UpdateEventsRepositoryTests: XCTestCase { } override func tearDown() async throws { - try await super.tearDown() coreDataStackHelper = CoreDataStackHelper() stack = nil updateEventsAPI = nil pushChannel = nil updateEventDecryptor = nil sut = nil - mockUserDefaults.removePersistentDomain( - forName: Scaffolding.defaultsTestSuiteName - ) - mockUserDefaults = nil + updateEventsLocalStore = nil try coreDataStackHelper.cleanupDirectory() } @@ -94,7 +87,11 @@ final class UpdateEventsRepositoryTests: XCTestCase { // MARK: - Pull pending events - func testItThrowsErrorWhenPullingPendingEventsWithoutLastEventID() async throws { + func testPullPendingEvents_It_Throws_Error_When_Pulling_Pending_Events_Without_Last_Event_ID() async throws { + // Mock + + updateEventsLocalStore.lastEventID_MockMethod = { nil } + do { // When try await sut.pullPendingEvents() @@ -106,20 +103,16 @@ final class UpdateEventsRepositoryTests: XCTestCase { } } - func testItPullPendingEvents() async throws { - // Given some events already in the db. - try await insertStoredEventEnvelopes([ - Scaffolding.envelope1, - Scaffolding.envelope2 - ]) - + func testPullPendingEvents_It_Pulls_Pending_Events() async throws { // There is a last event id. - mockUserDefaults.set( - Scaffolding.lastEventID.uuidString, - forKey: Scaffolding.lastEventIDUserDefaultsKey - ) + + updateEventsLocalStore.lastEventID_MockValue = Scaffolding.lastEventID + updateEventsLocalStore.indexOfLastEventEnvelope_MockValue = 1 + updateEventsLocalStore.persistEventEnvelopeIndex_MockMethod = { _, _ in } + updateEventsLocalStore.storeLastEventIDId_MockMethod = { _ in } // There are two pages of events waiting to be pulled. + updateEventsAPI.getUpdateEventsSelfClientIDSinceEventID_MockValue = PayloadPager(start: "page1") { start in switch start { case "page1": @@ -160,169 +153,36 @@ final class UpdateEventsRepositoryTests: XCTestCase { XCTAssertEqual(decryptorInvocations[2].id, Scaffolding.envelope5.id) XCTAssertEqual(decryptorInvocations[3].id, Scaffolding.envelope6.id) - // Then there should now be 7 persisted events in the right order. - try await context.perform { [context] in - let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: true) - let storedEventEnvelopes = try context.fetch(request) - - guard storedEventEnvelopes.count == 6 else { - XCTFail("expected 6 stored events, got \(storedEventEnvelopes.count)") - return - } - - let decoder = JSONDecoder() - - let data1 = try XCTUnwrap(storedEventEnvelopes[0].data) - let storedEnvelope1 = try decoder.decode(UpdateEventEnvelope.self, from: data1) - XCTAssertEqual(storedEnvelope1, Scaffolding.envelope1) - XCTAssertEqual(storedEventEnvelopes[0].sortIndex, 0) - - let data2 = try XCTUnwrap(storedEventEnvelopes[1].data) - let storedEnvelope2 = try decoder.decode(UpdateEventEnvelope.self, from: data2) - XCTAssertEqual(storedEnvelope2, Scaffolding.envelope2) - XCTAssertEqual(storedEventEnvelopes[1].sortIndex, 1) - - let data3 = try XCTUnwrap(storedEventEnvelopes[2].data) - let storedEnvelope3 = try decoder.decode(UpdateEventEnvelope.self, from: data3) - XCTAssertEqual(storedEnvelope3, Scaffolding.envelope3) - XCTAssertEqual(storedEventEnvelopes[2].sortIndex, 2) - - let data4 = try XCTUnwrap(storedEventEnvelopes[3].data) - let storedEnvelope4 = try decoder.decode(UpdateEventEnvelope.self, from: data4) - XCTAssertEqual(storedEnvelope4, Scaffolding.envelope4) - XCTAssertEqual(storedEventEnvelopes[3].sortIndex, 3) - - let data5 = try XCTUnwrap(storedEventEnvelopes[4].data) - let storedEnvelope5 = try decoder.decode(UpdateEventEnvelope.self, from: data5) - XCTAssertEqual(storedEnvelope5, Scaffolding.envelope5) - XCTAssertEqual(storedEventEnvelopes[4].sortIndex, 4) - - let data6 = try XCTUnwrap(storedEventEnvelopes[5].data) - let storedEnvelope6 = try decoder.decode(UpdateEventEnvelope.self, from: data6) - XCTAssertEqual(storedEnvelope6, Scaffolding.envelope6) - XCTAssertEqual(storedEventEnvelopes[5].sortIndex, 5) - } - - // The the update event id was persisted for the last non-transient envelope. - - let lastEventID = try XCTUnwrap(mockUserDefaults.string(forKey: Scaffolding.lastEventIDUserDefaultsKey)) - - XCTAssertEqual(UUID(uuidString: lastEventID), Scaffolding.envelope6.id) - } - - // MARK: - Fetch next pending events - - func testItFetchesNoEnvelopesIfThereAreNone() async throws { - // Given no stored events. - - // When - let fetchedEnvelopes = try await sut.fetchNextPendingEvents(limit: 3) - - // Then it returns no envelopes. - XCTAssertTrue(fetchedEnvelopes.isEmpty) - } - - func testItFetchesLessThanTheLimitIfThereAreNotEnoughEnvelopes() async throws { - // Given there are stored envelopes. - try await insertStoredEventEnvelopes([Scaffolding.envelope3]) - - // When - let fetchedEnvelopes = try await sut.fetchNextPendingEvents(limit: 3) - - // Then it returns the one and only envelope. - XCTAssertEqual(fetchedEnvelopes, [Scaffolding.envelope3]) - } - - func testItDoesNotFetchMoreThanTheLimit() async throws { - // Given there are stored envelopes. - try await insertStoredEventEnvelopes([ - Scaffolding.envelope3, - Scaffolding.envelope4, - Scaffolding.envelope1, - Scaffolding.envelope5, - Scaffolding.envelope2 - ]) - - // When - let fetchedEnvelopes = try await sut.fetchNextPendingEvents(limit: 3) - - // Then the first 3 envelopes were returned. - guard fetchedEnvelopes.count == 3 else { - XCTFail("expected 3 envelopes, got \(fetchedEnvelopes.count)") - return - } - - XCTAssertEqual(fetchedEnvelopes[0], Scaffolding.envelope3) - XCTAssertEqual(fetchedEnvelopes[1], Scaffolding.envelope4) - XCTAssertEqual(fetchedEnvelopes[2], Scaffolding.envelope1) - } - - // MARK: - Delete next pending events - - func testItDeletesAllStoredEnvelopesIfLimitExceedsTotalNumberOfEnvelopes() async throws { - // Given there are stored envelopes. - try await insertStoredEventEnvelopes([ - Scaffolding.envelope1, - Scaffolding.envelope2, - Scaffolding.envelope3 - ]) - - // When it deletes more than 3. - try await sut.deleteNextPendingEvents(limit: 10) - - // Then all stored events were deleted. - try await context.perform { [context] in - let request = StoredUpdateEventEnvelope.fetchRequest() - let result = try context.fetch(request) - XCTAssertTrue(result.isEmpty) - } - } - - func testItDeletesStoredEnvelopesOnlyUpToTheLimit() async throws { - // Given there are stored envelopes. - try await insertStoredEventEnvelopes([ - Scaffolding.envelope1, - Scaffolding.envelope2, - Scaffolding.envelope3 - ]) - - // When it deletes 2 envelopes. - try await sut.deleteNextPendingEvents(limit: 2) - - // Then the first 2 envelopes were deleted. - try await context.perform { [context] in - let request = StoredUpdateEventEnvelope.sortedFetchRequest(asending: true) - let result = try context.fetch(request) - - XCTAssertEqual(result.count, 1) - - let envelope = try XCTUnwrap(result.first) - XCTAssertEqual(envelope.sortIndex, 2) + // Then - let decoder = JSONDecoder() - let decodedEnvelope = try decoder.decode(UpdateEventEnvelope.self, from: envelope.data) - XCTAssertEqual(decodedEnvelope, Scaffolding.envelope3) - } + XCTAssertEqual(updateEventsLocalStore.persistEventEnvelopeIndex_Invocations.count, 4) + XCTAssertEqual(updateEventsLocalStore.storeLastEventIDId_Invocations.count, 3) + XCTAssertEqual(updateEventsLocalStore.lastEventID_Invocations.count, 1) + XCTAssertEqual(updateEventsLocalStore.indexOfLastEventEnvelope_Invocations.count, 1) } // MARK: - Live events - func testItBuffersLiveEventsUntilIterationStarts() async throws { + func testStartBufferingLiveEvents_It_Buffers_Live_Events_Until_Iteration_Starts() async throws { // Mock push channel. + var liveEventsContinuation: AsyncThrowingStream.Continuation? pushChannel.open_MockValue = AsyncThrowingStream { liveEventsContinuation = $0 } // Given it starts buffering. + let liveEventStream = try await sut.startBufferingLiveEvents() // Given live events arrive. + liveEventsContinuation?.yield(Scaffolding.envelope1) liveEventsContinuation?.yield(Scaffolding.envelope2) liveEventsContinuation?.yield(Scaffolding.envelope3) // When iteration starts. + let task = Task { var receivedEnvelopes = [UpdateEventEnvelope]() for try await envelope in liveEventStream { @@ -335,6 +195,7 @@ final class UpdateEventsRepositoryTests: XCTestCase { let receivedEnvelopes = try await task.value // Then all three envelopes are received. + guard receivedEnvelopes.count == 3 else { XCTFail("Expected 3 envelopes, got \(receivedEnvelopes.count)") return @@ -345,6 +206,7 @@ final class UpdateEventsRepositoryTests: XCTestCase { XCTAssertEqual(receivedEnvelopes[2], Scaffolding.envelope3) // Then each envelope was decrypted. + let decryptionInvocations = updateEventDecryptor.decryptEventsIn_Invocations guard decryptionInvocations.count == 3 else { XCTFail("expected 4 decryption invocations, got \(decryptionInvocations.count)") @@ -356,30 +218,33 @@ final class UpdateEventsRepositoryTests: XCTestCase { XCTAssertEqual(decryptionInvocations[2], Scaffolding.envelope3) } - func testItStoresLastEventEnvelopeID() throws { - // Given - let id = UUID() + func testStoreLastEventEnvelopeID_It_Invokes_Local_Store_Method() throws { + // Mock + + updateEventsLocalStore.storeLastEventIDId_MockMethod = { _ in } // When - sut.storeLastEventEnvelopeID(id) + + sut.storeLastEventEnvelopeID(Scaffolding.lastEventID) // Then - let lastEventId = try XCTUnwrap(mockUserDefaults.string(forKey: Scaffolding.lastEventIDUserDefaultsKey)) - XCTAssertEqual(UUID(uuidString: lastEventId), id) + + XCTAssertEqual(updateEventsLocalStore.storeLastEventIDId_Invocations.count, 1) } - func testPullLastEventID_It_Stores_Last_Event_ID() async throws { + func testPullLastEventID_It_Invokes_Local_Store_Method() async throws { // Mock updateEventsAPI.getLastUpdateEventSelfClientID_MockValue = Scaffolding.envelope1 + updateEventsLocalStore.storeLastEventIDId_MockMethod = { _ in } // When try await sut.pullLastEventID() // Then - let lastEventId = try XCTUnwrap(mockUserDefaults.string(forKey: Scaffolding.lastEventIDUserDefaultsKey)) - XCTAssertEqual(UUID(uuidString: lastEventId), Scaffolding.envelope1.id) + + XCTAssertEqual(updateEventsLocalStore.storeLastEventIDId_Invocations.count, 1) } private enum Scaffolding { @@ -493,10 +358,6 @@ final class UpdateEventsRepositoryTests: XCTestCase { nextStart: "" ) - static let defaultsTestSuiteName = UUID().uuidString - - static let lastEventIDUserDefaultsKey = "\(selfUserID.uuid.uuidString)_lastEventID" - } } From 6e80d78cf88b531cca16c53d14e92634656f69a7 Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:33:02 +0100 Subject: [PATCH 08/14] add missing documentation for FeatureConfigLocalStore --- .../FeatureConfigLocalStore.swift | 23 +++++++++++++++++++ .../UpdateEvents/UpdateEventsLocalStore.swift | 2 ++ 2 files changed, 25 insertions(+) diff --git a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift index b8c12abe4cc..34424c95983 100644 --- a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift @@ -21,25 +21,48 @@ import WireDataModel protocol FeatureConfigLocalStoreProtocol { + /// Fetches a feature locally. + /// - parameter name: The name of the feature. + /// - returns: The feature found locally + func fetchFeature( name: Feature.Name ) async throws -> Feature + /// Stores a flag indicating whether the user needs to be notified of the feature. + /// - parameters: + /// - needsNotifyUser: The flag to update. + /// - feature: The feature to update the flag for. + func storeFeature( needsNotifyUser: Bool, feature: Feature ) async + /// Fetches a flag whether the user needs to be notified for the feature. + /// - parameter feature: A given feature. + /// - returns: Whether the user needs to be notified. + func featureNeedsNotifyUser( feature: Feature ) async -> Bool + /// Stores a feature locally. + /// - parameters: + /// - name: The name of the feature + /// - isEnabled: Whether the feature is enabled. + /// - config: The config of the feature if any. + func storeFeature( name: Feature.Name, isEnabled: Bool, config: (any Codable)? ) async + /// Fetches a feature config info. + /// - parameter feature: The feature to fetch the info from. + /// - returns: A status (enabled or disabled) and a config payload. + func featureConfig( feature: Feature ) async -> (status: Feature.Status, config: Data?) diff --git a/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift index fc76c74a5d5..3e4b10de929 100644 --- a/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift @@ -70,6 +70,8 @@ final class UpdateEventsLocalStore: UpdateEventsLocalStoreProtocol { case lastEventID } + // MARK: - Error + enum Error: Swift.Error { case failedToFetchStoredEvents(Swift.Error) case failedToDeleteStoredEvents(Swift.Error) From 0a90373139ec3807a9ecc13f4f5bafcf880d2182 Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Fri, 8 Nov 2024 08:54:22 +0100 Subject: [PATCH 09/14] ConversationLabels - add UTs for local store and repository --- .../project.pbxproj | 24 ++ .../ConversationLabelsLocalStore.swift | 154 +++++++ .../ConversationLabelsModelMappings.swift | 32 ++ .../ConversationLabelsRepository.swift | 121 +----- .../ConversationLabelsRepositoryError.swift | 5 +- .../Models/ConversationLabelInfo.swift | 26 ++ .../FeatureConfigLocalStore.swift | 4 +- .../UpdateEvents/UpdateEventsLocalStore.swift | 2 +- .../generated/AutoMockable.generated.swift | 49 +++ .../ConversationLabelsLocalStoreTests.swift | 310 ++++++++++++++ .../ConnectionsRepositoryTests.swift | 22 +- .../ConversationLabelsRepositoryTests.swift | 389 ++---------------- 12 files changed, 650 insertions(+), 488 deletions(-) create mode 100644 WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsLocalStore.swift create mode 100644 WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsModelMappings.swift create mode 100644 WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/Models/ConversationLabelInfo.swift create mode 100644 WireDomain/Tests/WireDomainTests/LocalStores/ConversationLabelsLocalStoreTests.swift diff --git a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj index 8ad877bc2e7..5b8434d0af9 100644 --- a/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj +++ b/WireDomain/Project/WireDomain Project.xcodeproj/project.pbxproj @@ -48,6 +48,10 @@ C96B75752CDD1357003A85EB /* FeatureState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75742CDD1357003A85EB /* FeatureState.swift */; }; C96B75772CDD154C003A85EB /* UpdateEventsLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75762CDD154C003A85EB /* UpdateEventsLocalStoreTests.swift */; }; C96B75792CDD159A003A85EB /* UpdateEventsLocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75782CDD159A003A85EB /* UpdateEventsLocalStore.swift */; }; + C96B757B2CDDEDC4003A85EB /* ConversationLabelsLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B757A2CDDEDC4003A85EB /* ConversationLabelsLocalStoreTests.swift */; }; + C96B757D2CDDEDF8003A85EB /* ConversationLabelsLocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B757C2CDDEDF8003A85EB /* ConversationLabelsLocalStore.swift */; }; + C96B757F2CDDEEAE003A85EB /* ConversationLabelsModelMappings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B757E2CDDEEAE003A85EB /* ConversationLabelsModelMappings.swift */; }; + C96B75822CDDEEDC003A85EB /* ConversationLabelInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96B75812CDDEEDC003A85EB /* ConversationLabelInfo.swift */; }; C97BCCAA2C98704B004F2D0D /* WireDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 01D0DCA62C1C8C870076CB1C /* WireDomain.framework */; }; C97C013D2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */; }; C97C014B2CB00F92000683C5 /* OneOnOneResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */; }; @@ -208,6 +212,10 @@ C96B75742CDD1357003A85EB /* FeatureState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureState.swift; sourceTree = ""; }; C96B75762CDD154C003A85EB /* UpdateEventsLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateEventsLocalStoreTests.swift; sourceTree = ""; }; C96B75782CDD159A003A85EB /* UpdateEventsLocalStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateEventsLocalStore.swift; sourceTree = ""; }; + C96B757A2CDDEDC4003A85EB /* ConversationLabelsLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLabelsLocalStoreTests.swift; sourceTree = ""; }; + C96B757C2CDDEDF8003A85EB /* ConversationLabelsLocalStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLabelsLocalStore.swift; sourceTree = ""; }; + C96B757E2CDDEEAE003A85EB /* ConversationLabelsModelMappings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLabelsModelMappings.swift; sourceTree = ""; }; + C96B75812CDDEEDC003A85EB /* ConversationLabelInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationLabelInfo.swift; sourceTree = ""; }; C97C013C2CAD7D69000683C5 /* UserPropertiesDeleteEventProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPropertiesDeleteEventProcessorTests.swift; sourceTree = ""; }; C97C014A2CB00F92000683C5 /* OneOnOneResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OneOnOneResolver.swift; path = ../../OneOnOneResolver.swift; sourceTree = ""; }; C97C014F2CB01BDF000683C5 /* OneOnOneResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneResolverTests.swift; sourceTree = ""; }; @@ -513,6 +521,7 @@ children = ( C96B756E2CDCFFEB003A85EB /* Mock */, C96B755C2CDBB176003A85EB /* ConversationLocalStoreTests.swift */, + C96B757A2CDDEDC4003A85EB /* ConversationLabelsLocalStoreTests.swift */, C96B75762CDD154C003A85EB /* UpdateEventsLocalStoreTests.swift */, C96B755E2CDBCD24003A85EB /* UserLocalStoreTests.swift */, C96B75602CDCAFC6003A85EB /* ConnectionsLocalStoreTests.swift */, @@ -540,6 +549,14 @@ path = Models; sourceTree = ""; }; + C96B75802CDDEEC5003A85EB /* Models */ = { + isa = PBXGroup; + children = ( + C96B75812CDDEEDC003A85EB /* ConversationLabelInfo.swift */, + ); + path = Models; + sourceTree = ""; + }; C97C01582CB40010000683C5 /* FederationEventProcessor */ = { isa = PBXGroup; children = ( @@ -663,7 +680,10 @@ C99322D12C986E3A0065E10F /* ConversationsLabels */ = { isa = PBXGroup; children = ( + C96B75802CDDEEC5003A85EB /* Models */, C99322CF2C986E3A0065E10F /* ConversationLabelsRepository.swift */, + C96B757E2CDDEEAE003A85EB /* ConversationLabelsModelMappings.swift */, + C96B757C2CDDEDF8003A85EB /* ConversationLabelsLocalStore.swift */, C99322D02C986E3A0065E10F /* ConversationLabelsRepositoryError.swift */, ); path = ConversationsLabels; @@ -1047,6 +1067,7 @@ buildActionMask = 2147483647; files = ( C99322D22C986E3A0065E10F /* TeamRepository.swift in Sources */, + C96B75822CDDEEDC003A85EB /* ConversationLabelInfo.swift in Sources */, EEAD0A042C46775700CC8658 /* ConversationMLSWelcomeEventProcessor.swift in Sources */, C99322E72C986E3A0065E10F /* ConnectionsRepositoryError.swift in Sources */, C98433D02CC26A1D009723D4 /* MLSProvider.swift in Sources */, @@ -1069,6 +1090,7 @@ C9FDF3EC2CAA988900D78098 /* ConnectionsModelMappings.swift in Sources */, C99322D42C986E3A0065E10F /* SelfUserProvider.swift in Sources */, C97C01D72CC13E1A000683C5 /* UserLocalStore.swift in Sources */, + C96B757D2CDDEDF8003A85EB /* ConversationLabelsLocalStore.swift in Sources */, EEAD0A332C46B99800CC8658 /* FederationDeleteEventProcessor.swift in Sources */, C99322E42C986E3A0065E10F /* ConversationRepository.swift in Sources */, C99322E02C986E3A0065E10F /* ConversationLocalStore+Metadata.swift in Sources */, @@ -1107,6 +1129,7 @@ EEAD0A262C46AD4D00CC8658 /* UserLegalholdRequestEventProcessor.swift in Sources */, EEAD09FE2C46774200CC8658 /* ConversationMemberUpdateEventProcessor.swift in Sources */, EEAD09F42C46709800CC8658 /* ConversationCodeUpdateEventProcessor.swift in Sources */, + C96B757F2CDDEEAE003A85EB /* ConversationLabelsModelMappings.swift in Sources */, EE368CCF2C2DAA87009DBAB0 /* FeatureConfigEventProcessor.swift in Sources */, EECC35A82C2EB70400679448 /* SyncState.swift in Sources */, C99322D82C986E3A0065E10F /* UpdateEventsRepository.swift in Sources */, @@ -1136,6 +1159,7 @@ files = ( C9F691292C9B164A008CC41F /* UserPushRemoveEventProcessorTests.swift in Sources */, C93961932C91B15B00EA971A /* ConversationRepositoryTests.swift in Sources */, + C96B757B2CDDEDC4003A85EB /* ConversationLabelsLocalStoreTests.swift in Sources */, C96B75292CDB7688003A85EB /* ConversationMemberUpdateEventProcessorTests.swift in Sources */, C96B752A2CDB7688003A85EB /* ConversationMemberJoinEventTests.swift in Sources */, C96B752B2CDB7688003A85EB /* ConversationProtocolUpdateEventProcessorTests.swift in Sources */, diff --git a/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsLocalStore.swift new file mode 100644 index 00000000000..f53d1ca0179 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsLocalStore.swift @@ -0,0 +1,154 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireDataModel + +// sourcery: AutoMockable +public protocol ConversationLabelsLocalStoreProtocol { + + /// Save label and related conversations objects to local storage. + /// - Parameter conversationLabel: conversation label from WireAPI + + func storeLabel( + _ conversationLabel: ConversationLabelInfo + ) async throws + + /// Delete old `folder` labels and related conversations objects from local storage. + /// - Parameter excludedLabels: remote labels that should be excluded from deletion. + /// - Only old labels of type `folder` are deleted, `favorite` labels always remain in the local storage. + + func deleteOldLabelsLocally( + excludedLabels: [ConversationLabelInfo] + ) async throws +} + +public final class ConversationLabelsLocalStore: ConversationLabelsLocalStoreProtocol { + + // MARK: - Error + + enum Error: Swift.Error { + case failedToStoreLabelLocally(UUID) + } + + // MARK: - Properties + + private let context: NSManagedObjectContext + private let logger = WireLogger(tag: "conversation-labels") + + // MARK: - Object lifecycle + + init( + context: NSManagedObjectContext + ) { + self.context = context + } + + // MARK: - Public + + /// Save label and related conversations objects to local storage. + /// - Parameter conversationLabel: conversation label from WireAPI + + public func storeLabel( + _ conversationLabel: ConversationLabelInfo + ) async throws { + try await context.perform { [context] in + var created = false + let label: Label? = if conversationLabel.type == Label.Kind.favorite.rawValue { + Label.fetchFavoriteLabel(in: context) + } else { + Label.fetchOrCreate(remoteIdentifier: conversationLabel.id, create: true, in: context, created: &created) + } + + guard let label else { + throw Error.failedToStoreLabelLocally(conversationLabel.id) + } + + label.name = conversationLabel.name + label.kind = Label.Kind(rawValue: conversationLabel.type) ?? .folder + + let conversations = ZMConversation.fetchObjects( + withRemoteIdentifiers: Set(conversationLabel.conversationIDs), + in: context + ) as? Set ?? Set() + + label.conversations = conversations + label.modifiedKeys = nil + + do { + try context.save() + } catch { + throw Error.failedToStoreLabelLocally(conversationLabel.id) + } + } + } + + public func deleteOldLabelsLocally( + excludedLabels: [ConversationLabelInfo] + ) async throws { + try await context.perform { [self] in + let uuids = excludedLabels.map { $0.id.uuidData as NSData } + let predicateFormat = "type == \(Label.Kind.folder.rawValue) AND NOT remoteIdentifier_data IN %@" + + let predicate = NSPredicate( + format: predicateFormat, + uuids as CVarArg + ) + + let fetchRequest: NSFetchRequest + fetchRequest = NSFetchRequest(entityName: Label.entityName()) + fetchRequest.predicate = predicate + + /// Since batch operations bypass the context processing, + /// relationships rules are often ignored (e.g delete rule) + /// Nevertheless, CoreData automatically handles two specific scenarios: + /// `Cascade` delete rule and `Nullify` delete rule on an optional property + /// Since `conversations` is nullify and optional, we can safely perform a batch delete. + + let deleteRequest = NSBatchDeleteRequest( + fetchRequest: fetchRequest + ) + + deleteRequest.resultType = .resultTypeObjectIDs + + do { + let batchDelete = try context.execute(deleteRequest) as? NSBatchDeleteResult + + guard let deleteResult = batchDelete?.result as? [NSManagedObjectID] else { + throw ConversationLabelsRepositoryError.failedToDeleteStoredLabels + } + + let deletedObjects: [AnyHashable: Any] = [ + NSDeletedObjectsKey: deleteResult + ] + + /// Since `NSBatchDeleteRequest` only operates at the SQL level (in the persistent store itself), + /// we need to manually update our in-memory objects after execution. + + NSManagedObjectContext.mergeChanges( + fromRemoteContextSave: deletedObjects, + into: [context] + ) + + } catch { + logger.error("Failed to delete old labels: \(error)") + throw error + } + } + } + +} diff --git a/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsModelMappings.swift b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsModelMappings.swift new file mode 100644 index 00000000000..9aea7120199 --- /dev/null +++ b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsModelMappings.swift @@ -0,0 +1,32 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireAPI + +extension WireAPI.ConversationLabel { + + func toDomainModel() -> ConversationLabelInfo { + .init( + id: id, + name: name, + type: type, + conversationIDs: conversationIDs + ) + } + +} diff --git a/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsRepository.swift b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsRepository.swift index b167f61fc06..b94a44c71c4 100644 --- a/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsRepository.swift +++ b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsRepository.swift @@ -16,7 +16,6 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import CoreData import WireAPI import WireDataModel @@ -42,25 +41,21 @@ public class ConversationLabelsRepository: ConversationLabelsRepositoryProtocol // MARK: - Properties private let userPropertiesAPI: any UserPropertiesAPI - // swiftlint:disable:next todo_requires_jira_link - // TODO: create ConversationLabelsLocalStore - private let context: NSManagedObjectContext + private let conversationLabelsLocalStore: any ConversationLabelsLocalStoreProtocol private let logger = WireLogger(tag: "conversation-labels") // MARK: - Object lifecycle init( userPropertiesAPI: any UserPropertiesAPI, - context: NSManagedObjectContext + conversationLabelsLocalStore: any ConversationLabelsLocalStoreProtocol ) { self.userPropertiesAPI = userPropertiesAPI - self.context = context + self.conversationLabelsLocalStore = conversationLabelsLocalStore } // MARK: - Public - /// Retrieve from backend and store conversation labels locally - public func pullConversationLabels() async throws { let conversationLabels = try await userPropertiesAPI.getLabels() try await updateConversationLabels(conversationLabels) @@ -70,7 +65,10 @@ public class ConversationLabelsRepository: ConversationLabelsRepositoryProtocol _ conversationLabels: [ConversationLabel] ) async throws { await storeLabelsLocally(conversationLabels) - try await deleteOldLabelsLocally(excludedLabels: conversationLabels) + + try await conversationLabelsLocalStore.deleteOldLabelsLocally( + excludedLabels: conversationLabels.map { $0.toDomainModel() } + ) } // MARK: - Private @@ -81,7 +79,9 @@ public class ConversationLabelsRepository: ConversationLabelsRepositoryProtocol await withThrowingTaskGroup(of: Void.self) { taskGroup in for conversationLabel in conversationLabels { taskGroup.addTask { [self] in - try await storeLabelLocally(conversationLabel) + try await conversationLabelsLocalStore.storeLabel( + conversationLabel.toDomainModel() + ) } } @@ -91,9 +91,9 @@ public class ConversationLabelsRepository: ConversationLabelsRepositoryProtocol case .success: continue case .failure(let error): - let repoError = error as? ConversationLabelsRepositoryError - if case .failedToStoreLabelLocally(let label) = repoError { - logger.error("Failed to store conversation label with id \(label.id): \(error)") + let repoError = error as? ConversationLabelsLocalStore.Error + if case .failedToStoreLabelLocally(let id) = repoError { + logger.error("Failed to store conversation label with id \(id.safeForLoggingDescription): \(error)") } else { logger.error("Failed to store conversation with error: \(error)") } @@ -102,99 +102,4 @@ public class ConversationLabelsRepository: ConversationLabelsRepositoryProtocol } } - /// Save label and related conversations objects to local storage. - /// - Parameter conversationLabel: conversation label from WireAPI - - private func storeLabelLocally( - _ conversationLabel: ConversationLabel - ) async throws { - try await context.perform { [context] in - var created = false - let label: Label? = if conversationLabel.type == Label.Kind.favorite.rawValue { - Label.fetchFavoriteLabel(in: context) - } else { - Label.fetchOrCreate(remoteIdentifier: conversationLabel.id, create: true, in: context, created: &created) - } - - guard let label else { - throw ConversationLabelsRepositoryError.failedToStoreLabelLocally(conversationLabel) - } - - label.name = conversationLabel.name - label.kind = Label.Kind(rawValue: conversationLabel.type) ?? .folder - - let conversations = ZMConversation.fetchObjects( - withRemoteIdentifiers: Set(conversationLabel.conversationIDs), - in: context - ) as? Set ?? Set() - - label.conversations = conversations - label.modifiedKeys = nil - - do { - try context.save() - } catch { - throw ConversationLabelsRepositoryError.failedToStoreLabelLocally(conversationLabel) - } - } - } - - /// Delete old `folder` labels and related conversations objects from local storage. - /// - Parameter excludedLabels: remote labels that should be excluded from deletion. - /// - Only old labels of type `folder` are deleted, `favorite` labels always remain in the local storage. - - private func deleteOldLabelsLocally( - excludedLabels remoteLabels: [ConversationLabel] - ) async throws { - try await context.perform { [self] in - let uuids = remoteLabels.map { $0.id.uuidData as NSData } - let predicateFormat = "type == \(Label.Kind.folder.rawValue) AND NOT remoteIdentifier_data IN %@" - - let predicate = NSPredicate( - format: predicateFormat, - uuids as CVarArg - ) - - let fetchRequest: NSFetchRequest - fetchRequest = NSFetchRequest(entityName: Label.entityName()) - fetchRequest.predicate = predicate - - /// Since batch operations bypass the context processing, - /// relationships rules are often ignored (e.g delete rule) - /// Nevertheless, CoreData automatically handles two specific scenarios: - /// `Cascade` delete rule and `Nullify` delete rule on an optional property - /// Since `conversations` is nullify and optional, we can safely perform a batch delete. - - let deleteRequest = NSBatchDeleteRequest( - fetchRequest: fetchRequest - ) - - deleteRequest.resultType = .resultTypeObjectIDs - - do { - let batchDelete = try context.execute(deleteRequest) as? NSBatchDeleteResult - - guard let deleteResult = batchDelete?.result as? [NSManagedObjectID] else { - throw ConversationLabelsRepositoryError.failedToDeleteStoredLabels - } - - let deletedObjects: [AnyHashable: Any] = [ - NSDeletedObjectsKey: deleteResult - ] - - /// Since `NSBatchDeleteRequest` only operates at the SQL level (in the persistent store itself), - /// we need to manually update our in-memory objects after execution. - - NSManagedObjectContext.mergeChanges( - fromRemoteContextSave: deletedObjects, - into: [context] - ) - - } catch { - logger.error("Failed to delete old labels: \(error)") - throw error - } - } - } - } diff --git a/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsRepositoryError.swift b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsRepositoryError.swift index d5c17089b81..fa999e54fb7 100644 --- a/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsRepositoryError.swift +++ b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/ConversationLabelsRepositoryError.swift @@ -16,16 +16,13 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // +import Foundation import WireAPI /// Errors originating from `ConversationLabelsRepository`. enum ConversationLabelsRepositoryError: Error, Equatable { - /// Unable to store label locally - - case failedToStoreLabelLocally(ConversationLabel) - /// Unable to pull labels from backend case failedToCollectLabelsRemotely diff --git a/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/Models/ConversationLabelInfo.swift b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/Models/ConversationLabelInfo.swift new file mode 100644 index 00000000000..75c956c98ed --- /dev/null +++ b/WireDomain/Sources/WireDomain/Repositories/ConversationsLabels/Models/ConversationLabelInfo.swift @@ -0,0 +1,26 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +public struct ConversationLabelInfo: Sendable { + let id: UUID + let name: String? + let type: Int16 + let conversationIDs: [UUID] +} diff --git a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift index 34424c95983..f978de9d771 100644 --- a/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/FeatureConfig/FeatureConfigLocalStore.swift @@ -24,7 +24,7 @@ protocol FeatureConfigLocalStoreProtocol { /// Fetches a feature locally. /// - parameter name: The name of the feature. /// - returns: The feature found locally - + func fetchFeature( name: Feature.Name ) async throws -> Feature @@ -52,7 +52,7 @@ protocol FeatureConfigLocalStoreProtocol { /// - name: The name of the feature /// - isEnabled: Whether the feature is enabled. /// - config: The config of the feature if any. - + func storeFeature( name: Feature.Name, isEnabled: Bool, diff --git a/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift index 3e4b10de929..ac716b8aa1c 100644 --- a/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift +++ b/WireDomain/Sources/WireDomain/Repositories/UpdateEvents/UpdateEventsLocalStore.swift @@ -71,7 +71,7 @@ final class UpdateEventsLocalStore: UpdateEventsLocalStoreProtocol { } // MARK: - Error - + enum Error: Swift.Error { case failedToFetchStoredEvents(Swift.Error) case failedToDeleteStoredEvents(Swift.Error) diff --git a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift index ef53ad07fbc..3745f32f6a4 100644 --- a/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift +++ b/WireDomain/Sources/WireDomainSupport/Sourcery/generated/AutoMockable.generated.swift @@ -134,6 +134,55 @@ public class MockConnectionsRepositoryProtocol: ConnectionsRepositoryProtocol { } +public class MockConversationLabelsLocalStoreProtocol: ConversationLabelsLocalStoreProtocol { + + // MARK: - Life cycle + + public init() {} + + + // MARK: - storeLabel + + public var storeLabel_Invocations: [ConversationLabelInfo] = [] + public var storeLabel_MockError: Error? + public var storeLabel_MockMethod: ((ConversationLabelInfo) async throws -> Void)? + + public func storeLabel(_ conversationLabel: ConversationLabelInfo) async throws { + storeLabel_Invocations.append(conversationLabel) + + if let error = storeLabel_MockError { + throw error + } + + guard let mock = storeLabel_MockMethod else { + fatalError("no mock for `storeLabel`") + } + + try await mock(conversationLabel) + } + + // MARK: - deleteOldLabelsLocally + + public var deleteOldLabelsLocallyExcludedLabels_Invocations: [[ConversationLabelInfo]] = [] + public var deleteOldLabelsLocallyExcludedLabels_MockError: Error? + public var deleteOldLabelsLocallyExcludedLabels_MockMethod: (([ConversationLabelInfo]) async throws -> Void)? + + public func deleteOldLabelsLocally(excludedLabels: [ConversationLabelInfo]) async throws { + deleteOldLabelsLocallyExcludedLabels_Invocations.append(excludedLabels) + + if let error = deleteOldLabelsLocallyExcludedLabels_MockError { + throw error + } + + guard let mock = deleteOldLabelsLocallyExcludedLabels_MockMethod else { + fatalError("no mock for `deleteOldLabelsLocallyExcludedLabels`") + } + + try await mock(excludedLabels) + } + +} + public class MockConversationLabelsRepositoryProtocol: ConversationLabelsRepositoryProtocol { // MARK: - Life cycle diff --git a/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLabelsLocalStoreTests.swift b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLabelsLocalStoreTests.swift new file mode 100644 index 00000000000..d95171b9b71 --- /dev/null +++ b/WireDomain/Tests/WireDomainTests/LocalStores/ConversationLabelsLocalStoreTests.swift @@ -0,0 +1,310 @@ +// +// Wire +// Copyright (C) 2024 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +@testable import WireDataModel +import WireDataModelSupport +@testable import WireDomain +import WireTestingPackage +import XCTest + +final class ConversationLabelsLocalStoreTests: XCTestCase { + + private var sut: ConversationLabelsLocalStore! + private var stack: CoreDataStack! + private var coreDataStackHelper: CoreDataStackHelper! + private var modelHelper: ModelHelper! + + private var conversation1: ZMConversation! + private var conversation2: ZMConversation! + private var conversation3: ZMConversation! + + private var context: NSManagedObjectContext { + stack.syncContext + } + + override func setUp() async throws { + modelHelper = ModelHelper() + coreDataStackHelper = CoreDataStackHelper() + /// Batch requests don't work with in-memory store + /// so we need to use a persistent store. + stack = try await coreDataStackHelper.createStack(inMemoryStore: false) + await cleanUpEntity() + await setupConversations() + + sut = ConversationLabelsLocalStore( + context: context + ) + } + + override func tearDown() async throws { + try coreDataStackHelper.cleanupDirectory() + coreDataStackHelper = nil + sut = nil + modelHelper = nil + } + + // MARK: - Tests + + func testStoreLabel_Given_Local_Store_Empty_It_Creates_Label_Locally() async throws { + // Mock + + let conversationLabel = Scaffolding.conversationLabel1 + + // When + + try await sut.storeLabel(conversationLabel) + + // Then + + try await context.perform { [context] in + let fetchRequest = NSFetchRequest