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}