Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AB testing: schedule a refresh based on TTL #15226

Merged
merged 7 commits into from
Nov 6, 2020
Merged
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