diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc186f..c178114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to this project are documented here. The format follows ### Added +- `inbox().repost`, `unrepost`, `bookmark` and `unbookmark` for X items, with `reposted`, + `bookmarked`, `canRepost` and `canBookmark` on `InboxItem`. Need `inbox` and `publish`. +- `accounts().followXUser(accountId, userId)` and `unfollowXUser` (`XFollowResult`), and the X + reads `getXPost(postId, accountId)` (`XPost`) and `searchXPosts(XSearchParams)` (`XSearchPage`, + paged by `meta().nextToken()`). Follow needs `inbox` and `publish`; the reads need `inbox`. +- A 402 `platform_balance_spent` on any of these means X's own pay-per-use balance is spent, and + a 409 `reconnect_required` means the X account was connected before the repost, bookmark and + follow grants and has to be reconnected. Every call is an X API call metered to the workspace. - `ads().sendPixelConversions(params, idempotencyKey)` sends Conversions API events with a required `Idempotency-Key`; a replay answers `ConversionUploadResult.replayed()` true. `ads().listConversionBatches` lists what was stored, with hashed identifiers only. diff --git a/README.md b/README.md index 46229a2..52d985c 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `posts()` | `list`, `autoPaginate`, `stream`, `get`, `create`, `update`, `delete`, `duplicate`, `publish`, `retry`, `cancel`, `preflight`, `deliveries`, `publishRuns`, `analytics`, `bulkShift`, `bulkLabel`, `bulkDelete`, `validateImport`, `commitImport`, `rollbackImport`, `unpublish` | | `workspaces()` | `list`, `get`, `create`, `update`, `delete`, `analytics` | -| `accounts()` | `list`, `get`, `create`, `rename`, `move`, `delete`, `healthSummary`, `health`, `togglePrimary`, `validate`, `refreshToken`, `analytics`, `createTelegramConnectCode`, `getTelegramConnectStatus`, `getTelegramBotCommands`, `setTelegramBotCommands`, `deleteTelegramBotCommands`, `redditSubreddits`, `redditSubredditRules`, `redditFlairs`, `setRedditDefaultSubreddit`, `listSlackChannels`, `listSlackMembers`, `getSlackIdentity`, `updateSlackIdentity`, `getIceBreakers`, `setIceBreakers`, `deleteIceBreakers`, `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting`, `deleteGreeting`, `getWebhookSubscription`, `resubscribeWebhook`, `communities()`, `listDiscordChannels`, `switchDiscordChannel`, `getDiscordIdentity`, `updateDiscordIdentity`, `listDiscordPins`, `deleteDiscordMessage`, `pinDiscordMessage`, `unpinDiscordMessage`, `crosspostDiscordMessage`, `createDiscordThread`, `sendDiscordDm`, `listDiscordEvents`, `getDiscordEvent`, `createDiscordEvent`, `updateDiscordEvent`, `deleteDiscordEvent`, `listDiscordMembers`, `getDiscordMember`, `listDiscordRoles`, `createDiscordRole`, `updateDiscordRole`, `deleteDiscordRole`, `addDiscordMemberRole`, `removeDiscordMemberRole`, `platformMetrics` | +| `accounts()` | `list`, `get`, `create`, `rename`, `move`, `delete`, `healthSummary`, `health`, `togglePrimary`, `validate`, `refreshToken`, `analytics`, `createTelegramConnectCode`, `getTelegramConnectStatus`, `getTelegramBotCommands`, `setTelegramBotCommands`, `deleteTelegramBotCommands`, `redditSubreddits`, `redditSubredditRules`, `redditFlairs`, `setRedditDefaultSubreddit`, `listSlackChannels`, `listSlackMembers`, `getSlackIdentity`, `updateSlackIdentity`, `getIceBreakers`, `setIceBreakers`, `deleteIceBreakers`, `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting`, `deleteGreeting`, `getWebhookSubscription`, `resubscribeWebhook`, `communities()`, `listDiscordChannels`, `switchDiscordChannel`, `getDiscordIdentity`, `updateDiscordIdentity`, `listDiscordPins`, `deleteDiscordMessage`, `pinDiscordMessage`, `unpinDiscordMessage`, `crosspostDiscordMessage`, `createDiscordThread`, `sendDiscordDm`, `listDiscordEvents`, `getDiscordEvent`, `createDiscordEvent`, `updateDiscordEvent`, `deleteDiscordEvent`, `listDiscordMembers`, `getDiscordMember`, `listDiscordRoles`, `createDiscordRole`, `updateDiscordRole`, `deleteDiscordRole`, `addDiscordMemberRole`, `removeDiscordMemberRole`, `platformMetrics`, `followXUser`, `unfollowXUser`, `getXPost`, `searchXPosts` | | `accountGroups()` | `list`, `get`, `create`, `update`, `delete`, `setMembers` | | `labels()` | `list`, `get`, `create`, `update`, `delete` | | `webhooks()` | `list`, `create`, `update`, `delete`, `test`, `deliveries`, `delivery`, `redeliver`, `verify` | @@ -108,13 +108,13 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `automations()` | `list`, `get`, `create`, `update`, `delete`, `toggle`, `runs`, `run`, `trigger`, `stats`, `versions`, `version`, `restoreVersion` | | `media()` | `list`, `limits`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | | `ai()` | `credits`, `generateCaption`, `rewrite`, `repurposeUrl` | -| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `vote`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `passThreadControl`, `takeThreadControl`, `handover`, `listApprovals`, `approveReply`, `rejectReply` | +| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `repost`, `unrepost`, `bookmark`, `unbookmark`, `vote`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `passThreadControl`, `takeThreadControl`, `handover`, `listApprovals`, `approveReply`, `rejectReply` | | `contacts()` | `list`, `get`, `create`, `update`, `delete`, `conversations`, `importCsv`, `listFields`, `createField`, `updateField`, `updateFieldOptions`, `deleteField`, `conversationAnalytics` | | `broadcasts()` | `list`, `get`, `create`, `update`, `delete`, `send`, `cancel`, `recipients` | | `sequences()` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` | | `knowledge()` | `list`, `create`, `createText`, `createUrl`, `createFile`, `update`, `delete`, `sync`, `search` | | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `goals`, `catalogs`, `createCatalog`, `catalog`, `updateCatalog`, `deleteCatalog`, `catalogProducts`, `writeCatalogProducts`, `productFeeds`, `createProductFeed`, `deleteProductFeed`, `feedUploads`, `startFeedUpload`, `productSets`, `createProductSet`, `updateProductSet`, `deleteProductSet`, `reachFrequency`, `createReachFrequency`, `reachFrequencyPrediction`, `reserveReachFrequency`, `cancelReachFrequency`, `library`, `partnershipCreators`, `requestPartnership`, `revokePartnership`, `accountActivity`, `labels`, `createLabel`, `updateLabel`, `deleteLabel`, `applyLabel`, `studies`, `createStudy`, `study`, `deleteStudy`, `iosCampaignLimits`, `highDemandPeriods`, `createHighDemandPeriod`, `deleteHighDemandPeriod`, `valueRuleSets`, `createValueRuleSet`, `deleteValueRuleSet`, `uploadConversions`, `sendPixelConversions`, `listConversionBatches`, `pixels`, `createPixel`, `pixel`, `updatePixel`, `sharePixel`, `pixelStats`, `eventMatchQuality`, `customConversions`, `createCustomConversion`, `customConversion`, `updateCustomConversion`, `deleteCustomConversion` | -| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `vote`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` | +| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `repost`, `unrepost`, `bookmark`, `unbookmark`, `vote`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` | | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage` | | `whatsapp()` | `getProfile`, `updateProfile`, `requestDisplayName`, `setUsername`, `listTemplates`, `listTemplateLibrary`, `getTemplate`, `createTemplate`, `importTemplate`, `updateTemplate`, `deleteTemplate`, `listGroups`, `createGroup`, `getGroup`, `updateGroup`, `deleteGroup`, `getGroupInviteLink`, `resetGroupInviteLink`, `removeGroupParticipants`, `listBlocked`, `blockUsers`, `unblockUsers`, `getCommerceSettings`, `updateCommerceSettings`, `linkCatalog`, `listFlows`, `getFlow`, `createFlow`, `updateFlow`, `deleteFlow`, `uploadFlowJson`, `publishFlow`, `deprecateFlow`, `listFlowResponses`, `getEncryptionKeyStatus`, `setEncryptionKey`, `getAccountEvents`, `listSandboxSessions`, `createSandboxSession` | | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `addAudienceCompanies`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `bidPricing`, `supplyForecast`, `conversionRules`, `createConversionRule`, `conversionRule`, `updateConversionRule`, `deleteConversionRule`, `attachConversionRule`, `detachConversionRule`, `conversionMetrics`, `sendConversionEvents` | @@ -369,10 +369,30 @@ drafted that a person still has to send; `approveReply` sends the draft (or your `rejectReply` discards it. The actions that act on the platform as the account also need the `publish` scope: `like`, -`unlike`, `pin`, `unpin`, `react`, `editComment`, `startConversation`, `setTyping`, `handover`, a `reply` with +`unlike`, `repost`, `unrepost`, `bookmark`, `unbookmark`, `pin`, `unpin`, `react`, `editComment`, `startConversation`, `setTyping`, `handover`, a `reply` with `InboxReplyParams` carrying `mediaIds` or `quickReplies`, and deleting our own reply. Each works only where the item's matching `can*` flag is true. +### X engagement + +On X an item can also be reposted or bookmarked (`canRepost`, `canBookmark`), and an account can +follow or unfollow a user by their numeric X user id. `getXPost` and `searchXPosts` read X directly +with the account's token; the search is paged by `meta().nextToken()`. + +```java +client.inbox().repost(item.id()); +client.accounts().followXUser(accountId, "2244994945"); + +XPost post = client.accounts().getXPost("1790000000000000000", accountId); +XSearchPage page = client.accounts().searchXPosts( + XSearchParams.create(accountId, "from:yourbrand -is:retweet").maxResults(50)); +``` + +Follow, repost and bookmark need `publish`; the reads need `inbox`. Every one of these is an X API +call metered to the workspace. An X account connected before these actions existed must be +reconnected first, and answers `409 reconnect_required` until it is; a spent X balance answers +`402 platform_balance_spent`. + ## Contacts The people behind the inbox: one person however many handles they write from. An inbound item files its author, a reply files whoever you answered, and both fold into whatever is already on file. Needs the `inbox` scope. diff --git a/src/main/java/com/fopost/sdk/model/InboxItem.java b/src/main/java/com/fopost/sdk/model/InboxItem.java index f358909..42110d0 100644 --- a/src/main/java/com/fopost/sdk/model/InboxItem.java +++ b/src/main/java/com/fopost/sdk/model/InboxItem.java @@ -11,7 +11,8 @@ * The {@code can*} flags say which actions the platform allows on it. {@code canDelete} covers a * comment someone left or our own reply; {@code canPin} and {@code canEdit} are our own comments * only; {@code canPrivateReply} means a DM can be opened from it with - * {@code StartConversationParams.privateReply}. {@code reaction} is our reaction on a DM. + * {@code StartConversationParams.privateReply}. {@code reaction} is our reaction on a DM. {@code reposted} and + * {@code bookmarked}, with {@code canRepost} and {@code canBookmark}, are X only. * {@code moderationStatus} is the platform's own state for a comment: published, held, spam or * rejected, and null where the platform does not report one. */ @@ -41,11 +42,15 @@ public record InboxItem( Boolean canHide, Boolean canDelete, Boolean liked, + Boolean reposted, + Boolean bookmarked, String vote, Boolean pinned, String reaction, Instant editedAt, Boolean canLike, + Boolean canRepost, + Boolean canBookmark, Boolean canVote, Boolean canPin, Boolean canEdit, diff --git a/src/main/java/com/fopost/sdk/model/XFollowResult.java b/src/main/java/com/fopost/sdk/model/XFollowResult.java new file mode 100644 index 0000000..30680d0 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/XFollowResult.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** The follow state of an X user after {@code followXUser} or {@code unfollowXUser}. */ +public record XFollowResult(String accountId, String userId, Boolean following) {} diff --git a/src/main/java/com/fopost/sdk/model/XPost.java b/src/main/java/com/fopost/sdk/model/XPost.java new file mode 100644 index 0000000..e0e39e7 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/XPost.java @@ -0,0 +1,25 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * A post read from X with a connected account's token. + * + *
{@code conversationId} is the thread root and {@code parentExternalId} the post this one
+ * replied to; both are null on a standalone post. The counts are what X reported at read time.
+ */
+public record XPost(
+ String id,
+ String text,
+ String permalink,
+ String authorName,
+ String authorHandle,
+ String authorAvatarUrl,
+ Long authorFollowers,
+ String conversationId,
+ String parentExternalId,
+ Long likes,
+ Long replies,
+ Long reposts,
+ Long quotes,
+ Instant platformCreatedAt) {}
diff --git a/src/main/java/com/fopost/sdk/model/XSearchPage.java b/src/main/java/com/fopost/sdk/model/XSearchPage.java
new file mode 100644
index 0000000..f1d0f93
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/XSearchPage.java
@@ -0,0 +1,25 @@
+package com.fopost.sdk.model;
+
+import java.util.Iterator;
+import java.util.List;
+
+/** One page of an X search: the posts plus the cursor for the next page. Iterates over its posts. */
+public record XSearchPage(List Every call needs the {@code inbox} scope on an API key. The calls that act on the platform as
- * the account (like, pin, react, edit, start a conversation, typing, a reply with media or quick
- * replies, deleting our own reply) also need the {@code publish} scope.
+ * the account (like, repost, bookmark, pin, react, edit, start a conversation, typing, a reply
+ * with media or quick replies, deleting our own reply) also need the {@code publish} scope.
*/
public final class InboxResource {
@@ -215,6 +215,31 @@ public InboxItem unlike(String itemId) {
return http.convert(ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/unlike", null)), InboxItem.class);
}
+ /**
+ * Repost to the account's own timeline (X). Only where {@code canRepost} is true. An X account
+ * connected before the repost grant answers 409 {@code reconnect_required} until it is
+ * reconnected; a spent X balance answers 402 {@code platform_balance_spent}.
+ */
+ public InboxItem repost(String itemId) {
+ return http.convert(ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/repost", null)), InboxItem.class);
+ }
+
+ public InboxItem unrepost(String itemId) {
+ return http.convert(
+ ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/unrepost", null)), InboxItem.class);
+ }
+
+ /** Bookmark on the account (X). Only where {@code canBookmark} is true; same errors as repost. */
+ public InboxItem bookmark(String itemId) {
+ return http.convert(
+ ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/bookmark", null)), InboxItem.class);
+ }
+
+ public InboxItem unbookmark(String itemId) {
+ return http.convert(
+ ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/unbookmark", null)), InboxItem.class);
+ }
+
/** Pin our own comment. Only where {@code canPin} is true. */
public InboxItem pin(String itemId) {
return http.convert(ApiClient.unwrap(http.post("/v1/inbox/" + itemId + "/pin", null)), InboxItem.class);
diff --git a/src/test/java/com/fopost/sdk/XEngagementTest.java b/src/test/java/com/fopost/sdk/XEngagementTest.java
new file mode 100644
index 0000000..00318ac
--- /dev/null
+++ b/src/test/java/com/fopost/sdk/XEngagementTest.java
@@ -0,0 +1,124 @@
+package com.fopost.sdk;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fopost.sdk.model.InboxItem;
+import com.fopost.sdk.model.XFollowResult;
+import com.fopost.sdk.model.XPost;
+import com.fopost.sdk.model.XSearchPage;
+import com.fopost.sdk.param.XSearchParams;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+class XEngagementTest {
+
+ @Test
+ void repostAndBookmarkPostWithoutABodyAndReadTheNewFlags() {
+ FakeTransport transport = new FakeTransport()
+ .enqueue(200, """
+ {"data":{"id":"i1","platform":"twitter","liked":false,"reposted":true,
+ "bookmarked":false,"can_like":true,"can_repost":true,"can_bookmark":true}}""")
+ .enqueue(200, "{\"data\":{\"id\":\"i1\",\"reposted\":false}}")
+ .enqueue(200, "{\"data\":{\"id\":\"i1\",\"bookmarked\":true,\"canBookmark\":true}}")
+ .enqueue(200, "{\"data\":{\"id\":\"i1\",\"bookmarked\":false}}");
+ FoPost client = TestSupport.client(transport);
+
+ InboxItem reposted = client.inbox().repost("i1");
+ assertEquals("POST", transport.last().method());
+ assertEquals("https://api.fopost.test/v1/inbox/i1/repost", transport.last().url());
+ assertEquals("", transport.lastBody());
+ assertTrue(reposted.reposted());
+ assertFalse(reposted.bookmarked());
+ assertTrue(reposted.canRepost());
+ assertTrue(reposted.canBookmark());
+
+ assertFalse(client.inbox().unrepost("i1").reposted());
+ assertEquals("https://api.fopost.test/v1/inbox/i1/unrepost", transport.last().url());
+
+ InboxItem bookmarked = client.inbox().bookmark("i1");
+ assertEquals("https://api.fopost.test/v1/inbox/i1/bookmark", transport.last().url());
+ assertTrue(bookmarked.bookmarked());
+ assertTrue(bookmarked.canBookmark());
+
+ assertFalse(client.inbox().unbookmark("i1").bookmarked());
+ assertEquals("https://api.fopost.test/v1/inbox/i1/unbookmark", transport.last().url());
+ }
+
+ @Test
+ void followAndUnfollowSendTheUserIdAndReadTheState() {
+ FakeTransport transport = new FakeTransport()
+ .enqueue(200, "{\"data\":{\"account_id\":\"a1\",\"user_id\":\"2244994945\",\"following\":true}}")
+ .enqueue(200, "{\"data\":{\"account_id\":\"a1\",\"user_id\":\"2244994945\",\"following\":false}}");
+ FoPost client = TestSupport.client(transport);
+
+ XFollowResult followed = client.accounts().followXUser("a1", "2244994945");
+ assertEquals("POST", transport.last().method());
+ assertEquals("https://api.fopost.test/v1/accounts/a1/x/follow", transport.last().url());
+ assertEquals("{\"user_id\":\"2244994945\"}", transport.lastBody());
+ assertEquals("a1", followed.accountId());
+ assertEquals("2244994945", followed.userId());
+ assertTrue(followed.following());
+
+ XFollowResult unfollowed = client.accounts().unfollowXUser("a1", "2244994945");
+ assertEquals("https://api.fopost.test/v1/accounts/a1/x/unfollow", transport.last().url());
+ assertEquals("{\"user_id\":\"2244994945\"}", transport.lastBody());
+ assertFalse(unfollowed.following());
+ }
+
+ @Test
+ void postLookupNamesTheAccountItReadsAs() {
+ FakeTransport transport = new FakeTransport()
+ .enqueue(200, """
+ {"data":{"id":"1790000000000000000","text":"Shipping today",
+ "permalink":"https://x.com/yourbrand/status/1790000000000000000",
+ "author_name":"Your Brand","author_handle":"yourbrand",
+ "author_avatar_url":null,"author_followers":1200,
+ "conversation_id":"1790000000000000000","parent_external_id":null,
+ "likes":10,"replies":2,"reposts":3,"quotes":1,
+ "platform_created_at":"2026-10-18T09:00:00.000Z"}}""");
+ FoPost client = TestSupport.client(transport);
+
+ XPost post = client.accounts().getXPost("1790000000000000000", "a1");
+
+ assertEquals("GET", transport.last().method());
+ assertEquals("https://api.fopost.test/v1/x/posts/1790000000000000000?account_id=a1", transport.last().url());
+ assertEquals("Shipping today", post.text());
+ assertEquals("yourbrand", post.authorHandle());
+ assertEquals(1200L, post.authorFollowers());
+ assertEquals(3L, post.reposts());
+ assertNull(post.parentExternalId());
+ assertEquals(Instant.parse("2026-10-18T09:00:00Z"), post.platformCreatedAt());
+ }
+
+ @Test
+ void searchEncodesTheQueryAndCursorAndReadsTheNextToken() {
+ FakeTransport transport = new FakeTransport()
+ .enqueue(200, """
+ {"data":[{"id":"1","text":"first"},{"id":"2","text":"second"}],
+ "meta":{"next_token":"b26v89c19zqg8o3fpdy"}}""")
+ .enqueue(200, "{\"data\":[],\"meta\":{\"next_token\":null}}");
+ FoPost client = TestSupport.client(transport);
+
+ XSearchPage page = client.accounts().searchXPosts(
+ XSearchParams.create("a1", "from:yourbrand -is:retweet").maxResults(50));
+
+ assertEquals("GET", transport.last().method());
+ assertEquals(
+ "https://api.fopost.test/v1/x/search?account_id=a1&q=from%3Ayourbrand+-is%3Aretweet&max_results=50",
+ transport.last().url());
+ assertEquals(2, page.size());
+ assertEquals("second", page.data().get(1).text());
+ assertEquals("b26v89c19zqg8o3fpdy", page.meta().nextToken());
+
+ XSearchPage last = client.accounts().searchXPosts(
+ XSearchParams.create("a1", "from:yourbrand").nextToken(page.meta().nextToken()));
+ assertEquals(
+ "https://api.fopost.test/v1/x/search?account_id=a1&q=from%3Ayourbrand&next_token=b26v89c19zqg8o3fpdy",
+ transport.last().url());
+ assertTrue(last.isEmpty());
+ assertNull(last.meta().nextToken());
+ }
+}