krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.6.0: HutchTests/APICacheTests.swift · raw
1import Foundation
2import Testing
3@testable import Hutch
4
5@Suite(.serialized)
6struct APICacheTests {
7 private struct Payload: Codable, Sendable, Equatable {
8 let value: String
9 }
10
11 private struct GraphPayload: Decodable, Sendable, Equatable {
12 let item: Payload
13 }
14
15 @Test
16 func cacheReadWriteRoundTrip() async throws {
17 let cache = makeCache()
18 let data = try JSONEncoder().encode(Payload(value: "cached"))
19
20 _ = try await cache.write(payload: data, cacheKey: "repo|one", resourceType: .repositoryDetail, ttl: 60)
21 let entry = try await cache.read(cacheKey: "repo|one")
22 let decoded = try JSONDecoder().decode(Payload.self, from: entry.payload)
23
24 #expect(decoded == Payload(value: "cached"))
25 #expect(entry.metadata.cacheKey == "repo|one")
26 #expect(entry.metadata.resourceType == .repositoryDetail)
27 }
28
29 @Test
30 func expiredEntryBehaviorAndPruneExpired() async throws {
31 let cache = makeCache()
32 let data = Data("expired".utf8)
33
34 let metadata = try await cache.write(payload: data, cacheKey: "ticket|old", resourceType: .ticketDetail, ttl: -1)
35 #expect(metadata.isExpired())
36 let entry = try await cache.read(cacheKey: "ticket|old")
37 #expect(entry.payload == data)
38
39 await cache.pruneExpired(now: Date())
40
41 await expectCacheMiss(cache, key: "ticket|old")
42 }
43
44 @Test
45 func invalidationByPrefixRemovesMatchingEntriesOnly() async throws {
46 let cache = makeCache()
47 _ = try await cache.write(payload: Data("a".utf8), cacheKey: "todo|ticket|1", resourceType: .ticketDetail, ttl: 60)
48 _ = try await cache.write(payload: Data("b".utf8), cacheKey: "todo|tickets", resourceType: .ticketList, ttl: 60)
49 _ = try await cache.write(payload: Data("c".utf8), cacheKey: "builds|job|1", resourceType: .buildDetail, ttl: 60)
50
51 await cache.removeByPrefix("todo|ticket")
52
53 await expectCacheMiss(cache, key: "todo|ticket|1")
54 await expectCacheMiss(cache, key: "todo|tickets")
55 _ = try await cache.read(cacheKey: "builds|job|1")
56 }
57
58 @Test
59 func maxEntrySizeEnforced() async throws {
60 let directory = temporaryDirectory()
61 let cache = PersistentAPICache(configuration: APICacheConfiguration(
62 directory: directory,
63 maxCacheSizeBytes: 1024,
64 maxEntrySizeBytes: 3,
65 memoryEntryLimit: 4,
66 schemaVersion: 1
67 ))
68
69 do {
70 _ = try await cache.write(payload: Data("toolarge".utf8), cacheKey: "large", resourceType: .buildLog, ttl: 60)
71 Issue.record("Expected max-entry enforcement.")
72 } catch APICacheError.entryTooLarge(let bytes) {
73 #expect(bytes == 8)
74 } catch {
75 Issue.record("Unexpected error: \(error)")
76 }
77 }
78
79 @Test
80 func pruneToSizeLimitUsesLRU() async throws {
81 let directory = temporaryDirectory()
82 let cache = PersistentAPICache(configuration: APICacheConfiguration(
83 directory: directory,
84 maxCacheSizeBytes: 9,
85 maxEntrySizeBytes: 20,
86 memoryEntryLimit: 4,
87 schemaVersion: 1
88 ))
89
90 _ = try await cache.write(payload: Data("1111".utf8), cacheKey: "old", resourceType: .repositoryFile, ttl: 60)
91 try await Task.sleep(for: .milliseconds(5))
92 _ = try await cache.write(payload: Data("2222".utf8), cacheKey: "middle", resourceType: .repositoryFile, ttl: 60)
93 try await Task.sleep(for: .milliseconds(5))
94 _ = try await cache.write(payload: Data("3333".utf8), cacheKey: "new", resourceType: .repositoryFile, ttl: 60)
95
96 await cache.pruneToSizeLimit()
97
98 await expectCacheMiss(cache, key: "old")
99 _ = try await cache.read(cacheKey: "middle")
100 _ = try await cache.read(cacheKey: "new")
101 }
102
103 @Test
104 func cacheFirstThenRefreshReturnsUsableStaleCacheWhenRefreshFails() async throws {
105 let cache = makeCache()
106 let staleEnvelope = #"{"data":{"item":{"value":"stale"}}}"#.data(using: .utf8)!
107 _ = try await cache.write(payload: staleEnvelope, cacheKey: "resource", resourceType: .repositoryDetail, ttl: -1)
108 CachedURLProtocol.reset(responses: [.failure])
109 let client = makeClient(cache: cache)
110
111 let result = try await client.executeCached(
112 service: .git,
113 query: "{ item { value } }",
114 responseType: GraphPayload.self,
115 cacheKey: "resource",
116 resourceType: .repositoryDetail,
117 ttl: 60,
118 policy: .cacheFirstThenRefresh
119 )
120
121 #expect(result.value.item.value == "stale")
122 #expect(result.isFromCache)
123 }
124
125 @Test
126 func refreshIgnoringCacheUpdatesCache() async throws {
127 let cache = makeCache()
128 CachedURLProtocol.reset(responses: [.success("fresh")])
129 let client = makeClient(cache: cache)
130
131 let result = try await client.executeCached(
132 service: .git,
133 query: "{ item { value } }",
134 responseType: GraphPayload.self,
135 cacheKey: "resource",
136 resourceType: .repositoryDetail,
137 ttl: 60,
138 policy: .refreshIgnoringCache
139 )
140 let cached = try await client.executeCached(
141 service: .git,
142 query: "{ item { value } }",
143 responseType: GraphPayload.self,
144 cacheKey: "resource",
145 resourceType: .repositoryDetail,
146 ttl: 60,
147 policy: .cacheOnly
148 )
149
150 #expect(result.value.item.value == "fresh")
151 #expect(cached.value.item.value == "fresh")
152 }
153
154 @Test
155 func networkOnlyBypassesCacheAndDoesNotWrite() async throws {
156 let cache = makeCache()
157 _ = try await cache.write(
158 payload: #"{"data":{"item":{"value":"cached"}}}"#.data(using: .utf8)!,
159 cacheKey: "resource",
160 resourceType: .repositoryDetail,
161 ttl: 60
162 )
163 CachedURLProtocol.reset(responses: [.success("network")])
164 let client = makeClient(cache: cache)
165
166 let result = try await client.executeCached(
167 service: .git,
168 query: "{ item { value } }",
169 responseType: GraphPayload.self,
170 cacheKey: "resource",
171 resourceType: .repositoryDetail,
172 ttl: 60,
173 policy: .networkOnly
174 )
175 let cached = try await client.executeCached(
176 service: .git,
177 query: "{ item { value } }",
178 responseType: GraphPayload.self,
179 cacheKey: "resource",
180 resourceType: .repositoryDetail,
181 ttl: 60,
182 policy: .cacheOnly
183 )
184
185 #expect(result.value.item.value == "network")
186 #expect(cached.value.item.value == "cached")
187 }
188
189 @Test
190 func plainMutationPathDoesNotReadFromCache() async throws {
191 let cache = makeCache()
192 _ = try await cache.write(
193 payload: #"{"data":{"item":{"value":"cached"}}}"#.data(using: .utf8)!,
194 cacheKey: "mutation-resource",
195 resourceType: .debug,
196 ttl: 60
197 )
198 CachedURLProtocol.reset(responses: [.success("network")])
199 let client = makeClient(cache: cache)
200
201 let result = try await client.execute(
202 service: .git,
203 query: "mutation update { item { value } }",
204 responseType: GraphPayload.self
205 )
206
207 #expect(result.item.value == "network")
208 #expect(CachedURLProtocol.requestCount == 1)
209 }
210
211 @Test
212 func duplicateConcurrentRequestsAreCoalesced() async throws {
213 let cache = makeCache()
214 CachedURLProtocol.reset(responses: [.success("fresh")], responseDelay: 0.05)
215 let client = makeClient(cache: cache)
216
217 async let first: CachedValue<GraphPayload> = client.executeCached(
218 service: .git,
219 query: "{ item { value } }",
220 responseType: GraphPayload.self,
221 cacheKey: "same-resource",
222 resourceType: .repositoryDetail,
223 ttl: 60,
224 policy: .refreshIgnoringCache
225 )
226 async let second: CachedValue<GraphPayload> = client.executeCached(
227 service: .git,
228 query: "{ item { value } }",
229 responseType: GraphPayload.self,
230 cacheKey: "same-resource",
231 resourceType: .repositoryDetail,
232 ttl: 60,
233 policy: .refreshIgnoringCache
234 )
235
236 let values = try await [first.value.item.value, second.value.item.value]
237 #expect(values == ["fresh", "fresh"])
238 #expect(CachedURLProtocol.requestCount == 1)
239 }
240
241 private func makeCache() -> PersistentAPICache {
242 PersistentAPICache(configuration: .temporary(directory: temporaryDirectory()))
243 }
244
245 private func makeClient(cache: any APICache) -> SRHTClient {
246 SRHTClient(session: CachedURLProtocol.makeSession(), token: "token", cache: cache)
247 }
248
249 private func temporaryDirectory() -> URL {
250 FileManager.default.temporaryDirectory
251 .appendingPathComponent("HutchAPICacheTests-\(UUID().uuidString)", isDirectory: true)
252 }
253
254 private func expectCacheMiss(_ cache: any APICache, key: String) async {
255 do {
256 _ = try await cache.read(cacheKey: key)
257 Issue.record("Expected cache miss for \(key).")
258 } catch APICacheError.miss {
259 } catch {
260 Issue.record("Unexpected error for \(key): \(error).")
261 }
262 }
263}
264
265private enum CachedURLProtocolResponse: Sendable {
266 case success(String)
267 case failure
268}
269
270private final class CachedURLProtocol: URLProtocol, @unchecked Sendable {
271 nonisolated(unsafe) private static var responses: [CachedURLProtocolResponse] = []
272 nonisolated(unsafe) private static var delay: TimeInterval = 0
273 nonisolated(unsafe) static var requestCount = 0
274
275 override class func canInit(with _: URLRequest) -> Bool { true }
276 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
277
278 override func startLoading() {
279 Self.requestCount += 1
280 if Self.delay > 0 {
281 Thread.sleep(forTimeInterval: Self.delay)
282 }
283 let next = Self.responses.isEmpty ? .success("fresh") : Self.responses.removeFirst()
284 switch next {
285 case .success(let value):
286 let data = #"{"data":{"item":{"value":"\#(value)"}}}"#.data(using: .utf8)!
287 let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
288 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
289 client?.urlProtocol(self, didLoad: data)
290 client?.urlProtocolDidFinishLoading(self)
291 case .failure:
292 client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
293 }
294 }
295
296 override func stopLoading() {}
297
298 static func reset(responses: [CachedURLProtocolResponse], responseDelay: TimeInterval = 0) {
299 Self.responses = responses
300 Self.delay = responseDelay
301 Self.requestCount = 0
302 }
303
304 static func makeSession() -> URLSession {
305 let config = URLSessionConfiguration.ephemeral
306 config.protocolClasses = [CachedURLProtocol.self]
307 return URLSession(configuration: config)
308 }
309}