diff --git a/Package.swift b/Package.swift index ea9d140..497c0b6 100644 --- a/Package.swift +++ b/Package.swift @@ -58,5 +58,10 @@ let package = Package( exclude: ["Dummy.swift", "Info.plist"], resources: [.process("PrivacyInfo.xcprivacy")], publicHeadersPath: "."), + .testTarget( + name: "mParticle-AdobeTests", + dependencies: ["mParticle-Adobe"], + path: "mParticle-AdobeTests" + ), ] ) diff --git a/mParticle-Adobe-Media/MPIAdobe.m b/mParticle-Adobe-Media/MPIAdobe.m index a33beba..aa41196 100644 --- a/mParticle-Adobe-Media/MPIAdobe.m +++ b/mParticle-Adobe-Media/MPIAdobe.m @@ -81,7 +81,13 @@ @interface MPIAdobe () @implementation MPIAdobe -- (void)sendRequestWithMarketingCloudId:(NSString *)marketingCloudId advertiserId:(NSString *)advertiserId pushToken:(NSString *)pushToken organizationId:(NSString *)organizationId userIdentities:(NSDictionary *)userIdentities audienceManagerServer:(NSString *)audienceManagerServer completion:(void (^)(NSString *marketingCloudId, NSString *blob, NSString *locationHint, NSError *))completion { +- (void)sendRequestWithMarketingCloudId:(NSString *)marketingCloudId + advertiserId:(NSString *)advertiserId + pushToken:(NSString *)pushToken + organizationId:(NSString *)organizationId + userIdentities:(NSDictionary *)userIdentities + audienceManagerServer:(NSString *)audienceManagerServer + completion:(void (^)(NSString *marketingCloudId, NSString *blob, NSString *locationHint, NSError *))completion { if (audienceManagerServer != nil && audienceManagerServer.length > 0) { host = audienceManagerServer; @@ -147,8 +153,6 @@ - (void)sendRequestWithMarketingCloudId:(NSString *)marketingCloudId advertiserI __weak MPIAdobe *weakSelf = self; [[session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { - - void (^callbackWithCode)(MPIAdobeErrorCode code, NSString *message, NSError *error) = ^void(MPIAdobeErrorCode code, NSString *message, NSError *error) { MPIAdobeError *adobeError = [[MPIAdobeError alloc] initWithCode:code message:message error:error]; NSError *compositeError = [NSError errorWithDomain:errorDomain code:adobeError.code userInfo:@{MPIAdobeErrorKey:adobeError}]; diff --git a/mParticle-Adobe/MPIAdobe.h b/mParticle-Adobe/MPIAdobe.h index 09da66a..03b962d 100644 --- a/mParticle-Adobe/MPIAdobe.h +++ b/mParticle-Adobe/MPIAdobe.h @@ -1,7 +1,16 @@ #import +@protocol SessionProtocol + +- (NSURLSessionDataTask * _Nonnull)dataTaskWithRequest:(NSURLRequest * _Nonnull)request + completionHandler:(void (NS_SWIFT_SENDABLE ^_Nonnull)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler; + +@end + @interface MPIAdobe : NSObject +- (instancetype)initWithSession:(id) session; + - (void)sendRequestWithMarketingCloudId:(NSString *)marketingCloudId advertiserId:(NSString *)advertiserId pushToken:(NSString *)pushToken organizationId:(NSString *)organizationId userIdentities:(NSDictionary *)userIdentities audienceManagerServer:(NSString *)audienceManagerServer completion:(void (^)(NSString *marketingCloudId, NSString *locationHint, NSString *blob, NSError *error))completion; - (NSString *)marketingCloudIdFromUserDefaults; diff --git a/mParticle-Adobe/MPIAdobe.m b/mParticle-Adobe/MPIAdobe.m index 07b360c..71fc106 100644 --- a/mParticle-Adobe/MPIAdobe.m +++ b/mParticle-Adobe/MPIAdobe.m @@ -41,6 +41,7 @@ static NSString *const marketingCloudIdUserDefaultsKey = @"ADBMOBILE_PERSISTED_MID"; + @interface MPIAdobeError () - (id)initWithCode:(MPIAdobeErrorCode)code message:(NSString *)message error:(NSError *)error; @@ -79,7 +80,23 @@ @interface MPIAdobe () @implementation MPIAdobe -- (void)sendRequestWithMarketingCloudId:(NSString *)marketingCloudId advertiserId:(NSString *)advertiserId pushToken:(NSString *)pushToken organizationId:(NSString *)organizationId userIdentities:(NSDictionary *)userIdentities audienceManagerServer:(NSString *)audienceManagerServer completion:(void (^)(NSString *marketingCloudId, NSString *locationHint, NSString *blob, NSError *))completion { +id _session; + +- (instancetype)initWithSession:(id) session { + self = [super init]; + if (self != nil) { + _session = session; + } + return self; +} + +- (void)sendRequestWithMarketingCloudId:(NSString *)marketingCloudId + advertiserId:(NSString *)advertiserId + pushToken:(NSString *)pushToken + organizationId:(NSString *)organizationId + userIdentities:(NSDictionary *)userIdentities + audienceManagerServer:(NSString *)audienceManagerServer + completion:(void (^)(NSString *marketingCloudId, NSString *locationHint, NSString *blob, NSError *))completion { if (audienceManagerServer != nil && audienceManagerServer.length > 0) { host = audienceManagerServer; @@ -139,13 +156,14 @@ - (void)sendRequestWithMarketingCloudId:(NSString *)marketingCloudId advertiserI NSURL *url = components.URL; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; - NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; - NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; __weak MPIAdobe *weakSelf = self; - [[session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { - + [[_session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { + __strong typeof(self) strongSelf = weakSelf; + if (!strongSelf) { + return; + } void (^callbackWithCode)(MPIAdobeErrorCode code, NSString *message, NSError *error) = ^void(MPIAdobeErrorCode code, NSString *message, NSError *error) { MPIAdobeError *adobeError = [[MPIAdobeError alloc] initWithCode:code message:message error:error]; @@ -166,8 +184,13 @@ - (void)sendRequestWithMarketingCloudId:(NSString *)marketingCloudId advertiserI NSDictionary *errorDictionary = dictionary[errorResponseKey]; if (errorDictionary) { - NSError *error = [NSError errorWithDomain:serverErrorDomain code:0 userInfo:errorDictionary]; - return callbackWithCode(MPIAdobeErrorCodeServerError, @"Server returned an error", error); + if ([errorDictionary isKindOfClass:[NSDictionary class]]) { + NSError *error = [NSError errorWithDomain:serverErrorDomain code:0 userInfo:errorDictionary]; + return callbackWithCode(MPIAdobeErrorCodeServerError, @"Server returned an error", error); + } else { + NSError *error = [NSError errorWithDomain:serverErrorDomain code:0 userInfo:@{}]; + return callbackWithCode(MPIAdobeErrorCodeServerError, @"Server returned an error", error); + } } NSString *marketingCloudId = [dictionary[marketingCloudIdKey] isKindOfClass:[NSString class]] ? dictionary[marketingCloudIdKey] : nil; diff --git a/mParticle-Adobe/MPKitAdobe.m b/mParticle-Adobe/MPKitAdobe.m index 6f0f4c9..6d5400f 100644 --- a/mParticle-Adobe/MPKitAdobe.m +++ b/mParticle-Adobe/MPKitAdobe.m @@ -26,6 +26,9 @@ @interface MPKitAdobe () @end +@interface NSURLSession (SessionProtocol) +@end + @implementation MPKitAdobe static NSString *_midOverride = nil; @@ -70,7 +73,9 @@ - (MPKitExecStatus *)didFinishLaunchingWithConfiguration:(NSDictionary *)configu _configuration = configuration; _started = YES; - _adobe = [[MPIAdobe alloc] init]; + NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; + _adobe = [[MPIAdobe alloc] initWithSession: session]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didEnterBackground:) @@ -155,7 +160,13 @@ - (void)sendNetworkRequest { NSString *pushToken = [self pushToken]; FilteredMParticleUser *user = [self currentUser]; NSDictionary *userIdentities = user.userIdentities; - [_adobe sendRequestWithMarketingCloudId:marketingCloudId advertiserId:advertiserId pushToken:pushToken organizationId:_organizationId userIdentities:userIdentities audienceManagerServer:_audienceManagerServer completion:^(NSString *marketingCloudId, NSString *locationHint, NSString *blob, NSError *error) { + [_adobe sendRequestWithMarketingCloudId:marketingCloudId + advertiserId:advertiserId + pushToken:pushToken + organizationId:_organizationId + userIdentities:userIdentities + audienceManagerServer:_audienceManagerServer + completion:^(NSString *marketingCloudId, NSString *locationHint, NSString *blob, NSError *error) { if (error) { NSLog(@"mParticle -> Adobe kit request failed with error: %@", error); return; diff --git a/mParticle-Adobe/mParticle_Adobe.h b/mParticle-Adobe/mParticle_Adobe.h index 0bfc517..6bfe76b 100644 --- a/mParticle-Adobe/mParticle_Adobe.h +++ b/mParticle-Adobe/mParticle_Adobe.h @@ -11,5 +11,8 @@ FOUNDATION_EXPORT const unsigned char mParticle_AdobeVersionString[]; #if defined(__has_include) && __has_include() #import #else + #import "MPKitAdobe.h" +#import "MPIAdobe.h" + #endif diff --git a/mParticle-AdobeTests/MPAdobeTests.swift b/mParticle-AdobeTests/MPAdobeTests.swift new file mode 100644 index 0000000..7e5be6b --- /dev/null +++ b/mParticle-AdobeTests/MPAdobeTests.swift @@ -0,0 +1,195 @@ +// +// MPKitAdobeTests.swift +// mParticle-Adobe +// +// Created by Denis Chilik on 10/9/25. +// +@testable import mParticle_Adobe +import Foundation +import XCTest + +import Foundation + +extension URLSession: @retroactive SessionProtocol { +} + +final class MPAdobeTests: XCTestCase { + var session: SessionProtocolMock! + + override func setUp() { + super.setUp() + + session = SessionProtocolMock() + } + + func testSendRequestEncodeAllParametersIntoURL() { + let sut = MPIAdobe(session: session)! + sut.sendRequest( + withMarketingCloudId: "marketingCloudId", + advertiserId: "advertiserId", + pushToken: "pushToken", + organizationId: "organizationId", + userIdentities: [ + NSNumber(value: MPUserIdentity.other.rawValue) : "1", + NSNumber(value: MPUserIdentity.customerId.rawValue) : "2", + NSNumber(value: MPUserIdentity.facebook.rawValue) : "3", + NSNumber(value: MPUserIdentity.twitter.rawValue) : "4", + NSNumber(value: MPUserIdentity.google.rawValue) : "5", + NSNumber(value: MPUserIdentity.microsoft.rawValue) : "6", + NSNumber(value: MPUserIdentity.yahoo.rawValue) : "7", + NSNumber(value: MPUserIdentity.email.rawValue) : "8", + NSNumber(value: MPUserIdentity.alias.rawValue) : "9", + NSNumber(value: MPUserIdentity.facebookCustomAudienceId.rawValue) : "10", + NSNumber(value: MPUserIdentity.other5.rawValue) : "11", + ], + audienceManagerServer: "audienceManagerServer" + ) { _, _, _, _ in } + + let expected = "https://audienceManagerServer/id?d_mid=marketingCloudId&d_cid=20915%2501advertiserId&d_cid=20920%2501pushToken&d_cid_ic=google%25015&d_cid_ic=facebook%25013&d_cid_ic=customerid%25012&d_cid_ic=twitter%25014&d_cid_ic=alias%25019&d_cid_ic=microsoft%25016&d_cid_ic=email%25018&d_cid_ic=yahoo%25017&d_cid_ic=other%25011&d_cid_ic=facebookcustomaudienceid%250110&d_orgid=organizationId&d_ptfm=ios&d_ver=2" + + guard + let actualURL = session.dataTaskRequestParam?.url, + let expectedURL = URL(string: expected), + let actualComponents = URLComponents(url: actualURL, resolvingAgainstBaseURL: false), + let expectedComponents = URLComponents(url: expectedURL, resolvingAgainstBaseURL: false) + else { + XCTFail("URLs could not be parsed") + return + } + + XCTAssertEqual(actualComponents.scheme, expectedComponents.scheme) + XCTAssertEqual(actualComponents.host, expectedComponents.host) + XCTAssertEqual(actualComponents.path, expectedComponents.path) + + let actualItems = Set(actualComponents.queryItems ?? []) + let expectedItems = Set(expectedComponents.queryItems ?? []) + + XCTAssertEqual(actualItems, expectedItems) + } + + func testCompletionCallback_success() { + let sut = MPIAdobe(session: session)! + sut.sendRequest( + withMarketingCloudId: "", + advertiserId: "", + pushToken: "", + organizationId: "", + userIdentities: [:], + audienceManagerServer: "" + ) { marketingCloudId, locationHint, blob, error in + XCTAssertNil(error) + XCTAssertEqual(marketingCloudId, "mock_mid") + XCTAssertEqual(locationHint, "mock_region") + XCTAssertEqual(blob, "mock_blob") + } + + let json: [String: Any] = [ + "d_mid": "mock_mid", + "d_blob": "mock_blob", + "dcs_region": "mock_region" + ] + + let data = try! JSONSerialization.data(withJSONObject: json, options: []) + + session.dataTaskCompletionHandlerParam?(data, URLResponse(), nil) + } + + func testCompletionCallback_success_empty_json() { + let sut = MPIAdobe(session: session)! + sut.sendRequest( + withMarketingCloudId: "", + advertiserId: "", + pushToken: "", + organizationId: "", + userIdentities: [:], + audienceManagerServer: "" + ) { marketingCloudId, locationHint, blob, error in + XCTAssertNil(error) + XCTAssertNil(marketingCloudId) + XCTAssertNil(locationHint) + XCTAssertNil(blob) + } + + let json: [String: Any] = [:] + + let data = try! JSONSerialization.data(withJSONObject: json, options: []) + + session.dataTaskCompletionHandlerParam?(data, URLResponse(), nil) + } + + func testCompletionCallback_success_parametersNotStrings() { + let sut = MPIAdobe(session: session)! + sut.sendRequest( + withMarketingCloudId: "", + advertiserId: "", + pushToken: "", + organizationId: "", + userIdentities: [:], + audienceManagerServer: "" + ) { marketingCloudId, locationHint, blob, error in + XCTAssertNil(error) + XCTAssertNil(marketingCloudId) + XCTAssertNil(locationHint) + XCTAssertNil(blob) + } + + let json: [String: Any] = [ + "d_mid": 1, + "d_blob": 2, + "dcs_region": 3 + ] + + let data = try! JSONSerialization.data(withJSONObject: json, options: []) + session.dataTaskCompletionHandlerParam?(data, URLResponse(), nil) + } + + func testCompletionCallback_success_errorFromBackend() { + let sut = MPIAdobe(session: session)! + sut.sendRequest( + withMarketingCloudId: "", + advertiserId: "", + pushToken: "", + organizationId: "", + userIdentities: [:], + audienceManagerServer: "" + ) { marketingCloudId, locationHint, blob, error in + XCTAssertNil(marketingCloudId) + XCTAssertNil(locationHint) + XCTAssertNil(blob) + XCTAssertNotNil(error) + } + + let json: [String: Any] = [ + "error_msg": [ + "some_key": "Invalid request parameters" + ] + ] + + let data = try! JSONSerialization.data(withJSONObject: json, options: []) + session.dataTaskCompletionHandlerParam?(data, URLResponse(), nil) + } + + func testCompletionCallback_success_errorErrorMsgContains_shouldNotCrash() { + let sut = MPIAdobe(session: session)! + sut.sendRequest( + withMarketingCloudId: "", + advertiserId: "", + pushToken: "", + organizationId: "", + userIdentities: [:], + audienceManagerServer: "" + ) { marketingCloudId, locationHint, blob, error in + XCTAssertNil(marketingCloudId) + XCTAssertNil(locationHint) + XCTAssertNil(blob) + XCTAssertNotNil(error) + } + + let json: [String: Any] = [ + "error_msg": "Invalid request parameters" + ] + + let data = try! JSONSerialization.data(withJSONObject: json, options: []) + session.dataTaskCompletionHandlerParam?(data, URLResponse(), nil) + } +} diff --git a/mParticle-AdobeTests/SessionProtocolMock.swift b/mParticle-AdobeTests/SessionProtocolMock.swift new file mode 100644 index 0000000..9208c99 --- /dev/null +++ b/mParticle-AdobeTests/SessionProtocolMock.swift @@ -0,0 +1,25 @@ +// +// SessionProtocolMock.swift +// mParticle-Adobe +// +// Created by Denis Chilik on 10/14/25. +// + +@testable import mParticle_Adobe + +class SessionProtocolMock: SessionProtocol { + var dataTaskCalled = false + var dataTaskRequestParam: URLRequest? + var dataTaskCompletionHandlerParam: ((Data?, URLResponse?, (any Error)?) -> Void)? + + func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask { + dataTaskCalled = true + dataTaskRequestParam = request + dataTaskCompletionHandlerParam = completionHandler + + let config = URLSessionConfiguration.default + let session = URLSession(configuration: config) + + return session.dataTask(with: URLRequest(url: URL(string: "https://localhost")!)) + } +}