diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05b24d8..5d08096 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/).
-## [Unreleased]
+## [0.3.0] - 2026-10-18
### Added
@@ -94,6 +94,35 @@ All notable changes to this project are documented here. The format follows
`updateSlackIdentity` (`UpdateSlackIdentityParams`) for a Slack account. A webhook-connected
account answers 409 `webhook_connection`.
+- `ads()`: the campaign tree and its objects, read live from Meta: `accountTree`, and
+ create/get/update/delete/duplicate for campaigns, ad sets and network ads, plus `bulkSetStatus`.
+ The writes need the `publish` scope as well as `ads`.
+- `ads()`: `creatives`, `createCreative` (image, video or carousel), `creative`, `deleteCreative`;
+ `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`; `estimateReach`; `insights`
+ and `adInsights` with an optional breakdown and daily timeline.
+- `ads()`: `leadForm`, `archiveLeadForm`, the cursor-paged `leadsFeed`, and `leadPages`,
+ `subscribeLeadPage`, `unsubscribeLeadPage`.
+- `CreateAdParams.urlTags` and `Ad.Creative.urlTags`.
+
+- `inbox()`: `like`, `unlike`, `pin`, `unpin`, `react`, `editComment`, `startConversation`
+ (`StartConversationParams`), `setTyping`, and `reply` with `InboxReplyParams` for media and quick
+ replies. All of them also need the `publish` scope, as does deleting our own reply.
+- `InboxItem` gains `liked`, `pinned`, `reaction`, `editedAt` and the `canLike`, `canPin`,
+ `canEdit`, `canReact`, `canSendMedia`, `canQuickReply` and `canPrivateReply` flags;
+ `InboxAccount` gains `canStartConversation`.
+- `validate` resource (`client.validate()`): `post`, `length` and `media` check a draft, a text
+ length, or a media url against the platform rules without creating a post. Needs the `posts`
+ scope.
+- `accounts()`: `redditSubreddits`, `redditSubredditRules`, `redditFlairs` and
+ `setRedditDefaultSubreddit` for a Reddit account, plus `validate().subreddit` to check a
+ subreddit before publishing and `inbox().vote(itemId, direction)` with `up`, `down` or `none`
+ to take an earlier vote back. A flair id is valid only in the subreddit it came from.
+- `blogs` resource (`client.blogs()`): `listBlogs`, `listArticles`, `getArticle`, `createArticle`,
+ `updateArticle`, `deleteArticle`, `listProducts` and `updateProduct` reach the blogs, articles
+ and products a connected site already owns, addressed by the platform's own ids.
+ `updateArticle` edits the live article in place. Reads need the `posts` scope; writes also
+ need `publish`.
+
### Changed
- `blog()` matches the live API contract: the workspace travels as `workspace_id` on the query
@@ -117,30 +146,6 @@ All notable changes to this project are documented here. The format follows
- **Breaking:** `BlogSettings.autoPublish` and `BlogParams.Settings.autoPublish(...)`. Every
article now goes through `submitArticle` before it publishes.
-## [0.3.0] - 2026-09-19
-
-### Added
-
-- `ads()`: the campaign tree and its objects, read live from Meta: `accountTree`, and
- create/get/update/delete/duplicate for campaigns, ad sets and network ads, plus `bulkSetStatus`.
- The writes need the `publish` scope as well as `ads`.
-- `ads()`: `creatives`, `createCreative` (image, video or carousel), `creative`, `deleteCreative`;
- `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`; `estimateReach`; `insights`
- and `adInsights` with an optional breakdown and daily timeline.
-- `ads()`: `leadForm`, `archiveLeadForm`, the cursor-paged `leadsFeed`, and `leadPages`,
- `subscribeLeadPage`, `unsubscribeLeadPage`.
-- `CreateAdParams.urlTags` and `Ad.Creative.urlTags`.
-
-- `inbox()`: `like`, `unlike`, `pin`, `unpin`, `react`, `editComment`, `startConversation`
- (`StartConversationParams`), `setTyping`, and `reply` with `InboxReplyParams` for media and quick
- replies. All of them also need the `publish` scope, as does deleting our own reply.
-- `InboxItem` gains `liked`, `pinned`, `reaction`, `editedAt` and the `canLike`, `canPin`,
- `canEdit`, `canReact`, `canSendMedia`, `canQuickReply` and `canPrivateReply` flags;
- `InboxAccount` gains `canStartConversation`.
-- `validate` resource (`client.validate()`): `post`, `length` and `media` check a draft, a text
- length, or a media url against the platform rules without creating a post. Needs the `posts`
- scope.
-
## [0.2.0] - 2026-09-19
### Added
diff --git a/README.md b/README.md
index 4aab69e..1869ada 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| `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` |
| `googleAds()` | `recommendations`, `optimizationScore`, `applyRecommendations`, `dismissRecommendations`, `keywords`, `createKeyword`, `updateKeyword`, `deleteKeyword`, `keywordIdeas`, `keywordMetrics`, `searchTerms`, `bidStrategies`, `createBidStrategy`, `adSchedule`, `setAdSchedule`, `negativeKeywordLists`, `createNegativeKeywordList`, `addNegativeKeywords`, `attachNegativeKeywordList`, `assets`, `createAsset`, `attachAsset`, `deleteAsset`, `assetGroups`, `createAssetGroup`, `updateAssetGroup`, `deleteAssetGroup`, `localServicesLeads`, `conversionActions`, `createConversionAction`, `uploadConversions`, `uploadConversionAdjustments`, `query` |
| `googleBusiness()` | `getLocation`, `updateLocation`, `getAttributes`, `updateAttributes`, `getMenus`, `replaceMenus`, `getServices`, `replaceServices`, `listMedia`, `addMedia`, `deleteMedia`, `listPlaceActions`, `createPlaceAction`, `updatePlaceAction`, `deletePlaceAction`, `getVerificationOptions`, `startVerification`, `completeVerification`, `getPerformance`, `getSearchKeywords`, `assign` |
+| `blogs()` | `listBlogs`, `listArticles`, `getArticle`, `createArticle`, `updateArticle`, `deleteArticle`, `listProducts`, `updateProduct` |
| `validate()` | `post`, `length`, `media`, `subreddit` |
| `activity()` | `list` |
diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java
index b99d595..afea03f 100644
--- a/src/main/java/com/fopost/sdk/FoPost.java
+++ b/src/main/java/com/fopost/sdk/FoPost.java
@@ -16,6 +16,7 @@
import com.fopost.sdk.resource.BlogResource;
import com.fopost.sdk.resource.BroadcastsResource;
import com.fopost.sdk.resource.ContactsResource;
+import com.fopost.sdk.resource.BlogsResource;
import com.fopost.sdk.resource.InboxResource;
import com.fopost.sdk.resource.KnowledgeResource;
import com.fopost.sdk.resource.GoogleAdsResource;
@@ -86,6 +87,7 @@ public final class FoPost {
private final AdsResource ads;
private final GoogleAdsResource googleAds;
private final BlogResource blog;
+ private final BlogsResource blogs;
private final ValidateResource validate;
private FoPost(ApiClient http) {
@@ -111,6 +113,7 @@ private FoPost(ApiClient http) {
this.ads = new AdsResource(http);
this.googleAds = new GoogleAdsResource(http);
this.blog = new BlogResource(http);
+ this.blogs = new BlogsResource(http);
this.validate = new ValidateResource(http);
}
@@ -186,6 +189,11 @@ public GoogleBusinessResource googleBusiness() {
return googleBusiness;
}
+ /** Articles and products that already live on a connected site. */
+ public BlogsResource blogs() {
+ return blogs;
+ }
+
public InboxResource inbox() {
return inbox;
}
diff --git a/src/main/java/com/fopost/sdk/model/RemoteArticle.java b/src/main/java/com/fopost/sdk/model/RemoteArticle.java
new file mode 100644
index 0000000..bf0395d
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/RemoteArticle.java
@@ -0,0 +1,25 @@
+package com.fopost.sdk.model;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * An article that already lives on a connected site, addressed by the
+ * platform's own id.
+ *
+ *
{@code status} is one of {@code published}, {@code draft}, {@code pending}
+ * or {@code scheduled}.
+ */
+public record RemoteArticle(
+ String id,
+ String blogId,
+ String title,
+ String bodyHtml,
+ String excerpt,
+ String status,
+ String authorName,
+ List tags,
+ String imageUrl,
+ String url,
+ Instant publishedAt,
+ Instant updatedAt) {}
diff --git a/src/main/java/com/fopost/sdk/model/RemoteBlog.java b/src/main/java/com/fopost/sdk/model/RemoteBlog.java
new file mode 100644
index 0000000..898d561
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/RemoteBlog.java
@@ -0,0 +1,10 @@
+package com.fopost.sdk.model;
+
+/**
+ * A blog on a connected site. {@code id} is the platform's own id, never a
+ * FoPost id.
+ *
+ * A Shopify store reports every blog it has; WordPress has one implicit blog
+ * and reports it under the id {@code default}, so both answer the same shape.
+ */
+public record RemoteBlog(String id, String title, String handle, String url) {}
diff --git a/src/main/java/com/fopost/sdk/model/RemoteProduct.java b/src/main/java/com/fopost/sdk/model/RemoteProduct.java
new file mode 100644
index 0000000..34d35a1
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/model/RemoteProduct.java
@@ -0,0 +1,24 @@
+package com.fopost.sdk.model;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * A product on a connected store. {@code price} is the lowest variant price, as
+ * a decimal string; {@code status} is {@code active}, {@code draft} or
+ * {@code archived}.
+ */
+public record RemoteProduct(
+ String id,
+ String title,
+ String handle,
+ String status,
+ String description,
+ String vendor,
+ String productType,
+ List tags,
+ String imageUrl,
+ String url,
+ String price,
+ String currency,
+ Instant updatedAt) {}
diff --git a/src/main/java/com/fopost/sdk/param/ArticleListParams.java b/src/main/java/com/fopost/sdk/param/ArticleListParams.java
new file mode 100644
index 0000000..5a75896
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/ArticleListParams.java
@@ -0,0 +1,36 @@
+package com.fopost.sdk.param;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Filters for listing a blog's articles. Every field is optional. */
+public final class ArticleListParams {
+
+ private final Map query = new LinkedHashMap<>();
+
+ public static ArticleListParams create() {
+ return new ArticleListParams();
+ }
+
+ /** How many to return, 1 to 50. The API defaults to 20. */
+ public ArticleListParams limit(int limit) {
+ query.put("limit", limit);
+ return this;
+ }
+
+ /** {@code published}, {@code draft}, {@code pending} or {@code scheduled}. */
+ public ArticleListParams status(String status) {
+ query.put("status", status);
+ return this;
+ }
+
+ /** Matches the article title. */
+ public ArticleListParams q(String q) {
+ query.put("q", q);
+ return this;
+ }
+
+ public Map toQuery() {
+ return new LinkedHashMap<>(query);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/param/ArticleParams.java b/src/main/java/com/fopost/sdk/param/ArticleParams.java
new file mode 100644
index 0000000..9f05090
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/ArticleParams.java
@@ -0,0 +1,63 @@
+package com.fopost.sdk.param;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The body of a create or update on an article.
+ *
+ * Only what is set here travels, so on an update an omitted field keeps
+ * whatever the site already had. A create needs at least {@code title} and
+ * {@code body}; an update needs at least one field.
+ */
+public final class ArticleParams {
+
+ private final Map body = new LinkedHashMap<>();
+
+ public static ArticleParams create() {
+ return new ArticleParams();
+ }
+
+ public ArticleParams title(String title) {
+ body.put("title", title);
+ return this;
+ }
+
+ /** FoPost body markup; the site's own format is rendered from it. */
+ public ArticleParams body(String value) {
+ body.put("body", value);
+ return this;
+ }
+
+ public ArticleParams excerpt(String excerpt) {
+ body.put("excerpt", excerpt);
+ return this;
+ }
+
+ /** {@code published}, {@code draft}, {@code pending} or {@code scheduled}. */
+ public ArticleParams status(String status) {
+ body.put("status", status);
+ return this;
+ }
+
+ public ArticleParams tags(List tags) {
+ body.put("tags", tags);
+ return this;
+ }
+
+ public ArticleParams authorName(String authorName) {
+ body.put("author_name", authorName);
+ return this;
+ }
+
+ /** Public http(s) URL of the featured image. */
+ public ArticleParams imageUrl(String imageUrl) {
+ body.put("image_url", imageUrl);
+ return this;
+ }
+
+ public Map toBody() {
+ return new LinkedHashMap<>(body);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/param/ProductListParams.java b/src/main/java/com/fopost/sdk/param/ProductListParams.java
new file mode 100644
index 0000000..3479040
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/ProductListParams.java
@@ -0,0 +1,36 @@
+package com.fopost.sdk.param;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Filters for listing a store's products. Every field is optional. */
+public final class ProductListParams {
+
+ private final Map query = new LinkedHashMap<>();
+
+ public static ProductListParams create() {
+ return new ProductListParams();
+ }
+
+ /** How many to return, 1 to 50. The API defaults to 20. */
+ public ProductListParams limit(int limit) {
+ query.put("limit", limit);
+ return this;
+ }
+
+ /** {@code active}, {@code draft} or {@code archived}. */
+ public ProductListParams status(String status) {
+ query.put("status", status);
+ return this;
+ }
+
+ /** Matches the product title. */
+ public ProductListParams q(String q) {
+ query.put("q", q);
+ return this;
+ }
+
+ public Map toQuery() {
+ return new LinkedHashMap<>(query);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/param/ProductParams.java b/src/main/java/com/fopost/sdk/param/ProductParams.java
new file mode 100644
index 0000000..127a9e6
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/param/ProductParams.java
@@ -0,0 +1,54 @@
+package com.fopost.sdk.param;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The body of an update on a store product. Only what is set travels, so an
+ * omitted field keeps whatever the store already had. Set at least one.
+ */
+public final class ProductParams {
+
+ private final Map body = new LinkedHashMap<>();
+
+ public static ProductParams create() {
+ return new ProductParams();
+ }
+
+ public ProductParams title(String title) {
+ body.put("title", title);
+ return this;
+ }
+
+ /** Body markup, rendered to HTML on the store. */
+ public ProductParams description(String description) {
+ body.put("description", description);
+ return this;
+ }
+
+ /** {@code active}, {@code draft} or {@code archived}. */
+ public ProductParams status(String status) {
+ body.put("status", status);
+ return this;
+ }
+
+ public ProductParams tags(List tags) {
+ body.put("tags", tags);
+ return this;
+ }
+
+ public ProductParams productType(String productType) {
+ body.put("product_type", productType);
+ return this;
+ }
+
+ public ProductParams vendor(String vendor) {
+ body.put("vendor", vendor);
+ return this;
+ }
+
+ public Map toBody() {
+ return new LinkedHashMap<>(body);
+ }
+}
diff --git a/src/main/java/com/fopost/sdk/resource/BlogsResource.java b/src/main/java/com/fopost/sdk/resource/BlogsResource.java
new file mode 100644
index 0000000..db6deef
--- /dev/null
+++ b/src/main/java/com/fopost/sdk/resource/BlogsResource.java
@@ -0,0 +1,110 @@
+package com.fopost.sdk.resource;
+
+import com.fopost.sdk.internal.ApiClient;
+import com.fopost.sdk.model.RemoteArticle;
+import com.fopost.sdk.model.RemoteBlog;
+import com.fopost.sdk.model.RemoteProduct;
+import com.fopost.sdk.param.ArticleListParams;
+import com.fopost.sdk.param.ArticleParams;
+import com.fopost.sdk.param.ProductListParams;
+import com.fopost.sdk.param.ProductParams;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Articles and products that already live on a connected site, addressed by the
+ * platform's own ids rather than FoPost ids.
+ *
+ * Reads need the {@code posts} scope; anything that changes the site needs
+ * {@code posts} and {@code publish}. An account on a platform that cannot manage
+ * articles answers 400 {@code unsupported_platform}.
+ *
+ *
An update changes the live article in place and never creates a second
+ * post, so a link already shared keeps working.
+ */
+public final class BlogsResource {
+
+ private final ApiClient http;
+
+ public BlogsResource(ApiClient http) {
+ this.http = http;
+ }
+
+ private static String base(String accountId) {
+ return "/v1/accounts/" + accountId + "/blogs";
+ }
+
+ /** The blogs the account can write to. WordPress reports one, under {@code default}. */
+ public List listBlogs(String accountId) {
+ return http.convertList(
+ ApiClient.unwrap(http.get(base(accountId), null)), RemoteBlog.class);
+ }
+
+ public List listArticles(String accountId, String blogId) {
+ return listArticles(accountId, blogId, null);
+ }
+
+ public List listArticles(
+ String accountId, String blogId, ArticleListParams params) {
+ Map query = params == null ? null : params.toQuery();
+ return http.convertList(
+ ApiClient.unwrap(http.get(base(accountId) + "/" + blogId + "/articles", query)),
+ RemoteArticle.class);
+ }
+
+ public RemoteArticle getArticle(String accountId, String blogId, String articleId) {
+ return http.convert(
+ ApiClient.unwrap(
+ http.get(base(accountId) + "/" + blogId + "/articles/" + articleId, null)),
+ RemoteArticle.class);
+ }
+
+ /** Writes a new article. Needs the {@code publish} scope. */
+ public RemoteArticle createArticle(String accountId, String blogId, ArticleParams params) {
+ return http.convert(
+ ApiClient.unwrap(
+ http.post(base(accountId) + "/" + blogId + "/articles", params.toBody())),
+ RemoteArticle.class);
+ }
+
+ /** Changes the live article in place; never creates a duplicate. */
+ public RemoteArticle updateArticle(
+ String accountId, String blogId, String articleId, ArticleParams params) {
+ return http.convert(
+ ApiClient.unwrap(
+ http.request(
+ "PATCH",
+ base(accountId) + "/" + blogId + "/articles/" + articleId,
+ params.toBody(),
+ null)),
+ RemoteArticle.class);
+ }
+
+ /** Removes the article from the site. This cannot be undone. */
+ public void deleteArticle(String accountId, String blogId, String articleId) {
+ http.delete(base(accountId) + "/" + blogId + "/articles/" + articleId);
+ }
+
+ public List listProducts(String accountId) {
+ return listProducts(accountId, null);
+ }
+
+ public List listProducts(String accountId, ProductListParams params) {
+ Map query = params == null ? null : params.toQuery();
+ return http.convertList(
+ ApiClient.unwrap(http.get("/v1/accounts/" + accountId + "/products", query)),
+ RemoteProduct.class);
+ }
+
+ /** Changes the product on the store. Only what is set travels. */
+ public RemoteProduct updateProduct(String accountId, String productId, ProductParams params) {
+ return http.convert(
+ ApiClient.unwrap(
+ http.request(
+ "PATCH",
+ "/v1/accounts/" + accountId + "/products/" + productId,
+ params.toBody(),
+ null)),
+ RemoteProduct.class);
+ }
+}
diff --git a/src/test/java/com/fopost/sdk/BlogsTest.java b/src/test/java/com/fopost/sdk/BlogsTest.java
new file mode 100644
index 0000000..e6c3a2d
--- /dev/null
+++ b/src/test/java/com/fopost/sdk/BlogsTest.java
@@ -0,0 +1,112 @@
+package com.fopost.sdk;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.fopost.sdk.param.ArticleListParams;
+import com.fopost.sdk.param.ArticleParams;
+import com.fopost.sdk.param.ProductParams;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class BlogsTest {
+
+ private static final String ARTICLE =
+ "{\"id\":\"99\",\"blog_id\":\"11\",\"title\":\"Spring drop\","
+ + "\"body_html\":\"Hello
\",\"excerpt\":\"A short summary\","
+ + "\"status\":\"published\",\"author_name\":\"Store Owner\",\"tags\":[\"news\"],"
+ + "\"image_url\":null,\"url\":\"https://demo.myshopify.com/blogs/article/spring-drop\","
+ + "\"published_at\":\"2026-09-01T10:00:00.000Z\",\"updated_at\":null}";
+
+ @Test
+ void listBlogsReadsEveryBlogOnTheSite() {
+ FakeTransport transport = new FakeTransport()
+ .enqueue(200, "{\"data\":[{\"id\":\"11\",\"title\":\"News\",\"handle\":\"news\",\"url\":null}]}");
+
+ var blogs = TestSupport.client(transport).blogs().listBlogs("a1");
+
+ assertEquals("https://api.fopost.test/v1/accounts/a1/blogs", transport.last().url());
+ assertEquals(1, blogs.size());
+ assertEquals("11", blogs.get(0).id());
+ assertEquals("News", blogs.get(0).title());
+ }
+
+ @Test
+ void listArticlesSendsTheFilters() {
+ FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":[" + ARTICLE + "]}");
+
+ var articles = TestSupport.client(transport)
+ .blogs()
+ .listArticles("a1", "11", ArticleListParams.create().limit(5).status("draft").q("spring"));
+
+ assertEquals(
+ "https://api.fopost.test/v1/accounts/a1/blogs/11/articles?limit=5&status=draft&q=spring",
+ transport.last().url());
+ assertEquals("99", articles.get(0).id());
+ assertEquals(List.of("news"), articles.get(0).tags());
+ assertEquals("Store Owner", articles.get(0).authorName());
+ }
+
+ /**
+ * The article id is in the path and only what the caller set is sent, which
+ * is what stops an edit from creating a second post on the site.
+ */
+ @Test
+ void updateArticleChangesItInPlace() {
+ FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":" + ARTICLE + "}");
+
+ TestSupport.client(transport)
+ .blogs()
+ .updateArticle("a1", "11", "99", ArticleParams.create().title("Spring drop, restocked"));
+
+ assertEquals("PATCH", transport.last().method());
+ assertEquals(
+ "https://api.fopost.test/v1/accounts/a1/blogs/11/articles/99", transport.last().url());
+ assertEquals("{\"title\":\"Spring drop, restocked\"}", transport.lastBody());
+ }
+
+ @Test
+ void createArticleSendsOnlyWhatItWasGiven() {
+ FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":" + ARTICLE + "}");
+
+ TestSupport.client(transport)
+ .blogs()
+ .createArticle(
+ "a1", "11", ArticleParams.create().title("Spring drop").body("Hello").status("draft"));
+
+ assertEquals("POST", transport.last().method());
+ assertEquals(
+ "{\"title\":\"Spring drop\",\"body\":\"Hello\",\"status\":\"draft\"}",
+ transport.lastBody());
+ }
+
+ @Test
+ void deleteArticleHitsTheArticleRoute() {
+ FakeTransport transport = new FakeTransport().enqueue(204, "");
+
+ TestSupport.client(transport).blogs().deleteArticle("a1", "11", "99");
+
+ assertEquals("DELETE", transport.last().method());
+ assertEquals(
+ "https://api.fopost.test/v1/accounts/a1/blogs/11/articles/99", transport.last().url());
+ }
+
+ @Test
+ void updateProductSendsOnlyWhatChanged() {
+ FakeTransport transport = new FakeTransport()
+ .enqueue(
+ 200,
+ "{\"data\":{\"id\":\"7\",\"title\":\"Mug XL\",\"handle\":\"mug\",\"status\":\"draft\","
+ + "\"description\":null,\"vendor\":null,\"product_type\":\"Drinkware\","
+ + "\"tags\":[],\"image_url\":null,\"url\":null,\"price\":\"12.00\","
+ + "\"currency\":\"USD\",\"updated_at\":null}}");
+
+ var product = TestSupport.client(transport)
+ .blogs()
+ .updateProduct("a1", "7", ProductParams.create().title("Mug XL").productType("Drinkware"));
+
+ assertEquals("PATCH", transport.last().method());
+ assertEquals(
+ "{\"title\":\"Mug XL\",\"product_type\":\"Drinkware\"}", transport.lastBody());
+ assertEquals("12.00", product.price());
+ }
+}