summaryrefslogtreecommitdiff
path: root/HutchTests
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-05-06 20:30:34 -0500
committerChristian Cleberg <[email protected]>2026-05-06 20:30:34 -0500
commit57e4f34b4613c09beb0cb757ac2ba2b43cc04daf (patch)
tree4c83a914f36919a5d1164fa39e7e0d5915c8730a /HutchTests
parentfba49d0955a6030406956b2bb1c2a60d184cfda6 (diff)
downloadhutch-57e4f34b4613c09beb0cb757ac2ba2b43cc04daf.tar.gz
hutch-57e4f34b4613c09beb0cb757ac2ba2b43cc04daf.tar.bz2
hutch-57e4f34b4613c09beb0cb757ac2ba2b43cc04daf.zip
feat: add persistent stale-while-revalidate API cache
Introduce an actor-backed persistent cache layer at the SRHTClient boundary for read-only SourceHut data. Cache entries now store stable metadata including key, resource type, fetched/expires/access timestamps, payload hash, schema version, and payload size, with bounded memory and disk usage. Add centralized cache key builders and TTL defaults for repository, file, ticket, build, log, profile, status, and list-style resources. Support networkOnly, cacheOnly, cacheFirstThenRefresh, and refreshIgnoringCache policies, plus request coalescing for duplicate in-flight cache keys. Integrate first-pass caching into high-value low-risk read paths: - build detail and completed/active build logs - ticket detail - README lookup - repository tree, blob, and linked file reads Keep mutation paths network-only and add simple prefix invalidation after ticket and build mutations. Add compact cached/stale UI status rows and a Settings action to clear the persistent cache. Add focused cache tests covering round trips, expiration, stale fallback, policy behavior, request coalescing, prefix invalidation, size limits, LRU pruning, expired pruning, and mutation bypass behavior. Document storage, key, TTL, invalidation, limitations, and next recommended targets.
Diffstat (limited to 'HutchTests')
-rw-r--r--HutchTests/APICacheTests.swift309
-rw-r--r--HutchTests/AppStateTests.swift1
2 files changed, 310 insertions, 0 deletions
diff --git a/HutchTests/APICacheTests.swift b/HutchTests/APICacheTests.swift
new file mode 100644
index 0000000..89c6c53
--- /dev/null
+++ b/HutchTests/APICacheTests.swift
@@ -0,0 +1,309 @@
+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 {
+ } 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() {}
+
+ 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)
+ }
+}
diff --git a/HutchTests/AppStateTests.swift b/HutchTests/AppStateTests.swift
index 45812ed..88a8272 100644
--- a/HutchTests/AppStateTests.swift
+++ b/HutchTests/AppStateTests.swift
@@ -1,3 +1,4 @@
+import Foundation
import Testing
@testable import Hutch