Files
andros b15dac53f1 Parser: stop truncating posts on inline * heading in post bodies
OrgSocialParser.parsePosts() collected lines after `* Posts` until the
next top-level heading, then stopped. That assumption broke the moment
a user wrote an Org heading inside a post body — one such post on
2025-10-14 hid every post that followed it (six months of content)
until the same user tried to see their own freshly-published post and
noticed it was missing.

Fix: read from `* Posts` to end of file. Org Social spec defines no
further top-level sections, and `* foo` inside a body is body content.
Regression test covers this case.

Also make the own profile always take precedence when the TimelineFetcher
merges feeds — the caller's bypassCache copy beats any relay-wide
download that may be stale.
2026-04-21 17:40:01 +02:00

889 lines
28 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: - Fixtures
private let sampleFeed = """
#+TITLE: Test Feed
#+NICK: tester
#+DESCRIPTION: A test profile.
#+AVATAR: https://example.com/avatar.jpg
#+LINK: https://example.com
#+LINK: https://blog.example.com
#+LOCATION: Valencia, Spain
#+BIRTHDAY: 1990-05-15
#+LANGUAGE: en es
#+FOLLOW: shom https://shom.dev/social.org
#+FOLLOW: https://tanrax.com/social.org
#+GROUP: Emacs https://relay.org-social.org
#+GROUP: Swift Dev https://relay.example.com
#+CONTACT: mailto:hi@example.com
#+PINNED: 2025-03-10T09:00:00+01:00
* Posts
** 2025-03-10T09:00:00+01:00
:PROPERTIES:
:LANG: en
:TAGS: emacs swift
:CLIENT: org-social-ios
:END:
Hello, Org Social!
** 2025-03-09T18:00:00+01:00
:PROPERTIES:
:LANG: es
:REPLY_TO: https://shom.dev/social.org#2025-03-09T10:00:00+01:00
:END:
Totalmente de acuerdo.
**
:PROPERTIES:
:ID: 2025-03-08T12:00:00+0100
:MOOD: ❤️
:REPLY_TO: https://tanrax.com/social.org#2025-03-08T10:00:00+01:00
:END:
**
:PROPERTIES:
:ID: 2025-03-07T10:00:00+01:00
:POLL_END: 2025-03-14T10:00:00+01:00
:END:
What's your favorite language?
- [ ] Swift
- [ ] Kotlin
- [ ] Rust
** 2025-03-06T08:00:00+01:00
:PROPERTIES:
:INCLUDE: https://shom.dev/social.org#2025-03-05T20:00:00+01:00
:END:
Great post worth sharing.
** 2025-03-05T07:00:00+01:00
:PROPERTIES:
:VISIBILITY: mention
:END:
Hey [[org-social:https://shom.dev/social.org][shom]], private message.
"""
private let feedWithHeaderId = """
#+TITLE: Header ID Feed
#+NICK: header
* Posts
** 2025-01-01T00:00:00+00:00
:PROPERTIES:
:ID: 2020-01-01T00:00:00+00:00
:END:
Header wins over property.
"""
private let feedNoPostsSection = """
#+TITLE: No Posts
#+NICK: empty
"""
private let feedInvalidProps = """
#+TITLE: Validation Feed
#+NICK: validator
* Posts
** 2025-03-10T09:00:00+01:00
:PROPERTIES:
:LANG: INVALID-LANG-CODE-TOO-LONG
:TAGS: valid-tag another_tag
:REPLY_TO: not-a-url-timestamp
:VISIBILITY: secret
:END:
Content with invalid properties.
"""
// MARK: - PostWriter fixtures
private let baseFeed = """
#+TITLE: My Feed
#+NICK: me
* Posts
** 2025-03-09T10:00:00+01:00
:PROPERTIES:
:LANG: en
:END:
Existing post.
"""
// MARK: - Network tests (serialized to avoid shared MockURLProtocol state races)
@Suite("Network", .serialized)
struct NetworkTests {
// MARK: - FeedFetcher tests
@Suite("FeedFetcher")
struct FeedFetcherTests {
private func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
@Test("Returns feed content on HTTP 200")
func fetchSuccess() async throws {
MockURLProtocol.reset()
MockURLProtocol.ok("#+TITLE: Test Feed\n#+NICK: tester\n", for: "https://example.com/social.org")
let fetcher = FeedFetcher(session: makeSession())
let content = try await fetcher.fetch(from: URL(string: "https://example.com/social.org")!)
#expect(content.contains("#+TITLE: Test Feed"))
}
@Test("Throws invalidURL for non-HTTP scheme")
func fetchInvalidScheme() async throws {
let fetcher = FeedFetcher()
await #expect(throws: FeedFetcherError.invalidURL) {
_ = try await fetcher.fetch(from: URL(string: "file:///social.org")!)
}
}
@Test("Throws httpError on 404")
func fetchHTTP404() async throws {
MockURLProtocol.reset()
MockURLProtocol.respond(status: 404, for: "https://example.com/social.org")
let fetcher = FeedFetcher(session: makeSession())
await #expect(throws: FeedFetcherError.httpError(statusCode: 404)) {
_ = try await fetcher.fetch(from: URL(string: "https://example.com/social.org")!)
}
}
@Test("Throws networkError on connection failure")
func fetchNetworkError() async throws {
MockURLProtocol.reset()
MockURLProtocol.throwHandler = { _ in throw URLError(.notConnectedToInternet) }
let fetcher = FeedFetcher(session: makeSession())
do {
_ = try await fetcher.fetch(from: URL(string: "https://example.com/social.org")!)
Issue.record("Expected networkError to be thrown")
} catch FeedFetcherError.networkError {
// expected
}
}
}
// MARK: - OrgSocialParser tests
@Suite("OrgSocialParser — Profile headers")
struct ParserProfileTests {
let parser = OrgSocialParser()
@Test("Parses title and nick")
func titleAndNick() {
let profile = parser.parse(sampleFeed)
#expect(profile.title == "Test Feed")
#expect(profile.nick == "tester")
}
@Test("Parses optional scalar fields")
func scalarFields() {
let profile = parser.parse(sampleFeed)
#expect(profile.description == "A test profile.")
#expect(profile.avatar == URL(string: "https://example.com/avatar.jpg"))
#expect(profile.location == "Valencia, Spain")
#expect(profile.birthday == "1990-05-15")
#expect(profile.pinned == "2025-03-10T09:00:00+01:00")
}
@Test("Parses multiple LINK values")
func links() {
let profile = parser.parse(sampleFeed)
#expect(profile.links.count == 2)
#expect(profile.links.contains(URL(string: "https://example.com")!))
#expect(profile.links.contains(URL(string: "https://blog.example.com")!))
}
@Test("Parses space-separated LANGUAGE")
func languages() {
let profile = parser.parse(sampleFeed)
#expect(profile.languages == ["en", "es"])
}
@Test("Parses FOLLOW with and without nick")
func follows() {
let profile = parser.parse(sampleFeed)
#expect(profile.follows.count == 2)
let named = profile.follows.first { $0.name != nil }
#expect(named?.name == "shom")
#expect(named?.url == URL(string: "https://shom.dev/social.org"))
let anonymous = profile.follows.first { $0.name == nil }
#expect(anonymous?.url == URL(string: "https://tanrax.com/social.org"))
}
@Test("Parses GROUP entries with multi-word names")
func groups() {
let profile = parser.parse(sampleFeed)
#expect(profile.groups.count == 2)
let emacs = profile.groups.first { $0.name == "Emacs" }
#expect(emacs?.relayURL == URL(string: "https://relay.org-social.org"))
let swift = profile.groups.first { $0.name == "Swift Dev" }
#expect(swift?.relayURL == URL(string: "https://relay.example.com"))
}
@Test("Parses CONTACT entries")
func contacts() {
let profile = parser.parse(sampleFeed)
#expect(profile.contacts == ["mailto:hi@example.com"])
}
@Test("Returns empty profile for feed with no headers")
func emptyFeed() {
let profile = parser.parse("")
#expect(profile.title == nil)
#expect(profile.nick == nil)
#expect(profile.posts.isEmpty)
}
@Test("feedURL is nil after parsing (must be set by caller)")
func feedURLIsNil() {
let profile = parser.parse(sampleFeed)
#expect(profile.feedURL == nil)
}
}
@Suite("OrgSocialParser — Posts")
struct ParserPostTests {
let parser = OrgSocialParser()
@Test("Parses correct number of posts")
func postCount() {
let profile = parser.parse(sampleFeed)
#expect(profile.posts.count == 6)
}
@Test("Parses post with ID in header")
func idInHeader() {
let profile = parser.parse(sampleFeed)
let post = profile.posts.first { $0.timestamp == "2025-03-10T09:00:00+01:00" }
#expect(post != nil)
#expect(post?.text == "Hello, Org Social!")
}
@Test("Parses LANG and TAGS")
func langAndTags() {
let profile = parser.parse(sampleFeed)
let post = profile.posts.first { $0.timestamp == "2025-03-10T09:00:00+01:00" }
#expect(post?.lang == "en")
#expect(post?.tags == ["emacs", "swift"])
#expect(post?.client == "org-social-ios")
}
@Test("Parses REPLY_TO")
func replyTo() {
let profile = parser.parse(sampleFeed)
let post = profile.posts.first { $0.timestamp == "2025-03-09T18:00:00+01:00" }
#expect(post?.replyTo == "https://shom.dev/social.org#2025-03-09T10:00:00+01:00")
#expect(post?.lang == "es")
}
@Test("Parses ID from :PROPERTIES: with +0100 timezone format")
func idInProperties() {
let profile = parser.parse(sampleFeed)
let post = profile.posts.first { $0.timestamp == "2025-03-08T12:00:00+0100" }
#expect(post != nil)
#expect(post?.mood == "❤️")
}
@Test("Header ID takes priority over property ID")
func headerIdPriority() {
let profile = parser.parse(feedWithHeaderId)
#expect(profile.posts.count == 1)
#expect(profile.posts.first?.timestamp == "2025-01-01T00:00:00+00:00")
#expect(profile.posts.first?.text == "Header wins over property.")
}
@Test("Parses POLL_END")
func pollEnd() {
let profile = parser.parse(sampleFeed)
let post = profile.posts.first { $0.timestamp == "2025-03-07T10:00:00+01:00" }
#expect(post?.pollEnd != nil)
#expect(post?.text.contains("What's your favorite language?") == true)
}
@Test("Parses INCLUDE (boost)")
func boost() {
let profile = parser.parse(sampleFeed)
let post = profile.posts.first { $0.timestamp == "2025-03-06T08:00:00+01:00" }
#expect(post?.include == "https://shom.dev/social.org#2025-03-05T20:00:00+01:00")
}
@Test("Parses VISIBILITY: mention")
func visibility() {
let profile = parser.parse(sampleFeed)
let post = profile.posts.first { $0.timestamp == "2025-03-05T07:00:00+01:00" }
#expect(post?.visibility == "mention")
#expect(post?.text.contains("[[org-social:") == true)
}
@Test("Returns empty posts for feed with no Posts section")
func noPostsSection() {
let profile = parser.parse(feedNoPostsSection)
#expect(profile.posts.isEmpty)
}
@Test("Discards invalid property values")
func invalidProps() {
let profile = parser.parse(feedInvalidProps)
let post = profile.posts.first
#expect(post != nil)
#expect(post?.lang == nil) // too long / invalid format
#expect(post?.tags == ["valid-tag", "another_tag"])
#expect(post?.replyTo == nil) // not URL#timestamp
#expect(post?.visibility == nil) // "secret" not valid
}
@Test("Strips comment and property lines from text")
func textFiltering() {
let feed = """
#+TITLE: Filter Test
#+NICK: filter
* Posts
** 2025-03-10T09:00:00+01:00
:PROPERTIES:
:LANG: en
:END:
# This is a comment
Visible line.
:some-property: value
Another visible line.
"""
let profile = parser.parse(feed)
let text = profile.posts.first?.text ?? ""
#expect(!text.contains("# This is a comment"))
#expect(text.contains("Visible line."))
#expect(!text.contains(":some-property:"))
#expect(text.contains("Another visible line."))
}
}
// MARK: - PostWriter tests
@Suite("PostWriter — block generation")
struct PostWriterBlockTests {
let writer = PostWriter()
let feedURL = URL(string: "https://me.example.com/social.org")!
@Test("Builds minimal post block")
func minimalBlock() throws {
let ts = "2025-04-01T12:00:00+01:00"
let block = writer.buildPostBlock(timestamp: ts, options: NewPostOptions(text: "Hello!"))
#expect(block.contains("** \(ts)"))
#expect(block.contains(":PROPERTIES:"))
#expect(block.contains(":CLIENT: iOS"))
#expect(block.contains(":END:"))
#expect(block.contains("Hello!"))
// Optional fields absent when nil
#expect(!block.contains(":LANG:"))
#expect(!block.contains(":TAGS:"))
#expect(!block.contains(":REPLY_TO:"))
}
@Test("Includes all optional fields when provided")
func fullBlock() {
let opts = NewPostOptions(
text: "Full post",
lang: "en",
tags: "swift ios",
replyTo: "https://friend.example.com/social.org#2025-03-09T10:00:00+01:00",
mood: "🚀",
group: "Swift Dev https://relay.example.com",
visibility: .mention,
client: "test-client"
)
let block = writer.buildPostBlock(timestamp: "2025-04-01T12:00:00+01:00", options: opts)
#expect(block.contains(":LANG: en"))
#expect(block.contains(":TAGS: swift ios"))
#expect(block.contains(":REPLY_TO: https://friend.example.com/social.org#2025-03-09T10:00:00+01:00"))
#expect(block.contains(":MOOD: 🚀"))
#expect(block.contains(":GROUP: Swift Dev https://relay.example.com"))
#expect(block.contains(":VISIBILITY: mention"))
#expect(block.contains(":CLIENT: test-client"))
#expect(block.contains("Full post"))
}
@Test("Boost block includes INCLUDE, no text required")
func boostBlock() {
let opts = NewPostOptions(
text: "",
include: "https://friend.example.com/social.org#2025-03-09T10:00:00+01:00"
)
let block = writer.buildPostBlock(timestamp: "2025-04-01T12:00:00+01:00", options: opts)
#expect(block.contains(":INCLUDE: https://friend.example.com/social.org#2025-03-09T10:00:00+01:00"))
}
}
@Suite("PostWriter — appendPost")
struct PostWriterAppendTests {
let writer = PostWriter()
let feedURL = URL(string: "https://me.example.com/social.org")!
@Test("Returns updated content with new post appended")
func appendsPost() throws {
let opts = NewPostOptions(text: "New post!", lang: "en")
let (updated, postURL) = try writer.appendPost(to: baseFeed, feedURL: feedURL, options: opts)
#expect(updated.contains("New post!"))
#expect(updated.contains("Existing post."))
#expect(postURL.hasPrefix("https://me.example.com/social.org#"))
}
@Test("New post URL follows feedURL#timestamp format")
func postURLFormat() throws {
let (_, postURL) = try writer.appendPost(
to: baseFeed,
feedURL: feedURL,
options: NewPostOptions(text: "Test")
)
let parts = postURL.components(separatedBy: "#")
#expect(parts.count == 2)
#expect(parts[0] == "https://me.example.com/social.org")
// Timestamp matches RFC 3339 pattern
let ts = parts[1]
let hasTimestamp = ts.range(of: #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"#, options: String.CompareOptions.regularExpression) != nil
#expect(hasTimestamp)
}
@Test("New post appears after existing posts")
func orderPreserved() throws {
let (updated, _) = try writer.appendPost(
to: baseFeed,
feedURL: feedURL,
options: NewPostOptions(text: "Newest")
)
let existingIdx = updated.range(of: "Existing post.")!.lowerBound
let newIdx = updated.range(of: "Newest")!.lowerBound
#expect(newIdx > existingIdx)
}
@Test("Throws missingPostsSection when feed has no * Posts")
func throwsOnMissingSection() {
let feedWithoutPosts = "#+TITLE: Broken\n#+NICK: broken\n"
#expect(throws: PostWriterError.missingPostsSection) {
_ = try writer.appendPost(
to: feedWithoutPosts,
feedURL: feedURL,
options: NewPostOptions(text: "Oops")
)
}
}
@Test("Roundtrip: parser reads back the appended post")
func roundtrip() throws {
let opts = NewPostOptions(text: "Roundtrip post", lang: "es", tags: "test")
let (updated, _) = try writer.appendPost(to: baseFeed, feedURL: feedURL, options: opts)
let profile = OrgSocialParser().parse(updated)
let parsed = profile.posts.first { $0.text == "Roundtrip post" }
#expect(parsed != nil)
#expect(parsed?.lang == "es")
#expect(parsed?.tags == ["test"])
}
}
@Suite("PostWriter — upload")
struct PostWriterUploadTests {
let feedURL = URL(string: "https://host.example.com/social.org")!
private func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
@Test("Uploads to POST /upload with correct multipart fields")
func uploadSendsMultipart() async throws {
MockURLProtocol.reset()
MockURLProtocol.ok("", for: "https://host.example.com/upload")
let writer = PostWriter(session: makeSession())
try await writer.upload(content: "#+TITLE: Feed\n", to: feedURL)
let req = try #require(MockURLProtocol.lastRequest)
#expect(req.url?.absoluteString == "https://host.example.com/upload")
#expect(req.httpMethod == "POST")
let contentType = req.value(forHTTPHeaderField: "Content-Type") ?? ""
#expect(contentType.hasPrefix("multipart/form-data; boundary="))
// Body contains both multipart fields
let bodyString = String(data: req.httpBody ?? Data(), encoding: .utf8) ?? ""
#expect(bodyString.contains("name=\"vfile\""))
#expect(bodyString.contains("https://host.example.com/social.org"))
#expect(bodyString.contains("name=\"file\""))
#expect(bodyString.contains("#+TITLE: Feed"))
}
@Test("Throws uploadFailed on HTTP 500")
func uploadHTTPError() async {
MockURLProtocol.reset()
MockURLProtocol.respond(status: 500, for: "https://host.example.com/upload")
let writer = PostWriter(session: makeSession())
await #expect(throws: PostWriterError.uploadFailed(statusCode: 500)) {
try await writer.upload(content: "content", to: feedURL)
}
}
}
// MARK: - TimelineFetcher tests
@Suite("TimelineFetcher")
struct TimelineFetcherTests {
// Feeds used across tests
private let myFeedURL = "https://me.example.com/social.org"
private let friendFeedURL = "https://friend.example.com/social.org"
private let relayURL = "https://relay.example.com"
private let myFeed = """
#+TITLE: My Feed
#+NICK: me
#+FOLLOW: friend https://friend.example.com/social.org
* Posts
** 2025-03-10T09:00:00+01:00
:PROPERTIES:
:LANG: en
:END:
My own post.
"""
private let friendFeed = """
#+TITLE: Friend Feed
#+NICK: friend
* Posts
** 2025-03-10T10:00:00+01:00
:PROPERTIES:
:LANG: en
:END:
Friend post.
** 2025-03-10T08:00:00+01:00
:PROPERTIES:
:LANG: en
:MOOD: ❤️
:REPLY_TO: https://me.example.com/social.org#2025-03-10T09:00:00+01:00
:END:
"""
private let relayResponse = """
{"data": ["https://me.example.com/social.org", "https://friend.example.com/social.org"]}
"""
private func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
private func makeProfile() -> OrgSocialProfile {
let parser = OrgSocialParser()
var profile = parser.parse(myFeed)
profile.feedURL = URL(string: myFeedURL)
return profile
}
@Test("Fetches timeline using follow list when relay is disabled")
func timelineWithoutRelay() async {
MockURLProtocol.reset()
MockURLProtocol.ok(myFeed, for: myFeedURL)
MockURLProtocol.ok(friendFeed, for: friendFeedURL)
let profile = makeProfile()
let options = TimelineOptions(useRelay: false, maxPostAgeDays: nil)
let posts = await TimelineFetcher(session: makeSession()).fetch(following: profile, options: options)
// Own post + friend's text post (reaction is filtered)
#expect(posts.count == 2)
#expect(posts.contains { $0.text == "My own post." })
#expect(posts.contains { $0.text == "Friend post." })
}
@Test("Fetches timeline using relay feed list when relay is enabled")
func timelineWithRelay() async {
MockURLProtocol.reset()
MockURLProtocol.ok(relayResponse, for: relayURL + "/feeds/")
MockURLProtocol.ok(myFeed, for: myFeedURL)
MockURLProtocol.ok(friendFeed, for: friendFeedURL)
let profile = makeProfile()
let options = TimelineOptions(
relayURL: URL(string: relayURL)!,
useRelay: true,
maxPostAgeDays: nil
)
let posts = await TimelineFetcher(session: makeSession()).fetch(following: profile, options: options)
#expect(posts.count == 2)
}
@Test("Falls back to follow list when relay is unreachable")
func relayFallback() async {
MockURLProtocol.reset()
// relay returns nothing (not registered in map → 404-like error)
MockURLProtocol.ok(myFeed, for: myFeedURL)
MockURLProtocol.ok(friendFeed, for: friendFeedURL)
let profile = makeProfile()
let options = TimelineOptions(
relayURL: URL(string: relayURL)!,
useRelay: true,
maxPostAgeDays: nil
)
let posts = await TimelineFetcher(session: makeSession()).fetch(following: profile, options: options)
// Still gets posts from own profile + follow list fallback
#expect(!posts.isEmpty)
}
@Test("Filters pure reactions (MOOD + REPLY_TO, no text) from timeline")
func reactionsFiltered() async {
MockURLProtocol.reset()
MockURLProtocol.ok(friendFeed, for: friendFeedURL)
let profile = makeProfile()
let options = TimelineOptions(useRelay: false, maxPostAgeDays: nil)
let posts = await TimelineFetcher(session: makeSession()).fetch(following: profile, options: options)
let reactionPosts = posts.filter { $0.mood != nil && ($0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
#expect(reactionPosts.isEmpty)
}
@Test("Posts are sorted newest first")
func sortedByDateDescending() async {
MockURLProtocol.reset()
MockURLProtocol.ok(myFeed, for: myFeedURL)
MockURLProtocol.ok(friendFeed, for: friendFeedURL)
let profile = makeProfile()
let options = TimelineOptions(useRelay: false, maxPostAgeDays: nil)
let posts = await TimelineFetcher(session: makeSession()).fetch(following: profile, options: options)
for i in 0..<(posts.count - 1) {
#expect(posts[i].date >= posts[i + 1].date)
}
}
@Test("Author metadata is attached to posts")
func authorMetadata() async {
MockURLProtocol.reset()
MockURLProtocol.ok(friendFeed, for: friendFeedURL)
let profile = makeProfile()
let options = TimelineOptions(useRelay: false, maxPostAgeDays: nil)
let posts = await TimelineFetcher(session: makeSession()).fetch(following: profile, options: options)
let friendPost = posts.first { $0.authorNick == "friend" }
#expect(friendPost != nil)
#expect(friendPost?.authorURL == URL(string: friendFeedURL))
}
@Test("Language filter excludes non-matching posts")
func languageFilter() async {
let mixedFeed = """
#+TITLE: Mixed
#+NICK: mixed
* Posts
** 2025-03-10T09:00:00+01:00
:PROPERTIES:
:LANG: en
:END:
English post.
** 2025-03-10T08:00:00+01:00
:PROPERTIES:
:LANG: es
:END:
Post en español.
"""
MockURLProtocol.reset()
MockURLProtocol.ok(mixedFeed, for: friendFeedURL)
let profile = makeProfile()
let options = TimelineOptions(
useRelay: false,
maxPostAgeDays: nil,
languageFilter: ["en"]
)
let posts = await TimelineFetcher(session: makeSession()).fetch(following: profile, options: options)
#expect(posts.allSatisfy { $0.lang == "en" || $0.lang == nil })
#expect(!posts.contains { $0.lang == "es" })
}
@Test("Own profile posts are always included")
func ownProfileIncluded() async {
MockURLProtocol.reset()
// No friend feed registered — only our own posts should appear
let profile = makeProfile()
let options = TimelineOptions(useRelay: false, maxPostAgeDays: nil)
let posts = await TimelineFetcher(session: makeSession()).fetch(following: profile, options: options)
#expect(posts.contains { $0.text == "My own post." })
}
}
} // end NetworkTests
// MARK: - Mock URLProtocol
/// URL mock with per-URL response dispatch. All state is class-level; use inside
/// a `.serialized` suite to avoid cross-test races.
final class MockURLProtocol: URLProtocol, @unchecked Sendable {
/// URL string → (body, HTTP status code)
nonisolated(unsafe) static var responses: [String: (Data, Int)] = [:]
/// Last request received (body read from httpBodyStream when httpBody is nil)
nonisolated(unsafe) static var lastRequest: URLRequest?
/// Fallback for tests that need to simulate a network-level throw (not an HTTP error)
nonisolated(unsafe) static var throwHandler: ((URLRequest) throws -> Void)?
static func reset() {
responses = [:]
lastRequest = nil
throwHandler = nil
}
/// Register a 200 OK response for `url`.
static func ok(_ body: String, for url: String) {
responses[url] = (Data(body.utf8), 200)
}
/// Register a response with a specific HTTP status code for `url`.
static func respond(status: Int, for url: String, body: String = "") {
responses[url] = (Data(body.utf8), status)
}
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let urlString = request.url?.absoluteString ?? ""
// Read body from httpBodyStream when httpBody is nil (URLProtocol behaviour)
var enriched = request
if enriched.httpBody == nil, let stream = enriched.httpBodyStream {
stream.open()
var body = Data()
var buf = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable {
let n = stream.read(&buf, maxLength: buf.count)
if n > 0 { body.append(contentsOf: buf[0..<n]) }
}
stream.close()
enriched.httpBody = body
}
MockURLProtocol.lastRequest = enriched
// Throw-based handler (used only by network-error tests)
if let throwHandler = MockURLProtocol.throwHandler {
do {
try throwHandler(enriched)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
return
}
// URL-keyed response dispatch
if let (data, statusCode) = MockURLProtocol.responses[urlString] {
let response = HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
return
}
client?.urlProtocol(self, didFailWithError: URLError(.fileDoesNotExist))
}
override func stopLoading() {}
}
@Suite("OrgSocialParser bug regressions")
struct OrgSocialParserRegressionTests {
let parser = OrgSocialParser()
@Test("level-1 heading inside a post body does not terminate parsing")
func levelOneHeadingInBody() {
let feed = """
#+TITLE: A
#+NICK: a
* Posts
** 2025-10-14T08:07:08+0200
:PROPERTIES:
:LANG: en
:END:
My reply:
* This is a section inside my post
Content under it.
* Another section
More content.
** 2026-04-21T17:00:10+0200
:PROPERTIES:
:LANG: en
:END:
I am writing from iOS
"""
let p = parser.parse(feed)
#expect(p.posts.count == 2)
#expect(p.posts.contains { $0.timestamp == "2026-04-21T17:00:10+0200" })
let earlier = p.posts.first { $0.timestamp == "2025-10-14T08:07:08+0200" }
#expect(earlier?.text.contains("This is a section inside my post") == true)
}
}