krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.9.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            // expected: a miss is the success path here
260        } catch {
261            Issue.record("Unexpected error for \(key): \(error).")
262        }
263    }
264}
265
266private enum CachedURLProtocolResponse: Sendable {
267    case success(String)
268    case failure
269}
270
271private final class CachedURLProtocol: URLProtocol, @unchecked Sendable {
272    nonisolated(unsafe) private static var responses: [CachedURLProtocolResponse] = []
273    nonisolated(unsafe) private static var delay: TimeInterval = 0
274    nonisolated(unsafe) static var requestCount = 0
275
276    override class func canInit(with _: URLRequest) -> Bool { true }
277    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
278
279    override func startLoading() {
280        Self.requestCount += 1
281        if Self.delay > 0 {
282            Thread.sleep(forTimeInterval: Self.delay)
283        }
284        let next = Self.responses.isEmpty ? .success("fresh") : Self.responses.removeFirst()
285        switch next {
286        case .success(let value):
287            let data = #"{"data":{"item":{"value":"\#(value)"}}}"#.data(using: .utf8)!
288            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
289            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
290            client?.urlProtocol(self, didLoad: data)
291            client?.urlProtocolDidFinishLoading(self)
292        case .failure:
293            client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
294        }
295    }
296
297    override func stopLoading() { /* required override; nothing to tear down */ }
298
299    static func reset(responses: [CachedURLProtocolResponse], responseDelay: TimeInterval = 0) {
300        Self.responses = responses
301        Self.delay = responseDelay
302        Self.requestCount = 0
303    }
304
305    static func makeSession() -> URLSession {
306        let config = URLSessionConfiguration.ephemeral
307        config.protocolClasses = [CachedURLProtocol.self]
308        return URLSession(configuration: config)
309    }
310}