Skip to content

Commit

Permalink
Merge pull request #15226 from wordpress-mobile/issue/ab_testing_ttl
Browse files Browse the repository at this point in the history
AB testing: schedule a refresh based on TTL
  • Loading branch information
leandroalonso committed Nov 6, 2020
2 parents 6a945ed + 2fedfb9 commit e2f10c1
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 41 deletions.
93 changes: 89 additions & 4 deletions WordPress/Classes/Utility/ABTesting/ExPlat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,59 @@ class ExPlat: ABTesting {
let service: ExPlatService

private let assignmentsKey = "ab-testing-assignments"
private let ttlDateKey = "ab-testing-ttl-date"

init(service: ExPlatService = ExPlatService.withDefaultApi()) {
self.service = service
private var ttl: TimeInterval {
guard let ttlDate = UserDefaults.standard.object(forKey: ttlDateKey) as? Date else {
return 0
}

return ttlDate.timeIntervalSinceReferenceDate - Date().timeIntervalSinceReferenceDate
}

private(set) var scheduledTimer: Timer?

init(configuration: ExPlatConfiguration,
service: ExPlatService? = nil) {
self.service = service ?? ExPlatService(configuration: configuration)
subscribeToNotifications()
}

deinit {
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
}

/// Only refresh if the TTL has expired
///
func refreshIfNeeded(completion: (() -> Void)? = nil) {
guard ttl > 0 else {
completion?()
scheduleRefresh()
return
}

refresh(completion: completion)
}

/// Force the assignments to refresh
///
func refresh(completion: (() -> Void)? = nil) {
service.getAssignments { assignments in
guard let assignments = assignments else {
service.getAssignments { [weak self] assignments in
guard let `self` = self,
let assignments = assignments else {
completion?()
return
}

let validVariations = assignments.variations.filter { $0.value != nil }
UserDefaults.standard.setValue(validVariations, forKey: self.assignmentsKey)

var ttlDate = Date()
ttlDate.addTimeInterval(TimeInterval(assignments.ttl))
UserDefaults.standard.setValue(ttlDate, forKey: self.ttlDateKey)
self.scheduleRefresh()

completion?()
}
}
Expand All @@ -37,4 +76,50 @@ class ExPlat: ABTesting {
return .other(variation)
}
}

private func scheduleRefresh() {
if ttl > 0 {
scheduledTimer?.invalidate()

/// Schedule the refresh on a background thread
DispatchQueue.global(qos: .background).async { [weak self] in
guard let `self` = self else {
return
}

self.scheduledTimer = Timer.scheduledTimer(withTimeInterval: self.ttl, repeats: true) { [weak self] timer in
self?.refresh()
timer.invalidate()
}

RunLoop.current.run()
}


} else {
refresh()
}
}

/// Check if the app is entering background and/or foreground
/// and start/stop the timers
///
private func subscribeToNotifications() {
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(applicationDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(applicationWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
}

/// When the app goes to background stop the timer
///
@objc private func applicationDidEnterBackground() {
scheduledTimer?.invalidate()
}

/// When the app enter foreground refresh the assignments or
/// start the timer
///
@objc private func applicationWillEnterForeground() {
refreshIfNeeded()
}
}
95 changes: 66 additions & 29 deletions WordPress/Classes/Utility/ABTesting/ExPlatService.swift
Original file line number Diff line number Diff line change
@@ -1,42 +1,79 @@
import Foundation

protocol ExPlatConfiguration {
var platform: String { get }
var oAuthToken: String? { get }
var userAgent: String? { get }
var anonId: String? { get }
}

class ExPlatService {
let wordPressComRestApi: WordPressComRestApi
let platform: String
let oAuthToken: String?
let userAgent: String?
let anonId: String?

let assignmentsPath = "wpcom/v2/experiments/0.1.0/assignments/calypso"
var assignmentsEndpoint: String {
return "https://public-api.wordpress.com/wpcom/v2/experiments/0.1.0/assignments/\(platform)"
}

init(wordPressComRestApi: WordPressComRestApi) {
self.wordPressComRestApi = wordPressComRestApi
init(configuration: ExPlatConfiguration) {
self.platform = configuration.platform
self.oAuthToken = configuration.oAuthToken
self.userAgent = configuration.userAgent
self.anonId = configuration.anonId
}

func getAssignments(completion: @escaping (Assignments?) -> Void) {
wordPressComRestApi.GET(assignmentsPath,
parameters: nil,
success: { responseObject, _ in
do {
let decoder = JSONDecoder()
let data = try JSONSerialization.data(withJSONObject: responseObject, options: [])
let assignments = try decoder.decode(Assignments.self, from: data)
completion(assignments)
} catch {
DDLogError("Error parsing the experiment response: \(error)")
completion(nil)
}
}, failure: { error, _ in
guard var urlComponents = URLComponents(string: assignmentsEndpoint) else {
completion(nil)
})
}
}
return
}

// Query items
urlComponents.queryItems = [URLQueryItem(name: "_locale", value: Locale.current.languageCode)]

if let anonId = anonId {
urlComponents.queryItems?.append(URLQueryItem.init(name: "anon_id", value: anonId))
}

guard let url = urlComponents.url else {
completion(nil)
return
}

var request = URLRequest(url: url)
request.httpMethod = "GET"

// HTTP fields (including oAuthToken if provided)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")

if let oAuthToken = oAuthToken {
request.setValue( "Bearer \(oAuthToken)", forHTTPHeaderField: "Authorization")
}

// User-Agent
if let userAgent = userAgent {
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
}

let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard let data = data, error == nil else {
completion(nil)
return
}

extension ExPlatService {
class func withDefaultApi() -> ExPlatService {
let accountService = AccountService(managedObjectContext: ContextManager.shared.mainContext)
let defaultAccount = accountService.defaultWordPressComAccount()
let token: String? = defaultAccount?.authToken
do {
let decoder = JSONDecoder()
let assignments = try decoder.decode(Assignments.self, from: data)
completion(assignments)
} catch {
DDLogError("Error parsing the experiment response: \(error)")
completion(nil)
}
}

let api = WordPressComRestApi.defaultApi(oAuthToken: token,
userAgent: WPUserAgent.wordPress(),
localeKey: WordPressComRestApi.LocaleKeyV2)
return ExPlatService(wordPressComRestApi: api)
task.resume()
}
}
20 changes: 15 additions & 5 deletions WordPress/WordPressTest/ABTesting/ExPlatServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class ExPlatServiceTests: XCTestCase {
func testRefresh() {
let expectation = XCTestExpectation(description: "Return assignments")
stubAssignmentsResponseWithFile("explat-assignments.json")
let service = ExPlatService.withDefaultApi()
let service = ExPlatService(configuration: ExPlatTestConfiguration())

service.getAssignments { assignments in
XCTAssertEqual(assignments?.ttl, 60)
Expand All @@ -31,7 +31,7 @@ class ExPlatServiceTests: XCTestCase {
func testRefreshDecodeFails() {
let expectation = XCTestExpectation(description: "Do not return assignments")
stubAssignmentsResponseWithFile("explat-malformed-assignments.json")
let service = ExPlatService.withDefaultApi()
let service = ExPlatService(configuration: ExPlatTestConfiguration())

service.getAssignments { assignments in
XCTAssertNil(assignments)
Expand All @@ -46,7 +46,7 @@ class ExPlatServiceTests: XCTestCase {
func testRefreshServerFails() {
let expectation = XCTestExpectation(description: "Do not return assignments")
stubAssignmentsResponseWithError()
let service = ExPlatService.withDefaultApi()
let service = ExPlatService(configuration: ExPlatTestConfiguration())

service.getAssignments { assignments in
XCTAssertNil(assignments)
Expand All @@ -61,11 +61,11 @@ class ExPlatServiceTests: XCTestCase {
}

private func stubAssignmentsResponseWithError() {
stubAssignments(withStatus: 503)
stubAssignments(withFile: "explat-malformed-assignments.json", withStatus: 503)
}

private func stubAssignments(withFile file: String = "explat-assignments.json", withStatus status: Int32? = nil) {
let endpoint = "wpcom/v2/experiments/0.1.0/assignments/calypso"
let endpoint = "wpcom/v2/experiments/0.1.0/assignments/wpios_test"
stub(condition: { request in
return (request.url!.absoluteString as NSString).contains(endpoint) && request.httpMethod! == "GET"
}) { _ in
Expand All @@ -74,3 +74,13 @@ class ExPlatServiceTests: XCTestCase {
}
}
}

class ExPlatTestConfiguration: ExPlatConfiguration {
var platform = "wpios_test"

var oAuthToken: String?

var userAgent: String?

var anonId: String?
}
25 changes: 22 additions & 3 deletions WordPress/WordPressTest/ABTesting/ExPlatTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class ExPlatTests: XCTestCase {
//
func testRefresh() {
let expectation = XCTestExpectation(description: "Save experiments")
let abTesting = ExPlat(service: ExPlatServiceMock())
let abTesting = ExPlat(configuration: ExPlatTestConfiguration(), service: ExPlatServiceMock())

abTesting.refresh {
XCTAssertEqual(abTesting.experiment("experiment"), .control)
Expand All @@ -23,7 +23,7 @@ class ExPlatTests: XCTestCase {
func testError() {
let expectation = XCTestExpectation(description: "Keep experiments")
let serviceMock = ExPlatServiceMock()
let abTesting = ExPlat(service: serviceMock)
let abTesting = ExPlat(configuration: ExPlatTestConfiguration(), service: serviceMock)
abTesting.refresh {

serviceMock.returnAssignments = false
Expand All @@ -37,13 +37,32 @@ class ExPlatTests: XCTestCase {

wait(for: [expectation], timeout: 2.0)
}

// Schedule a timer to automatically refresh
//
func testScheduleRefresh() {
let expectation = XCTestExpectation(description: "Automatically refresh")
let serviceMock = ExPlatServiceMock()
let abTesting = ExPlat(configuration: ExPlatTestConfiguration(), service: serviceMock)
abTesting.refresh {

DispatchQueue.main.async {
XCTAssertTrue(abTesting.scheduledTimer!.isValid)
XCTAssertEqual(round(abTesting.scheduledTimer!.timeInterval), 60)
expectation.fulfill()
}

}

wait(for: [expectation], timeout: 2.0)
}
}

private class ExPlatServiceMock: ExPlatService {
var returnAssignments = true

init() {
super.init(wordPressComRestApi: WordPressComMockRestApi())
super.init(configuration: ExPlatTestConfiguration())
}

override func getAssignments(completion: @escaping (Assignments?) -> Void) {
Expand Down

0 comments on commit e2f10c1

Please sign in to comment.