1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
@testable import Grain
import XCTest
/// The force-update gate: whether a build is blocked, from what the server says
/// and, when the server can't be reached or makes no sense, from what it last
/// said. Getting it wrong either strands a supported build or lets an
/// unsupported one through to errors.
@MainActor
final class AppUpdateGateTests: GrainTestCase {
private let server = URL(string: "https://grain.test")!
override func tearDown() async throws {
MockURLProtocol.handler = nil
try await super.tearDown()
}
private func document(minimumBuild: Int, storeUrl: String = "https://apps.apple.com/app/id1") -> String {
"""
{
"ios": { "minimumBuild": \(minimumBuild), "storeUrl": "\(storeUrl)" },
"android": { "minimumVersionCode": 0, "storeUrl": "https://play.google.com/store/apps/details?id=social.grain" }
}
"""
}
private func makeGate(build: Int?) -> AppUpdateGate {
AppUpdateGate(currentBuild: build, serverURL: server, session: MockURLProtocol.mockSession())
}
private func failRequests() {
MockURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) }
}
// MARK: - Build against minimum
func testABuildBelowTheMinimumIsBlocked() {
XCTAssertTrue(AppVersionPolicy(minimumBuild: 60).blocks(build: 59))
}
func testABuildAtOrAboveTheMinimumIsNot() {
XCTAssertFalse(AppVersionPolicy(minimumBuild: 59).blocks(build: 59))
XCTAssertFalse(AppVersionPolicy(minimumBuild: 59).blocks(build: 60))
}
func testAnUnreadableBuildNumberIsNeverBlocked() {
XCTAssertFalse(AppVersionPolicy(minimumBuild: 60).blocks(build: nil))
}
// MARK: - Parsing
func testParsesTheIOSMinimumAndStoreURL() throws {
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(document(minimumBuild: 61).utf8)))
XCTAssertEqual(policy.minimumBuild, 61)
XCTAssertEqual(policy.storeURL, URL(string: "https://apps.apple.com/app/id1"))
}
func testAMissingIOSKeyMeansNoMinimum() throws {
let json = #"{ "android": { "minimumVersionCode": 99 } }"#
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(json.utf8)))
XCTAssertNil(policy.minimumBuild)
XCTAssertFalse(policy.blocks(build: 1))
}
func testAMissingMinimumBuildMeansNoMinimum() throws {
let json = #"{ "ios": { "storeUrl": "https://apps.apple.com/app/id1" } }"#
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(json.utf8)))
XCTAssertNil(policy.minimumBuild)
XCTAssertFalse(policy.blocks(build: 1))
}
func testAMissingStoreURLFallsBackToTheAppStoreListing() throws {
let json = #"{ "ios": { "minimumBuild": 60 } }"#
let policy = try XCTUnwrap(AppVersionPolicy.parse(Data(json.utf8)))
XCTAssertEqual(policy.storeURL, URL(string: "https://apps.apple.com/app/id6747730230"))
}
func testMalformedDocumentsAreRejected() {
for body in ["", "not json", "[]", #"{ "ios": "60" }"#, #"{ "ios": { "minimumBuild": "60" } }"#] {
XCTAssertNil(AppVersionPolicy.parse(Data(body.utf8)), "Should reject \(body.debugDescription)")
}
}
// MARK: - Fetching and the cache
func testNothingCachedAndNoFetchMeansNotBlocked() {
XCTAssertFalse(makeGate(build: 59).isUpdateRequired)
}
func testAFetchedMinimumAboveTheBuildBlocksIt() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60, storeUrl: "https://apps.apple.com/app/id2"))
let gate = makeGate(build: 59)
await gate.check()
XCTAssertTrue(gate.isUpdateRequired)
XCTAssertEqual(gate.storeURL, URL(string: "https://apps.apple.com/app/id2"))
}
func testAFailedFetchWithNothingCachedDoesNotBlock() async {
failRequests()
let gate = makeGate(build: 59)
await gate.check()
XCTAssertFalse(gate.isUpdateRequired)
}
func testAMalformedResponseWithNothingCachedDoesNotBlock() async {
MockURLProtocol.respondWithJSON("<html>oops</html>")
let gate = makeGate(build: 59)
await gate.check()
XCTAssertFalse(gate.isUpdateRequired)
}
func testAnErrorStatusWithNothingCachedDoesNotBlock() async {
MockURLProtocol.respondWithError(statusCode: 503)
let gate = makeGate(build: 59)
await gate.check()
XCTAssertFalse(gate.isUpdateRequired)
}
/// The case the cache exists for: a blocked build opened offline.
func testTheCachedMinimumBlocksAtLaunchBeforeAnyFetch() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60, storeUrl: "https://apps.apple.com/app/id3"))
await makeGate(build: 59).check()
let relaunched = makeGate(build: 59)
XCTAssertTrue(relaunched.isUpdateRequired)
XCTAssertEqual(relaunched.storeURL, URL(string: "https://apps.apple.com/app/id3"))
}
func testTheCachedMinimumStillBlocksWhenTheFetchFails() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60))
await makeGate(build: 59).check()
failRequests()
let relaunched = makeGate(build: 59)
await relaunched.check()
XCTAssertTrue(relaunched.isUpdateRequired)
}
func testTheCachedMinimumStillBlocksWhenTheResponseIsMalformed() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60))
await makeGate(build: 59).check()
MockURLProtocol.respondWithJSON("{")
let relaunched = makeGate(build: 59)
await relaunched.check()
XCTAssertTrue(relaunched.isUpdateRequired)
}
/// Lowering or removing the minimum has to reach a build that was blocked,
/// or it stays locked out on the strength of an old answer.
func testAFetchedLowerMinimumUnblocksAndReplacesTheCache() async {
MockURLProtocol.respondWithJSON(document(minimumBuild: 60))
await makeGate(build: 59).check()
MockURLProtocol.respondWithJSON(#"{ "ios": {} }"#)
let relaunched = makeGate(build: 59)
XCTAssertTrue(relaunched.isUpdateRequired)
await relaunched.check()
XCTAssertFalse(relaunched.isUpdateRequired)
XCTAssertFalse(makeGate(build: 59).isUpdateRequired, "The cleared minimum should be what is remembered")
}
func testRequestsAppVersionJSONOnTheConfiguredServerWithAShortTimeout() async throws {
var seen: URLRequest?
MockURLProtocol.handler = { request in
seen = request
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(#"{}"#.utf8), response)
}
await makeGate(build: 59).check()
let request = try XCTUnwrap(seen)
XCTAssertEqual(request.url, URL(string: "https://grain.test/app-version.json"))
XCTAssertLessThanOrEqual(request.timeoutInterval, AppUpdateGate.timeout)
}
}