Files
andros 15eb8acbd0 Network performance: ETag caching, partial Range fetches, progressive timeline streaming
- 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
2026-05-16 10:12:41 +02:00

290 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}