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..