15eb8acbd0
- FeedCache actor: conditional GET with ETag/Last-Modified, returns 304 hits from cache - PartialFeedFetcher: Range requests for header-only Discover fetches and date-filtered timeline fetches (2 requests instead of 3 per feed) - TimelineFetcher: AsyncStream-based streaming emits posts as each feed arrives instead of waiting for all feeds - TimelineViewModel: consumes stream progressively so posts appear immediately on first feed - Discover uses header-only fetch (16 KB vs full file) - maxConcurrentDownloads raised from 10 to 20
290 lines
12 KiB
Swift
290 lines
12 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import OrgSocialKit
|
||
|
||
// MARK: - Concurrency-safe capture box
|
||
|
||
/// Wraps a mutable value so it can be captured by `@Sendable` closures without
|
||
/// triggering Swift 6 sendability errors. Tests are `.serialized`, so no real
|
||
/// data race can occur; `@unchecked Sendable` silences the compiler check.
|
||
private final class Box<T>: @unchecked Sendable {
|
||
var value: T
|
||
init(_ value: T) { self.value = value }
|
||
}
|
||
|
||
// MARK: - Mock URLProtocol
|
||
|
||
/// Intercepts URLSession requests and delegates to a handler closure.
|
||
/// `nonisolated(unsafe)` is required because URLProtocol's class machinery
|
||
/// is called from arbitrary threads. Tests using this protocol must be
|
||
/// run `.serialized` so the static handler is never mutated concurrently.
|
||
private final class MockHTTPProtocol: URLProtocol, @unchecked Sendable {
|
||
|
||
nonisolated(unsafe) static var handler: (@Sendable (URLRequest) -> (Data, HTTPURLResponse))?
|
||
|
||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||
|
||
override func startLoading() {
|
||
guard let (data, response) = MockHTTPProtocol.handler?(request) else {
|
||
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
|
||
return
|
||
}
|
||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||
client?.urlProtocol(self, didLoad: data)
|
||
client?.urlProtocolDidFinishLoading(self)
|
||
}
|
||
|
||
override func stopLoading() {}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private func mockSession() -> URLSession {
|
||
let config = URLSessionConfiguration.ephemeral
|
||
config.protocolClasses = [MockHTTPProtocol.self]
|
||
return URLSession(configuration: config)
|
||
}
|
||
|
||
private func makeResponse(url: URL, status: Int, headers: [String: String] = [:]) -> HTTPURLResponse {
|
||
HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers)!
|
||
}
|
||
|
||
// MARK: - FeedCache unit tests
|
||
|
||
@Suite("FeedCache – conditional GET", .serialized)
|
||
struct FeedCacheTests {
|
||
|
||
private let testURL = URL(string: "https://shom.dev/social.org")!
|
||
private let fakeContent = "#+TITLE: Test Feed\n#+NICK: tester\n\n* Posts\n\n** 2025-01-01T10:00:00+01:00\nHello.\n"
|
||
private let fakeEtag = "\"deadbeef42\""
|
||
private let fakeDate = "Wed, 01 Jan 2025 09:00:00 GMT"
|
||
|
||
// MARK: Cache actor
|
||
|
||
@Test("record stores entry when ETag is present")
|
||
func recordStoresWithEtag() async {
|
||
let cache = FeedCache()
|
||
await cache.record(url: testURL, etag: fakeEtag, lastModified: nil, content: fakeContent)
|
||
let cached = await cache.cachedContent(for: testURL)
|
||
#expect(cached == fakeContent)
|
||
}
|
||
|
||
@Test("record stores entry when only Last-Modified is present")
|
||
func recordStoresWithLastModified() async {
|
||
let cache = FeedCache()
|
||
await cache.record(url: testURL, etag: nil, lastModified: fakeDate, content: fakeContent)
|
||
#expect(await cache.has(testURL))
|
||
}
|
||
|
||
@Test("record does NOT store when both ETag and Last-Modified are nil")
|
||
func recordRequiresAtLeastOneValidator() async {
|
||
let cache = FeedCache()
|
||
await cache.record(url: testURL, etag: nil, lastModified: nil, content: fakeContent)
|
||
#expect(await cache.cachedContent(for: testURL) == nil)
|
||
#expect(await cache.has(testURL) == false)
|
||
}
|
||
|
||
@Test("conditionalHeaders returns stored ETag and Last-Modified")
|
||
func conditionalHeadersReturnsValues() async {
|
||
let cache = FeedCache()
|
||
await cache.record(url: testURL, etag: fakeEtag, lastModified: fakeDate, content: fakeContent)
|
||
let (etag, lastMod) = await cache.conditionalHeaders(for: testURL)
|
||
#expect(etag == fakeEtag)
|
||
#expect(lastMod == fakeDate)
|
||
}
|
||
|
||
@Test("conditionalHeaders returns (nil, nil) when no entry exists")
|
||
func conditionalHeadersEmptyForUnknownURL() async {
|
||
let cache = FeedCache()
|
||
let (etag, lastMod) = await cache.conditionalHeaders(for: testURL)
|
||
#expect(etag == nil)
|
||
#expect(lastMod == nil)
|
||
}
|
||
|
||
@Test("invalidate removes the entry and clears conditionalHeaders")
|
||
func invalidateRemovesEntry() async {
|
||
let cache = FeedCache()
|
||
await cache.record(url: testURL, etag: fakeEtag, lastModified: nil, content: fakeContent)
|
||
await cache.invalidate(testURL)
|
||
#expect(await cache.has(testURL) == false)
|
||
#expect(await cache.cachedContent(for: testURL) == nil)
|
||
let (etag, _) = await cache.conditionalHeaders(for: testURL)
|
||
#expect(etag == nil)
|
||
}
|
||
|
||
@Test("has returns false before record and true after")
|
||
func hasTogglesBetweenRecordAndInvalidate() async {
|
||
let cache = FeedCache()
|
||
#expect(await cache.has(testURL) == false)
|
||
await cache.record(url: testURL, etag: fakeEtag, lastModified: nil, content: fakeContent)
|
||
#expect(await cache.has(testURL) == true)
|
||
await cache.invalidate(testURL)
|
||
#expect(await cache.has(testURL) == false)
|
||
}
|
||
|
||
@Test("different URLs are cached independently")
|
||
func independentURLCacheEntries() async {
|
||
let url2 = URL(string: "https://rossabaker.com/social.org")!
|
||
let cache = FeedCache()
|
||
await cache.record(url: testURL, etag: "\"etag1\"", lastModified: nil, content: "content1")
|
||
await cache.record(url: url2, etag: "\"etag2\"", lastModified: nil, content: "content2")
|
||
#expect(await cache.cachedContent(for: testURL) == "content1")
|
||
#expect(await cache.cachedContent(for: url2) == "content2")
|
||
let (e1, _) = await cache.conditionalHeaders(for: testURL)
|
||
let (e2, _) = await cache.conditionalHeaders(for: url2)
|
||
#expect(e1 == "\"etag1\"")
|
||
#expect(e2 == "\"etag2\"")
|
||
}
|
||
|
||
// MARK: FeedFetcher + cache (mock server)
|
||
|
||
@Test("first fetch stores ETag in cache")
|
||
func firstFetchPopulatesCache() async throws {
|
||
let cache = FeedCache()
|
||
let fetcher = FeedFetcher(session: mockSession(), cache: cache)
|
||
|
||
MockHTTPProtocol.handler = { [fakeContent, fakeEtag] _ in
|
||
let data = fakeContent.data(using: .utf8)!
|
||
return (data, makeResponse(url: self.testURL, status: 200, headers: ["ETag": fakeEtag]))
|
||
}
|
||
|
||
let content = try await fetcher.fetch(from: testURL)
|
||
#expect(content == fakeContent)
|
||
#expect(await cache.has(testURL))
|
||
let (storedEtag, _) = await cache.conditionalHeaders(for: testURL)
|
||
#expect(storedEtag == fakeEtag)
|
||
}
|
||
|
||
@Test("second fetch sends If-None-Match and returns cached content on 304")
|
||
func secondFetchHandles304() async throws {
|
||
let cache = FeedCache()
|
||
let fetcher = FeedFetcher(session: mockSession(), cache: cache)
|
||
let callCount = Box(0)
|
||
|
||
MockHTTPProtocol.handler = { [fakeContent, fakeEtag, callCount] req in
|
||
callCount.value += 1
|
||
if req.value(forHTTPHeaderField: "If-None-Match") != nil {
|
||
return (Data(), makeResponse(url: req.url!, status: 304))
|
||
}
|
||
return (fakeContent.data(using: .utf8)!,
|
||
makeResponse(url: req.url!, status: 200, headers: ["ETag": fakeEtag]))
|
||
}
|
||
|
||
let first = try await fetcher.fetch(from: testURL)
|
||
let second = try await fetcher.fetch(from: testURL)
|
||
|
||
#expect(first == fakeContent)
|
||
#expect(second == fakeContent, "304 must return the cached body")
|
||
#expect(callCount.value == 2, "Both fetches should hit the (mock) server")
|
||
}
|
||
|
||
@Test("second fetch sends If-None-Match header with stored ETag value")
|
||
func secondFetchSendsConditionalHeader() async throws {
|
||
let cache = FeedCache()
|
||
let fetcher = FeedFetcher(session: mockSession(), cache: cache)
|
||
let capturedConditional = Box<String?>(nil)
|
||
|
||
MockHTTPProtocol.handler = { [fakeContent, fakeEtag, capturedConditional] req in
|
||
if req.value(forHTTPHeaderField: "If-None-Match") != nil {
|
||
capturedConditional.value = req.value(forHTTPHeaderField: "If-None-Match")
|
||
return (Data(), makeResponse(url: req.url!, status: 304))
|
||
}
|
||
return (fakeContent.data(using: .utf8)!,
|
||
makeResponse(url: req.url!, status: 200, headers: ["ETag": fakeEtag]))
|
||
}
|
||
|
||
_ = try await fetcher.fetch(from: testURL)
|
||
_ = try? await fetcher.fetch(from: testURL)
|
||
#expect(capturedConditional.value == fakeEtag)
|
||
}
|
||
|
||
@Test("bypassCache=true does not send If-None-Match even after prior fetch")
|
||
func bypassCacheSkipsConditionalHeader() async throws {
|
||
let cache = FeedCache()
|
||
let fetcher = FeedFetcher(session: mockSession(), cache: cache)
|
||
let bypassRequest = Box<URLRequest?>(nil)
|
||
|
||
MockHTTPProtocol.handler = { [fakeContent, fakeEtag, bypassRequest] req in
|
||
if req.value(forHTTPHeaderField: "Cache-Control") == "no-cache" {
|
||
bypassRequest.value = req
|
||
}
|
||
return (fakeContent.data(using: .utf8)!,
|
||
makeResponse(url: req.url!, status: 200, headers: ["ETag": fakeEtag]))
|
||
}
|
||
|
||
_ = try await fetcher.fetch(from: testURL)
|
||
_ = try await fetcher.fetch(from: testURL, bypassCache: true)
|
||
|
||
#expect(bypassRequest.value?.value(forHTTPHeaderField: "If-None-Match") == nil,
|
||
"bypassCache must not send If-None-Match")
|
||
}
|
||
|
||
@Test("bypassCache=true invalidates prior cache entry")
|
||
func bypassCacheInvalidatesEntry() async throws {
|
||
let cache = FeedCache()
|
||
let fetcher = FeedFetcher(session: mockSession(), cache: cache)
|
||
|
||
MockHTTPProtocol.handler = { [fakeContent, fakeEtag] _ in
|
||
(fakeContent.data(using: .utf8)!,
|
||
makeResponse(url: self.testURL, status: 200, headers: ["ETag": fakeEtag]))
|
||
}
|
||
|
||
_ = try await fetcher.fetch(from: testURL)
|
||
#expect(await cache.has(testURL))
|
||
|
||
_ = try await fetcher.fetch(from: testURL, bypassCache: true)
|
||
// After bypass the entry should be gone (invalidated before re-fetch, and
|
||
// bypassCache suppresses re-recording)
|
||
#expect(await cache.has(testURL) == false)
|
||
}
|
||
|
||
@Test("fetch without ETag in response does not cache")
|
||
func fetchWithoutEtagDoesNotCache() async throws {
|
||
let cache = FeedCache()
|
||
let fetcher = FeedFetcher(session: mockSession(), cache: cache)
|
||
|
||
MockHTTPProtocol.handler = { [fakeContent] _ in
|
||
// No ETag header
|
||
(fakeContent.data(using: .utf8)!,
|
||
makeResponse(url: self.testURL, status: 200))
|
||
}
|
||
|
||
_ = try await fetcher.fetch(from: testURL)
|
||
#expect(await cache.has(testURL) == false, "Should not cache without ETag or Last-Modified")
|
||
}
|
||
|
||
// MARK: Live integration test
|
||
|
||
@Test("live feed: repeated fetch returns identical content")
|
||
func liveFeedRepeatedFetchIsIdentical() async throws {
|
||
// Two consecutive fetches of the same live feed must return the same content.
|
||
// If the server supports ETag, the second fetch goes via 304 + cache.
|
||
// If not, both fetches download the same body (assuming the feed is stable).
|
||
let cache = FeedCache()
|
||
let fetcher = FeedFetcher(cache: cache)
|
||
let url = URL(string: "https://shom.dev/social.org")!
|
||
|
||
let first = try await fetcher.fetch(from: url)
|
||
#expect(!first.isEmpty)
|
||
#expect(first.contains("* Posts") || first.contains("#+"))
|
||
|
||
let second = try await fetcher.fetch(from: url)
|
||
#expect(second == first, "Repeated fetch must return identical content")
|
||
}
|
||
|
||
@Test("live feed: first fetch may populate cache when server sends ETag")
|
||
func liveFeedEtagCachedWhenPresent() async throws {
|
||
let cache = FeedCache()
|
||
let fetcher = FeedFetcher(cache: cache)
|
||
let url = URL(string: "https://rossabaker.com/social.org")!
|
||
|
||
_ = try await fetcher.fetch(from: url)
|
||
// Cache is populated only when the server sends ETag or Last-Modified.
|
||
// We cannot assert `has == true` universally, but we can verify that if
|
||
// it was cached, the second fetch returns the same content.
|
||
let second = try await fetcher.fetch(from: url)
|
||
#expect(!second.isEmpty)
|
||
}
|
||
}
|