diff --git a/Tests/AgentTests/post-editor/custom-post-preserve-publish-statuses.md b/Tests/AgentTests/post-editor/custom-post-preserve-publish-statuses.md new file mode 100644 index 000000000000..37afa42cc243 --- /dev/null +++ b/Tests/AgentTests/post-editor/custom-post-preserve-publish-statuses.md @@ -0,0 +1,35 @@ +# Preserve User-Selected Status When Publishing a Custom Post + +Regression test for the bug where publishing a REST custom post from the pre-publishing sheet flattens a user-selected status (`private`, `pending`, or `future`/scheduled) to a public `publish`, discarding the user's intent. + +## Prerequisites +- Logged in to the app with the test account. +- The site has at least one custom post type registered with REST API support, and the custom post types entry is visible on the My Site screen. If no custom post type is available, fail with "Prerequisite not met: site has no REST custom post type". + +## Status Matrix + +Perform the steps below **once per row**. Each row creates a separate post with its own title. + +| Status | Title | How to set it from the pre-publish sheet | +| --------- | ---------------------- | -------------------------------------------------------------------------------------- | +| `private` | CPT private preserve | Open "Post Settings" and set Visibility to "Private". | +| `pending` | CPT pending preserve | Scroll to "More Options" and toggle "Pending Review" on. | +| `future` | CPT scheduled preserve | Tap the "Date" row and pick a date at least 7 days in the future, then confirm. | + +## Steps (per row) +1. From "My Site", tap **"More"**, then tap one of the available custom post types (e.g., "Books"). +2. Tap the FAB ("+") to create a new custom post and enter the row's **Title**. +3. Tap **"Publish"** in the top-right corner to open the pre-publish sheet. +4. Apply the row's **"How to set it"** action. +5. Return to the pre-publish sheet. For `future`, the primary button changes from "Publish" to "Schedule". +6. Tap the primary button to commit. Dismiss the confirmation screen. + +## Verification (per row, via REST API) +- Look up the post by title against the custom post type's REST endpoint (e.g., `/wp/v2/?search=&status=any`). Authenticate with the application password — private and future posts aren't returned to anonymous requests. +- **Regression assertion:** the post's `status` field is exactly the row's `Status`. Any other value (especially `"publish"`) indicates the bug has regressed. + +## Cleanup (REST API) +- Trash every post created during this test, regardless of pass or fail. + +## Expected Outcome +- For each row, the custom post is saved with the user-selected status preserved through the publish path. diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditorService.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditorService.swift index 7aff6a9ee07a..e30c0514d27e 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditorService.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostEditorService.swift @@ -85,9 +85,10 @@ class CustomPostEditorService { let capabilities = PostSettingsCapabilities(from: details) // At the moment, category & tags are separated from custom taxonomies. We can unify them as taxonomies later, // by which point we won't need this filter logic. - self.taxonomies = (try? blog.taxonomies - .filter { capabilities.customTaxonomySlugs.contains($0.slug) } - .sorted(using: KeyPathComparator(\.name))) ?? [] + self.taxonomies = + (try? blog.taxonomies + .filter { capabilities.customTaxonomySlugs.contains($0.slug) } + .sorted(using: KeyPathComparator(\.name))) ?? [] switch self.state { case let .newPost(params): @@ -122,12 +123,12 @@ class CustomPostEditorService { case (.newPost(let existing), true): var params = settings.makeCreateParameters(from: existing, taxonomies: taxonomies) + params.status = params.status?.normalizedPublishStatus() ?? .publish // Update content if let delegate { let hasTitle = details.supports.map[.title] == .bool(true) let editorContent = try await delegate.editorContent(for: self) - params.status = .publish params.title = hasTitle ? editorContent.title : nil params.content = editorContent.content } @@ -140,7 +141,7 @@ class CustomPostEditorService { case (.existingPost(let post, _), true): var params = settings.makeUpdateParameters(from: post, taxonomies: taxonomies) - params.status = .publish + params.status = PostStatus(settings.status).normalizedPublishStatus() // Update content if let delegate { @@ -162,7 +163,7 @@ class CustomPostEditorService { switch state { case .newPost(let existing): var params = existing - params.status = publish ? .publish : .draft + params.status = publish ? (existing.status?.normalizedPublishStatus() ?? .publish) : .draft params.title = hasTitle ? content.title : nil params.content = content.content try await create(params: params) @@ -181,7 +182,7 @@ class CustomPostEditorService { params = PostUpdateParams(meta: nil) } if publish { - params.status = .publish + params.status = pending.map { PostStatus($0.status).normalizedPublishStatus() } ?? .publish } params.title = hasTitle ? content.title : nil params.content = content.content @@ -195,7 +196,8 @@ class CustomPostEditorService { guard try await !hasBeenModified(post: post) else { throw PostUpdateError.conflicts } let endpoint = details.toPostEndpointType() - let updatedPost = try await wpService.posts().updatePost(endpointType: endpoint, postId: post.id, params: params) + let updatedPost = try await wpService.posts() + .updatePost(endpointType: endpoint, postId: post.id, params: params) state = .existingPost(updatedPost) initialSettings = settings @@ -265,7 +267,8 @@ extension PostCreateParams { params.status = .draft if let categoryID = blog.settings?.defaultCategoryID, - categoryID != PostCategory.uncategorized { + categoryID != PostCategory.uncategorized + { params.categories = [TermId(categoryID.int64Value)] } @@ -278,3 +281,19 @@ extension PostCreateParams { return params } } + +private extension PostStatus { + /// Maps a user-selected status to the one used by a publish action. + /// `.future`, `.private`, and `.pending` are preserved because they carry + /// their own publishing semantics (scheduled, password/private visibility, + /// submit for review); every other selection (draft) collapses to + /// `.publish` so the post is published normally. + func normalizedPublishStatus() -> PostStatus { + switch self { + case .future: return .future + case .private: return .private + case .pending: return .pending + default: return .publish + } + } +}