Skip to content

Commit 40a70ef

Browse files
crowcrow
and
crow
authored
Preference center SMS and Email opt-in wrap up and testing (#3094)
* wip # Conflicts: # Airship/AirshipCore/Tests/TestContactAPIClient.swift * Update preference center config and add test * Add contact API client tests * Add contact manager tests * Cleanup and docs --------- Co-authored-by: crow <[email protected]>
1 parent b109889 commit 40a70ef

13 files changed

+1748
-363
lines changed

Airship.xcworkspace/xcshareddata/swiftpm/Package.resolved

-15
This file was deleted.

Airship/AirshipCore/Source/AirshipContact.swift

+7
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,13 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked
559559
)
560560
}
561561

562+
/**
563+
* Resends an opt-in message
564+
* - Parameters:
565+
* - channelID: The channel ID.
566+
* - type: The channel type.
567+
* - options: The SMS/email channel options
568+
*/
562569
public func resend(_ channel: ContactChannel) {
563570
guard self.privacyManager.isEnabled(.contacts) else {
564571
AirshipLogger.warn(

Airship/AirshipCore/Source/ContactChannelsProvider.swift

+32-4
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,40 @@
22

33
import Foundation
44

5+
/**
6+
* Contact channels provider protocol for receiving contact updates.
7+
* @note For internal use only. :nodoc:
8+
*/
59
protocol ContactChannelsProviderProtocol: AnyActor {
610
func contactUpdates(contactID: String) async throws -> AsyncStream<[ContactChannel]>
711
}
812

13+
/// Provides a stream of contact updates at a regular interval
914
final actor ContactChannelsProvider: ContactChannelsProviderProtocol {
1015
private let audienceOverrides: AudienceOverridesProvider
1116
private let apiClient: ContactChannelsAPIClientProtocol
1217
private let cachedChannelsList: CachedValue<(String, [ContactChannel])>
1318
private let fetchQueue: AirshipSerialQueue = AirshipSerialQueue()
14-
private static let maxChannelListCacheAge: TimeInterval = 600
19+
private let maxChannelListCacheAge: TimeInterval
1520

1621
private let taskSleeper: AirshipTaskSleeper
1722

1823
init(
1924
audienceOverrides: AudienceOverridesProvider,
2025
apiClient: ContactChannelsAPIClientProtocol,
2126
date: AirshipDateProtocol = AirshipDate.shared,
22-
taskSleeper: AirshipTaskSleeper = .shared
27+
taskSleeper: AirshipTaskSleeper = .shared,
28+
maxChannelListCacheAgeSeconds: TimeInterval = 600
2329
) {
2430
self.audienceOverrides = audienceOverrides
2531
self.apiClient = apiClient
2632
self.cachedChannelsList = CachedValue(date: date)
2733
self.taskSleeper = taskSleeper
34+
self.maxChannelListCacheAge = maxChannelListCacheAgeSeconds
2835
}
2936

3037
func contactUpdates(contactID: String) async throws -> AsyncStream<[ContactChannel]> {
38+
/// Fetch the initial channels list and apply overrides
3139
let overrideUpdates = await self.audienceOverrides.contactOverrideUpdates(contactID: contactID)
3240
let fetched = try await self.resolveChannelsList(contactID)
3341
let initialHistory = await self.audienceOverrides.contactOverrides(contactID: contactID)
@@ -56,7 +64,7 @@ final actor ContactChannelsProvider: ContactChannelsProviderProtocol {
5664
}
5765
}
5866

59-
try await self.taskSleeper.sleep(timeInterval: Self.maxChannelListCacheAge)
67+
try await self.taskSleeper.sleep(timeInterval: self.maxChannelListCacheAge)
6068

6169
overrideUpdates = await self.audienceOverrides.contactOverrideUpdates(contactID: contactID)
6270

@@ -94,7 +102,7 @@ final actor ContactChannelsProvider: ContactChannelsProviderProtocol {
94102

95103
self.cachedChannelsList.set(
96104
value: (contactID, list),
97-
expiresIn: Self.maxChannelListCacheAge
105+
expiresIn: self.maxChannelListCacheAge
98106
)
99107

100108
return list
@@ -127,6 +135,26 @@ extension Array where Element == ContactChannel {
127135
}
128136

129137
case .associated(let contact, let registeredChannelID):
138+
let alreadyAssociated = mutated.contains {
139+
let channelID = $0.channelID
140+
let canonicalAddress = $0.canonicalAddress
141+
142+
if let canonicalAddress, contact.canonicalAddress == canonicalAddress {
143+
return true
144+
}
145+
146+
if let channelID, registeredChannelID == channelID {
147+
return true
148+
}
149+
150+
return false
151+
}
152+
153+
/// If we've already associated this address/channel, bail early with no-op
154+
if alreadyAssociated {
155+
break
156+
}
157+
130158
mutated.removeAll {
131159
let channelID = $0.channelID
132160
let canonicalAddress = $0.canonicalAddress

Airship/AirshipCore/Source/ContactProtocol.swift

+6-3
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ public protocol AirshipContactProtocol: AirshipBaseContactProtocol {
130130
/// The named user ID current value publisher.
131131
var namedUserIDPublisher: AnyPublisher<String?, Never> { get }
132132

133-
/// Conflict event publisher
133+
/// Conflict event publisher.
134134
var conflictEventPublisher: AnyPublisher<ContactConflictEvent, Never> { get }
135135

136136
/// Notifies any edits to the subscription lists.
@@ -153,9 +153,9 @@ public protocol AirshipContactProtocol: AirshipBaseContactProtocol {
153153
func validateSMS(_ msisdn: String, sender: String) async throws -> Bool
154154

155155
/**
156-
* Re-sends the double opt in prompt via the channel
156+
* Re-sends the double opt in prompt via the pending or registered channel.
157157
* - Parameters:
158-
* - channel: The channel to resend the double opt-in prompt to
158+
* - channel: The pending or registered channel to resend the double opt-in prompt to.
159159
*/
160160
func resend(_ channel: ContactChannel)
161161

@@ -166,7 +166,10 @@ public protocol AirshipContactProtocol: AirshipBaseContactProtocol {
166166
*/
167167
func disassociateChannel(_ channel: ContactChannel)
168168

169+
/// Contact channel updates stream.
169170
var contactChannelUpdates: AsyncStream<[ContactChannel]> { get async throws }
171+
172+
/// Contact channel updates publisher.
170173
var contactChannelPublisher: AnyPublisher<[ContactChannel], Never> { get async throws }
171174
}
172175

Airship/AirshipCore/Tests/AirshipContactTest.swift

-1
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,6 @@ class AirshipContactTest: XCTestCase {
377377
}
378378

379379
func testAssociateChannel() async throws {
380-
let options = EmailRegistrationOptions.commercialOptions(transactionalOptedIn: Date(), commercialOptedIn: Date(), properties: nil)
381380
self.contact.associateChannel(
382381
"some-channel-id",
383382
type: .email

Airship/AirshipCore/Tests/ContactAPIClientTest.swift

+113
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,119 @@ class ContactAPIClientTest: XCTestCase {
487487
}
488488
}
489489

490+
func testDisassociate() async throws {
491+
let expectedChannelType: ChannelType = .email
492+
let expectedChannelID: String = "some channel"
493+
let expectedContactID: String = "contact"
494+
495+
let response = try await contactAPIClient.disassociateChannel(contactID: expectedContactID, channelID: expectedChannelID, type: expectedChannelType)
496+
XCTAssertTrue(response.isSuccess)
497+
498+
let request = self.session.lastRequest!
499+
XCTAssertEqual(
500+
"https://example.com/api/contacts/disassociate/\(expectedContactID)",
501+
request.url!.absoluteString
502+
)
503+
504+
let body = try JSONSerialization.jsonObject(
505+
with: request.body!,
506+
options: []
507+
) as! [String: Any]
508+
509+
let expectedBody = [
510+
"channel_type": expectedChannelType.stringValue,
511+
"channel_id": expectedChannelID,
512+
"opt_out": true
513+
] as [String : Any]
514+
515+
XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary)
516+
}
517+
518+
func testResendEmail() async throws {
519+
let expectedChannelType: ChannelType = .email
520+
let expectedEmail: String = "[email protected]"
521+
522+
let expectedResendOptions = ResendOptions(address: expectedEmail)
523+
524+
let response = try await contactAPIClient.resend(resendOptions: expectedResendOptions)
525+
XCTAssertTrue(response.isSuccess)
526+
527+
let request = self.session.lastRequest!
528+
XCTAssertEqual(
529+
"https://example.com/api/channels/resend",
530+
request.url!.absoluteString
531+
)
532+
533+
let body = try JSONSerialization.jsonObject(
534+
with: request.body!,
535+
options: []
536+
) as! [String: Any]
537+
538+
let expectedBody = [
539+
"channel_type": expectedChannelType.stringValue,
540+
"email_address": expectedEmail
541+
] as [String : Any]
542+
543+
XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary)
544+
}
545+
546+
func testResendSMS() async throws {
547+
let expectedChannelType: ChannelType = .sms
548+
let expectedMSISDN: String = "1234"
549+
let expectedSenderID: String = "1234"
550+
551+
let expectedResendOptions = ResendOptions(msisdn: expectedMSISDN, senderID: expectedSenderID)
552+
553+
let response = try await contactAPIClient.resend(resendOptions: expectedResendOptions)
554+
XCTAssertTrue(response.isSuccess)
555+
556+
let request = self.session.lastRequest!
557+
XCTAssertEqual(
558+
"https://example.com/api/channels/resend",
559+
request.url!.absoluteString
560+
)
561+
562+
let body = try JSONSerialization.jsonObject(
563+
with: request.body!,
564+
options: []
565+
) as! [String: Any]
566+
567+
let expectedBody = [
568+
"channel_type": expectedChannelType.stringValue,
569+
"sender": expectedSenderID,
570+
"msisdn": expectedMSISDN
571+
] as [String : Any]
572+
573+
XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary)
574+
}
575+
576+
func testResendChannel() async throws {
577+
let expectedChannelType: ChannelType = .email
578+
let expectedChannelID: String = "some channel"
579+
let expectedResendOptions = ResendOptions(channelID: expectedChannelID, channelType: expectedChannelType)
580+
581+
let response = try await contactAPIClient.resend(resendOptions: expectedResendOptions)
582+
XCTAssertTrue(response.isSuccess)
583+
584+
let request = self.session.lastRequest!
585+
XCTAssertEqual(
586+
"https://example.com/api/channels/resend",
587+
request.url!.absoluteString
588+
)
589+
590+
let body = try JSONSerialization.jsonObject(
591+
with: request.body!,
592+
options: []
593+
) as! [String: Any]
594+
595+
let expectedBody = [
596+
"channel_type": expectedChannelType.stringValue,
597+
"channel_id": expectedChannelID
598+
] as [String : Any]
599+
600+
XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary)
601+
}
602+
490603
func testUpdate() async throws {
491604
let tagUpdates = [
492605
TagGroupUpdate(group: "tag-set", tags: [], type: .set),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/* Copyright Airship and Contributors */
2+
3+
import XCTest
4+
5+
@testable import AirshipCore
6+
7+
final class ContactChannelsProviderTest: XCTestCase {
8+
9+
override func setUpWithError() throws {
10+
// Put setup code here. This method is called before the invocation of each test method in the class.
11+
}
12+
13+
override func tearDownWithError() throws {
14+
// Put teardown code here. This method is called after the invocation of each test method in the class.
15+
}
16+
17+
func testExample() throws {
18+
// This is an example of a functional test case.
19+
// Use XCTAssert and related functions to verify your tests produce the correct results.
20+
// Any test you write for XCTest can be annotated as throws and async.
21+
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
22+
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
23+
}
24+
25+
func testContactUpdates() async throws {
26+
// Setup mocks and initial conditions
27+
let contactID = "some id"
28+
let expectedChannels = [ContactChannel(id: "some channel"), ContactChannel(id: "some channel")]
29+
let mockOverrides = MockAudienceOverrides()
30+
let sleeper = MockSleeper()
31+
let tester = YourClass(audienceOverrides: mockOverrides, taskSleeper: sleeper)
32+
33+
// Prepare the AsyncStream
34+
let stream = tester.contactUpdates(contactID: contactID)
35+
var results = [AsyncStream<[ContactChannel]>]()
36+
37+
// Collect results
38+
for try await channels in stream {
39+
results.append(channels)
40+
// Optionally, break after receiving enough results or certain conditions
41+
}
42+
43+
// Assert conditions
44+
XCTAssertEqual(results.count, expectedCount)
45+
XCTAssertEqual(results.last, expectedChannels)
46+
}
47+
48+
func testPerformanceExample() throws {
49+
// This is an example of a performance test case.
50+
self.measure {
51+
// Put the code you want to measure the time of here.
52+
}
53+
}
54+
}

0 commit comments

Comments
 (0)