@@ -19,212 +19,218 @@ import XCTest
1919class DefaultCmabClientTests : XCTestCase {
2020 var client : DefaultCmabClient !
2121 var mockSession : MockURLSession !
22+ var shortRetryConfig : CmabRetryConfig !
2223
2324 override func setUp( ) {
2425 super. setUp ( )
2526 mockSession = MockURLSession ( )
26- client = DefaultCmabClient ( session: mockSession)
27+ shortRetryConfig = CmabRetryConfig ( maxRetries: 2 , initialBackoff: 0.01 , maxBackoff: 0.05 , backoffMultiplier: 1.0 )
28+ client = DefaultCmabClient ( session: mockSession, retryConfig: shortRetryConfig)
2729 }
2830
2931 override func tearDown( ) {
3032 client = nil
3133 mockSession = nil
34+ shortRetryConfig = nil
3235 super. tearDown ( )
3336 }
3437
35- func testFetchDecisionSuccess( ) {
36- let expectedVariationId = " variation-123 "
37- let responseJSON : [ String : Any ] = [
38+ // MARK: - Helpers
39+
40+ func makeSuccessResponse( variationId: String ) -> ( Data , URLResponse , Error ? ) {
41+ let json : [ String : Any ] = [
3842 " predictions " : [
39- [ " variation_id " : expectedVariationId ]
43+ [ " variation_id " : variationId ]
4044 ]
4145 ]
42- let responseData = try ! JSONSerialization . data ( withJSONObject: responseJSON, options: [ ] )
43- mockSession. nextData = responseData
44- mockSession. nextResponse = HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
45- statusCode: 200 , httpVersion: nil , headerFields: nil )
46- mockSession. nextError = nil
46+ let data = try ! JSONSerialization . data ( withJSONObject: json, options: [ ] )
47+ let response = HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
48+ statusCode: 200 , httpVersion: nil , headerFields: nil ) !
49+ return ( data, response, nil )
50+ }
51+
52+ func makeFailureResponse( ) -> ( Data , URLResponse , Error ? ) {
53+ let response = HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
54+ statusCode: 500 , httpVersion: nil , headerFields: nil ) !
55+ return ( Data ( ) , response, nil )
56+ }
57+
58+ // MARK: - Test Cases
59+
60+ func testFetchDecision_SuccessOnFirstTry( ) {
61+ let ( successData, successResponse, _) = makeSuccessResponse ( variationId: " variation-123 " )
62+ mockSession. responses = [ ( successData, successResponse, nil ) ]
4763
4864 let expectation = self . expectation ( description: " Completion called " )
49-
5065 client. fetchDecision (
51- ruleId: " abc " ,
52- userId: " user1 " ,
53- attributes: [ " foo " : " bar " ] ,
54- cmabUUID: " uuid "
66+ ruleId: " abc " , userId: " user1 " ,
67+ attributes: [ " foo " : " bar " ] , cmabUUID: " uuid "
5568 ) { result in
56- switch result {
57- case . success ( let variationId) :
58- XCTAssertEqual ( variationId , expectedVariationId )
59- case . failure ( let error ) :
60- XCTFail ( " Expected success, got failure: \( error ) " )
69+ if case let . success ( variationId ) = result {
70+ XCTAssertEqual ( variationId, " variation-123 " )
71+ XCTAssertEqual ( self . mockSession . callCount , 1 )
72+ } else {
73+ XCTFail ( " Expected success result " )
6174 }
6275 expectation. fulfill ( )
6376 }
64-
65- waitForExpectations ( timeout: 2 , handler: nil )
77+ waitForExpectations ( timeout: 1 )
6678 }
6779
68- func testFetchDecisionHttpError( ) {
69- mockSession. nextData = Data ( )
70- mockSession. nextResponse = HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
71- statusCode: 500 , httpVersion: nil , headerFields: nil )
72- mockSession. nextError = nil
80+ func testFetchDecision_SuccessOnSecondTry( ) {
81+ let ( successData, successResponse, _) = makeSuccessResponse ( variationId: " variation-retry " )
82+ let fail = makeFailureResponse ( )
83+ mockSession. responses = [ fail, ( successData, successResponse, nil ) ]
7384
7485 let expectation = self . expectation ( description: " Completion called " )
75-
7686 client. fetchDecision (
77- ruleId: " abc " ,
78- userId: " user1 " ,
79- attributes: [ " foo " : " bar " ] ,
80- cmabUUID: " uuid "
87+ ruleId: " abc " , userId: " user1 " ,
88+ attributes: [ " foo " : " bar " ] , cmabUUID: " uuid "
8189 ) { result in
82- switch result {
83- case . success ( _ ) :
84- XCTFail ( " Expected failure, got success " )
85- case . failure ( let error ) :
86- XCTAssertTrue ( " \( error ) " . contains ( " HTTP error code " ) )
90+ if case let . success ( variationId ) = result {
91+ XCTAssertEqual ( variationId , " variation-retry " )
92+ XCTAssertEqual ( self . mockSession . callCount , 2 )
93+ } else {
94+ XCTFail ( " Expected success after retry " )
8795 }
8896 expectation. fulfill ( )
8997 }
90-
91- waitForExpectations ( timeout: 2 , handler: nil )
98+ waitForExpectations ( timeout: 2 )
9299 }
93100
94- func testFetchDecisionInvalidJson( ) {
95- mockSession. nextData = Data ( " not a json " . utf8)
96- mockSession. nextResponse = HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
97- statusCode: 200 , httpVersion: nil , headerFields: nil )
98- mockSession. nextError = nil
101+ func testFetchDecision_SuccessOnThirdTry( ) {
102+ let ( successData, successResponse, _) = makeSuccessResponse ( variationId: " success-third " )
103+ let fail = makeFailureResponse ( )
104+ mockSession. responses = [ fail, fail, ( successData, successResponse, nil ) ]
99105
100106 let expectation = self . expectation ( description: " Completion called " )
101-
102107 client. fetchDecision (
103- ruleId: " abc " ,
104- userId: " user1 " ,
105- attributes: [ " foo " : " bar " ] ,
106- cmabUUID: " uuid "
108+ ruleId: " abc " , userId: " user1 " ,
109+ attributes: [ " foo " : " bar " ] , cmabUUID: " uuid "
107110 ) { result in
108- switch result {
109- case . success( _ ) :
110- XCTFail ( " Expected failure, got success " )
111- case . failure ( let error ) :
112- XCTAssertTrue ( error is CmabClientError )
111+ if case let . success ( variationId ) = result {
112+ XCTAssertEqual ( variationId , " success-third " )
113+ XCTAssertEqual ( self . mockSession . callCount , 3 )
114+ } else {
115+ XCTFail ( " Expected success after two retries " )
113116 }
114117 expectation. fulfill ( )
115118 }
119+ waitForExpectations ( timeout: 2 )
120+ }
121+
122+ func testFetchDecision_ExhaustsAllRetries( ) {
123+ let fail = makeFailureResponse ( )
124+ mockSession. responses = [ fail, fail, fail]
116125
117- waitForExpectations ( timeout: 2 , handler: nil )
126+ let expectation = self . expectation ( description: " Completion called " )
127+ client. fetchDecision (
128+ ruleId: " abc " , userId: " user1 " ,
129+ attributes: [ " foo " : " bar " ] , cmabUUID: " uuid "
130+ ) { result in
131+ if case let . failure( error) = result {
132+ XCTAssertTrue ( " \( error) " . contains ( " Exhausted all retries " ) )
133+ XCTAssertEqual ( self . mockSession. callCount, 3 )
134+ } else {
135+ XCTFail ( " Expected failure after all retries " )
136+ }
137+ expectation. fulfill ( )
138+ }
139+ waitForExpectations ( timeout: 2 )
118140 }
119141
120- func testFetchDecisionInvalidResponseStructure( ) {
121- let responseJSON : [ String : Any ] = [
122- " not_predictions " : [ ]
142+ func testFetchDecision_HttpError( ) {
143+ mockSession. responses = [
144+ ( Data ( ) , HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
145+ statusCode: 500 , httpVersion: nil , headerFields: nil ) , nil )
123146 ]
124- let responseData = try ! JSONSerialization . data ( withJSONObject: responseJSON, options: [ ] )
125- mockSession. nextData = responseData
126- mockSession. nextResponse = HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
127- statusCode: 200 , httpVersion: nil , headerFields: nil )
128- mockSession. nextError = nil
129147
130148 let expectation = self . expectation ( description: " Completion called " )
131-
132149 client. fetchDecision (
133- ruleId: " abc " ,
134- userId: " user1 " ,
135- attributes: [ " foo " : " bar " ] ,
136- cmabUUID: " uuid "
150+ ruleId: " abc " , userId: " user1 " ,
151+ attributes: [ " foo " : " bar " ] , cmabUUID: " uuid "
137152 ) { result in
138- switch result {
139- case . success( _) :
140- XCTFail ( " Expected failure, got success " )
141- case . failure( let error) :
142- XCTAssertEqual ( error as? CmabClientError , . invalidResponse)
153+ if case let . failure( error) = result {
154+ XCTAssertTrue ( " \( error) " . contains ( " HTTP error code " ) )
155+ } else {
156+ XCTFail ( " Expected failure on HTTP error " )
143157 }
144158 expectation. fulfill ( )
145159 }
146-
147- waitForExpectations ( timeout: 2 , handler: nil )
160+ waitForExpectations ( timeout: 2 )
148161 }
149162
150- func testFetchDecisionRetriesOnFailure( ) {
151- let expectedVariationId = " variation-retry "
152- var callCount = 0
153-
154- let responseJSON : [ String : Any ] = [
155- " predictions " : [
156- [ " variation_id " : expectedVariationId]
157- ]
163+ func testFetchDecision_InvalidJson( ) {
164+ mockSession. responses = [
165+ ( Data ( " not a json " . utf8) , HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
166+ statusCode: 200 , httpVersion: nil , headerFields: nil ) , nil )
158167 ]
159- let responseData = try ! JSONSerialization . data ( withJSONObject: responseJSON, options: [ ] )
160168
161- mockSession. onRequest = { _ in
162- callCount += 1
163- if callCount == 1 {
164- self . mockSession. nextData = Data ( )
165- self . mockSession. nextResponse = HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
166- statusCode: 500 , httpVersion: nil , headerFields: nil )
167- self . mockSession. nextError = nil
169+ let expectation = self . expectation ( description: " Completion called " )
170+ client. fetchDecision (
171+ ruleId: " abc " , userId: " user1 " ,
172+ attributes: [ " foo " : " bar " ] , cmabUUID: " uuid "
173+ ) { result in
174+ if case let . failure( error) = result {
175+ XCTAssertTrue ( error is CmabClientError )
176+ XCTAssertEqual ( self . mockSession. callCount, 1 )
168177 } else {
169- self . mockSession. nextData = responseData
170- self . mockSession. nextResponse = HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
171- statusCode: 200 , httpVersion: nil , headerFields: nil )
172- self . mockSession. nextError = nil
178+ XCTFail ( " Expected failure on invalid JSON " )
173179 }
180+ expectation. fulfill ( )
174181 }
182+ waitForExpectations ( timeout: 2 )
183+ }
184+
185+ func testFetchDecision_Invalid_Response_Structure( ) {
186+ let responseJSON : [ String : Any ] = [ " not_predictions " : [ ] ]
187+ let responseData = try ! JSONSerialization . data ( withJSONObject: responseJSON, options: [ ] )
188+ mockSession. responses = [
189+ ( responseData, HTTPURLResponse ( url: URL ( string: " https://prediction.cmab.optimizely.com/predict/abc " ) !,
190+ statusCode: 200 , httpVersion: nil , headerFields: nil ) , nil )
191+ ]
175192
176193 let expectation = self . expectation ( description: " Completion called " )
177-
178194 client. fetchDecision (
179- ruleId: " abc " ,
180- userId: " user1 " ,
181- attributes: [ " foo " : " bar " ] ,
182- cmabUUID: " uuid "
195+ ruleId: " abc " , userId: " user1 " ,
196+ attributes: [ " foo " : " bar " ] , cmabUUID: " uuid "
183197 ) { result in
184- switch result {
185- case . success( let variationId) :
186- XCTAssertEqual ( variationId, expectedVariationId)
187- XCTAssertTrue ( callCount >= 2 )
188- case . failure( let error) :
189- XCTFail ( " Expected success, got failure: \( error) " )
198+ if case let . failure( error) = result {
199+ XCTAssertEqual ( error as? CmabClientError , . invalidResponse)
200+ XCTAssertEqual ( self . mockSession. callCount, 1 )
201+ } else {
202+ XCTFail ( " Expected failure on invalid response structure " )
190203 }
191204 expectation. fulfill ( )
192205 }
193-
194- waitForExpectations ( timeout: 3 , handler: nil )
206+ waitForExpectations ( timeout: 2 )
195207 }
196208}
197209
210+ // MARK: - MockURLSession for ordered responses
211+
198212extension DefaultCmabClientTests {
199213 class MockURLSessionDataTask : URLSessionDataTask {
200214 private let closure : ( ) -> Void
201215 override var state : URLSessionTask . State { . completed }
202- init ( closure: @escaping ( ) -> Void ) {
203- self . closure = closure
204- }
205-
206- override func resume( ) {
207- closure ( )
208- }
216+ init ( closure: @escaping ( ) -> Void ) { self . closure = closure }
217+ override func resume( ) { closure ( ) }
209218 }
210219
211220 class MockURLSession : URLSession {
212221 typealias CompletionHandler = ( Data ? , URLResponse ? , Error ? ) -> Void
213-
214- var nextData : Data ?
215- var nextResponse : URLResponse ?
216- var nextError : Error ?
217- var onRequest : ( ( URLRequest ) -> Void ) ?
218-
222+ var responses : [ ( Data ? , URLResponse ? , Error ? ) ] = [ ]
223+ var callCount = 0
224+
219225 override func dataTask(
220226 with request: URLRequest ,
221227 completionHandler: @escaping CompletionHandler
222228 ) -> URLSessionDataTask {
223- onRequest ? ( request)
224- return MockURLSessionDataTask {
225- completionHandler ( self . nextData, self . nextResponse, self . nextError)
226- }
229+
230+ let idx = callCount
231+ callCount += 1
232+ let tuple = idx < responses. count ? responses [ idx] : ( nil , nil , nil )
233+ return MockURLSessionDataTask { completionHandler ( tuple. 0 , tuple. 1 , tuple. 2 ) }
227234 }
228235 }
229-
230236}
0 commit comments