From f124eb1e0cdbf5bc69f9a76a5b28bd248fdd247d Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 9 Sep 2026 10:07:39 -0700 Subject: [PATCH] feat(scheduled): show a queued post's destinations, edit its time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub #55 asked for a full scheduled-post editor — time, content and cross-post destinations — to match what the web help page describes. Read-only recon on 2026-09-09 found the content and destination halves have no route to call: • OPTIONS /api/messages/[id] -> Allow: DELETE, GET, HEAD, OPTIONS, PATCH, so PATCH is the only update verb a message has; • /api/messages/scheduled/[id] and /api/messages/[id]/schedule both 404, and /api/messages/scheduled allows GET, HEAD, OPTIONS only; • that PATCH honours scheduledAt alone — a content body returns 400 "No valid updates provided" and a content sent beside scheduledAt is silently discarded (2026-09-06 §1c V1 write-probe); • the deployed web client issues no PATCH /api/messages/[id] anywhere in its bundles, so it does not ship that editor either. So this builds everything the API can actually support and is explicit about the rest rather than offering controls that could not save. Kit — model scheduledCrossPostConfig, a live message field the client did not decode: { mastodonProviderIds, crossPostToBluesky, crossPostToLinkedIn }, key names taken from the web's own badge component. crossPostToTwitter is decoded speculatively and documented as unconfirmed on read. Domain — add ScheduledDestinations plus Message.scheduledDestinations (fetch-time only, not persisted, matching the linkPreviews precedent). Replace the narrow reschedule with a single updateScheduled(messageId: edit:) taking a ScheduledPostEdit. It validates entirely client-side and before any request: an empty edit, a past date, or a content/destination change each throw a typed MessagesError naming exactly what was refused, so a doomed call is never sent and an unsupported edit can never look like it saved. App — scheduled rows now show their destinations without opening anything; the sheet becomes EditScheduledPostSheet, with the time editable and the content and destinations shown read-only alongside a line saying why. The composer's schedule dialog gains a destinations summary and a "Post now instead" link, mirroring the web's combined dialog additively rather than restructuring the composer. The optimistic reschedule copy now routes through replacing(...), fixing a latent bug where rescheduling dropped the row's link previews and cross-post pills. Also answers the question the issue raised about a second defect: the composer does NOT drop cross-post selections on a scheduled post — it sends them in the same create body as scheduledAt. Pinned by a test. A new live contract test fails on purpose if a scheduled-post editor route ever appears, which is the signal to reopen #55. Co-Authored-By: Claude Opus 5 (1M context) --- App/Features/Compose/ComposerViewModel.swift | 35 ++++ App/Features/Compose/ComposerWindowView.swift | 24 +++ .../Scheduled/ScheduledPostsRootView.swift | 176 ++++++++++++++++-- .../Scheduled/ScheduledPostsViewModel.swift | 51 +++-- .../Timeline/Message+OptimisticUpdates.swift | 18 +- AppTests/ComposerViewModelTests.swift | 88 +++++++++ AppTests/ScheduledPostsViewModelTests.swift | 107 ++++++++++- AppTests/Support/StubMessagesService.swift | 14 +- .../InterlinedDomain/Models/Mappers.swift | 23 ++- .../InterlinedDomain/Models/Message.swift | 81 +++++++- .../Services/MessagesService.swift | 133 ++++++++++++- .../InterlinedDomainTests/MapperTests.swift | 108 +++++++++++ .../MessagesServiceTests.swift | 118 +++++++++++- .../Support/Fixtures.swift | 11 +- .../InterlinedKit/DTOs/MessageDTO.swift | 68 ++++++- .../Endpoints/MessagesEndpoint.swift | 10 + .../InterlinedKitTests/ContractTests.swift | 58 ++++++ .../MessagesEndpointTests.swift | 74 ++++++++ 18 files changed, 1128 insertions(+), 69 deletions(-) diff --git a/App/Features/Compose/ComposerViewModel.swift b/App/Features/Compose/ComposerViewModel.swift index 8bf25a6..8634c7f 100644 --- a/App/Features/Compose/ComposerViewModel.swift +++ b/App/Features/Compose/ComposerViewModel.swift @@ -197,6 +197,41 @@ final class ComposerViewModel { /// the user gets an immediate signal instead of a server-side rejection. var isOverMessageLimit: Bool { messageCharacterCount > messageCharacterLimit } + // MARK: - Scheduled destinations (GitHub #55) + + /// The cross-post destinations the draft will fan out to, as the schedule + /// dialog names them. + /// + /// The web's schedule dialog lists the connected networks alongside the + /// date so you can see where a queued post is going at the moment you queue + /// it. macOS keeps its per-network toggles where they are (they are shared + /// with the send-now path) and mirrors the *information* here instead — + /// deliberately additive, so the composer's structure is untouched. + /// + /// Derived from the same toggles `submitNewPost` sends, so the summary + /// cannot drift from what is actually posted. Mastodon counts only when a + /// provider id was actually entered: the toggle alone sends an empty + /// `mastodonProviderIds`, which fans out nowhere. + var scheduledDestinationNames: [String] { + var names: [String] = [] + if crossPostToMastodon, !Self.normalise(providerIds: mastodonProviderIdsInput).isEmpty { + names.append("Mastodon") + } + if crossPostToBluesky { names.append("Bluesky") } + if crossPostToLinkedIn { names.append("LinkedIn") } + if crossPostToTwitter { names.append("X") } + return names + } + + /// One line naming where the scheduled post will land. Falls back to the + /// InterlinedList-only wording so the dialog always states a destination + /// rather than showing a blank where the list would be. + var scheduledDestinationSummary: String { + let names = scheduledDestinationNames + guard !names.isEmpty else { return "InterlinedList only" } + return names.joined(separator: " \u{00B7} ") + } + // MARK: - Validation /// Whether the current draft would be accepted for submit. Empty body is diff --git a/App/Features/Compose/ComposerWindowView.swift b/App/Features/Compose/ComposerWindowView.swift index fda629b..9797e96 100644 --- a/App/Features/Compose/ComposerWindowView.swift +++ b/App/Features/Compose/ComposerWindowView.swift @@ -320,6 +320,30 @@ struct ComposerWindowView: View { ) .datePickerStyle(.compact) .disabled(!viewModel.canUseSubscriberFeatures) + + // GitHub #55 — the web's schedule dialog names the networks a + // queued post will reach, next to the date. The per-network + // toggles stay in `crossPostSection` (they serve the send-now + // path too); this mirrors the information so the schedule + // decision is made with its destinations in view. + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + Text("Goes to \(viewModel.scheduledDestinationSummary)") + } + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityLabel("Scheduled post destinations: \(viewModel.scheduledDestinationSummary)") + + // The web lets you click the scheduled date to drop the + // schedule and post immediately; this is the same escape hatch. + Button("Post now instead") { + viewModel.isScheduled = false + } + .buttonStyle(.link) + .font(.ilMono(10)) + .disabled(!viewModel.canUseSubscriberFeatures) + .help("Cancel scheduling and publish this message right away.") } } } diff --git a/App/Features/Scheduled/ScheduledPostsRootView.swift b/App/Features/Scheduled/ScheduledPostsRootView.swift index 8fafdb5..51c1837 100644 --- a/App/Features/Scheduled/ScheduledPostsRootView.swift +++ b/App/Features/Scheduled/ScheduledPostsRootView.swift @@ -7,10 +7,19 @@ // behavior without touching SwiftUI. // // Rows support cancel (DELETE /api/messages/[id]) and reschedule -// (PUT /api/messages/[id] with a new `scheduledAt`). Both operations +// (PATCH /api/messages/[id] with a new `scheduledAt`). Both operations // use the optimistic-UI pattern (NW-3): the list is updated locally // before the network call and rolled back on failure. // +// GitHub #55: each row now shows the cross-post destinations the post will fan +// out to, so they are readable without opening anything. The reschedule sheet +// became `EditScheduledPostSheet`, which shows the post's content and +// destinations alongside the (editable) publish time. Those two are read-only on +// purpose, not by oversight: the live API has no route that edits them — +// `PATCH /api/messages/[id]` honours `scheduledAt` alone and silently discards a +// `content` sent beside it, and the web client ships no message `PATCH` at all. +// The sheet says so plainly rather than offering a control that cannot save. +// // Per Decision 0003 the view consumes only `InterlinedDomain`. import SwiftUI @@ -27,7 +36,7 @@ struct ScheduledPostsRootView: View { @Environment(\.openWindow) private var openWindow @State private var viewModel: ScheduledPostsViewModel? - @State private var reschedulingPost: Message? = nil + @State private var editingPost: Message? = nil var body: some View { NavigationStack { @@ -65,10 +74,10 @@ struct ScheduledPostsRootView: View { } } } - .sheet(item: $reschedulingPost) { post in - RescheduleSheet(post: post) { newDate in + .sheet(item: $editingPost) { post in + EditScheduledPostSheet(post: post) { newDate in Task { await viewModel?.reschedule(post: post, to: newDate) } - reschedulingPost = nil + editingPost = nil } } .task { @@ -119,14 +128,24 @@ struct ScheduledPostsRootView: View { ForEach(viewModel.posts) { post in ScheduledPostRow(post: post) .contextMenu { - Button("Reschedule\u{2026}") { - reschedulingPost = post + Button("Edit\u{2026}") { + editingPost = post } Button("Cancel Post", role: .destructive) { Task { await viewModel.cancel(post: post) } } } } + } header: { + // A failed cancel / reschedule is reported here rather than + // replacing the list: the rows are still valid, only the last + // mutation was not applied. + if let actionError = viewModel.actionError { + actionErrorBanner( + message: actionError.localizedDescription, + onDismiss: { viewModel.clearActionError() } + ) + } } } .listStyle(.inset) @@ -135,6 +154,29 @@ struct ScheduledPostsRootView: View { } } + private func actionErrorBanner( + message: String, + onDismiss: @escaping () -> Void + ) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.accentColor) + Text(message) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .textCase(nil) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + Button("Dismiss", action: onDismiss) + .buttonStyle(.link) + .font(.ilMono(10)) + .textCase(nil) + } + .padding(.vertical, 4) + .accessibilityElement(children: .contain) + .accessibilityLabel("Scheduled post action failed. \(message)") + } + // MARK: - States private var loadingState: some View { @@ -203,55 +245,140 @@ struct ScheduledPostsRootView: View { } } -// MARK: - RescheduleSheet +// MARK: - EditScheduledPostSheet -private struct RescheduleSheet: View { +/// The editor for a queued post (GitHub #55). +/// +/// The publish time is editable and is the one change the live API accepts. The +/// body and the cross-post destinations are shown but not editable — see the +/// file header: no live route can change them, so offering a text field here +/// would let the user type an edit that silently never saves. Showing them +/// read-only still answers the question the sheet exists to answer ("what is +/// this post, and where is it going?") and the footnote says why they are +/// fixed. +private struct EditScheduledPostSheet: View { let post: Message let onReschedule: (Date) -> Void @State private var selectedDate: Date @Environment(\.dismiss) private var dismiss + /// How far ahead of now the earliest selectable time sits. A minute of + /// slack, rather than `Date()` exactly, so a sheet left open for a moment + /// cannot submit a time that went stale between render and click. + private static let minimumLeadTime: TimeInterval = 60 + init(post: Message, onReschedule: @escaping (Date) -> Void) { self.post = post self.onReschedule = onReschedule - _selectedDate = State(initialValue: post.scheduledAt ?? Date().addingTimeInterval(3600)) + // Boundary: a post about to fire (or one whose time has just passed + // while the list sat on screen) would seed a selection outside the + // picker's own range. Clamp it forward so the sheet opens on a valid, + // submittable time instead of relying on SwiftUI to silently fix it. + let floorDate = Date().addingTimeInterval(Self.minimumLeadTime) + let seed = post.scheduledAt ?? Date().addingTimeInterval(3600) + _selectedDate = State(initialValue: max(seed, floorDate)) + } + + private var earliestSelectableDate: Date { + Date().addingTimeInterval(Self.minimumLeadTime) } var body: some View { VStack(alignment: .leading, spacing: 16) { - Text("Reschedule Post") + Text("Edit Scheduled Post") .font(.ilTitle(18)) - Text(post.text.isEmpty ? "(No text)" : post.text) - .font(.ilBody()) - .lineLimit(2) - .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Message") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Text(post.text.isEmpty ? "(No text)" : post.text) + .font(.ilBody()) + .lineLimit(4) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Destinations") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + ScheduledDestinationsLabel(destinations: post.scheduledDestinations) + } + DatePicker( "Publish at", selection: $selectedDate, - in: Date()..., + in: earliestSelectableDate..., displayedComponents: [.date, .hourAndMinute] ) + + Text("Only the publish time can be changed. To change the message or its " + + "destinations, cancel this post and schedule a new one.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) Spacer() - Button("Reschedule") { + Button("Save") { onReschedule(selectedDate) dismiss() } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) + .disabled(selectedDate <= Date()) } } .padding(20) - .frame(minWidth: 320, minHeight: 200) + .frame(minWidth: 360, minHeight: 300) + } +} + +// MARK: - ScheduledDestinationsLabel + +/// The destination summary shared by the row and the edit sheet (GitHub #55). +/// +/// Three distinct states, deliberately not collapsed into two: `nil` means the +/// server sent no config for this message (also what a row painted from the +/// on-disk cache shows before the first revalidation), while an empty config +/// means the post really is going to InterlinedList only. +private struct ScheduledDestinationsLabel: View { + let destinations: ScheduledDestinations? + + var body: some View { + Group { + if let destinations, !destinations.isEmpty { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + Text(destinations.displayNames.joined(separator: " \u{00B7} ")) + } + } else if destinations != nil { + Text("InterlinedList only") + } else { + Text("Destinations unavailable") + } + } + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .accessibilityLabel(accessibilityLabel) + } + + private var accessibilityLabel: String { + guard let destinations else { return "Cross-post destinations unavailable" } + guard !destinations.isEmpty else { return "Posting to InterlinedList only" } + return "Also posting to \(destinations.displayNames.joined(separator: ", "))" } } // MARK: - ScheduledPostRow -/// One queued scheduled post: its publish time plus a body preview. +/// One queued scheduled post: its publish time, a body preview, and — per +/// GitHub #55 — the cross-post destinations it will fan out to, so they are +/// readable without opening the editor. private struct ScheduledPostRow: View { let post: Message @@ -271,6 +398,12 @@ private struct ScheduledPostRow: View { .font(.ilMono(10)) .foregroundStyle(.secondary) } + // Only drawn when the server actually sent a config — a row with no + // destination data stays as compact as it was before #55 rather than + // carrying an "unavailable" line on every entry. + if post.scheduledDestinations != nil { + ScheduledDestinationsLabel(destinations: post.scheduledDestinations) + } } .padding(.vertical, 4) .accessibilityElement(children: .combine) @@ -287,6 +420,11 @@ private struct ScheduledPostRow: View { if !post.tags.isEmpty { parts.append("Tags: \(post.tags.joined(separator: ", "))") } + if let destinations = post.scheduledDestinations { + parts.append(destinations.isEmpty + ? "Posting to InterlinedList only" + : "Also posting to \(destinations.displayNames.joined(separator: ", "))") + } return parts.joined(separator: ". ") } diff --git a/App/Features/Scheduled/ScheduledPostsViewModel.swift b/App/Features/Scheduled/ScheduledPostsViewModel.swift index 621afb4..48f6dab 100644 --- a/App/Features/Scheduled/ScheduledPostsViewModel.swift +++ b/App/Features/Scheduled/ScheduledPostsViewModel.swift @@ -11,6 +11,15 @@ // surfaces both actions; `actionError` captures the last mutation failure // without replacing the loaded list. // +// GitHub #55: the reschedule intent now routes through the service's single +// `updateScheduled(messageId:edit:)` entry point and validates the new date +// locally first. Content and destination edits are deliberately absent — the +// live API has no route for them (`PATCH /api/messages/[id]` honours +// `scheduledAt` alone and silently discards a `content` sent beside it), so the +// view shows those fields read-only rather than offering an edit that could not +// be saved. Destinations themselves are now displayed, from the message's +// `scheduledDestinations`. +// // Per Decision 0003 this view model consumes only `InterlinedDomain`. import Foundation @@ -157,29 +166,29 @@ final class ScheduledPostsViewModel { /// `scheduledAt`, calls the service, then on success replaces the /// optimistic copy with the server's authoritative value. On failure, /// restores the snapshot. + /// + /// A date at or before now is rejected **before** the optimistic mutation: + /// the list is left untouched and no service call is made, so an invalid + /// pick can never flash a bad time into the row (GitHub #55). + /// + /// The optimistic copy comes from `byRescheduling(to:)` rather than a + /// hand-built `Message`. The hand-built one omitted every fetch-time field, + /// so rescheduling visibly dropped the row's destination chips and link + /// previews until the next refresh. func reschedule(post: Message, to newDate: Date) async { guard let index = posts.firstIndex(where: { $0.id == post.id }) else { return } + guard newDate > Date() else { + actionError = MessagesError.scheduledDateNotInFuture + return + } let snapshot = posts - let optimistic = Message( - id: post.id, - author: post.author, - text: post.text, - createdAt: post.createdAt, - updatedAt: post.updatedAt, - tags: post.tags, - visibility: post.visibility, - digCount: post.digCount, - didDig: post.didDig, - repostCount: post.repostCount, - replyCount: post.replyCount, - parentID: post.parentID, - repost: post.repost, - scheduledAt: newDate - ) - posts[index] = optimistic + posts[index] = post.byRescheduling(to: newDate) actionError = nil do { - let confirmed = try await messages.reschedule(messageId: post.id, newDate: newDate) + let confirmed = try await messages.updateScheduled( + messageId: post.id, + edit: ScheduledPostEdit(scheduledAt: newDate) + ) if let currentIdx = posts.firstIndex(where: { $0.id == post.id }) { posts[currentIdx] = confirmed } @@ -188,4 +197,10 @@ final class ScheduledPostsViewModel { actionError = error } } + + /// Clears the error left by the last failed cancel / reschedule so a sheet + /// can dismiss its banner without re-running the action. + func clearActionError() { + actionError = nil + } } diff --git a/App/Features/Timeline/Message+OptimisticUpdates.swift b/App/Features/Timeline/Message+OptimisticUpdates.swift index fcec76f..d64c609 100644 --- a/App/Features/Timeline/Message+OptimisticUpdates.swift +++ b/App/Features/Timeline/Message+OptimisticUpdates.swift @@ -32,13 +32,24 @@ extension Message { replacing(repostCount: repostCount + 1) } + /// A copy moved to a new publish time, for the optimistic reschedule of a + /// queued post (GitHub #55). Routing through `replacing(...)` is the point: + /// the scheduled list previously built its optimistic copy by hand and + /// omitted `crossPostResults`, `crossPostLocations`, `linkPreviews` — and, + /// once it existed, `scheduledDestinations` — so rescheduling a post wiped + /// its destination chips until the next refetch. + func byRescheduling(to newDate: Date) -> Message { + replacing(scheduledAt: newDate) + } + /// Field-wise copy. Only the named counters vary; everything else — /// including the fetch-time-only `linkPreviews` and `crossPostLocations` /// — is carried across untouched. private func replacing( digCount: Int? = nil, didDig: Bool? = nil, - repostCount: Int? = nil + repostCount: Int? = nil, + scheduledAt: Date? = nil ) -> Message { Message( id: id, @@ -54,10 +65,11 @@ extension Message { replyCount: replyCount, parentID: parentID, repost: repost, - scheduledAt: scheduledAt, + scheduledAt: scheduledAt ?? self.scheduledAt, crossPostResults: crossPostResults, crossPostLocations: crossPostLocations, - linkPreviews: linkPreviews + linkPreviews: linkPreviews, + scheduledDestinations: scheduledDestinations ) } } diff --git a/AppTests/ComposerViewModelTests.swift b/AppTests/ComposerViewModelTests.swift index 8f50a5d..9f0e1af 100644 --- a/AppTests/ComposerViewModelTests.swift +++ b/AppTests/ComposerViewModelTests.swift @@ -1025,6 +1025,94 @@ final class ComposerViewModelTests: XCTestCase { XCTAssertFalse(viewModel.linkedInNotConfigured) } + // MARK: - Scheduled destination summary (GitHub #55) + + func test_givenSeveralNetworksEnabled_whenSummarising_thenListsThemInWebOrder() async { + // Happy path: the schedule dialog names every selected network, in the + // order the web lists them. + let viewModel = ComposerViewModel( + messages: StubMessagesService(), + eventBus: ComposerEventBus(), + mode: .newPost + ) + viewModel.crossPostToMastodon = true + viewModel.mastodonProviderIdsInput = "prov-1, prov-2" + viewModel.crossPostToBluesky = true + viewModel.crossPostToLinkedIn = true + viewModel.crossPostToTwitter = true + + XCTAssertEqual( + viewModel.scheduledDestinationNames, + ["Mastodon", "Bluesky", "LinkedIn", "X"] + ) + XCTAssertEqual( + viewModel.scheduledDestinationSummary, + "Mastodon \u{00B7} Bluesky \u{00B7} LinkedIn \u{00B7} X" + ) + } + + func test_givenMastodonToggledWithNoProviderIds_whenSummarising_thenMastodonIsOmitted() async { + // Invalid input: the toggle alone sends an empty `mastodonProviderIds`, + // which fans out nowhere — so claiming Mastodon as a destination would + // be a lie the user acts on. + let viewModel = ComposerViewModel( + messages: StubMessagesService(), + eventBus: ComposerEventBus(), + mode: .newPost + ) + viewModel.crossPostToMastodon = true + viewModel.mastodonProviderIdsInput = " " + + XCTAssertEqual(viewModel.scheduledDestinationNames, []) + XCTAssertEqual(viewModel.scheduledDestinationSummary, "InterlinedList only") + } + + func test_givenNoNetworksEnabled_whenSummarising_thenSaysInterlinedListOnly() async { + // Empty / boundary: the dialog always states a destination rather than + // rendering a blank where the list would be. + let viewModel = ComposerViewModel( + messages: StubMessagesService(), + eventBus: ComposerEventBus(), + mode: .newPost + ) + + XCTAssertTrue(viewModel.scheduledDestinationNames.isEmpty) + XCTAssertEqual(viewModel.scheduledDestinationSummary, "InterlinedList only") + } + + func test_givenScheduledPost_whenSubmitting_thenCrossPostFlagsTravelWithTheSchedule() async throws { + // Upstream-contract guard, and the answer to the question issue #55 + // raised: are composer cross-post selections actually carried onto a + // *scheduled* post, or silently dropped? They are carried — the create + // call sends them in the same body as `scheduledAt`. This test pins that + // down so a future composer refactor cannot quietly break it. + let stub = StubMessagesService() + await stub.enqueueCreatePost(success: MessageFixtures.message(id: "m-1", text: "later")) + let viewModel = ComposerViewModel( + messages: stub, + eventBus: ComposerEventBus(), + mode: .newPost, + entitlements: EntitlementsService(customerStatus: .subscriber) + ) + viewModel.body = "later" + let when = Date().addingTimeInterval(7200) + viewModel.isScheduled = true + viewModel.scheduledAt = when + viewModel.crossPostToBluesky = true + viewModel.crossPostToTwitter = true + + await viewModel.submit() + + let recorded = await stub.recorded + guard case .createPost(_, _, _, _, _, let scheduledAt, _, let bluesky, _, let twitter) = + recorded.last?.kind else { + return XCTFail("Expected a createPost call, got \(String(describing: recorded.last?.kind))") + } + XCTAssertEqual(scheduledAt, when) + XCTAssertTrue(bluesky, "A scheduled post must keep its Bluesky selection") + XCTAssertTrue(twitter, "A scheduled post must keep its X selection") + } + // MARK: - Content-limit test helpers private func makeLimits(messageMaxContentLength: Int) -> ContentLimits { diff --git a/AppTests/ScheduledPostsViewModelTests.swift b/AppTests/ScheduledPostsViewModelTests.swift index 5e2a721..0a34cf1 100644 --- a/AppTests/ScheduledPostsViewModelTests.swift +++ b/AppTests/ScheduledPostsViewModelTests.swift @@ -18,7 +18,11 @@ final class ScheduledPostsViewModelTests: XCTestCase { /// A `Message` with a non-nil `scheduledAt` (the list only shows queued /// posts). `MessageFixtures.message` always sets `scheduledAt: nil`, so we /// build directly here. - private func scheduledMessage(id: String, at: Date = Date().addingTimeInterval(3600)) -> Message { + private func scheduledMessage( + id: String, + at: Date = Date().addingTimeInterval(3600), + destinations: ScheduledDestinations? = nil + ) -> Message { Message( id: id, author: MessageFixtures.author(), @@ -33,7 +37,8 @@ final class ScheduledPostsViewModelTests: XCTestCase { replyCount: nil, parentID: nil, repost: nil, - scheduledAt: at + scheduledAt: at, + scheduledDestinations: destinations ) } @@ -165,7 +170,7 @@ final class ScheduledPostsViewModelTests: XCTestCase { let post = scheduledMessage(id: "s-1", at: originalDate) await stub.enqueueScheduledPosts(success: [post]) let confirmed = scheduledMessage(id: "s-1", at: newDate) - await stub.enqueueReschedule(success: confirmed) + await stub.enqueueUpdateScheduled(success: confirmed) let viewModel = ScheduledPostsViewModel(messages: stub) await viewModel.load() @@ -181,7 +186,7 @@ final class ScheduledPostsViewModelTests: XCTestCase { let newDate = Date(timeIntervalSince1970: 1_800_003_600) let post = scheduledMessage(id: "s-1", at: originalDate) await stub.enqueueScheduledPosts(success: [post]) - await stub.enqueueReschedule(failure: TestError.upstream("conflict")) + await stub.enqueueUpdateScheduled(failure: TestError.upstream("conflict")) let viewModel = ScheduledPostsViewModel(messages: stub) await viewModel.load() @@ -190,4 +195,98 @@ final class ScheduledPostsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.posts.first?.scheduledAt, originalDate) XCTAssertNotNil(viewModel.actionError) } + + // MARK: - reschedule validation + destinations (GitHub #55) + + func test_givenPastDate_whenRescheduling_thenRejectsWithoutTouchingTheList() async { + // Invalid input: a past time is refused client-side. The row must keep + // its original date — the optimistic write is skipped entirely, so a + // bad pick never flashes into the list — and no service call is made. + let stub = StubMessagesService() + let originalDate = Date(timeIntervalSince1970: 1_800_000_000) + let post = scheduledMessage(id: "s-1", at: originalDate) + await stub.enqueueScheduledPosts(success: [post]) + let viewModel = ScheduledPostsViewModel(messages: stub) + await viewModel.load() + + await viewModel.reschedule(post: post, to: Date(timeIntervalSince1970: 1)) + + XCTAssertEqual(viewModel.posts.first?.scheduledAt, originalDate) + XCTAssertEqual(viewModel.actionError as? MessagesError, .scheduledDateNotInFuture) + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1, "Only the initial load — no updateScheduled call") + } + + func test_givenReschedule_whenOptimisticallyApplied_thenKeepsTheRowsDestinations() async { + // Boundary / regression: the optimistic copy used to be hand-built and + // dropped every fetch-time field, so rescheduling wiped the row's + // destination chips until the next refresh. The in-flight copy must + // carry them. + let stub = StubMessagesService() + let originalDate = Date(timeIntervalSince1970: 1_800_000_000) + let newDate = Date(timeIntervalSince1970: 1_800_003_600) + let destinations = ScheduledDestinations( + mastodonProviderIds: ["prov-1"], + bluesky: true + ) + let post = scheduledMessage(id: "s-1", at: originalDate, destinations: destinations) + await stub.enqueueScheduledPosts(success: [post]) + // The failure path leaves the optimistic copy observable long enough to + // assert on it, then rolls back — proving both halves at once. + await stub.enqueueUpdateScheduled(failure: TestError.upstream("conflict")) + let viewModel = ScheduledPostsViewModel(messages: stub) + await viewModel.load() + + let optimistic = post.byRescheduling(to: newDate) + XCTAssertEqual(optimistic.scheduledAt, newDate) + XCTAssertEqual(optimistic.scheduledDestinations, destinations) + + await viewModel.reschedule(post: post, to: newDate) + + // Rolled back, destinations intact. + XCTAssertEqual(viewModel.posts.first?.scheduledAt, originalDate) + XCTAssertEqual(viewModel.posts.first?.scheduledDestinations, destinations) + } + + func test_givenReschedule_whenSucceeding_thenSendsATimeOnlyEdit() async { + // Happy path on the payload itself: the view model must never ask for a + // content or destination change, because the live API cannot apply one. + let stub = StubMessagesService() + let newDate = Date(timeIntervalSince1970: 1_800_003_600) + let post = scheduledMessage(id: "s-1", at: Date(timeIntervalSince1970: 1_800_000_000)) + await stub.enqueueScheduledPosts(success: [post]) + await stub.enqueueUpdateScheduled(success: scheduledMessage(id: "s-1", at: newDate)) + let viewModel = ScheduledPostsViewModel(messages: stub) + await viewModel.load() + + await viewModel.reschedule(post: post, to: newDate) + + let recorded = await stub.recorded + guard case .updateScheduled(let messageId, let edit) = recorded.last?.kind else { + return XCTFail("Expected an updateScheduled call, got \(String(describing: recorded.last?.kind))") + } + XCTAssertEqual(messageId, "s-1") + XCTAssertEqual(edit.scheduledAt, newDate) + XCTAssertNil(edit.content) + XCTAssertNil(edit.destinations) + XCTAssertEqual(edit.unsupportedFields, []) + } + + func test_givenActionError_whenCleared_thenBannerStateResets() async { + // Empty/boundary: dismissing the failure banner must not re-run or + // resurrect the failed action. + let stub = StubMessagesService() + let post = scheduledMessage(id: "s-1", at: Date(timeIntervalSince1970: 1_800_000_000)) + await stub.enqueueScheduledPosts(success: [post]) + let viewModel = ScheduledPostsViewModel(messages: stub) + await viewModel.load() + await viewModel.reschedule(post: post, to: Date(timeIntervalSince1970: 1)) + XCTAssertNotNil(viewModel.actionError) + + viewModel.clearActionError() + + XCTAssertNil(viewModel.actionError) + let recorded = await stub.recorded + XCTAssertEqual(recorded.count, 1) + } } diff --git a/AppTests/Support/StubMessagesService.swift b/AppTests/Support/StubMessagesService.swift index 3052261..39e50bd 100644 --- a/AppTests/Support/StubMessagesService.swift +++ b/AppTests/Support/StubMessagesService.swift @@ -35,7 +35,7 @@ struct RecordedMessagesCall: Sendable, Equatable { case uploadImage(byteCount: Int) case uploadVideo(byteCount: Int, contentType: String) case cancelScheduled(messageId: String) - case reschedule(messageId: String, newDate: Date) + case updateScheduled(messageId: String, edit: ScheduledPostEdit) } let kind: Kind } @@ -63,7 +63,7 @@ actor StubMessagesService: MessagesServicing { private var uploadImageOutcomes: [Result] = [] private var uploadVideoOutcomes: [Result] = [] private var cancelScheduledOutcomes: [Result] = [] - private var rescheduleOutcomes: [Result] = [] + private var updateScheduledOutcomes: [Result] = [] /// Cache-first read surface (PLAN.md §5 SWR). Default `[]` so unprepared /// paths behave like a cold cache; set a value to prime a paint-first test @@ -120,8 +120,8 @@ actor StubMessagesService: MessagesServicing { func enqueueCancelScheduledSuccess() { cancelScheduledOutcomes.append(.success(())) } func enqueueCancelScheduled(failure error: Error) { cancelScheduledOutcomes.append(.failure(error)) } - func enqueueReschedule(success message: Message) { rescheduleOutcomes.append(.success(message)) } - func enqueueReschedule(failure error: Error) { rescheduleOutcomes.append(.failure(error)) } + func enqueueUpdateScheduled(success message: Message) { updateScheduledOutcomes.append(.success(message)) } + func enqueueUpdateScheduled(failure error: Error) { updateScheduledOutcomes.append(.failure(error)) } // MARK: MessagesServicing — reads @@ -266,9 +266,9 @@ actor StubMessagesService: MessagesServicing { let _: Void = try take(&cancelScheduledOutcomes, label: "cancelScheduled") } - func reschedule(messageId: String, newDate: Date) async throws -> Message { - recorded.append(.init(kind: .reschedule(messageId: messageId, newDate: newDate))) - return try take(&rescheduleOutcomes, label: "reschedule") + func updateScheduled(messageId: String, edit: ScheduledPostEdit) async throws -> Message { + recorded.append(.init(kind: .updateScheduled(messageId: messageId, edit: edit))) + return try take(&updateScheduledOutcomes, label: "updateScheduled") } // MARK: - Internals diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift index 09279e4..e3e2e42 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift @@ -80,7 +80,28 @@ extension Message { // `compactMap` drops entries whose `url` string will not parse so a // `LinkPreview` always carries a usable `URL`. Not persisted in // SwiftData — re-derived from the DTO on every load (see Message). - linkPreviews: (dto.linkMetadata?.links ?? []).compactMap(LinkPreview.init(from:)) + linkPreviews: (dto.linkMetadata?.links ?? []).compactMap(LinkPreview.init(from:)), + // GitHub #55: the destinations a queued post will fan out to. Stays + // `nil` when the server sent no config — the normal case for an + // already-published message — so the UI can tell "not a scheduled + // post" apart from "scheduled with nothing selected". Not persisted + // in SwiftData; re-derived on every load (see Message). + scheduledDestinations: dto.scheduledCrossPostConfig.map(ScheduledDestinations.init(from:)) + ) + } +} + +extension ScheduledDestinations { + /// Resolves the DTO's four independent optionals into definite values: an + /// omitted key means the network is not a destination. A config object that + /// selects nothing maps to `.none` rather than `nil`, preserving the + /// distinction between "scheduled, no cross-posts" and "not scheduled". + public init(from dto: ScheduledCrossPostConfigDTO) { + self.init( + mastodonProviderIds: dto.mastodonProviderIds ?? [], + bluesky: dto.crossPostToBluesky ?? false, + linkedIn: dto.crossPostToLinkedIn ?? false, + twitter: dto.crossPostToTwitter ?? false ) } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift index 71e6dfc..56617c8 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift @@ -80,6 +80,19 @@ public struct Message: Sendable, Equatable, Identifiable { /// when a row is rendered purely from the local cache before a refresh. public let linkPreviews: [LinkPreview] + /// The cross-post destinations a **queued scheduled post** will fan out to + /// when it fires (GitHub #55). `nil` when the server sent no + /// `scheduledCrossPostConfig` for this message — which is the normal case + /// for anything already published. + /// + /// SCOPE DECISION (mirrors `linkPreviews` and `crossPostLocations`): the + /// destinations are a fetch-time projection and are **not** persisted in + /// SwiftData (`MessageRecord`). They are re-derived from the DTO on every + /// load, so a row painted purely from the on-disk cache before the first + /// revalidation shows no destination chips; they appear as soon as the + /// background refresh lands. This deliberately avoids a schema migration. + public let scheduledDestinations: ScheduledDestinations? + public init( id: String, author: UserSummary, @@ -97,7 +110,8 @@ public struct Message: Sendable, Equatable, Identifiable { scheduledAt: Date? = nil, crossPostResults: [CrossPostResult] = [], crossPostLocations: [CrossPostLocation] = [], - linkPreviews: [LinkPreview] = [] + linkPreviews: [LinkPreview] = [], + scheduledDestinations: ScheduledDestinations? = nil ) { self.id = id self.author = author @@ -116,6 +130,7 @@ public struct Message: Sendable, Equatable, Identifiable { self.crossPostResults = crossPostResults self.crossPostLocations = crossPostLocations self.linkPreviews = linkPreviews + self.scheduledDestinations = scheduledDestinations } } @@ -132,6 +147,70 @@ public indirect enum Repost: Sendable, Equatable { } } +// MARK: - ScheduledDestinations (GitHub #55) + +/// Which networks a queued scheduled post will publish to when it fires. +/// +/// The domain projection of `ScheduledCrossPostConfigDTO`. Where the DTO has +/// four independent optionals (the server omits keys for unselected networks), +/// this resolves them to definite values so the UI never branches on `nil`: +/// an absent key means "not a destination". +/// +/// Deliberately *not* the same type as `CrossPostLocation`: that models where a +/// published message actually landed and always carries a live permalink. This +/// models a stated intent for a post that has not gone anywhere yet, so it has +/// no URLs to offer — only names. +public struct ScheduledDestinations: Sendable, Equatable { + /// The Mastodon provider ids selected. Empty when Mastodon is not a + /// destination. Kept as ids (not names) because resolving an id to an + /// instance name needs the account's linked-identity list, which the + /// message payload does not carry. + public let mastodonProviderIds: [String] + public let bluesky: Bool + public let linkedIn: Bool + /// X / Twitter. See `ScheduledCrossPostConfigDTO.crossPostToTwitter` — the + /// server accepts this on create but is not confirmed to echo it back, so in + /// practice this is usually `false` even for a post scheduled with X + /// selected. Modelled so the value is carried the moment the server does + /// send it. + public let twitter: Bool + + public init( + mastodonProviderIds: [String] = [], + bluesky: Bool = false, + linkedIn: Bool = false, + twitter: Bool = false + ) { + self.mastodonProviderIds = mastodonProviderIds + self.bluesky = bluesky + self.linkedIn = linkedIn + self.twitter = twitter + } + + /// No network selected — the post publishes to InterlinedList only. + public static let none = ScheduledDestinations() + + /// True when the post fans out nowhere beyond InterlinedList. The UI shows + /// "InterlinedList only" rather than an empty chip row, so a reader can tell + /// "no destinations" apart from "destinations not loaded yet". + public var isEmpty: Bool { + mastodonProviderIds.isEmpty && !bluesky && !linkedIn && !twitter + } + + /// Human-facing destination labels, in the order the web lists them + /// (Mastodon, Bluesky, LinkedIn, X). Mastodon collapses to a single label + /// regardless of how many provider ids are selected — matching the web + /// badge, which draws one Mastodon icon per config, not one per id. + public var displayNames: [String] { + var names: [String] = [] + if !mastodonProviderIds.isEmpty { names.append("Mastodon") } + if bluesky { names.append("Bluesky") } + if linkedIn { names.append("LinkedIn") } + if twitter { names.append("X") } + return names + } +} + // MARK: - CrossPostResult (NW-2) /// The per-platform cross-post outcome attached to a published message. diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift index 99c1928..d75e6c9 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift @@ -42,6 +42,23 @@ public enum MessagesError: Error, Sendable, Equatable { /// work-consolidation.md §1c · V1). Throwing here keeps the failure honest /// and local instead of letting the UI believe an edit was saved. case editingNotSupported + + /// A scheduled-post edit asked to change something the live API cannot + /// change — the message body and/or its cross-post destinations + /// (GitHub #55). `fields` names them so the UI can say exactly what was + /// refused. See `ScheduledPostEdit` for the probe record; the short version + /// is that `PATCH /api/messages/[id]` honours `scheduledAt` alone and + /// silently discards a `content` sent beside it, and the web client ships no + /// message `PATCH` at all. + case scheduledEditNotSupported(fields: [String]) + + /// A reschedule targeted a time at or before now. Rejected client-side — + /// the server requires a future time, and catching it here gives the user an + /// immediate, specific message instead of a round-trip and a generic 400. + case scheduledDateNotInFuture + + /// A scheduled-post edit requested no change at all. + case noScheduledChanges } extension MessagesError: LocalizedError, CustomStringConvertible { @@ -63,6 +80,15 @@ extension MessagesError: LocalizedError, CustomStringConvertible { case .editingNotSupported: return "InterlinedList does not support editing a message after it is posted. " + "You can reschedule a post that has not gone out yet, or delete this one and post again." + case .scheduledEditNotSupported(let fields): + let list = ListFormatter.localizedString(byJoining: fields) + return "InterlinedList can only change a scheduled post's publish time — " + + "its \(list) cannot be edited once scheduled. " + + "Cancel this post and schedule a new one to change that." + case .scheduledDateNotInFuture: + return "Pick a time in the future. A scheduled post can't be moved to a time that has already passed." + case .noScheduledChanges: + return "Nothing to update — no changes were made to this scheduled post." } } } @@ -265,11 +291,90 @@ public protocol MessagesServicing: Sendable { /// (the server treats it as gone once published). func cancelScheduled(messageId: String) async throws - /// Reschedules a pending post to `newDate`. Re-fetches the existing body - /// first so the update call can supply the full content (the PUT requires - /// a complete body, not a patch). Returns the authoritative updated - /// `Message` (with `scheduledAt == newDate`). - func reschedule(messageId: String, newDate: Date) async throws -> Message + /// Applies an edit to a **queued** scheduled post — the single entry point + /// for changing anything about a post that has not gone out yet + /// (GitHub #55), replacing the narrower `reschedule`. + /// + /// Only `edit.scheduledAt` reaches the network. `edit.content` and + /// `edit.destinations` are rejected **before** the request is built, because + /// the live API cannot apply them (see `ScheduledPostEdit` for the probe + /// record). Failing here rather than sending the fields keeps the client + /// honest: the server's behaviour for a content body is either a `400` or, + /// worse, a silent discard that would leave the UI showing an edit that was + /// never saved. + /// + /// Validation order — all client-side, none of it issues a request: + /// 1. an edit that asks for nothing → `MessagesError.noScheduledChanges`; + /// 2. an edit carrying content or destinations → + /// `MessagesError.scheduledEditNotSupported(fields:)`; + /// 3. a `scheduledAt` at or before now → + /// `MessagesError.scheduledDateNotInFuture`. + /// + /// Returns the authoritative updated `Message` (with `scheduledAt` moved), + /// written through to the cache. + func updateScheduled(messageId: String, edit: ScheduledPostEdit) async throws -> Message +} + +// MARK: - ScheduledPostEdit (GitHub #55) + +/// A requested change to a queued scheduled post. +/// +/// Every field is optional and `nil` means "leave alone", so the caller states +/// only what it wants changed. The type can express a content or destination +/// edit even though the live API cannot perform one — deliberately: the UI needs +/// to be able to *ask*, so that `updateScheduled` can answer with a specific, +/// typed refusal naming the fields instead of the call silently doing nothing. +/// +/// LIVE API REALITY (probed read-only 2026-09-09, GitHub #55; write behaviour +/// from the 2026-09-06 §1c · V1 sweep): +/// • `PATCH /api/messages/[id]` is the only update verb a message has +/// (`OPTIONS` → `Allow: DELETE, GET, HEAD, OPTIONS, PATCH`). +/// • There is no scheduled-post editor route: `/api/messages/scheduled/[id]` +/// and `/api/messages/[id]/schedule` both 404, and `/api/messages/scheduled` +/// allows `GET, HEAD, OPTIONS` only. +/// • That `PATCH` honours `scheduledAt` and nothing else — a content, tags or +/// visibility body returns `400 "No valid updates provided"`, and `content` +/// sent alongside `scheduledAt` is accepted but **silently discarded**. +/// • The deployed web client issues no `PATCH /api/messages/[id]` at all, so +/// the content/destination editor its help page describes is not shipped +/// there either. This is an upstream gap, not a macOS one. +public struct ScheduledPostEdit: Sendable, Equatable { + + /// The new publish time. Must be in the future. + public var scheduledAt: Date? + + /// A new message body. **Not applicable on the live API** — supplying it + /// makes `updateScheduled` throw `.scheduledEditNotSupported`. + public var content: String? + + /// A new cross-post destination set. **Not applicable on the live API** — + /// supplying it makes `updateScheduled` throw `.scheduledEditNotSupported`. + public var destinations: ScheduledDestinations? + + public init( + scheduledAt: Date? = nil, + content: String? = nil, + destinations: ScheduledDestinations? = nil + ) { + self.scheduledAt = scheduledAt + self.content = content + self.destinations = destinations + } + + /// The names of the requested changes the live API cannot apply, in a + /// stable order so the error message is deterministic. Empty when the edit + /// only moves the publish time. + public var unsupportedFields: [String] { + var fields: [String] = [] + if content != nil { fields.append("content") } + if destinations != nil { fields.append("destinations") } + return fields + } + + /// True when the edit asks for no change at all. + public var isEmpty: Bool { + scheduledAt == nil && content == nil && destinations == nil + } } // MARK: - MessagesService @@ -696,9 +801,23 @@ public final class MessagesService: MessagesServicing { await store?.remove(id: messageId) } - public func reschedule(messageId: String, newDate: Date) async throws -> Message { + public func updateScheduled(messageId: String, edit: ScheduledPostEdit) async throws -> Message { + // Every guard below runs before the request is built, so a rejected + // edit costs no round-trip and cannot half-apply. + guard !edit.isEmpty else { throw MessagesError.noScheduledChanges } + + let unsupported = edit.unsupportedFields + guard unsupported.isEmpty else { + throw MessagesError.scheduledEditNotSupported(fields: unsupported) + } + + // `isEmpty` is false and nothing unsupported was asked for, so the edit + // must carry a date. + guard let newDate = edit.scheduledAt else { throw MessagesError.noScheduledChanges } + guard newDate > Date() else { throw MessagesError.scheduledDateNotInFuture } + // `scheduledAt` is the only key the live PATCH honours, so send just - // that. The previous implementation round-tripped the whole message + // that. An earlier implementation round-tripped the whole message // through `CreateMessageRequest`, which cost an extra GET and shipped // content/tags/visibility fields the server discards. let request = RescheduleMessageRequest(scheduledAt: newDate) diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift index c04ab2f..bde8ab5 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift @@ -601,3 +601,111 @@ final class MapperTests: XCTestCase { XCTAssertEqual(preview.displayHost, "example.com") } } + +// MARK: - ScheduledDestinations (GitHub #55) + +/// The destination projection is what the Scheduled pane renders, so its +/// mapping is covered against the live shape: the web's own badge component +/// reads `mastodonProviderIds`, `crossPostToBluesky` and `crossPostToLinkedIn` +/// off `scheduledCrossPostConfig`, and the server omits keys for networks that +/// were not selected. +final class ScheduledDestinationsMapperTests: XCTestCase { + + // Happy path — every network selected maps across and reads back in the + // web's display order. + func test_givenEveryNetworkSelected_whenMapped_thenAllAreCarried() { + // Given + let dto = ScheduledCrossPostConfigDTO( + mastodonProviderIds: ["prov-1", "prov-2"], + crossPostToBluesky: true, + crossPostToLinkedIn: true, + crossPostToTwitter: true + ) + + // When + let destinations = ScheduledDestinations(from: dto) + + // Then + XCTAssertEqual(destinations.mastodonProviderIds, ["prov-1", "prov-2"]) + XCTAssertTrue(destinations.bluesky) + XCTAssertTrue(destinations.linkedIn) + XCTAssertTrue(destinations.twitter) + XCTAssertFalse(destinations.isEmpty) + // One Mastodon label for two provider ids, matching the web badge. + XCTAssertEqual(destinations.displayNames, ["Mastodon", "Bluesky", "LinkedIn", "X"]) + } + + // Invalid / partial input — the server omits unselected keys entirely, so + // absent must resolve to "not a destination", never to a crash or a true. + func test_givenOmittedKeys_whenMapped_thenAbsentMeansNotADestination() { + // Given — only Bluesky was selected, so that is the only key sent. + let dto = ScheduledCrossPostConfigDTO(crossPostToBluesky: true) + + // When + let destinations = ScheduledDestinations(from: dto) + + // Then + XCTAssertEqual(destinations.mastodonProviderIds, []) + XCTAssertTrue(destinations.bluesky) + XCTAssertFalse(destinations.linkedIn) + XCTAssertFalse(destinations.twitter) + XCTAssertEqual(destinations.displayNames, ["Bluesky"]) + } + + // Boundary — a config that selects nothing is NOT the same as no config: + // it means the post publishes to InterlinedList only, and the UI says so. + func test_givenConfigSelectingNothing_whenMapped_thenIsEmptyButPresent() { + // Given + let dto = ScheduledCrossPostConfigDTO( + mastodonProviderIds: [], + crossPostToBluesky: false, + crossPostToLinkedIn: false, + crossPostToTwitter: false + ) + + // When + let destinations = ScheduledDestinations(from: dto) + + // Then + XCTAssertTrue(destinations.isEmpty) + XCTAssertEqual(destinations.displayNames, []) + } + + // Happy path through the full message decode: a scheduled post carries its + // destinations end to end, from wire JSON to the rendered projection. + func test_givenScheduledMessageJSON_whenDecoded_thenDestinationsSurvive() throws { + // Given — the live shape, with Mastodon and LinkedIn selected. + let json = Fixtures.messageObject( + id: "m-1", + scheduledAt: "2027-01-15T09:00:00Z", + scheduledCrossPostConfigJSON: """ + {"mastodonProviderIds":["prov-1"],"crossPostToLinkedIn":true} + """ + ) + + // When + let dto = try JSONCoders.makeDecoder().decode(MessageDTO.self, from: Data(json.utf8)) + let message = Message(from: dto) + + // Then + XCTAssertEqual(message.scheduledDestinations?.mastodonProviderIds, ["prov-1"]) + XCTAssertEqual(message.scheduledDestinations?.linkedIn, true) + XCTAssertEqual(message.scheduledDestinations?.bluesky, false) + XCTAssertEqual(message.scheduledDestinations?.displayNames, ["Mastodon", "LinkedIn"]) + } + + // Boundary — an already-published message sends `scheduledCrossPostConfig: + // null`, which must map to nil, not to `.none`. The UI relies on that + // difference to tell "not scheduled" from "scheduled, nothing selected". + func test_givenNullConfig_whenDecoded_thenDestinationsAreNil() throws { + // Given + let json = Fixtures.messageObject(id: "m-2") + + // When + let dto = try JSONCoders.makeDecoder().decode(MessageDTO.self, from: Data(json.utf8)) + let message = Message(from: dto) + + // Then + XCTAssertNil(message.scheduledDestinations) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift index 56a45b1..cab4ba5 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift @@ -516,7 +516,7 @@ final class MessagesServiceTests: XCTestCase { XCTAssertTrue(description.contains("delete")) } - // MARK: - reschedule (the write the live API does support) + // MARK: - updateScheduled (the write the live API does support) func test_givenNewDate_whenRescheduling_thenPatchesWithScheduledAtOnly() async throws { // Given — the live reply is a bare MessageDTO, not the create envelope. @@ -527,7 +527,10 @@ final class MessagesServiceTests: XCTestCase { let newDate = Date(timeIntervalSince1970: 1_800_000_000) // When - let updated = try await service.reschedule(messageId: "m-42", newDate: newDate) + let updated = try await service.updateScheduled( + messageId: "m-42", + edit: ScheduledPostEdit(scheduledAt: newDate) + ) // Then XCTAssertEqual(updated.id, "m-42") @@ -547,9 +550,9 @@ final class MessagesServiceTests: XCTestCase { let store = InMemoryMessageStore() let service = MessagesService(api: api, store: store) - _ = try await service.reschedule( + _ = try await service.updateScheduled( messageId: "m-42", - newDate: Date(timeIntervalSince1970: 1_800_000_000) + edit: ScheduledPostEdit(scheduledAt: Date(timeIntervalSince1970: 1_800_000_000)) ) let cached = await store.cachedMessage(id: "m-42") @@ -566,7 +569,10 @@ final class MessagesServiceTests: XCTestCase { let service = MessagesService(api: api) do { - _ = try await service.reschedule(messageId: "m-42", newDate: .distantFuture) + _ = try await service.updateScheduled( + messageId: "m-42", + edit: ScheduledPostEdit(scheduledAt: .distantFuture) + ) XCTFail("Expected an APIError") } catch let error as APIError { XCTAssertEqual( @@ -576,6 +582,108 @@ final class MessagesServiceTests: XCTestCase { } } + // MARK: - updateScheduled: the edits the live API cannot apply (GitHub #55) + + func test_givenContentEdit_whenUpdatingScheduled_thenRefusesWithoutCallingTheAPI() async throws { + // Invalid input: the live PATCH silently DISCARDS a content sent beside + // scheduledAt, so a request that looks successful would leave the user + // believing an edit saved. Refuse locally, and prove no call was made. + let api = StubAPIClient() + let service = MessagesService(api: api) + + do { + _ = try await service.updateScheduled( + messageId: "m-42", + edit: ScheduledPostEdit(scheduledAt: .distantFuture, content: "rewritten") + ) + XCTFail("Expected scheduledEditNotSupported") + } catch let error as MessagesError { + XCTAssertEqual(error, .scheduledEditNotSupported(fields: ["content"])) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "A refused edit must not reach the network") + } + + func test_givenDestinationEdit_whenUpdatingScheduled_thenNamesEveryUnsupportedField() async throws { + // Both unsupported fields at once — the error must name them in a + // stable order so the user-facing sentence is deterministic. + let api = StubAPIClient() + let service = MessagesService(api: api) + + do { + _ = try await service.updateScheduled( + messageId: "m-42", + edit: ScheduledPostEdit( + scheduledAt: .distantFuture, + content: "rewritten", + destinations: ScheduledDestinations(bluesky: true) + ) + ) + XCTFail("Expected scheduledEditNotSupported") + } catch let error as MessagesError { + XCTAssertEqual(error, .scheduledEditNotSupported(fields: ["content", "destinations"])) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenPastDate_whenUpdatingScheduled_thenRejectsBeforeCallingTheAPI() async throws { + // Invalid input: a past publish time is rejected client-side so the + // user gets a specific message instead of a round-trip and a generic 400. + let api = StubAPIClient() + let service = MessagesService(api: api) + + do { + _ = try await service.updateScheduled( + messageId: "m-42", + edit: ScheduledPostEdit(scheduledAt: Date(timeIntervalSince1970: 1)) + ) + XCTFail("Expected scheduledDateNotInFuture") + } catch let error as MessagesError { + XCTAssertEqual(error, .scheduledDateNotInFuture) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "A past-dated reschedule must not reach the network") + } + + func test_givenEmptyEdit_whenUpdatingScheduled_thenReportsNoChanges() async throws { + // Boundary: an edit that asks for nothing is a no-op, not a request. + let api = StubAPIClient() + let service = MessagesService(api: api) + + do { + _ = try await service.updateScheduled(messageId: "m-42", edit: ScheduledPostEdit()) + XCTFail("Expected noScheduledChanges") + } catch let error as MessagesError { + XCTAssertEqual(error, .noScheduledChanges) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenScheduledEditRefusal_whenDescribed_thenNamesTheFieldsAndTheWorkaround() { + // The sentence is user-facing: it must say what cannot change and what + // to do instead, or the read-only sheet looks like a bug. + let description = MessagesError.scheduledEditNotSupported( + fields: ["content", "destinations"] + ).description + XCTAssertTrue(description.contains("content")) + XCTAssertTrue(description.contains("destinations")) + XCTAssertTrue(description.lowercased().contains("cancel")) + } + + func test_givenUnsupportedFields_whenEditOnlyMovesTheDate_thenNoneAreReported() { + // Boundary on the projection itself: a time-only edit is the supported + // case and must report nothing unsupported. + let edit = ScheduledPostEdit(scheduledAt: .distantFuture) + XCTAssertEqual(edit.unsupportedFields, []) + XCTAssertFalse(edit.isEmpty) + } + // MARK: - M2 write surface: delete func test_givenMessageId_whenDeleting_thenIssuesDeleteAndRemovesFromCache() async throws { diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift index 15c531f..314006a 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift @@ -22,13 +22,19 @@ enum Fixtures { pushCount: Int = 1, parentId: String? = nil, scheduledAt: String? = nil, - pushedMessageId: String? = nil + pushedMessageId: String? = nil, + /// Raw JSON for `scheduledCrossPostConfig` (GitHub #55). Passed as a + /// string so a test can assert on the exact live shape — including the + /// partial objects the server sends, where unselected networks are + /// omitted rather than sent as `false`. + scheduledCrossPostConfigJSON: String? = nil ) -> String { let displayNameJSON = displayName.map { "\"\($0)\"" } ?? "null" let tagsJSON = tags.map { "[" + $0.map { "\"\($0)\"" }.joined(separator: ",") + "]" } ?? "null" let parentJSON = parentId.map { "\"\($0)\"" } ?? "null" let scheduledJSON = scheduledAt.map { "\"\($0)\"" } ?? "null" let pushedIdJSON = pushedMessageId.map { "\"\($0)\"" } ?? "null" + let scheduledConfigJSON = scheduledCrossPostConfigJSON ?? "null" return """ { "id": "\(id)", @@ -49,7 +55,8 @@ enum Fixtures { "displayName": \(displayNameJSON), "avatar": "https://cdn.interlinedlist.com/\(username).png" }, - "dugByMe": \(dugByMe) + "dugByMe": \(dugByMe), + "scheduledCrossPostConfig": \(scheduledConfigJSON) } """ } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift index 31bfe31..b903f09 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift @@ -32,6 +32,22 @@ public struct MessageDTO: Decodable, Sendable, Equatable { public let dugByMe: Bool public let crossPosts: [CrossPostResultDTO]? + /// The cross-post destinations a **queued scheduled post** will fan out to + /// when it fires (GitHub #55). + /// + /// VERIFIED live 2026-09-09: `scheduledCrossPostConfig` is a real key on + /// every message the API returns — it is `null` on an already-published + /// message and carries the selection on a future-dated one. Its field names + /// are taken from the web client's own badge component, which reads + /// `mastodonProviderIds`, `crossPostToBluesky` and `crossPostToLinkedIn` off + /// this object to render the destination icons on a scheduled row. + /// + /// Distinct from both sibling cross-post fields: `crossPosts` is the + /// write-time fan-out *outcome* and `crossPostUrls` is the durable + /// "where it landed" permalink list. This is the *intent*, readable before + /// the post has gone anywhere. + public let scheduledCrossPostConfig: ScheduledCrossPostConfigDTO? + public init( id: String, content: String, @@ -52,7 +68,8 @@ public struct MessageDTO: Decodable, Sendable, Equatable { user: UserSummaryDTO, pushedMessage: PushedMessageBox? = nil, dugByMe: Bool, - crossPosts: [CrossPostResultDTO]? = nil + crossPosts: [CrossPostResultDTO]? = nil, + scheduledCrossPostConfig: ScheduledCrossPostConfigDTO? = nil ) { self.id = id self.content = content @@ -74,6 +91,52 @@ public struct MessageDTO: Decodable, Sendable, Equatable { self.pushedMessage = pushedMessage self.dugByMe = dugByMe self.crossPosts = crossPosts + self.scheduledCrossPostConfig = scheduledCrossPostConfig + } +} + +// MARK: - ScheduledCrossPostConfigDTO + +/// The cross-post destinations selected for a **scheduled** post, as the API +/// returns them under `MessageDTO.scheduledCrossPostConfig`. +/// +/// VERIFIED live 2026-09-09 (GitHub #55 recon): +/// • `GET /api/messages` returns `scheduledCrossPostConfig` on every message — +/// `null` for an already-published one. +/// • The key names below are lifted from the deployed web client's badge +/// component, which branches on exactly `mastodonProviderIds.length > 0`, +/// `crossPostToBluesky === true` and `crossPostToLinkedIn === true` to decide +/// which destination icons to draw on a scheduled row. +/// +/// `crossPostToTwitter` is modelled **speculatively**: `POST /api/messages` +/// accepts it when scheduling (confirmed in the web composer's create body), but +/// the web's badge component does not read it back, so whether the server echoes +/// it here is unconfirmed. Decoding it optionally costs nothing and means an X +/// selection is not silently lost if the server does send it — see +/// `ScheduledDestinations.twitter` in `InterlinedDomain` for how the UI treats it. +/// +/// Every field is optional: the server omits keys for unselected networks, so a +/// config object with no destinations at all decodes to all-nil rather than +/// failing. +public struct ScheduledCrossPostConfigDTO: Decodable, Sendable, Equatable { + /// The Mastodon provider ids the post will publish to. Absent or empty when + /// Mastodon is not a destination. + public let mastodonProviderIds: [String]? + public let crossPostToBluesky: Bool? + public let crossPostToLinkedIn: Bool? + /// See the type-level note — accepted on create, unconfirmed on read. + public let crossPostToTwitter: Bool? + + public init( + mastodonProviderIds: [String]? = nil, + crossPostToBluesky: Bool? = nil, + crossPostToLinkedIn: Bool? = nil, + crossPostToTwitter: Bool? = nil + ) { + self.mastodonProviderIds = mastodonProviderIds + self.crossPostToBluesky = crossPostToBluesky + self.crossPostToLinkedIn = crossPostToLinkedIn + self.crossPostToTwitter = crossPostToTwitter } } @@ -332,7 +395,8 @@ public extension MessageDTO { user: user, pushedMessage: pushedMessage, dugByMe: dugByMe, - crossPosts: crossPosts + crossPosts: crossPosts, + scheduledCrossPostConfig: scheduledCrossPostConfig ) } } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/MessagesEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/MessagesEndpoint.swift index ca9d443..376d423 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/MessagesEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/MessagesEndpoint.swift @@ -102,6 +102,16 @@ public enum Messages { /// with `400 "Can only edit scheduled posts that are in the future"`. /// See `RescheduleMessageRequest` for the probe transcript. /// + /// RE-CONFIRMED read-only 2026-09-09 (GitHub #55): `OPTIONS` on this path + /// reports `Allow: DELETE, GET, HEAD, OPTIONS, PATCH`, so `PATCH` is the + /// **only** update verb a message has. There is no dedicated scheduled-post + /// editor route either — `/api/messages/scheduled/[id]` and + /// `/api/messages/[id]/schedule` both 404, and `OPTIONS + /// /api/messages/scheduled` reports `Allow: GET, HEAD, OPTIONS` (read-only). + /// The deployed web client agrees: it issues no `PATCH /api/messages/[id]` + /// anywhere in its bundles, so the scheduled-post content/destination editor + /// its help page describes is not something the live product actually ships. + /// /// Editing a published message has **no route on the live API** — see /// `MessagesServicing.update` for how that is surfaced to callers. public static func reschedule(id: String, _ body: RescheduleMessageRequest) -> Request { diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift index b357024..23951d2 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift @@ -217,4 +217,62 @@ final class ContractTests: XCTestCase { } } + /// Pins the **absence** of a scheduled-post editor route (GitHub #55). + /// + /// macOS deliberately ships a time-only editor for a queued post, because + /// `PATCH /api/messages/[id]` honours `scheduledAt` alone and there is no + /// other route that touches a scheduled post. That is a server-side gap, and + /// the day it closes we want the gate to say so rather than the constraint + /// quietly outliving its reason. This test fails — on purpose — if the API + /// grows a scheduled-post editor, which is the signal to reopen #55. + /// + /// Verified read-only 2026-09-09: `/api/messages/scheduled` allows + /// `GET, HEAD, OPTIONS`; both candidate editor paths 404. + func test_givenLiveCredentials_whenOptioningScheduledRoutes_thenNoEditorRouteHasAppeared() async throws { + guard let credentials = credentialsFromEnvironment() else { + throw XCTSkip("Live credentials not set — skipping contract test.") + } + + let store = InMemoryTokenStore() + let (_, service) = makeLiveStack(tokenStore: store) + let token = try await service.signIn( + email: credentials.email, + password: credentials.password + ) + + func options(_ path: String) async throws -> (status: Int, allow: String?) { + var request = URLRequest(url: liveBaseURL.appendingPathComponent(path)) + request.httpMethod = "OPTIONS" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (_, response) = try await URLSession.shared.data(for: request) + let http = try XCTUnwrap(response as? HTTPURLResponse) + return (http.statusCode, http.value(forHTTPHeaderField: "Allow")) + } + + // The scheduled *list* is read-only — no write verb to update a queued + // post in bulk or in place. + let scheduled = try await options("/api/messages/scheduled") + let scheduledVerbs = Set( + (scheduled.allow ?? "").split(separator: ",").map { + $0.trimmingCharacters(in: .whitespaces).uppercased() + } + ) + XCTAssertFalse( + scheduledVerbs.contains("PATCH") || scheduledVerbs.contains("PUT") + || scheduledVerbs.contains("POST"), + "/api/messages/scheduled grew a write verb — live Allow is " + + "\(scheduled.allow ?? "none"). Reopen GitHub #55." + ) + + // Neither candidate per-post editor route exists. + for path in ["/api/messages/scheduled/probe", "/api/messages/probe/schedule"] { + let result = try await options(path) + XCTAssertEqual( + result.status, 404, + "\(path) now resolves (HTTP \(result.status), Allow: \(result.allow ?? "none")). " + + "A scheduled-post editor route may exist — reopen GitHub #55." + ) + } + } + } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift index b6d9c40..6bbc242 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift @@ -272,6 +272,80 @@ final class MessagesEndpointTests: XCTestCase { XCTAssertNil(dto.crossPosts) } + // MARK: - scheduledCrossPostConfig (GitHub #55) + + func test_givenScheduledConfig_whenDecoded_thenEveryDestinationKeyIsRead() throws { + // Happy path: the live shape, whose key names come from the web + // client's own scheduled-destination badge component. + let json = #""" + { + "id": "m-1", "content": "queued", "publiclyVisible": true, + "userId": "u-1", "createdAt": "2026-06-16T12:00:00Z", + "updatedAt": "2026-06-16T12:00:00Z", + "scheduledAt": "2027-01-15T09:00:00Z", + "digCount": 0, "pushCount": 0, + "user": {"id": "u-1", "username": "ada"}, + "pushedMessage": null, "dugByMe": false, + "scheduledCrossPostConfig": { + "mastodonProviderIds": ["prov-1", "prov-2"], + "crossPostToBluesky": true, + "crossPostToLinkedIn": true + } + } + """# + let dto = try JSONCoders.makeDecoder().decode(MessageDTO.self, from: Data(json.utf8)) + XCTAssertEqual(dto.scheduledCrossPostConfig?.mastodonProviderIds, ["prov-1", "prov-2"]) + XCTAssertEqual(dto.scheduledCrossPostConfig?.crossPostToBluesky, true) + XCTAssertEqual(dto.scheduledCrossPostConfig?.crossPostToLinkedIn, true) + // Not sent by the server here — must stay nil rather than defaulting. + XCTAssertNil(dto.scheduledCrossPostConfig?.crossPostToTwitter) + } + + func test_givenPartialScheduledConfig_whenDecoded_thenOmittedKeysStayNil() throws { + // Invalid/partial input: the server omits keys for networks that were + // not selected, so a config with one key must still decode. + let json = #""" + { + "id": "m-1", "content": "queued", "publiclyVisible": true, + "userId": "u-1", "createdAt": "2026-06-16T12:00:00Z", + "updatedAt": "2026-06-16T12:00:00Z", + "digCount": 0, "pushCount": 0, + "user": {"id": "u-1", "username": "ada"}, + "pushedMessage": null, "dugByMe": false, + "scheduledCrossPostConfig": {"crossPostToBluesky": true} + } + """# + let dto = try JSONCoders.makeDecoder().decode(MessageDTO.self, from: Data(json.utf8)) + XCTAssertEqual(dto.scheduledCrossPostConfig?.crossPostToBluesky, true) + XCTAssertNil(dto.scheduledCrossPostConfig?.mastodonProviderIds) + XCTAssertNil(dto.scheduledCrossPostConfig?.crossPostToLinkedIn) + } + + func test_givenNullScheduledConfig_whenDecoded_thenConfigIsNil() throws { + // Upstream shape for an already-published message: the key is present + // and null on every message the live API returns. + let json = #""" + { + "id": "m-1", "content": "published", "publiclyVisible": true, + "userId": "u-1", "createdAt": "2026-06-16T12:00:00Z", + "updatedAt": "2026-06-16T12:00:00Z", + "digCount": 0, "pushCount": 0, + "user": {"id": "u-1", "username": "ada"}, + "pushedMessage": null, "dugByMe": false, + "scheduledCrossPostConfig": null + } + """# + let dto = try JSONCoders.makeDecoder().decode(MessageDTO.self, from: Data(json.utf8)) + XCTAssertNil(dto.scheduledCrossPostConfig) + } + + func test_givenMessageMissingScheduledConfigKey_whenDecoded_thenConfigIsNil() throws { + // Boundary: the key absent entirely (older responses, and every stub + // fixture written before #55) must not break the decode. + let dto = try JSONCoders.makeDecoder().decode(MessageDTO.self, from: Data(messageJSON.utf8)) + XCTAssertNil(dto.scheduledCrossPostConfig) + } + // MARK: - create func test_givenPlainPost_whenCreateBuilt_thenPostsContentOnly() throws {