summaryrefslogtreecommitdiff
path: root/HutchTests/APICacheTests.swift
blob: 24db8709d9e3eb18e312783e6e7bfedbfb404a8e (plain) (blame)
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
import Foundation
import Testing
@testable import Hutch

@Suite(.serialized)
struct APICacheTests {
    private struct Payload: Codable, Sendable, Equatable {
        let value: String
    }

    private struct GraphPayload: Decodable, Sendable, Equatable {
        let item: Payload
    }

    @Test
    func cacheReadWriteRoundTrip() async throws {
        let cache = makeCache()
        let data = try JSONEncoder().encode(Payload(value: "cached"))

        _ = try await cache.write(payload: data, cacheKey: "repo|one", resourceType: .repositoryDetail, ttl: 60)
        let entry = try await cache.read(cacheKey: "repo|one")
        let decoded = try JSONDecoder().decode(Payload.self, from: entry.payload)

        #expect(decoded == Payload(value: "cached"))
        #expect(entry.metadata.cacheKey == "repo|one")
        #expect(entry.metadata.resourceType == .repositoryDetail)
    }

    @Test
    func expiredEntryBehaviorAndPruneExpired() async throws {
        let cache = makeCache()
        let data = Data("expired".utf8)

        let metadata = try await cache.write(payload: data, cacheKey: "ticket|old", resourceType: .ticketDetail, ttl: -1)
        #expect(metadata.isExpired())
        let entry = try await cache.read(cacheKey: "ticket|old")
        #expect(entry.payload == data)

        await cache.pruneExpired(now: Date())

        await expectCacheMiss(cache, key: "ticket|old")
    }

    @Test
    func invalidationByPrefixRemovesMatchingEntriesOnly() async throws {
        let cache = makeCache()
        _ = try await cache.write(payload: Data("a".utf8), cacheKey: "todo|ticket|1", resourceType: .ticketDetail, ttl: 60)
        _ = try await cache.write(payload: Data("b".utf8), cacheKey: "todo|tickets", resourceType: .ticketList, ttl: 60)
        _ = try await cache.write(payload: Data("c".utf8), cacheKey: "builds|job|1", resourceType: .buildDetail, ttl: 60)

        await cache.removeByPrefix("todo|ticket")

        await expectCacheMiss(cache, key: "todo|ticket|1")
        await expectCacheMiss(cache, key: "todo|tickets")
        _ = try await cache.read(cacheKey: "builds|job|1")
    }

    @Test
    func maxEntrySizeEnforced() async throws {
        let directory = temporaryDirectory()
        let cache = PersistentAPICache(configuration: APICacheConfiguration(
            directory: directory,
            maxCacheSizeBytes: 1024,
            maxEntrySizeBytes: 3,
            memoryEntryLimit: 4,
            schemaVersion: 1
        ))

        do {
            _ = try await cache.write(payload: Data("toolarge".utf8), cacheKey: "large", resourceType: .buildLog, ttl: 60)
            Issue.record("Expected max-entry enforcement.")
        } catch APICacheError.entryTooLarge(let bytes) {
            #expect(bytes == 8)
        } catch {
            Issue.record("Unexpected error: \(error)")
        }
    }

    @Test
    func pruneToSizeLimitUsesLRU() async throws {
        let directory = temporaryDirectory()
        let cache = PersistentAPICache(configuration: APICacheConfiguration(
            directory: directory,
            maxCacheSizeBytes: 9,
            maxEntrySizeBytes: 20,
            memoryEntryLimit: 4,
            schemaVersion: 1
        ))

        _ = try await cache.write(payload: Data("1111".utf8), cacheKey: "old", resourceType: .repositoryFile, ttl: 60)
        try await Task.sleep(for: .milliseconds(5))
        _ = try await cache.write(payload: Data("2222".utf8), cacheKey: "middle", resourceType: .repositoryFile, ttl: 60)
        try await Task.sleep(for: .milliseconds(5))
        _ = try await cache.write(payload: Data("3333".utf8), cacheKey: "new", resourceType: .repositoryFile, ttl: 60)

        await cache.pruneToSizeLimit()

        await expectCacheMiss(cache, key: "old")
        _ = try await cache.read(cacheKey: "middle")
        _ = try await cache.read(cacheKey: "new")
    }

    @Test
    func cacheFirstThenRefreshReturnsUsableStaleCacheWhenRefreshFails() async throws {
        let cache = makeCache()
        let staleEnvelope = #"{"data":{"item":{"value":"stale"}}}"#.data(using: .utf8)!
        _ = try await cache.write(payload: staleEnvelope, cacheKey: "resource", resourceType: .repositoryDetail, ttl: -1)
        CachedURLProtocol.reset(responses: [.failure])
        let client = makeClient(cache: cache)

        let result = try await client.executeCached(
            service: .git,
            query: "{ item { value } }",
            responseType: GraphPayload.self,
            cacheKey: "resource",
            resourceType: .repositoryDetail,
            ttl: 60,
            policy: .cacheFirstThenRefresh
        )

        #expect(result.value.item.value == "stale")
        #expect(result.isFromCache)
    }

