diff --git a/Countly.xcodeproj/project.pbxproj b/Countly.xcodeproj/project.pbxproj index 92f6ff7b..230c25ab 100644 --- a/Countly.xcodeproj/project.pbxproj +++ b/Countly.xcodeproj/project.pbxproj @@ -101,12 +101,15 @@ 96E680422BFF89AC0091E105 /* CountlyCrashReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */; }; 96F80BE72F17D066006B4F71 /* CountlyWebViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = 96F80BE62F17D066006B4F71 /* CountlyWebViewController.h */; }; 96F80BE92F17D06F006B4F71 /* CountlyWebViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 96F80BE82F17D06F006B4F71 /* CountlyWebViewController.m */; }; + A431BFDB50E23B1C056F93A1 /* CountlyQueueFlushRunnablesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B81507954AACF61B41133AA /* CountlyQueueFlushRunnablesTests.swift */; }; + CD99EB53BFF33C2DA9C50715 /* CountlyCallbackBaseTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A0A9216F7158834687B2631 /* CountlyCallbackBaseTestCase.swift */; }; D219374B248AC71C00E5798B /* CountlyPerformanceMonitoring.h in Headers */ = {isa = PBXBuildFile; fileRef = D2193749248AC71C00E5798B /* CountlyPerformanceMonitoring.h */; }; D219374C248AC71C00E5798B /* CountlyPerformanceMonitoring.m in Sources */ = {isa = PBXBuildFile; fileRef = D219374A248AC71C00E5798B /* CountlyPerformanceMonitoring.m */; }; D249BF5E254D3D180058A6C2 /* CountlyFeedbackWidget.h in Headers */ = {isa = PBXBuildFile; fileRef = D249BF5C254D3D170058A6C2 /* CountlyFeedbackWidget.h */; settings = {ATTRIBUTES = (Public, ); }; }; D249BF5F254D3D180058A6C2 /* CountlyFeedbackWidget.m in Sources */ = {isa = PBXBuildFile; fileRef = D249BF5D254D3D180058A6C2 /* CountlyFeedbackWidget.m */; }; D2CFEF972545FBE80026B044 /* CountlyFeedbacksInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = D2CFEF952545FBE80026B044 /* CountlyFeedbacksInternal.h */; }; D2CFEF982545FBE80026B044 /* CountlyFeedbacksInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = D2CFEF962545FBE80026B044 /* CountlyFeedbacksInternal.m */; }; + F041D46C963F3EA85882CB0D /* CountlyRequestCallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1C3803ECB0A386136072D6 /* CountlyRequestCallbackTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -120,6 +123,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 1A0A9216F7158834687B2631 /* CountlyCallbackBaseTestCase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CountlyCallbackBaseTestCase.swift; sourceTree = ""; }; 1A3110622A7128CD001CB507 /* CountlyViewData.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyViewData.m; sourceTree = ""; }; 1A3110642A7128DC001CB507 /* CountlyViewData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyViewData.h; sourceTree = ""; }; 1A31106E2A7141AF001CB507 /* CountlyViewTracking.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyViewTracking.h; sourceTree = ""; }; @@ -199,6 +203,8 @@ 3B20A9AF2245228600E3D7AE /* CountlyPersistency.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CountlyPersistency.m; sourceTree = ""; }; 3B20A9B12245228700E3D7AE /* CountlyConsentManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CountlyConsentManager.h; sourceTree = ""; }; 4C3A4C9F2EB4C40000827FEA /* EventThreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventThreadTests.swift; sourceTree = ""; }; + 6B81507954AACF61B41133AA /* CountlyQueueFlushRunnablesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CountlyQueueFlushRunnablesTests.swift; sourceTree = ""; }; + 7C1C3803ECB0A386136072D6 /* CountlyRequestCallbackTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CountlyRequestCallbackTests.swift; sourceTree = ""; }; 96095A5E2F20105600FDE933 /* TouchDelegatingView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TouchDelegatingView.h; sourceTree = ""; }; 962485B92D9E971400FA3C20 /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; 96329DDF2D9426F300BFD641 /* CountlyServerConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyServerConfigTests.swift; sourceTree = ""; }; @@ -268,6 +274,9 @@ 3964A3E62C2AF8E90091E677 /* CountlySegmentationTests.swift */, 399B464F2C52813700AD384E /* CountlyLocationTests.swift */, 3969D0222CB80848000F8A32 /* CountlyViewTests.swift */, + 6B81507954AACF61B41133AA /* CountlyQueueFlushRunnablesTests.swift */, + 7C1C3803ECB0A386136072D6 /* CountlyRequestCallbackTests.swift */, + 1A0A9216F7158834687B2631 /* CountlyCallbackBaseTestCase.swift */, ); path = CountlyTests; sourceTree = ""; @@ -548,6 +557,9 @@ 3972EDDB2C08A38D00EB9D3E /* CountlyEventStruct.swift in Sources */, 9673567F2EC60CD400C742D8 /* TestURLProtocol.swift in Sources */, 96E680422BFF89AC0091E105 /* CountlyCrashReporterTests.swift in Sources */, + A431BFDB50E23B1C056F93A1 /* CountlyQueueFlushRunnablesTests.swift in Sources */, + F041D46C963F3EA85882CB0D /* CountlyRequestCallbackTests.swift in Sources */, + CD99EB53BFF33C2DA9C50715 /* CountlyCallbackBaseTestCase.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/CountlyConnectionManager.h b/CountlyConnectionManager.h index e45e8e44..fa721db3 100644 --- a/CountlyConnectionManager.h +++ b/CountlyConnectionManager.h @@ -74,4 +74,43 @@ extern const NSInteger kCountlyGETRequestMaxLength; - (BOOL)isSessionStarted; +#pragma mark - Request Callbacks + +/** + * Callback block type for individual request results. + * @param response Response string from server (or error description if failed) + * @param success YES if request succeeded, NO if failed + */ +typedef void (^CLYRequestCallback)(NSString * _Nullable response, BOOL success); + +/** + * Runnable block type for queue flush completion. + * @discussion Executed when all requests in the queue complete successfully. + */ +typedef void (^CLYQueueFlushRunnable)(void); + +/** + * Adds a runnable to be executed when the request queue is successfully flushed. + * @discussion Multiple runnables can be registered and will all execute when queue becomes empty. + * @discussion Runnables are automatically removed after successful execution. + * @discussion Runnables only execute when ALL requests complete successfully. + * @param runnable Block to be executed when queue is successfully flushed + */ +- (void)addQueueFlushRunnable:(CLYQueueFlushRunnable _Nonnull)runnable; + +/** + * Removes all registered queue flush runnables. + */ +- (void)clearQueueFlushRunnables; + +/** + * Adds a request to the queue with an associated callback. + * @discussion The callback will be executed when this specific request completes. + * @discussion A unique callback ID will be automatically generated internally using UUID. + * @discussion Callback IDs are managed internally and cannot be accessed or modified by developers. + * @param queryString Query string for the request + * @param callback Block to be executed when this request completes + */ +- (void)addToQueueWithCallback:(NSString *)queryString callback:(CLYRequestCallback)callback; + @end diff --git a/CountlyConnectionManager.m b/CountlyConnectionManager.m index a55ad2f1..2837f3c1 100644 --- a/CountlyConnectionManager.m +++ b/CountlyConnectionManager.m @@ -18,7 +18,10 @@ @interface CountlyConnectionManager () @property (nonatomic, strong) NSDate *startTime; @property (nonatomic, assign) atomic_bool backoff; - +@property (nonatomic, strong) NSMutableDictionary *internalRequestCallbacks; +@property (nonatomic, strong) NSMutableArray *queueFlushRunnables; +@property (nonatomic) BOOL hasAnyRequestFailed; +@property (nonatomic, strong) dispatch_queue_t callbackQueue; // Serial queue for thread-safe callback/runnable access @end @@ -71,6 +74,7 @@ @interface CountlyConnectionManager () NSString* const kCountlyRCKeyABOptOut = @"ab_opt_out"; NSString* const kCountlyEndPointOverrideTag = @"&new_end_point="; NSString* const kCountlyNewEndPoint = @"new_end_point"; +NSString* const kCountlyCallbackID = @"callback_id"; CLYAttributionKey const CLYAttributionKeyIDFA = kCountlyQSKeyIDFA; CLYAttributionKey const CLYAttributionKeyADID = kCountlyQSKeyADID; @@ -105,6 +109,10 @@ - (instancetype)init unsentSessionLength = 0.0; isSessionStarted = NO; atomic_init(&_backoff, NO); + _internalRequestCallbacks = [NSMutableDictionary dictionary]; + _queueFlushRunnables = [NSMutableArray array]; + _hasAnyRequestFailed = NO; + _callbackQueue = dispatch_queue_create("ly.count.callbackQueue", DISPATCH_QUEUE_SERIAL); } return self; @@ -120,6 +128,11 @@ - (void)resetInstance { onceToken = 0; s_sharedInstance = nil; isSessionStarted = NO; + dispatch_sync(_callbackQueue, ^{ + [self->_internalRequestCallbacks removeAllObjects]; + [self->_queueFlushRunnables removeAllObjects]; + }); + _hasAnyRequestFailed = NO; } - (void)setHost:(NSString *)host @@ -213,7 +226,8 @@ - (void)proceedOnQueue if (!self.startTime) { self.startTime = [NSDate date]; // Record start time only when it's not already recorded - CLY_LOG_D(@"Proceeding on queue started, queued request count %lu", [CountlyPersistency.sharedInstance remainingRequestCount]); + self.hasAnyRequestFailed = NO; // Reset failure flag when starting queue processing + CLY_LOG_D(@"%s, Proceeding on queue started, queued request count %lu", __FUNCTION__, [CountlyPersistency.sharedInstance remainingRequestCount]); } NSString* firstItemInQueue = [CountlyPersistency.sharedInstance firstItemInQueue]; @@ -221,9 +235,34 @@ - (void)proceedOnQueue { // Calculate total time when the queue becomes empty NSTimeInterval elapsedTime = -[self.startTime timeIntervalSinceNow]; - CLY_LOG_D(@"Queue is empty. All requests are processed. Total time taken: %.2f seconds", elapsedTime); - // Reset start time for future queue processing + CLY_LOG_D(@"%s, Queue is empty. All requests are processed. Total time taken: %.2f seconds", __FUNCTION__, elapsedTime); + + // Execute and clear runnables only if all requests succeeded + if (!self.hasAnyRequestFailed) { + // Thread-safe copy and clear of runnables + __block NSArray *runnablesToExecute = nil; + dispatch_sync(_callbackQueue, ^{ + if (self->_queueFlushRunnables.count > 0) { + CLY_LOG_D(@"%s, All requests succeeded. Executing %lu queue flush runnables.", __FUNCTION__, (unsigned long)self->_queueFlushRunnables.count); + runnablesToExecute = [self->_queueFlushRunnables copy]; + [self->_queueFlushRunnables removeAllObjects]; + } + }); + + // Execute runnables outside the lock to prevent deadlocks + if (runnablesToExecute) { + for (CLYQueueFlushRunnable runnable in runnablesToExecute) { + runnable(); + } + CLY_LOG_D(@"%s, All queue flush runnables executed and removed.", __FUNCTION__); + } + } else { + CLY_LOG_D(@"%s, Some requests failed. Runnables will not be executed.", __FUNCTION__); + } + + // Reset start time and failure flag for future queue processing self.startTime = nil; + self.hasAnyRequestFailed = NO; return; } @@ -252,11 +291,20 @@ - (void)proceedOnQueue NSString* queryString = firstItemInQueue; NSString* endPoint = kCountlyEndpointI; - NSString* overrideEndPoint = [self extractAndRemoveOverrideEndPoint:&queryString]; + NSString* overrideEndPoint = [self extractAndRemoveParameter:&queryString parameter: kCountlyNewEndPoint]; if(overrideEndPoint) { endPoint = overrideEndPoint; } + NSString* callbackID = [self extractAndRemoveParameter:&queryString parameter: kCountlyCallbackID]; + __block CLYRequestCallback requestCallback = nil; + if(callbackID){ + dispatch_sync(_callbackQueue, ^{ + requestCallback = self.internalRequestCallbacks[callbackID]; + }); + } + + [CountlyCommon.sharedInstance startBackgroundTask]; queryString = [self appendRemainingRequest:queryString]; @@ -329,13 +377,24 @@ - (void)proceedOnQueue { CLY_LOG_D(@"Request <%p> successfully completed.", request); + if(requestCallback){ + requestCallback([response description], YES); + // Clean up callback after execution + if (callbackID) { + dispatch_sync(self->_callbackQueue, ^{ + [self.internalRequestCallbacks removeObjectForKey:callbackID]; + }); + } + } + [CountlyPersistency.sharedInstance removeFromQueue:firstItemInQueue]; [CountlyPersistency.sharedInstance saveToFile]; - + if(CountlyServerConfig.sharedInstance.backoffMechanism && [self backoff:duration queryString:queryString]){ CLY_LOG_D(@"%s, backed off dropping proceeding the queue", __FUNCTION__); self.startTime = nil; + self.hasAnyRequestFailed = NO; // Reset on backoff [self backoffCountdown]; } else { [self proceedOnQueue]; @@ -345,6 +404,19 @@ - (void)proceedOnQueue else { CLY_LOG_D(@"%s, request:[ <%p> ] failed! response:[ %@ ]", __FUNCTION__, request, [data cly_stringUTF8]); + + self.hasAnyRequestFailed = YES; // Mark that a request has failed + + if(requestCallback){ + requestCallback([data cly_stringUTF8], NO); + // Clean up callback after execution + if (callbackID) { + dispatch_sync(self->_callbackQueue, ^{ + [self.internalRequestCallbacks removeObjectForKey:callbackID]; + }); + } + } + [CountlyHealthTracker.sharedInstance logFailedNetworkRequestWithStatusCode:((NSHTTPURLResponse*)response).statusCode errorResponse: [data cly_stringUTF8]]; [CountlyHealthTracker.sharedInstance saveState]; self.startTime = nil; @@ -353,6 +425,18 @@ - (void)proceedOnQueue else { CLY_LOG_D(@"%s, request:[ <%p> ] failed! error:[ %@ ]", __FUNCTION__, request, error); + + self.hasAnyRequestFailed = YES; // Mark that a request has failed + + if(requestCallback){ + requestCallback([error description], NO); + // Clean up callback after execution + if (callbackID) { + dispatch_sync(self->_callbackQueue, ^{ + [self.internalRequestCallbacks removeObjectForKey:callbackID]; + }); + } + } #if (TARGET_OS_WATCH) [CountlyPersistency.sharedInstance saveToFile]; #endif @@ -442,16 +526,21 @@ - (void)backoffCountdown }); } - -- (NSString*)extractAndRemoveOverrideEndPoint:(NSString **)queryString +- (NSString*)extractAndRemoveParameter:(NSString **)queryString parameter:(NSString*)parameter { - if([*queryString containsString:kCountlyNewEndPoint]) { - NSString* overrideEndPoint = [*queryString cly_valueForQueryStringKey:kCountlyNewEndPoint]; - if(overrideEndPoint) { - NSString* stringToRemove = [kCountlyEndPointOverrideTag stringByAppendingString:overrideEndPoint]; + CLY_LOG_D(@"%s, Extracting parameter: %@", __FUNCTION__, parameter); + + if([*queryString containsString:parameter]) { + NSString* parameterExtracted = [*queryString cly_valueForQueryStringKey:parameter]; + if(parameterExtracted) { + NSString* stringToRemove = [NSString stringWithFormat:@"&%@=%@",parameter,parameterExtracted]; *queryString = [*queryString stringByReplacingOccurrencesOfString:stringToRemove withString:@""]; - return overrideEndPoint; + CLY_LOG_D(@"%s, Parameter extracted successfully: %@ = %@", __FUNCTION__, parameter, parameterExtracted); + return parameterExtracted; } + CLY_LOG_D(@"%s, Parameter found but value extraction failed for: %@", __FUNCTION__, parameter); + } else { + CLY_LOG_D(@"%s, Parameter not found in query string: %@", __FUNCTION__, parameter); } return nil; } @@ -1235,4 +1324,92 @@ - (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticat CFRelease(policy); } +#pragma mark - Request Callbacks + +- (void)registerRequestCallback:(NSString *)callbackID callback:(CLYRequestCallback)callback +{ + if (!callbackID || callbackID.length == 0) + { + CLY_LOG_W(@"%s, Callback ID is nil or empty. Callback registration ignored.", __FUNCTION__); + return; + } + + if (!callback) + { + CLY_LOG_W(@"%s, Callback block is nil. Callback registration ignored.", __FUNCTION__); + return; + } + + dispatch_sync(_callbackQueue, ^{ + CLY_LOG_D(@"%s, Registering request callback with ID: %@", __FUNCTION__, callbackID); + self.internalRequestCallbacks[callbackID] = callback; + }); +} + +- (void)removeRequestCallback:(NSString *)callbackID +{ + if (!callbackID || callbackID.length == 0) + { + CLY_LOG_W(@"%s, Callback ID is nil or empty. Callback removal ignored.", __FUNCTION__); + return; + } + + dispatch_sync(_callbackQueue, ^{ + CLY_LOG_D(@"%s, Removing request callback with ID: %@", __FUNCTION__, callbackID); + [self.internalRequestCallbacks removeObjectForKey:callbackID]; + }); +} + +- (void)addQueueFlushRunnable:(CLYQueueFlushRunnable)runnable +{ + if (!runnable) + { + CLY_LOG_W(@"%s, Runnable is nil. Cannot add to queue flush runnables.", __FUNCTION__); + return; + } + + CLYQueueFlushRunnable runnableCopy = [runnable copy]; + dispatch_sync(_callbackQueue, ^{ + CLY_LOG_D(@"%s, Adding queue flush runnable. Total count: %lu", __FUNCTION__, (unsigned long)(self->_queueFlushRunnables.count + 1)); + [self->_queueFlushRunnables addObject:runnableCopy]; + }); +} + +- (void)clearQueueFlushRunnables +{ + dispatch_sync(_callbackQueue, ^{ + CLY_LOG_D(@"%s, Clearing %lu queue flush runnables.", __FUNCTION__, (unsigned long)self->_queueFlushRunnables.count); + [self->_queueFlushRunnables removeAllObjects]; + }); +} + +- (void)addToQueueWithCallback:(NSString *)queryString callback:(CLYRequestCallback)callback +{ + if (!queryString || queryString.length == 0) + { + CLY_LOG_W(@"%s, Query string is nil or empty. Cannot add to queue with callback.", __FUNCTION__); + return; + } + + if (!callback) + { + CLY_LOG_W(@"%s, Callback is nil. Adding request without callback.", __FUNCTION__); + [CountlyPersistency.sharedInstance addToQueue:queryString]; + return; + } + + // Generate a unique callback ID + NSString* callbackID = [[NSUUID UUID] UUIDString]; + CLY_LOG_D(@"%s, Adding request to queue with callback ID: %@", __FUNCTION__, callbackID); + + // Register the callback + [self registerRequestCallback:callbackID callback:callback]; + + // Append callback_id parameter to query string + NSString* queryStringWithCallback = [queryString stringByAppendingFormat:@"&callback_id=%@", callbackID]; + + // Add to queue + [CountlyPersistency.sharedInstance addToQueue:queryStringWithCallback]; +} + @end diff --git a/CountlyTests/CountlyCallbackBaseTestCase.swift b/CountlyTests/CountlyCallbackBaseTestCase.swift new file mode 100644 index 00000000..24408acb --- /dev/null +++ b/CountlyTests/CountlyCallbackBaseTestCase.swift @@ -0,0 +1,148 @@ +// +// CountlyCallbackBaseTestCase.swift +// CountlyTests +// +// Shared base class for callback-related tests. +// Provides common SDK setup, MockURLProtocol configuration, and helpers. +// + +import XCTest +@testable import Countly + +/// Base test class for callback tests (CLYRequestCallback and CLYQueueFlushRunnable). +/// Uses class-level setup to start SDK once, avoiding the halt() singleton issue. +class CountlyCallbackBaseTestCase: XCTestCase { + + // MARK: - Static SDK Setup + + private static var isSDKStarted = false + static let testAppKey = "appkey" + static let testHost = "https://testing.count.ly/" + + override class func setUp() { + super.setUp() + guard !isSDKStarted else { return } + + // Configure MockURLProtocol to return valid JSON by default + MockURLProtocol.requestHandler = createSuccessHandler() + + let config = CountlyConfig() + config.appKey = testAppKey + config.host = testHost + config.enableDebug = true + config.manualSessionHandling = true + let sessionConfig = URLSessionConfiguration.default + sessionConfig.protocolClasses = [MockURLProtocol.self] + config.urlSessionConfiguration = sessionConfig + Countly.sharedInstance().start(with: config) + isSDKStarted = true + } + + // MARK: - Instance Setup/Teardown + + override func setUp() { + super.setUp() + // Reset MockURLProtocol to success handler for each test + MockURLProtocol.requestHandler = Self.createSuccessHandler() + // Clear any leftover runnables + connectionManager?.clearQueueFlushRunnables() + // Drain any pending queue requests + drainQueue() + } + + override func tearDown() { + connectionManager?.clearQueueFlushRunnables() + super.tearDown() + } + + // MARK: - Connection Manager Helper + + var connectionManager: CountlyConnectionManager? { + return CountlyConnectionManager.sharedInstance() + } + + // MARK: - Queue Helpers + + /// Drain the request queue to avoid state pollution between tests + func drainQueue() { + var waitCount = 0 + while CountlyPersistency.sharedInstance().remainingRequestCount() > 0 && waitCount < 50 { + connectionManager?.proceedOnQueue() + Thread.sleep(forTimeInterval: 0.1) + waitCount += 1 + } + } + + // MARK: - MockURLProtocol Helpers + + /// Type alias for request handler closure + typealias RequestHandler = (URLRequest) -> (Data?, URLResponse?, Error?) + + /// Creates a success handler returning valid JSON with status 200 + static func createSuccessHandler() -> RequestHandler { + return { request in + let jsonResponse = Data("{\"result\":\"Success\"}".utf8) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (jsonResponse, response, nil) + } + } + + /// Creates a success handler with custom status code (2xx range) + static func createSuccessHandler(statusCode: Int, result: String = "Success") -> RequestHandler { + return { request in + let jsonResponse = Data("{\"result\":\"\(result)\"}".utf8) + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (jsonResponse, response, nil) + } + } + + /// Creates an error handler returning specified HTTP status code + static func createErrorHandler(statusCode: Int, message: String) -> RequestHandler { + return { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (Data(message.utf8), response, nil) + } + } + + /// Creates a handler returning plain text (invalid JSON) with 200 + static func createInvalidJSONHandler() -> RequestHandler { + return { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (Data("OK".utf8), response, nil) + } + } + + /// Creates a handler returning JSON without "result" key + static func createMissingResultKeyHandler() -> RequestHandler { + return { request in + let jsonResponse = Data("{\"status\":\"ok\"}".utf8) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (jsonResponse, response, nil) + } + } +} diff --git a/CountlyTests/CountlyQueueFlushRunnablesTests.swift b/CountlyTests/CountlyQueueFlushRunnablesTests.swift new file mode 100644 index 00000000..0fb1de11 --- /dev/null +++ b/CountlyTests/CountlyQueueFlushRunnablesTests.swift @@ -0,0 +1,301 @@ +// +// CountlyQueueFlushRunnablesTests.swift +// CountlyTests +// +// Tests for queue flush runnables feature. +// + +import XCTest +@testable import Countly + +/// Tests for queue flush runnables feature (CLYQueueFlushRunnable). +class CountlyQueueFlushRunnablesTests: CountlyCallbackBaseTestCase { + + // MARK: - Tests + + /** + * Test adding a single queue flush runnable + * Verify that the runnable can be added without error + */ + func test_addQueueFlushRunnable_singleRunnable() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var runnableExecuted = false + connectionManager.addQueueFlushRunnable { + runnableExecuted = true + } + + // Runnable should not be executed yet (no queue flush) + XCTAssertFalse(runnableExecuted) + } + + /** + * Test adding multiple queue flush runnables + * Verify that multiple runnables can be registered + */ + func test_addQueueFlushRunnable_multipleRunnables() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var runnable1Executed = false + var runnable2Executed = false + var runnable3Executed = false + + connectionManager.addQueueFlushRunnable { + runnable1Executed = true + } + connectionManager.addQueueFlushRunnable { + runnable2Executed = true + } + connectionManager.addQueueFlushRunnable { + runnable3Executed = true + } + + // Runnables should not be executed yet + XCTAssertFalse(runnable1Executed) + XCTAssertFalse(runnable2Executed) + XCTAssertFalse(runnable3Executed) + } + + /** + * Test clearing all queue flush runnables + * Verify that clearQueueFlushRunnables removes all registered runnables + */ + func test_clearQueueFlushRunnables() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var runnableExecuted = false + connectionManager.addQueueFlushRunnable { + runnableExecuted = true + } + + // Clear runnables before any queue processing + connectionManager.clearQueueFlushRunnables() + + // Add a request and let it complete (queue will flush) + Countly.sharedInstance().addDirectRequest(["test": "request"]) + + TestUtils.sleep(2) {} + + // Runnable should NOT have executed because it was cleared + XCTAssertFalse(runnableExecuted) + } + + /** + * Test that runnables are executed when all requests succeed + * Uses MockURLProtocol which returns 200 OK with valid JSON + */ + func test_queueFlushRunnables_executedOnSuccess() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var runnableExecuted = false + connectionManager.addQueueFlushRunnable { + runnableExecuted = true + } + + // Add a request - MockURLProtocol will return success + Countly.sharedInstance().addDirectRequest(["test": "request"]) + + TestUtils.sleep(3) {} + + // Runnable should have executed after successful queue flush + XCTAssertTrue(runnableExecuted) + } + + /** + * Test that multiple runnables are all executed in order when queue flushes successfully + */ + func test_queueFlushRunnables_multipleExecutedInOrder() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var executionOrder: [Int] = [] + + connectionManager.addQueueFlushRunnable { + executionOrder.append(1) + } + connectionManager.addQueueFlushRunnable { + executionOrder.append(2) + } + connectionManager.addQueueFlushRunnable { + executionOrder.append(3) + } + + // Add a request - MockURLProtocol will return success + Countly.sharedInstance().addDirectRequest(["test": "request"]) + + TestUtils.sleep(3) {} + + // All runnables should have executed in order + XCTAssertEqual(executionOrder, [1, 2, 3]) + } + + /** + * Test that runnables are removed after successful execution + * Add runnable, let it execute, add another request - first runnable should not execute again + */ + func test_queueFlushRunnables_removedAfterExecution() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + var executionCount = 0 + connectionManager.addQueueFlushRunnable { + executionCount += 1 + } + + // First request - runnable should execute + Countly.sharedInstance().addDirectRequest(["test": "request1"]) + + TestUtils.sleep(3) {} + + XCTAssertEqual(executionCount, 1) + + // Second request - runnable should NOT execute again (was removed) + Countly.sharedInstance().addDirectRequest(["test": "request2"]) + + TestUtils.sleep(3) {} + + // Execution count should still be 1 + XCTAssertEqual(executionCount, 1) + } + + /** + * Test that runnables are NOT executed when a request fails + * Uses MockURLProtocol to simulate a failure response + */ + func test_queueFlushRunnables_notExecutedOnFailure() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + // Configure MockURLProtocol to return a failure (500 error) + MockURLProtocol.requestHandler = Self.createErrorHandler(statusCode: 500, message: "Server Error") + + var runnableExecuted = false + connectionManager.addQueueFlushRunnable { + runnableExecuted = true + } + + // Add a request - MockURLProtocol will return 500 error + Countly.sharedInstance().addDirectRequest(["test": "request"]) + + TestUtils.sleep(3) {} + + // Runnable should NOT have executed due to failure + XCTAssertFalse(runnableExecuted) + + // Restore success handler for subsequent tests + MockURLProtocol.requestHandler = Self.createSuccessHandler() + } + + /** + * Test thread safety - add runnables from multiple threads concurrently + */ + func test_queueFlushRunnables_threadSafety() throws { + guard let connectionManager = connectionManager else { + XCTFail("ConnectionManager not available") + return + } + + let expectation = XCTestExpectation(description: "All runnables added") + let totalRunnables = 100 + var executedCount = 0 + let lock = NSLock() + + let queue1 = DispatchQueue(label: "test.queue1", attributes: .concurrent) + let queue2 = DispatchQueue(label: "test.queue2", attributes: .concurrent) + let group = DispatchGroup() + + // Add runnables from multiple threads concurrently + for _ in 0..