Files
ios-mail/Modules/InboxContacts/Tests/Unit/Screens/Contacts/ContactsStateStoreTests.swift
T
2026-04-15 13:58:23 +02:00

667 lines
21 KiB
Swift

// Copyright (c) 2024 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail 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.
//
// Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/.
import Foundation
import InboxCore
import InboxTesting
import Testing
import proton_app_uniffi
@testable import InboxContacts
@MainActor
final class ContactsStateStoreTests {
private lazy var sut: ContactsStateStore = makeSUT(search: .initial)
private var stubbedContacts: [GroupedContacts] = []
private var watchContactsCallback: ContactsLiveQueryCallback?
private var createdLiveQueryCallbackWrapper: ContactsLiveQueryCallbackWrapper?
private let deleterSpy: DeleterSpy = .init()
@Test
func init_ItDoesNotStartWatchingContacts() {
#expect(createdLiveQueryCallbackWrapper == nil)
#expect(watchContactsCallback == nil)
}
@Test
func state_ItHasCorrectInitialState() {
let expectedState = ContactsScreenState(
search: .init(query: "", isActive: false),
allItems: [],
displayCreateContactSheet: false,
createContactURL: .none
)
#expect(sut.state == expectedState)
#expect(sut.state.displayItems == expectedState.allItems)
}
// MARK: - `onLoad` action
@Test
func onLoadAction_ItStartsWatchingContactsUpdates() async throws {
await sut.handle(action: .onLoad)
let callbackWrapper = try #require(createdLiveQueryCallbackWrapper)
#expect(callbackWrapper === watchContactsCallback)
}
@Test
func onLoadAction_ItLoadsAllContacts() async {
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
),
.init(
groupedBy: "A",
items: [
.contact(.aliceAdams),
.group(.advisorsGroup),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
]
stubbedContacts = groupedItems
await sut.handle(action: .onLoad)
#expect(
sut.state
== .init(
search: .initial,
allItems: groupedItems,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == sut.state.allItems)
#expect(deleterSpy.deleteContactCalls.isEmpty)
#expect(deleterSpy.deleteContactGroupCalls.isEmpty)
}
@Test
func onLoadAction_WhenContainsSpecificSearchPhrase_ItDisplaysFilteredItemsInOneSection() async {
sut = makeSUT(search: .active(query: "Andr"))
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
),
.init(
groupedBy: "A",
items: [
.contact(.aliceAdams),
.group(.advisorsGroup),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
.init(
groupedBy: "E",
items: [
.contact(.evanAndrage),
.contact(.elenaErickson),
]
),
]
stubbedContacts = groupedItems
await sut.handle(action: .onLoad)
let expectedDisplayItems: [GroupedContacts] = [
.init(
groupedBy: "",
items: [
.contact(.andrewAllen),
.contact(.evanAndrage),
]
)
]
#expect(
sut.state
== .init(
search: .init(query: "Andr", isActive: true),
allItems: groupedItems,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == expectedDisplayItems)
#expect(deleterSpy.deleteContactCalls.isEmpty)
#expect(deleterSpy.deleteContactGroupCalls.isEmpty)
}
@Test
func onLoad_WhenSearchIsActiveButEmptySearchPhrase_ItDisplaysAllItemsInOneSection() async {
sut = makeSUT(search: .active(query: ""))
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
),
.init(
groupedBy: "A",
items: [
.contact(.aliceAdams),
.group(.advisorsGroup),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
.init(
groupedBy: "E",
items: [
.contact(.evanAndrage),
.contact(.elenaErickson),
]
),
]
stubbedContacts = groupedItems
await sut.handle(action: .onLoad)
#expect(
sut.state
== .init(
search: .init(query: "", isActive: true),
allItems: groupedItems,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == [.init(groupedBy: "", items: sut.state.allItems.flatMap(\.items))])
#expect(deleterSpy.deleteContactCalls.isEmpty)
#expect(deleterSpy.deleteContactGroupCalls.isEmpty)
}
// MARK: - `onDeleteItem` action
@Test
func onDeleteItemActionForGroupItem_WhenSearchIsInactive_ItUpdatesStateCorrectlyAndTriggersContactGroupDeletions() async {
let itemToDelete: ContactItemType = .group(.advisorsGroup)
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
),
.init(
groupedBy: "A",
items: [
.contact(.aliceAdams),
.group(.advisorsGroup),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
]
stubbedContacts = groupedItems
await sut.handle(action: .onLoad)
await sut.handle(action: .onDeleteItem(itemToDelete))
#expect(
sut.state
== .init(
search: .initial,
allItems: groupedItems,
itemToDelete: itemToDelete,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == sut.state.allItems)
await simulateSuccessfulOnDeleteItemAlertAction(.group(.advisorsGroup), from: groupedItems)
let expectedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
),
.init(
groupedBy: "A",
items: [
.contact(.aliceAdams),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
]
#expect(
sut.state
== .init(
search: .initial,
allItems: expectedItems,
itemToDelete: nil,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == sut.state.allItems)
#expect(deleterSpy.deleteContactCalls.isEmpty)
#expect(deleterSpy.deleteContactGroupCalls == [ContactGroupItem.advisorsGroup.id])
}
@Test
func onDeleteItemActionForContactItem_WhenSearchIsActive_ItUpdatesStateCorrectlyAndTriggersContactDeletion() async {
sut = makeSUT(search: .active(query: ""))
let itemToDelete: ContactItemType = .contact(.vip)
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
itemToDelete
]
),
.init(
groupedBy: "A",
items: [
.group(.advisorsGroup),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
]
stubbedContacts = groupedItems
await sut.handle(action: .onLoad)
await sut.handle(action: .onDeleteItem(.contact(.vip)))
#expect(
sut.state
== .init(
search: .active(query: ""),
allItems: groupedItems,
itemToDelete: itemToDelete,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == [.init(groupedBy: "", items: groupedItems.flatMap(\.items))])
#expect(deleterSpy.deleteContactCalls.isEmpty)
#expect(deleterSpy.deleteContactGroupCalls.isEmpty)
await simulateSuccessfulOnDeleteItemAlertAction(.contact(.vip), from: groupedItems)
let expectedItems: [GroupedContacts] = [
.init(
groupedBy: "A",
items: [
.group(.advisorsGroup),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
)
]
#expect(
sut.state
== .init(
search: .active(query: ""),
allItems: expectedItems,
itemToDelete: nil,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == [.init(groupedBy: "", items: expectedItems.flatMap(\.items))])
#expect(deleterSpy.deleteContactCalls == [ContactItem.vip.id])
#expect(deleterSpy.deleteContactGroupCalls.isEmpty)
}
@Test
func onDeleteItemActionForContactItem_AndContactDeletionFails_ItRevertsStateToTheOneBeforeDeletion() async {
let itemToDelete: ContactItemType = .contact(.vip)
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
itemToDelete
]
),
.init(
groupedBy: "A",
items: [
.group(.advisorsGroup),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
]
stubbedContacts = groupedItems
deleterSpy.stubbedDeleteContactsErrors = [
ContactItem.vip.id: .other(.network)
]
await sut.handle(action: .onLoad)
await sut.handle(action: .onDeleteItem(itemToDelete))
await sut.handle(action: .onDeleteItemAlertAction(.confirm))
await createdLiveQueryCallbackWrapper?.delegate?(groupedItems)
#expect(
sut.state
== .init(
search: .initial,
allItems: groupedItems,
itemToDelete: nil,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == groupedItems)
#expect(deleterSpy.deleteContactCalls == [ContactItem.vip.id])
#expect(deleterSpy.deleteContactGroupCalls.isEmpty)
}
@Test
func onDeleteItemActionForContactGroupItem_AndContactGroupDeletionFails_ItRevertsStateToTheOneBeforeDeletion() async {
let itemToDelete: ContactItemType = .group(.advisorsGroup)
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
),
.init(
groupedBy: "A",
items: [
itemToDelete,
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
]
stubbedContacts = groupedItems
deleterSpy.stubbedDeleteContactGroupErrors = [
ContactGroupItem.advisorsGroup.id: .other(.network)
]
await sut.handle(action: .onLoad)
await sut.handle(action: .onDeleteItem(itemToDelete))
await sut.handle(action: .onDeleteItemAlertAction(.confirm))
await createdLiveQueryCallbackWrapper?.delegate?(groupedItems)
#expect(
sut.state
== .init(
search: .initial,
allItems: groupedItems,
itemToDelete: nil,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == groupedItems)
#expect(deleterSpy.deleteContactCalls.isEmpty)
#expect(deleterSpy.deleteContactGroupCalls == [ContactGroupItem.advisorsGroup.id])
}
@Test
func onDeleteItemActionForContactGroupItem_AndCancelsDeletion_ItDoesNotTriggerContactGroupDeletion() async {
let itemToDelete: ContactItemType = .group(.advisorsGroup)
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
),
.init(
groupedBy: "A",
items: [
itemToDelete,
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
]
stubbedContacts = groupedItems
await sut.handle(action: .onLoad)
await sut.handle(action: .onDeleteItem(itemToDelete))
await sut.handle(action: .onDeleteItemAlertAction(.cancel))
await createdLiveQueryCallbackWrapper?.delegate?(groupedItems)
#expect(
sut.state
== .init(
search: .initial,
allItems: groupedItems,
itemToDelete: nil,
displayCreateContactSheet: false,
createContactURL: .none
)
)
#expect(sut.state.displayItems == groupedItems)
#expect(deleterSpy.deleteContactCalls.isEmpty)
#expect(deleterSpy.deleteContactGroupCalls.isEmpty)
}
// MARK: - `onTapItem` action
@Test
func onTapItemAction_WhenTapOnContact_ItNavigatesToContactDetails() async {
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
),
.init(
groupedBy: "A",
items: [
.contact(.aliceAdams),
.group(.advisorsGroup),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
]
stubbedContacts = groupedItems
await sut.handle(action: .onLoad)
await sut.handle(action: .onTapItem(.contact(.amandaArcher)))
#expect(sut.router.stack == [.contactDetails(.init(ContactItem.amandaArcher))])
}
@Test
func onTapItemAction_WhenTapOnContactGroup_ItNavigatesToContactGroupDetails() async {
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
),
.init(
groupedBy: "A",
items: [
.contact(.aliceAdams),
.group(.advisorsGroup),
.contact(.andrewAllen),
.contact(.amandaArcher),
]
),
]
stubbedContacts = groupedItems
await sut.handle(action: .onLoad)
await sut.handle(action: .onTapItem(.group(.advisorsGroup)))
#expect(sut.router.stack == [.contactGroupDetails(.advisorsGroup)])
}
// MARK: - `goBack` action
@Test
func goBack_ItCleansUpTheStack() async {
let groupedItems: [GroupedContacts] = [
.init(
groupedBy: "#",
items: [
.contact(.vip)
]
)
]
stubbedContacts = groupedItems
await sut.handle(action: .onLoad)
await sut.handle(action: .onTapItem(.contact(.vip)))
await sut.handle(action: .goBack)
#expect(sut.router.stack.isEmpty)
}
// MARK: - `createTapped` action
@Test
func createTappedAction_ItDisplaysCreateContactSheet() async {
await sut.handle(action: .onLoad)
await sut.handle(action: .createTapped)
#expect(sut.state.displayCreateContactSheet)
}
// MARK: - `createSheetAction` action
@Test
func createSheetAction_WhenOpenWebView_ItClosesSheetAndSetsCreateContactState() async {
await sut.handle(action: .onLoad)
await sut.handle(action: .createTapped)
await sut.handle(action: .createSheetAction(.openSafari))
let domain: String = ApiConfig.testData.envId.domain
#expect(sut.state.displayCreateContactSheet == false)
#expect(sut.state.createContactURL?.url == URL(string: "https://mail.\(domain)/inbox#create-contact")!)
}
@Test
func createSheetAction_WhenDismiss_ItClosesSheetAndDoesNotSetCreateContactState() async {
await sut.handle(action: .onLoad)
await sut.handle(action: .createTapped)
await sut.handle(action: .createSheetAction(.dismiss))
#expect(sut.state.displayCreateContactSheet == false)
#expect(sut.state.createContactURL == nil)
}
// MARK: - `dismissCreateSheet` action
@Test
func dismissCreateSheetAction_ItResetsCreateContactState() async {
await sut.handle(action: .onLoad)
await sut.handle(action: .createTapped)
await sut.handle(action: .createSheetAction(.openSafari))
await sut.handle(action: .dismissCreateSheet)
#expect(sut.state.createContactURL == nil)
}
// MARK: - Private
private func makeSUT(search: ContactsScreenState.Search) -> ContactsStateStore {
.init(
apiConfig: .debugPreview,
state: .init(search: search, allItems: [], displayCreateContactSheet: false, createContactURL: .none),
mailUserSession: .testInstance(),
contactsWrappers: .init(
contactsProvider: .init(allContacts: { [unowned self] _ in .ok(stubbedContacts) }),
contactDeleter: { [unowned self] id, _ in
deleterSpy.deleteContactCalls.append(id)
if let error = deleterSpy.stubbedDeleteContactsErrors[id] {
return .error(error)
} else {
return .ok
}
},
contactGroupDeleter: { [unowned self] id, _ in
deleterSpy.deleteContactGroupCalls.append(id)
if let error = deleterSpy.stubbedDeleteContactGroupErrors[id] {
return .error(error)
} else {
return .ok
}
},
contactsWatcher: .init(watch: { [unowned self] _, callback in
watchContactsCallback = callback
return WatchContactListResult.ok(.init(contactList: [], handle: .init(noPointer: .init())))
})
),
makeContactsLiveQuery: { [unowned self] in
let wrapper = ContactsLiveQueryCallbackWrapper()
createdLiveQueryCallbackWrapper = wrapper
return wrapper
}
)
}
private func simulateSuccessfulOnDeleteItemAlertAction(
_ item: ContactItemType,
from groupedContacts: [GroupedContacts]
) async {
await sut.handle(action: .onDeleteItemAlertAction(.confirm))
let updatedItems = deleting(item: item, from: groupedContacts)
await createdLiveQueryCallbackWrapper?.delegate?(updatedItems)
}
private func deleting(item itemToDelete: ContactItemType, from items: [GroupedContacts]) -> [GroupedContacts] {
items.compactMap { groupedContacts in
let filteredItems = groupedContacts.items.filter { item in item != itemToDelete }
return filteredItems.isEmpty ? nil : groupedContacts.copy(items: filteredItems)
}
}
}
private class DeleterSpy {
var stubbedDeleteContactsErrors: [Id: ActionError] = [:]
var stubbedDeleteContactGroupErrors: [Id: ActionError] = [:]
var deleteContactCalls: [Id] = []
var deleteContactGroupCalls: [Id] = []
}