    @Test
    func refreshIgnoringCacheUpdatesCache() async throws {
        let cache = makeCache()
        CachedURLProtocol.reset(responses: [.success("fresh")])
        let client = makeClient(cache: cache)

        let result = try await client.executeCached(
            service: .git,
            query: "{ item { value } }",
            responseType: GraphPayload.self,
            cacheKey: "resource",
            resourceType: .repositoryDetail,
            ttl: 60,
            policy: .refreshIgnoringCache
        )
        let cached = try await client.executeCached(
            service: .git,
            query: "{ item { value } }",
            responseType: GraphPayload.self,
            cacheKey: "resource",
            resourceType: .repositoryDetail,
            ttl: 60,
            policy: .cacheOnly
        )

        #expect(result.value.item.value == "fresh")
        #expect(cached.value.item.value == "fresh")
    }

    @Test
    func networkOnlyBypassesCacheAndDoesNotWrite() async throws {
        let cache = makeCache()
        _ = try await cache.write(
            payload: #"{"data":{"item":{"value":"cached"}}}"#.data(using: .utf8)!,
            cacheKey: "resource",
            resourceType: .repositoryDetail,
            ttl: 60
        )
        CachedURLProtocol.reset(responses: [.success("network")])
        let client = makeClient(cache: cache)

        let result = try await client.executeCached(
            service: .git,
            query: "{ item { value } }",
            responseType: GraphPayload.self,
            cacheKey: "resource",
            resourceType: .repositoryDetail,
            ttl: 60,
            policy: .networkOnly
        )
        let cached = try await client.executeCached(
            service: .git,
            query: "{ item { value } }",
            responseType: GraphPayload.self,
            cacheKey: "resource",
            resourceType: .repositoryDetail,
            ttl: 60,
            policy: .cacheOnly
        )

        #expect(result.value.item.value == "network")
        #expect(cached.value.item.value == "cached")
    }

    @Test
    func plainMutationPathDoesNotReadFromCache() async throws {
        let cache = makeCache()
        _ = try await cache.write(
            payload: #"{"data":{"item":{"value":"cached"}}}"#.data(using: .utf8)!,
            cacheKey: "mutation-resource",
            resourceType: .debug,
            ttl: 60
        )
        CachedURLProtocol.reset(responses: [.success("network")])
        let client = makeClient(cache: cache)

        let result = try await client.execute(
            service: .git,
            query: "mutation update { item { value } }",
            responseType: GraphPayload.self
        )

        #expect(result.item.value == "network")
        #expect(CachedURLProtocol.requestCount == 1)
    }

    @Test
    func duplicateConcurrentRequestsAreCoalesced() async throws {
        let cache = makeCache()
        CachedURLProtocol.reset(responses: [.success("fresh")], responseDelay: 0.05)
        let client = makeClient(cache: cache)

        async let first: CachedValue<GraphPayload> = client.executeCached(
            service: .git,
            query: "{ item { value } }",
            responseType: GraphPayload.self,
            cacheKey: "same-resource",
            resourceType: .repositoryDetail,
            ttl: 60,
            policy: .refreshIgnoringCache
        )
        async let second: CachedValue<GraphPayload> = client.executeCached(
            service: .git,
            query: "{ item { value } }",
            responseType: GraphPayload.self,
            cacheKey: "same-resource",
            resourceType: .repositoryDetail,
            ttl: 60,
            policy: .refreshIgnoringCache
        )

        let values = try await [first.value.item.value, second.value.item.value]
        #expect(values == ["fresh", "fresh"])
        #expect(CachedURLProtocol.requestCount == 1)
    }

    private func makeCache() -> PersistentAPICache {
        PersistentAPICache(configuration: .temporary(directory: temporaryDirectory()))
    }

    private func makeClient(cache: any APICache) -> SRHTClient {
        SRHTClient(session: CachedURLProtocol.makeSession(), token: "token", cache: cache)
    }

    private func temporaryDirectory() -> URL {
        FileManager.default.temporaryDirectory
            .appendingPathComponent("HutchAPICacheTests-\(UUID().uuidString)", isDirectory: true)
    }

    private func expectCacheMiss(_ cache: any APICache, key: String) async {
        do {
            _ = try await cache.read(cacheKey: key)
            Issue.record("Expected cache miss for \(key).")
        } catch APICacheError.miss {
            // expected: a miss is the success path here
        } catch {
            Issue.record("Unexpected error for \(key): \(error).")
        }
    }
}

private enum CachedURLProtocolResponse: Sendable {
    case success(String)
    case failure
}

private final class CachedURLProtocol: URLProtocol, @unchecked Sendable {
    nonisolated(unsafe) private static var responses: [CachedURLProtocolResponse] = []
    nonisolated(unsafe) private static var delay: TimeInterval = 0
    nonisolated(unsafe) static var requestCount = 0

    override class func canInit(with _: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        Self.requestCount += 1
        if Self.delay > 0 {
            Thread.sleep(forTimeInterval: Self.delay)
        }
        let next = Self.responses.isEmpty ? .success("fresh") : Self.responses.removeFirst()
        switch next {
        case .success(let value):
            let data = #"{"data":{"item":{"value":"\#(value)"}}}"#.data(using: .utf8)!
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        case .failure:
            client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
        }
    }

    override func stopLoading() { /* required override; nothing to tear down */ }

    static func reset(responses: [CachedURLProtocolResponse], responseDelay: TimeInterval = 0) {
        Self.responses = responses
        Self.delay = responseDelay
        Self.requestCount = 0
    }

    static func makeSession() -> URLSession {
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [CachedURLProtocol.self]
        return URLSession(configuration: config)
    }
}