b15dac53f1
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.
889 lines
28 KiB
Swift
889 lines
28 KiB
Swift
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)
|
||
}
|
||
}
|