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
354 lines
14 KiB
Swift
354 lines
14 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import OrgSocialKit
|
||
|
||
// Well-known feeds registered on the public relay (relay.org-social.org/feeds/).
|
||
// All three are active and have many historical posts — ideal for testing
|
||
// Range-based partial downloads vs. full downloads.
|
||
private let shomURL = URL(string: "https://shom.dev/social.org")!
|
||
private let rossURL = URL(string: "https://rossabaker.com/social.org")!
|
||
private let sachaURL = URL(string: "https://sachachua.com/social.org")!
|
||
private let testFeeds = [shomURL, rossURL, sachaURL]
|
||
|
||
private let relayURL = URL(string: "https://relay.org-social.org")!
|
||
|
||
// MARK: - Feature 2: Header-only fetch
|
||
|
||
@Suite("PartialFeedFetcher – header-only fetch (Discover)", .serialized)
|
||
struct PartialFeedFetcherHeaderTests {
|
||
|
||
let partial = PartialFeedFetcher()
|
||
let full = ProfileFetcher()
|
||
|
||
// --- Correctness: metadata matches full fetch ---
|
||
|
||
@Test("fetchProfileHeader nick matches full profile for shom.dev")
|
||
func headerNickMatchesFullShom() async throws {
|
||
async let partialProfile = partial.fetchProfileHeader(from: shomURL)
|
||
async let fullProfile = try full.fetch(from: shomURL)
|
||
let p = await partialProfile
|
||
let f = try await fullProfile
|
||
#expect(p != nil, "fetchProfileHeader must not return nil")
|
||
#expect(p?.nick == f.nick, "nick must match full profile")
|
||
}
|
||
|
||
@Test("fetchProfileHeader title matches full profile for rossabaker.com")
|
||
func headerTitleMatchesFullRoss() async throws {
|
||
async let partialProfile = partial.fetchProfileHeader(from: rossURL)
|
||
async let fullProfile = try full.fetch(from: rossURL)
|
||
let p = await partialProfile
|
||
let f = try await fullProfile
|
||
#expect(p != nil)
|
||
#expect(p?.title == f.title, "title must match full profile")
|
||
}
|
||
|
||
@Test("fetchProfileHeader avatar matches full profile for sachachua.com")
|
||
func headerAvatarMatchesFullSacha() async throws {
|
||
async let partialProfile = partial.fetchProfileHeader(from: sachaURL)
|
||
async let fullProfile = try full.fetch(from: sachaURL)
|
||
let p = await partialProfile
|
||
let f = try await fullProfile
|
||
#expect(p != nil)
|
||
#expect(p?.avatar == f.avatar, "avatar URL must match full profile")
|
||
}
|
||
|
||
@Test("fetchProfileHeader description matches full profile for all test feeds")
|
||
func headerDescriptionMatchesFull() async throws {
|
||
for url in testFeeds {
|
||
async let partialProfile = partial.fetchProfileHeader(from: url)
|
||
async let fullProfile = try full.fetch(from: url)
|
||
let p = await partialProfile
|
||
let f = try await fullProfile
|
||
#expect(p?.description == f.description, "description mismatch for \(url)")
|
||
}
|
||
}
|
||
|
||
// --- Structural guarantees ---
|
||
|
||
@Test("fetchProfileHeader sets feedURL to the requested URL")
|
||
func headerSetsFeedURL() async {
|
||
for url in testFeeds {
|
||
let p = await partial.fetchProfileHeader(from: url)
|
||
#expect(p?.feedURL == url, "feedURL must be set for \(url)")
|
||
}
|
||
}
|
||
|
||
@Test("fetchProfileHeader always returns zero posts")
|
||
func headerAlwaysReturnsZeroPosts() async {
|
||
// Posts section is trimmed before parsing, so no posts should ever appear.
|
||
for url in testFeeds {
|
||
let p = await partial.fetchProfileHeader(from: url)
|
||
#expect(p != nil, "fetchProfileHeader must not return nil for \(url)")
|
||
#expect(p?.posts.isEmpty == true,
|
||
"header fetch must not include posts for \(url)")
|
||
}
|
||
}
|
||
|
||
@Test("fetchProfileHeader nick and feedURL are non-nil for all test feeds")
|
||
func headerHasIdentity() async {
|
||
for url in testFeeds {
|
||
let p = await partial.fetchProfileHeader(from: url)
|
||
#expect(p?.feedURL == url)
|
||
#expect(p?.nick != nil, "nick must be present for \(url)")
|
||
}
|
||
}
|
||
|
||
// --- Input validation ---
|
||
|
||
@Test("fetchProfileHeader returns nil for ftp:// scheme")
|
||
func headerInvalidSchemeReturnsNil() async {
|
||
let url = URL(string: "ftp://bad.example.com/social.org")!
|
||
let result = await partial.fetchProfileHeader(from: url)
|
||
#expect(result == nil)
|
||
}
|
||
|
||
@Test("fetchProfileHeader returns nil for non-existent host")
|
||
func headerNonExistentHostReturnsNil() async {
|
||
let url = URL(string: "https://this-host-does-not-exist.invalid/social.org")!
|
||
let result = await partial.fetchProfileHeader(from: url)
|
||
#expect(result == nil)
|
||
}
|
||
|
||
// --- Relay integration ---
|
||
|
||
@Test("fetchProfileHeader succeeds for feeds sampled from the real relay")
|
||
func headerSucceedsForRelayFeeds() async throws {
|
||
let client = RelayClient()
|
||
let allFeeds = try await client.fetchFeeds(from: relayURL)
|
||
#expect(!allFeeds.isEmpty, "Relay must return at least one feed")
|
||
|
||
// Sample 5 feeds deterministically (first five from the relay list).
|
||
let sampled = Array(allFeeds.prefix(5))
|
||
var successCount = 0
|
||
|
||
for url in sampled {
|
||
if let p = await partial.fetchProfileHeader(from: url),
|
||
p.nick != nil || p.title != nil {
|
||
successCount += 1
|
||
}
|
||
}
|
||
|
||
// Tolerate up to 2 offline feeds out of 5.
|
||
#expect(successCount >= 3,
|
||
"At least 3/5 relay feeds must return valid header metadata; got \(successCount)/\(sampled.count)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Feature 3: Date-filtered fetch (Timeline)
|
||
|
||
@Suite("PartialFeedFetcher – date-filtered fetch (Timeline)", .serialized)
|
||
struct PartialFeedFetcherSinceTests {
|
||
|
||
let partial = PartialFeedFetcher()
|
||
let full = ProfileFetcher()
|
||
let parser = OrgSocialParser()
|
||
|
||
private func cutoff(daysAgo: Int) -> Date {
|
||
Calendar.current.date(byAdding: .day, value: -daysAgo, to: Date())!
|
||
}
|
||
|
||
// --- Date correctness ---
|
||
|
||
@Test("fetchSince: every returned post is newer than the cutoff (shom.dev, 30 days)")
|
||
func sinceAllPostsWithinRange_Shom() async throws {
|
||
let since = cutoff(daysAgo: 30)
|
||
let content = await partial.fetchSince(from: shomURL, since: since)
|
||
#expect(content != nil, "fetchSince must not return nil for shom.dev")
|
||
|
||
let profile = parser.parse(content!)
|
||
for post in profile.posts {
|
||
#expect(post.date >= since,
|
||
"Post \(post.timestamp) is older than cutoff \(since)")
|
||
}
|
||
}
|
||
|
||
@Test("fetchSince: every returned post is newer than the cutoff (rossabaker, 60 days)")
|
||
func sinceAllPostsWithinRange_Ross() async throws {
|
||
let since = cutoff(daysAgo: 60)
|
||
let content = await partial.fetchSince(from: rossURL, since: since)
|
||
#expect(content != nil)
|
||
|
||
let profile = parser.parse(content!)
|
||
for post in profile.posts {
|
||
#expect(post.date >= since,
|
||
"Post \(post.timestamp) is older than cutoff \(since)")
|
||
}
|
||
}
|
||
|
||
@Test("fetchSince: every returned post is newer than the cutoff (sachachua, 14 days)")
|
||
func sinceAllPostsWithinRange_Sacha() async throws {
|
||
let since = cutoff(daysAgo: 14)
|
||
let content = await partial.fetchSince(from: sachaURL, since: since)
|
||
#expect(content != nil)
|
||
|
||
let profile = parser.parse(content!)
|
||
for post in profile.posts {
|
||
#expect(post.date >= since,
|
||
"Post \(post.timestamp) is older than cutoff \(since)")
|
||
}
|
||
}
|
||
|
||
// --- Count comparison: partial ≤ full ---
|
||
|
||
@Test("fetchSince post count is <= full fetch post count (shom.dev)")
|
||
func sincePostCountNotExceedsFull_Shom() async throws {
|
||
let since = cutoff(daysAgo: 30)
|
||
async let partialContent = partial.fetchSince(from: shomURL, since: since)
|
||
async let fullProfile = try full.fetch(from: shomURL)
|
||
|
||
let p = await partialContent
|
||
let f = try await fullProfile
|
||
|
||
#expect(p != nil)
|
||
let partialCount = parser.parse(p!).posts.count
|
||
#expect(partialCount <= f.posts.count,
|
||
"Partial fetch returned more posts (\(partialCount)) than full fetch (\(f.posts.count))")
|
||
}
|
||
|
||
@Test("fetchSince post count is <= full fetch post count (rossabaker.com)")
|
||
func sincePostCountNotExceedsFull_Ross() async throws {
|
||
let since = cutoff(daysAgo: 60)
|
||
async let partialContent = partial.fetchSince(from: rossURL, since: since)
|
||
async let fullProfile = try full.fetch(from: rossURL)
|
||
|
||
let p = await partialContent
|
||
let f = try await fullProfile
|
||
|
||
#expect(p != nil)
|
||
let partialCount = parser.parse(p!).posts.count
|
||
#expect(partialCount <= f.posts.count,
|
||
"Partial fetch returned more posts (\(partialCount)) than full fetch (\(f.posts.count))")
|
||
}
|
||
|
||
// --- Metadata consistency ---
|
||
|
||
@Test("fetchSince: profile metadata (nick/title/avatar) matches full fetch")
|
||
func sinceMetadataMatchesFull() async throws {
|
||
let since = cutoff(daysAgo: 30)
|
||
|
||
for url in testFeeds {
|
||
async let partialContent = partial.fetchSince(from: url, since: since)
|
||
async let fullProfile = try full.fetch(from: url)
|
||
|
||
let p = await partialContent
|
||
let f = try await fullProfile
|
||
|
||
#expect(p != nil, "fetchSince returned nil for \(url)")
|
||
let partialProfile = parser.parse(p!)
|
||
|
||
#expect(partialProfile.nick == f.nick,
|
||
"nick mismatch for \(url): '\(partialProfile.nick ?? "nil")' vs '\(f.nick ?? "nil")'")
|
||
#expect(partialProfile.title == f.title,
|
||
"title mismatch for \(url)")
|
||
#expect(partialProfile.avatar == f.avatar,
|
||
"avatar mismatch for \(url)")
|
||
}
|
||
}
|
||
|
||
// --- Posts from partial match posts from full (same timestamps) ---
|
||
|
||
@Test("fetchSince: returned posts are a subset of the full fetch posts")
|
||
func sincePostsAreSubsetOfFull() async throws {
|
||
let since = cutoff(daysAgo: 30)
|
||
async let partialContent = partial.fetchSince(from: shomURL, since: since)
|
||
async let fullProfile = try full.fetch(from: shomURL)
|
||
|
||
let p = await partialContent
|
||
let f = try await fullProfile
|
||
|
||
#expect(p != nil)
|
||
let partialPosts = parser.parse(p!).posts
|
||
let fullTimestamps = Set(f.posts.map(\.timestamp))
|
||
|
||
for post in partialPosts {
|
||
#expect(fullTimestamps.contains(post.timestamp),
|
||
"Partial post \(post.timestamp) not found in full fetch")
|
||
}
|
||
}
|
||
|
||
// --- Input validation ---
|
||
|
||
@Test("fetchSince returns nil for ftp:// scheme")
|
||
func sinceInvalidSchemeReturnsNil() async {
|
||
let url = URL(string: "ftp://bad.example.com/social.org")!
|
||
let result = await partial.fetchSince(from: url, since: cutoff(daysAgo: 14))
|
||
#expect(result == nil)
|
||
}
|
||
|
||
@Test("fetchSince returns nil for non-existent host")
|
||
func sinceNonExistentHostReturnsNil() async {
|
||
let url = URL(string: "https://this-host-does-not-exist.invalid/social.org")!
|
||
let result = await partial.fetchSince(from: url, since: cutoff(daysAgo: 14))
|
||
#expect(result == nil)
|
||
}
|
||
|
||
// --- Relay integration ---
|
||
|
||
@Test("fetchSince succeeds and produces valid org text for all test feeds")
|
||
func sinceSucceedsForAllTestFeeds() async throws {
|
||
let since = cutoff(daysAgo: 14)
|
||
|
||
for url in testFeeds {
|
||
let content = await partial.fetchSince(from: url, since: since)
|
||
#expect(content != nil, "fetchSince returned nil for \(url)")
|
||
|
||
let profile = parser.parse(content!)
|
||
// Header metadata must survive partial reconstruction.
|
||
#expect(profile.nick != nil || profile.title != nil,
|
||
"Partial fetch for \(url) must contain profile metadata")
|
||
|
||
// Every post in the result must be within the requested range.
|
||
for post in profile.posts {
|
||
#expect(post.date >= since,
|
||
"Post \(post.timestamp) from \(url) is outside the requested range")
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test("fetchSince with far-future cutoff returns empty posts but valid header")
|
||
func sinceFutureCutoffReturnsEmptyPosts() async throws {
|
||
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
||
let content = await partial.fetchSince(from: shomURL, since: tomorrow)
|
||
#expect(content != nil, "fetchSince must not return nil even when no posts match")
|
||
|
||
let profile = parser.parse(content!)
|
||
#expect(profile.nick != nil, "Header metadata must still be present")
|
||
#expect(profile.posts.isEmpty, "No posts should be returned for a future cutoff")
|
||
}
|
||
|
||
@Test("fetchSince with very old cutoff returns at least some posts")
|
||
func sinceOldCutoffReturnsSomePosts() async throws {
|
||
// 10 years ago — every feed should have posts newer than this.
|
||
let tenYearsAgo = Calendar.current.date(byAdding: .year, value: -10, to: Date())!
|
||
let content = await partial.fetchSince(from: shomURL, since: tenYearsAgo)
|
||
#expect(content != nil)
|
||
|
||
let profile = parser.parse(content!)
|
||
#expect(!profile.posts.isEmpty,
|
||
"At least some posts should be returned for a very old cutoff")
|
||
}
|
||
|
||
@Test("fetchSince relay sample: at least 3 of 5 feeds return valid partial content")
|
||
func sinceSucceedsForRelayFeedSample() async throws {
|
||
let client = RelayClient()
|
||
let allFeeds = try await client.fetchFeeds(from: relayURL)
|
||
#expect(!allFeeds.isEmpty)
|
||
|
||
let since = cutoff(daysAgo: 30)
|
||
let sampled = Array(allFeeds.prefix(5))
|
||
var successCount = 0
|
||
|
||
for url in sampled {
|
||
if let content = await partial.fetchSince(from: url, since: since) {
|
||
let profile = parser.parse(content)
|
||
if profile.nick != nil || profile.title != nil {
|
||
successCount += 1
|
||
}
|
||
}
|
||
}
|
||
|
||
#expect(successCount >= 3,
|
||
"At least 3/5 relay feeds must return valid partial content; got \(successCount)/\(sampled.count)")
|
||
}
|
||
}
|