import Foundation import Testing @testable import OrgSocialKit private let feed = """ #+TITLE: Test #+NICK: test * Posts """ private let feedURL = URL(string: "https://example.com/social.org")! private let relay = URL(string: "https://relay.org-social.org")! // Poll post with live votes on relay (andros Nov 2025) private let livePollURL = "https://host.org-social.org/andros/social.org#2025-11-26T09:03:48+0100" @Suite("PostWriter – boost block") struct BoostBlockTests { let writer = PostWriter() @Test("boost factory creates INCLUDE property") func boostFactory() { let opts = NewPostOptions.boost(of: "https://shom.dev/social.org#2025-09-06T22:36:20-0500") let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts) #expect(block.contains(":INCLUDE: https://shom.dev/social.org#2025-09-06T22:36:20-0500")) } @Test("boost with commentary includes body text") func boostWithCommentary() { let opts = NewPostOptions.boost(of: "https://shom.dev/social.org#2025-09-06T22:36:20-0500", commentary: "Great post!") let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts) #expect(block.contains("Great post!")) #expect(block.contains(":INCLUDE:")) } @Test("pure boost (no commentary) has no body text") func pureBoostNoBody() throws { let opts = NewPostOptions.boost(of: "https://shom.dev/social.org#2025-09-06T22:36:20-0500") let (updated, _) = try writer.appendPost(to: feed, feedURL: feedURL, options: opts) // The block must not contain any non-empty lines after :END: let lines = updated.components(separatedBy: "\n") let endIdx = lines.lastIndex { $0.trimmingCharacters(in: .whitespaces) == ":END:" } ?? 0 let bodyLines = lines[(endIdx + 1)...].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } #expect(bodyLines.isEmpty) } } @Suite("PostWriter – poll block") struct PollBlockTests { let writer = PostWriter() @Test("poll factory creates POLL_END property") func pollHasPollEnd() { let end = Date(timeIntervalSinceNow: 7 * 86400) let opts = NewPostOptions.poll(question: "Which editor?", options: ["Emacs", "Vim"], end: end) let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts) #expect(block.contains(":POLL_END:")) } @Test("poll question and options are separated by a blank line") func pollBlankLineBetweenQuestionAndOptions() { let end = Date(timeIntervalSinceNow: 86400) let opts = NewPostOptions.poll(question: "Pick one", options: ["A"], end: end) let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts) #expect(block.contains("Pick one\n\n- [ ] A")) } @Test("poll body contains checkboxes for each option") func pollCheckboxes() { let end = Date(timeIntervalSinceNow: 86400) let opts = NewPostOptions.poll(question: "Pick one", options: ["A", "B", "C"], end: end) let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts) #expect(block.contains("- [ ] A")) #expect(block.contains("- [ ] B")) #expect(block.contains("- [ ] C")) } @Test("poll vote creates POLL_OPTION property") func pollVoteBlock() { let opts = NewPostOptions.vote(for: "Emacs", on: "https://shom.dev/social.org#2025-01-01T00:00:00Z") let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts) #expect(block.contains(":POLL_OPTION: Emacs")) #expect(block.contains(":REPLY_TO: https://shom.dev/social.org#2025-01-01T00:00:00Z")) } @Test("vote block has no body text") func voteNoBody() throws { let opts = NewPostOptions.vote(for: "Emacs", on: "https://example.com/social.org#2025-01-01T00:00:00Z") let (updated, _) = try writer.appendPost(to: feed, feedURL: feedURL, options: opts) let lines = updated.components(separatedBy: "\n") let endIdx = lines.lastIndex { $0.trimmingCharacters(in: .whitespaces) == ":END:" } ?? 0 let bodyLines = lines[(endIdx + 1)...].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } #expect(bodyLines.isEmpty) } @Test("poll factory emits LANG, TAGS, MOOD, VISIBILITY when provided") func pollAcceptsSharedFields() { let end = Date(timeIntervalSinceNow: 86400) let opts = NewPostOptions.poll( question: "Pick one", options: ["A", "B"], end: end, lang: "es", tags: "poll survey", mood: "🗳", visibility: .mention ) let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts) #expect(block.contains(":LANG: es")) #expect(block.contains(":TAGS: poll survey")) #expect(block.contains(":MOOD: 🗳")) #expect(block.contains(":VISIBILITY: mention")) #expect(block.contains(":POLL_END:")) } @Test("poll factory omits LANG/TAGS/MOOD/VISIBILITY when not provided") func pollOmitsAbsentSharedFields() { let end = Date(timeIntervalSinceNow: 86400) let opts = NewPostOptions.poll(question: "Pick one", options: ["A"], end: end) let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts) #expect(!block.contains(":LANG:")) #expect(!block.contains(":TAGS:")) #expect(!block.contains(":MOOD:")) #expect(!block.contains(":VISIBILITY:")) } } @Suite("PostWriter – migration block") struct MigrationBlockTests { let writer = PostWriter() @Test("migration factory creates MIGRATION property") func migrationBlock() { let opts = NewPostOptions.migration(from: "https://old.example.com/social.org", to: "https://new.example.com/social.org") let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts) #expect(block.contains(":MIGRATION: https://old.example.com/social.org https://new.example.com/social.org")) } } @Suite("PostWriter – scheduled post") struct ScheduledPostTests { let writer = PostWriter() @Test("appendPost with future date uses that timestamp") func futureTimestamp() throws { let future = Date(timeIntervalSinceNow: 3600) let opts = NewPostOptions(text: "Scheduled post") let (_, postURL) = try writer.appendPost(to: feed, feedURL: feedURL, options: opts, date: future) let timestamp = postURL.components(separatedBy: "#").last ?? "" // The timestamp must parse to a future date (parse helper accepts // both compact and colon forms, matching the spec). let parsed = PostWriter.parseTimestamp(timestamp) #expect(parsed != nil) #expect(parsed! > Date()) } @Test("generateTimestamp emits compact timezone form (no colon)") func timestampCompactForm() { let ts = writer.generateTimestamp(for: Date(timeIntervalSince1970: 0)) // Last 5 chars must be `±HHMM` (compact) or exactly match `Z` is // disallowed by the new emission contract — we always emit `±HHMM`. let suffix = String(ts.suffix(5)) let matches = suffix.range(of: #"^[+-]\d{4}$"#, options: .regularExpression) != nil #expect(matches, "expected compact timezone, got: \(ts)") } @Test("scheduled post block contains future timestamp in heading") func futureTimestampInHeading() throws { let future = Date(timeIntervalSinceNow: 3600) let opts = NewPostOptions(text: "Will post later") let (updated, _) = try writer.appendPost(to: feed, feedURL: feedURL, options: opts, date: future) let generatedTimestamp = writer.generateTimestamp(for: future) #expect(updated.contains("** \(generatedTimestamp)")) } } @Suite("RelayClient – poll votes", .serialized) struct PollVotesTests { let client = RelayClient() @Test("fetchPollVotes returns options for known poll") func fetchVotesNonEmpty() async throws { let votes = try await client.fetchPollVotes(for: livePollURL, from: relay) #expect(!votes.isEmpty) } @Test("fetchPollVotes options have non-empty names") func voteOptionNames() async throws { let votes = try await client.fetchPollVotes(for: livePollURL, from: relay) for vote in votes { #expect(!vote.option.isEmpty) } } @Test("fetchPollVotes total vote count matches individual counts") func voteTotals() async throws { let votes = try await client.fetchPollVotes(for: livePollURL, from: relay) let total = votes.reduce(0) { $0 + $1.voteCount } #expect(total >= 0) } } @Suite("RelayClient – groups", .serialized) struct GroupsTests { let client = RelayClient() @Test("fetchGroupList returns known groups") func groupListNonEmpty() async throws { let groups = try await client.fetchGroupList(from: relay) #expect(!groups.isEmpty) } @Test("group names and slugs are non-empty strings") func groupNamesValid() async throws { let groups = try await client.fetchGroupList(from: relay) for g in groups { #expect(!g.name.isEmpty) #expect(!g.slug.isEmpty) } } @Test("fetchGroupPostURLs returns posts for Emacs group") func emacsGroupPosts() async throws { let urls = try await client.fetchGroupPostURLs(slug: "emacs", from: relay) #expect(!urls.isEmpty) for url in urls { #expect(url.contains("#"), "Group post URL missing #: \(url)") } } }