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