From 136cbcb8642d8da48c1aa80689c46dbae6f2bbfa Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Fri, 30 Oct 2026 18:10:59 +0200 Subject: [PATCH] Remove already added recipients from search results --- .../Accessibility/AccessibilityField.swift | 5 + .../Supporting files/Localizable.xcstrings | 130 ++++++++++------- .../Recipient/EncryptRecipientView.swift | 73 ++++++++-- .../Crypto/Recipient/RecipientsView.swift | 132 ++++++++++++------ RIADigiDoc/UI/Theme/Dimensions.swift | 2 + RIADigiDoc/Util/Recipient/RecipientUtil.swift | 8 ++ .../Recipient/RecipientUtilProtocol.swift | 2 + .../Util/Recipient/RecipientUtilTests.swift | 75 ++++++++++ 8 files changed, 322 insertions(+), 105 deletions(-) create mode 100644 RIADigiDocTests/Util/Recipient/RecipientUtilTests.swift diff --git a/RIADigiDoc/Domain/Model/Accessibility/AccessibilityField.swift b/RIADigiDoc/Domain/Model/Accessibility/AccessibilityField.swift index e3109b69..e22ae3f9 100644 --- a/RIADigiDoc/Domain/Model/Accessibility/AccessibilityField.swift +++ b/RIADigiDoc/Domain/Model/Accessibility/AccessibilityField.swift @@ -8,6 +8,7 @@ enum AccessibilityField: Hashable, Sendable { case container(ContainerField) case dataFile(DataFileField) case signature(SignatureField) + case recipient(RecipientField) case myEid(MyEidField) enum TopBarField: Hashable { @@ -28,6 +29,10 @@ enum AccessibilityField: Hashable, Sendable { case openSignatureOptionsButton } + enum RecipientField: Hashable { + case openRecipientOptionsButton + } + enum MyEidField: Hashable { case unblockPin1Button case changePin1Button diff --git a/RIADigiDoc/Supporting files/Localizable.xcstrings b/RIADigiDoc/Supporting files/Localizable.xcstrings index ca4051ac..78b66871 100644 --- a/RIADigiDoc/Supporting files/Localizable.xcstrings +++ b/RIADigiDoc/Supporting files/Localizable.xcstrings @@ -55,6 +55,24 @@ } } }, + "Added recipient" : { + "comment" : "Accessibility prefix for a row in the Encrypt Recipients added recipients list", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Added recipient" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisatud adressaat" + } + } + } + }, "Added recipients" : { "comment" : "Encrypt Recipient Added recipients text", "extractionState" : "manual", @@ -1925,92 +1943,92 @@ } } }, - "Decryption expired without date" : { - "comment" : "CDOC2 recipient decryption expired badge when the expiry date is unknown", + "Decryption expired" : { + "comment" : "CDOC2 recipient decryption expired badge", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Decryption expired" + "value" : "Decryption expired %@" } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Dekrüpteerimise võimalus aegunud" + "value" : "Dekrüpteerimise võimalus aegus %@" } } } }, - "Decryption expired" : { - "comment" : "CDOC2 recipient decryption expired badge", + "Decryption expired without date" : { + "comment" : "CDOC2 recipient decryption expired badge when the expiry date is unknown", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Decryption expired %@" + "value" : "Decryption expired" } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Dekrüpteerimise võimalus aegus %@" + "value" : "Dekrüpteerimise võimalus aegunud" } } } }, - "Decryption until" : { - "comment" : "CDOC2 recipient decryption valid until badge", + "Decryption method" : { + "comment" : "Title for signing method in container decrypt view", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Decryption until %@" + "value" : "Decryption method" } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Dekrüpteerimine võimalik kuni %@" + "value" : "Dekrüpteerimise meetod" } } } }, - "Decryption method" : { - "comment" : "Title for signing method in container decrypt view", + "Decryption method changed" : { + "comment" : "Decryption method selection button accessibility label", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Decryption method" + "value" : "Decryption method changed" } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Dekrüpteerimise meetod" + "value" : "Dekrüpteerimise meetod muudetud" } } } }, - "Decryption method changed" : { - "comment" : "Decryption method selection button accessibility label", + "Decryption until" : { + "comment" : "CDOC2 recipient decryption valid until badge", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Decryption method changed" + "value" : "Decryption until %@" } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Dekrüpteerimise meetod muudetud" + "value" : "Dekrüpteerimine võimalik kuni %@" } } } @@ -2177,24 +2195,6 @@ } } }, - "Encrypted files" : { - "comment" : "Crypto container files tab title", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Encrypted files" - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Krüpteeritud failid" - } - } - } - }, "Encrypt" : { "comment" : "Encrypt button", "extractionState" : "manual", @@ -2285,6 +2285,24 @@ } } }, + "Encrypted files" : { + "comment" : "Crypto container files tab title", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encrypted files" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krüpteeritud failid" + } + } + } + }, "Enter current PIN code" : { "comment" : "My eID current PIN or PUK code step title", "extractionState" : "manual", @@ -2375,38 +2393,38 @@ } } }, - "Expired Smart-ID transaction" : { - "comment" : "Smart-ID error", + "Expired on" : { + "comment" : "CDOC2 recipient certificate expired badge", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your Smart-ID transaction has expired or user account not found" + "value" : "Expired on %@" } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Sinu Smart-ID toiming aegus või kontot ei leitud" + "value" : "Aegus %@" } } } }, - "Expired on" : { - "comment" : "CDOC2 recipient certificate expired badge", + "Expired Smart-ID transaction" : { + "comment" : "Smart-ID error", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Expired on %@" + "value" : "Your Smart-ID transaction has expired or user account not found" } }, "et" : { "stringUnit" : { "state" : "translated", - "value" : "Aegus %@" + "value" : "Sinu Smart-ID toiming aegus või kontot ei leitud" } } } @@ -7447,7 +7465,7 @@ "et" : { "stringUnit" : { "state" : "translated", - "value" : "Adressaat on konteineris juba olemas" + "value" : "Adressaat on ümbrikus juba olemas" } } } @@ -7524,6 +7542,24 @@ } } }, + "Recipients already exist in the container" : { + "comment" : "Encrypt Recipients view message when every found recipient is already added", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recipients already exist in the container" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adressaadid on ümbrikus juba olemas" + } + } + } + }, "Remember me" : { "comment" : "Used in signing views to remember values", "extractionState" : "manual", diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift index b8dc9ecd..23f8736c 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift @@ -9,6 +9,7 @@ import CryptoObjCWrapper struct EncryptRecipientView: View { @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dismiss) private var dismiss @Environment(LanguageSettings.self) private var languageSettings @@ -19,7 +20,8 @@ struct EncryptRecipientView: View { @AccessibilityFocusState private var isTitleFocused: Bool @AccessibilityFocusState private var isSearchFieldFocused: Bool - @AccessibilityFocusState private var focusedRecipientIndex: Int? + @AccessibilityFocusState private var focusedSearchRecipient: Data? + @AccessibilityFocusState private var focusedAddedRecipient: Data? @FocusState private var isSearchFocused: Bool @State private var isSearchExpanded = false @@ -36,6 +38,8 @@ struct EncryptRecipientView: View { @State private var viewModel: EncryptRecipientViewModel @State private var encryptViewModel: EncryptViewModel + private let recipientUtil: RecipientUtilProtocol = Container.shared.recipientUtil() + init(cdocOption: EncryptionCdocOption) { _cdocOption = State(wrappedValue: cdocOption) _viewModel = State(wrappedValue: Container.shared.encryptRecipientViewModel()) @@ -43,13 +47,25 @@ struct EncryptRecipientView: View { } var filteredRecipients: [Addressee] { - viewModel.filteredRecipients() + viewModel.filteredRecipients().filter { !recipientUtil.isRecipientAdded($0, in: addedRecipients) } } var noRecipients: Bool { filteredRecipients.isEmpty } + var searchReturnedNoRecipients: Bool { + viewModel.recipients.isEmpty + } + + var allFoundRecipientsAdded: Bool { + noRecipients && !searchReturnedNoRecipients + } + + private var rowAnimation: Animation? { + reduceMotion ? nil : .easeInOut(duration: Dimensions.Duration.rowAnimation) + } + var noSearchResults: Bool { viewModel.searchText.isEmpty } @@ -85,11 +101,11 @@ struct EncryptRecipientView: View { Spacer().frame(height: Dimensions.Padding.MSPadding) if #available(iOS 26.0, *) { - ForEach(addedRecipients.enumerated(), id: \.offset) { index, item in + ForEach(addedRecipients.enumerated(), id: \.element.data) { index, item in addedRecipientRow(index: index, item: item) } } else { - ForEach(Array(addedRecipients.enumerated()), id: \.offset) { index, item in + ForEach(Array(addedRecipients.enumerated()), id: \.element.data) { index, item in addedRecipientRow(index: index, item: item) } } @@ -100,16 +116,17 @@ struct EncryptRecipientView: View { .scrollContentBackground(.hidden) .listRowSpacing(0) .listSectionSpacing(.compact) + .animation(rowAnimation, value: addedRecipients.map(\.data)) } private var filteredRecipientsSection: some View { VStack { if #available(iOS 26.0, *) { - ForEach(filteredRecipients.enumerated(), id: \.offset) { index, item in + ForEach(filteredRecipients.enumerated(), id: \.element.data) { index, item in recipientRow(index: index, item: item) } } else { - ForEach(Array(filteredRecipients.enumerated()), id: \.offset) { index, item in + ForEach(Array(filteredRecipients.enumerated()), id: \.element.data) { index, item in recipientRow(index: index, item: item) } } @@ -119,6 +136,7 @@ struct EncryptRecipientView: View { .scrollContentBackground(.hidden) .listRowSpacing(0) .listSectionSpacing(.compact) + .animation(rowAnimation, value: filteredRecipients.map(\.data)) } @ViewBuilder @@ -144,6 +162,11 @@ struct EncryptRecipientView: View { HStack { Spacer() Button(action: { + guard !addedRecipients.isEmpty else { + showMessage("Cannot create crypto container without recipients") + return + } + encryptionButtonEnabled = false pathManager.replaceLast( to: .encryptView( @@ -266,6 +289,10 @@ struct EncryptRecipientView: View { .scrollContentBackground(.hidden) } else if let emptyStateMessageKey = viewModel.emptyStateMessageKey { emptyStateView(languageSettings.localized(emptyStateMessageKey)) + } else if allFoundRecipientsAdded { + emptyStateView( + languageSettings.localized("Recipients already exist in the container") + ) } else { filteredRecipientsSection } @@ -276,7 +303,7 @@ struct EncryptRecipientView: View { addedRecipientsSection } } - .accessibilitySortPriority(filteredRecipients.isEmpty ? 2 : 0) + .accessibilitySortPriority(searchReturnedNoRecipients ? 2 : 0) } @ViewBuilder @@ -376,7 +403,6 @@ struct EncryptRecipientView: View { addedRecipients = await viewModel.filteredAddedRecipients() selectedRecipient = nil showRemoveRecipientModal = false - await viewModel.loadRecipients() } }, onCancel: { @@ -436,15 +462,13 @@ struct EncryptRecipientView: View { RecipientsView( recipient: item, recipientIndex: index, - showRemoveButton: false, + showMoreOptionsButton: false, onOpenRecipient: { Task { @MainActor in await viewModel.addRecipients(item) addedRecipients = await viewModel.filteredAddedRecipients() + moveFocusAfterAdding(fromIndex: index) } - }, - onRemoveRecipient: { - // do nothing } ) .padding(.vertical, Dimensions.Padding.SPadding) @@ -452,15 +476,17 @@ struct EncryptRecipientView: View { .contentShape(Rectangle()) .buttonStyle(.plain) .background(theme.surface) - .accessibilityFocused($focusedRecipientIndex, equals: index) + .transition(.opacity) + .accessibilityFocused($focusedSearchRecipient, equals: item.data) } private func addedRecipientRow(index: Int, item: Addressee) -> some View { RecipientsView( recipient: item, recipientIndex: index, - showRemoveButton: true, - onOpenRecipient: { + showMoreOptionsButton: true, + accessibilityPrefixKey: "Added recipient", + onShowRecipientDetails: { pathManager .navigate( to: .recipientDetailView( @@ -478,6 +504,23 @@ struct EncryptRecipientView: View { .contentShape(Rectangle()) .buttonStyle(.plain) .background(theme.surface) + .transition(.opacity) + .accessibilityFocused($focusedAddedRecipient, equals: item.data) + } + + private func moveFocusAfterAdding(fromIndex index: Int) { + focusedSearchRecipient = nil + focusedAddedRecipient = nil + + DispatchQueue.main.asyncAfter(deadline: .now() + Dimensions.Duration.focusHandoff) { + let remaining = filteredRecipients + + if remaining.isEmpty { + focusedAddedRecipient = addedRecipients.first?.data + } else { + focusedSearchRecipient = remaining[min(index, remaining.count - 1)].data + } + } } private func handlePasswordEncrypt(label: String, password: String) async { diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/RecipientsView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/RecipientsView.swift index d7be1a5e..07f851ce 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/RecipientsView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/RecipientsView.swift @@ -7,17 +7,24 @@ import CryptoObjCWrapper import UtilsLib struct RecipientsView: View { + @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled @Environment(LanguageSettings.self) private var languageSettings @AppTheme private var theme @AppTypography private var typography + @AccessibilityFocusState private var focusedField: AccessibilityField? + + @State private var showBottomSheet = false + let recipient: Addressee let recipientIndex: Int - let showRemoveButton: Bool + let showMoreOptionsButton: Bool + let accessibilityPrefixKey: String var onOpenRecipient: (() -> Void)? + var onShowRecipientDetails: (() -> Void)? var onRemoveRecipient: (() -> Void)? let nameUtil: NameUtilProtocol @@ -57,24 +64,90 @@ struct RecipientsView: View { ).date } + private var accessibilityPrefix: String { + "\(languageSettings.localized(accessibilityPrefixKey)) \(recipientIndex + 1)" + } + + private var moreOptionsLabel: String { + languageSettings.localized("More options") + } + + private var bottomSheetActions: [BottomSheetButton] { + RecipientBottomSheetActions.actions( + showRemoveRecipientButton: true, + onDetailsButtonClick: { onShowRecipientDetails?() }, + onRemoveRecipientButtonClick: { onRemoveRecipient?() } + ) + } + init( recipient: Addressee, recipientIndex: Int, - showRemoveButton: Bool, + showMoreOptionsButton: Bool, + accessibilityPrefixKey: String = "Recipient", onOpenRecipient: (() -> Void)? = nil, + onShowRecipientDetails: (() -> Void)? = nil, onRemoveRecipient: (() -> Void)? = nil, nameUtil: NameUtilProtocol = Container.shared.nameUtil(), recipientUtil: RecipientUtilProtocol = Container.shared.recipientUtil(), ) { self.recipient = recipient self.recipientIndex = recipientIndex - self.showRemoveButton = showRemoveButton + self.showMoreOptionsButton = showMoreOptionsButton + self.accessibilityPrefixKey = accessibilityPrefixKey self.onOpenRecipient = onOpenRecipient + self.onShowRecipientDetails = onShowRecipientDetails self.onRemoveRecipient = onRemoveRecipient self.nameUtil = nameUtil self.recipientUtil = recipientUtil } + private var recipientInfo: some View { + VStack(alignment: .leading, spacing: Dimensions.Padding.XXSPadding) { + StyledNameText(name: nameText, allCaps: false) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + .accessibilityLabel( + Text(verbatim: "\(accessibilityPrefix), \(nameText.lowercased())") + ) + + let certType = recipientUtil.getRecipientCertTypeText(certType: recipient.certType) + Text(verbatim: + "\(languageSettings.localized(certType)) " + + "\(languageSettings.localized("Valid to", [validToDate]))") + .font(typography.bodyMedium) + .foregroundStyle(theme.onSurfaceVariant) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } + .accessibilityElement(children: .combine) + } + + private var moreOptionsButton: some View { + Button(action: accessibleAction( + voiceOverEnabled: voiceOverEnabled, + focusedField: $focusedField, + action: { showBottomSheet = true } + ), label: { + Image("ic_m3_more_vert_48pt_wght400") + .resizable() + .scaledToFit() + .frame(width: Dimensions.Icon.IconSizeXXS, height: Dimensions.Icon.IconSizeXXS) + .foregroundStyle(theme.onSurfaceVariant) + .accessibilityHidden(true) + }) + .frame(minWidth: Dimensions.Icon.IconSizeXS, minHeight: Dimensions.Icon.IconSizeXS) + .contentShape(Rectangle()) + .accessibilityLabel(Text(verbatim: "\(accessibilityPrefix), \(moreOptionsLabel)")) + .accessibilityInputLabels([moreOptionsLabel]) + .bottomSheet(isPresented: $showBottomSheet, actions: bottomSheetActions) + .accessibilityFocusRestore( + focusedField: $focusedField, + field: .recipient(.openRecipientOptionsButton), + when: showBottomSheet + ) + } + var body: some View { HStack(spacing: Dimensions.Padding.MSPadding) { Image(iconRes) @@ -84,47 +157,19 @@ struct RecipientsView: View { .foregroundStyle(theme.onSurface) .accessibilityHidden(true) - Button( - action: { onOpenRecipient?() }, - label: { - VStack(alignment: .leading, spacing: Dimensions.Padding.XXSPadding) { - StyledNameText(name: nameText, allCaps: false) - .fixedSize(horizontal: false, vertical: true) - .multilineTextAlignment(.leading) - .accessibilityLabel({ - let prefix = languageSettings.localized("Recipient") - let name = nameText.lowercased() - return Text(verbatim: "\(prefix) \(recipientIndex + 1), \(name)") - }()) - - let certType = recipientUtil.getRecipientCertTypeText(certType: recipient.certType) - Text(verbatim: - "\(languageSettings.localized(certType)) " + - "\(languageSettings.localized("Valid to", [validToDate]))") - .font(typography.bodyMedium) - .foregroundStyle(theme.onSurfaceVariant) - .fixedSize(horizontal: false, vertical: true) - .multilineTextAlignment(.leading) - } - .accessibilityElement(children: .combine) - } - ) + if showMoreOptionsButton { + recipientInfo + } else { + Button( + action: { onOpenRecipient?() }, + label: { recipientInfo } + ) + } Spacer() - if showRemoveButton { - Button( - action: { onRemoveRecipient?() }, - label: { - Image("ic_m3_delete_48pt_wght400") - .resizable() - .scaledToFit() - .frame(width: Dimensions.Icon.IconSizeXXS, height: Dimensions.Icon.IconSizeXXS) - .foregroundStyle(theme.onSurface) - .accessibilityLabel( - Text(verbatim: languageSettings.localized("Remove recipient")) - ) - }) + if showMoreOptionsButton { + moreOptionsButton } } } @@ -143,8 +188,9 @@ struct RecipientsView: View { RecipientsView( recipient: recipient, recipientIndex: 0, - showRemoveButton: true, - onOpenRecipient: {}, + showMoreOptionsButton: true, + accessibilityPrefixKey: "Added recipient", + onShowRecipientDetails: {}, onRemoveRecipient: {} ) .environment(Container.shared.languageSettings()) diff --git a/RIADigiDoc/UI/Theme/Dimensions.swift b/RIADigiDoc/UI/Theme/Dimensions.swift index 196061fe..32bacd3f 100644 --- a/RIADigiDoc/UI/Theme/Dimensions.swift +++ b/RIADigiDoc/UI/Theme/Dimensions.swift @@ -51,6 +51,8 @@ enum Dimensions { enum Duration { static let focusAnimation: CGFloat = 0.1 + static let rowAnimation: CGFloat = 0.25 + static let focusHandoff: Double = 0.3 } enum Scaling { diff --git a/RIADigiDoc/Util/Recipient/RecipientUtil.swift b/RIADigiDoc/Util/Recipient/RecipientUtil.swift index 41f626d8..143c304b 100644 --- a/RIADigiDoc/Util/Recipient/RecipientUtil.swift +++ b/RIADigiDoc/Util/Recipient/RecipientUtil.swift @@ -25,4 +25,12 @@ struct RecipientUtil: RecipientUtilProtocol { return "Password" } } + + private func isSameRecipient(_ recipient: Addressee, _ other: Addressee) -> Bool { + recipient.data == other.data + } + + func isRecipientAdded(_ recipient: Addressee, in addedRecipients: [Addressee]) -> Bool { + addedRecipients.contains { isSameRecipient($0, recipient) } + } } diff --git a/RIADigiDoc/Util/Recipient/RecipientUtilProtocol.swift b/RIADigiDoc/Util/Recipient/RecipientUtilProtocol.swift index 862d1fd9..a61332c7 100644 --- a/RIADigiDoc/Util/Recipient/RecipientUtilProtocol.swift +++ b/RIADigiDoc/Util/Recipient/RecipientUtilProtocol.swift @@ -7,4 +7,6 @@ import CryptoObjCWrapper /// @mockable public protocol RecipientUtilProtocol: Sendable { func getRecipientCertTypeText(certType: CertType) -> String + + func isRecipientAdded(_ recipient: Addressee, in addedRecipients: [Addressee]) -> Bool } diff --git a/RIADigiDocTests/Util/Recipient/RecipientUtilTests.swift b/RIADigiDocTests/Util/Recipient/RecipientUtilTests.swift new file mode 100644 index 00000000..1a2ae270 --- /dev/null +++ b/RIADigiDocTests/Util/Recipient/RecipientUtilTests.swift @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +import CryptoObjCWrapper +import Foundation +import Testing + +@MainActor +struct RecipientUtilTests { + + private let recipientUtil: RecipientUtilProtocol + + init() { + recipientUtil = RecipientUtil() + } + + private func recipient( + certificate: String, + cnVal: String = "SURNAME,GIVENNAME,38001085718" + ) -> Addressee { + Addressee( + data: Data(certificate.utf8), + cnVal: cnVal, + givenName: "GIVENNAME", + surname: "SURNAME", + serialNumber: "38001085718", + certType: .iDCardType, + validTo: Date.distantFuture + ) + } + + @Test + func isRecipientAdded_trueWhenCertificateBytesMatch() { + let added = [recipient(certificate: "cert-a"), recipient(certificate: "cert-b")] + + #expect(recipientUtil.isRecipientAdded(recipient(certificate: "cert-b"), in: added)) + } + + @Test + func isRecipientAdded_falseWhenNoCertificateBytesMatch() { + let added = [recipient(certificate: "cert-a"), recipient(certificate: "cert-b")] + + #expect(!recipientUtil.isRecipientAdded(recipient(certificate: "cert-c"), in: added)) + } + + @Test + func isRecipientAdded_falseWhenNothingHasBeenAdded() { + #expect(!recipientUtil.isRecipientAdded(recipient(certificate: "cert-a"), in: [])) + } + + @Test + func isRecipientAdded_falseForSamePersonHoldingARenewedCertificate() { + let added = [recipient(certificate: "old-cert", cnVal: "SURNAME,GIVENNAME,38001085718")] + + let renewed = recipient(certificate: "renewed-cert", cnVal: "SURNAME,GIVENNAME,38001085718") + + #expect(!recipientUtil.isRecipientAdded(renewed, in: added)) + } + + @Test + func isRecipientAdded_trueForDifferentPeopleSharingCertificateBytes() { + let added = [recipient(certificate: "cert-a", cnVal: "OTHER,PERSON,60001019906")] + + let other = recipient(certificate: "cert-a", cnVal: "SURNAME,GIVENNAME,38001085718") + + #expect(recipientUtil.isRecipientAdded(other, in: added)) + } + + @Test + func isRecipientAdded_trueWhenEveryAddresseeHasEmptyCertificateBytes() { + let passwordLock = recipient(certificate: "", cnVal: "") + + #expect(recipientUtil.isRecipientAdded(passwordLock, in: [recipient(certificate: "")])) + } +}