Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/java/com/fopost/sdk/model/AdBusinessCenter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fopost.sdk.model;

/** A Business Center, or the network's equivalent grouping of ad accounts. */
public record AdBusinessCenter(String id, String name, String role) {}
17 changes: 17 additions & 0 deletions src/main/java/com/fopost/sdk/model/AdComment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.fopost.sdk.model;

/**
* A comment on an ad, read live from the network and never stored.
* {@code parentId} is the comment this one answers, when it is not on the ad itself.
*/
public record AdComment(
String id,
String adId,
String text,
String authorName,
String authorAvatarUrl,
String createdAt,
long likes,
long replyCount,
boolean hidden,
String parentId) {}
6 changes: 6 additions & 0 deletions src/main/java/com/fopost/sdk/model/AdCommentsPage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.fopost.sdk.model;

import java.util.List;

/** One page of an ad's comments. Pass {@code nextCursor} back as {@code after}; null means the end. */
public record AdCommentsPage(List<AdComment> comments, String nextCursor) {}
8 changes: 8 additions & 0 deletions src/main/java/com/fopost/sdk/model/AdIdentity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.fopost.sdk.model;

/**
* The account an ad runs as. Meta calls it a Page, TikTok an identity; an
* identity id is what every route calls a {@code pageId}. {@code type} is the
* network's own identity kind, e.g. {@code CUSTOMIZED_USER}.
*/
public record AdIdentity(String id, String type, String name, String avatarUrl) {}
5 changes: 5 additions & 0 deletions src/main/java/com/fopost/sdk/model/SparkPost.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.fopost.sdk.model;

/** A post already live on the network, offered as the source of a Spark ad. */
public record SparkPost(
String id, String identityId, String caption, String thumbnailUrl, String createdAt, Long views) {}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ public CreateAdCampaignParams paused(boolean paused) {
return this;
}

/**
* Hands targeting and creative rotation to the network. Needs its
* {@code smartPlus} capability.
*/
public CreateAdCampaignParams smartPlus(boolean smartPlus) {
body.put("smartPlus", smartPlus);
return this;
}

public Map<String, Object> toMap() {
return new LinkedHashMap<>(body);
}
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/fopost/sdk/param/CreateAdParams.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ public static CreateAdParams of(
return params;
}

/**
* Run a post already live on the network as a Spark ad, from
* {@code ads().sparkPosts(...)}. The post carries its own caption and
* media, so {@code text}, {@code headline} and {@code mediaUrl} are
* ignored. Needs the network's {@code sparkAds} capability.
*/
public CreateAdParams sparkPostId(String sparkPostId) {
body.put("sparkPostId", sparkPostId);
return this;
}

public CreateAdParams headline(String headline) {
body.put("headline", headline);
return this;
Expand Down
53 changes: 53 additions & 0 deletions src/main/java/com/fopost/sdk/param/UploadConversionsParams.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.fopost.sdk.param;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* The body of {@code POST /v1/ads/conversions}: offline events attributed to a
* pixel the ad account owns. Emails and phone numbers are hashed by the API
* before anything leaves FoPost.
*/
public final class UploadConversionsParams {

private final Map<String, Object> body = new LinkedHashMap<>();
private final List<Map<String, Object>> events = new ArrayList<>();

private UploadConversionsParams() {}

public static UploadConversionsParams of(
String workspaceId, String connectionId, String adAccountId, String pixelId) {
UploadConversionsParams params = new UploadConversionsParams();
params.body.put("workspaceId", workspaceId);
params.body.put("connectionId", connectionId);
params.body.put("adAccountId", adAccountId);
params.body.put("pixelId", pixelId);
return params;
}

/** Up to 1000 events per call. {@code occurredAt} is ISO 8601. */
public UploadConversionsParams event(String eventName, String occurredAt) {
Map<String, Object> event = new LinkedHashMap<>();
event.put("eventName", eventName);
event.put("occurredAt", occurredAt);
events.add(event);
return this;
}

/** Adds a field to the event added last. */
public UploadConversionsParams with(String field, Object value) {
if (events.isEmpty()) {
throw new IllegalStateException("Add an event before its fields");
}
events.get(events.size() - 1).put(field, value);
return this;
}

public Map<String, Object> toMap() {
Map<String, Object> out = new LinkedHashMap<>(body);
out.put("events", new ArrayList<>(events));
return out;
}
}
106 changes: 106 additions & 0 deletions src/main/java/com/fopost/sdk/resource/AdsResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@
import com.fopost.sdk.param.StartFeedUploadParams;
import com.fopost.sdk.param.UpdateCatalogParams;
import com.fopost.sdk.model.AdAccountTree;
import com.fopost.sdk.model.AdBusinessCenter;
import com.fopost.sdk.model.AdCampaign;
import com.fopost.sdk.model.AdCommentsPage;
import com.fopost.sdk.model.AdConnection;
import com.fopost.sdk.model.AdCreative;
import com.fopost.sdk.model.AdIdentity;
import com.fopost.sdk.model.AdInsightsReport;
import com.fopost.sdk.model.AdSet;
import com.fopost.sdk.model.AdSource;
Expand All @@ -52,6 +55,7 @@
import com.fopost.sdk.model.LeadsPage;
import com.fopost.sdk.model.NetworkAd;
import com.fopost.sdk.model.ReachEstimate;
import com.fopost.sdk.model.SparkPost;
import com.fopost.sdk.model.TargetingOption;
import com.fopost.sdk.param.AdInsightsParams;
import com.fopost.sdk.param.BoostPostParams;
Expand All @@ -67,6 +71,7 @@
import com.fopost.sdk.param.ReachEstimateParams;
import com.fopost.sdk.param.UpdateAdCampaignParams;
import com.fopost.sdk.param.UpdateAdSetParams;
import com.fopost.sdk.param.UploadConversionsParams;
import com.fopost.sdk.param.UpdateAudienceParams;
import com.fopost.sdk.param.UpdateNetworkAdParams;
import java.util.LinkedHashMap;
Expand Down Expand Up @@ -509,6 +514,107 @@ public List<TargetingOption> searchTargeting(String connectionId, String type, S
ApiClient.unwrap(http.get("/v1/ads/targeting/search", query)), TargetingOption.class);
}

// ─── Identities, Spark posts, conversions and ad comments ─────────────────

public List<AdBusinessCenter> tiktokBusinessCenters(String connectionId) {
return tiktokBusinessCenters(connectionId, null);
}

/**
* TikTok's Business Centers. The one network-named read on this resource,
* because no other network groups ad accounts this way.
*/
public List<AdBusinessCenter> tiktokBusinessCenters(String connectionId, String workspaceId) {
return http.convertList(
ApiClient.unwrap(http.get("/v1/ads/tiktok/business-centers", connectionQuery(workspaceId, connectionId))),
AdBusinessCenter.class);
}

public List<AdIdentity> tiktokIdentities(String connectionId, String adAccountId) {
return tiktokIdentities(connectionId, adAccountId, null);
}

/** The accounts an ad can run as; an identity id is a {@code pageId}. */
public List<AdIdentity> tiktokIdentities(String connectionId, String adAccountId, String workspaceId) {
Map<String, Object> query = connectionQuery(workspaceId, connectionId);
query.put("ad_account_id", adAccountId);
return http.convertList(ApiClient.unwrap(http.get("/v1/ads/tiktok/identities", query)), AdIdentity.class);
}

public List<SparkPost> sparkPosts(String connectionId, String adAccountId, String identityId) {
return sparkPosts(connectionId, adAccountId, identityId, null);
}

/** Posts already live under an identity, each a candidate Spark ad. */
public List<SparkPost> sparkPosts(
String connectionId, String adAccountId, String identityId, String workspaceId) {
Map<String, Object> query = connectionQuery(workspaceId, connectionId);
query.put("ad_account_id", adAccountId);
query.put("identity_id", identityId);
return http.convertList(ApiClient.unwrap(http.get("/v1/ads/spark-posts", query)), SparkPost.class);
}

/**
* Offline conversions against a pixel the ad account owns. Identifiers are
* hashed before anything leaves FoPost. Returns how many the network took.
*/
public long uploadConversions(UploadConversionsParams params) {
return ApiClient.unwrap(http.post("/v1/ads/conversions", params.toMap()))
.path("accepted")
.asLong();
}

public AdCommentsPage comments(String connectionId, String adId) {
return comments(connectionId, adId, null, null);
}

/** One page of an ad's comments; pass {@code nextCursor} back as {@code after}. */
public AdCommentsPage comments(String connectionId, String adId, String after, String workspaceId) {
Map<String, Object> query = connectionQuery(workspaceId, connectionId);
query.put("ad_id", adId);
if (after != null) {
query.put("after", after);
}
return http.convert(ApiClient.unwrap(http.get("/v1/ads/comments", query)), AdCommentsPage.class);
}

/**
* Answer a comment on an ad; returns the reply's id on the network. Needs
* the {@code publish} scope as well as {@code ads}.
*/
public String replyToComment(
String commentId, String workspaceId, String connectionId, String adId, String text) {
Map<String, Object> body = commentBody(workspaceId, connectionId, adId);
body.put("text", text);
return ApiClient.unwrap(http.post("/v1/ads/comments/" + commentId + "/reply", body))
.path("replyId")
.asText();
}

/** Needs the {@code publish} scope as well as {@code ads}. */
public void setCommentHidden(
String commentId, String workspaceId, String connectionId, String adId, boolean hidden) {
Map<String, Object> body = commentBody(workspaceId, connectionId, adId);
body.put("hidden", hidden);
http.post("/v1/ads/comments/" + commentId + "/hide", body);
}

/**
* One already gone on the network succeeds. Needs the {@code publish} scope
* as well as {@code ads}.
*/
public void deleteComment(String commentId, String workspaceId, String connectionId, String adId) {
http.request("DELETE", "/v1/ads/comments/" + commentId, commentBody(workspaceId, connectionId, adId), null);
}

private static Map<String, Object> commentBody(String workspaceId, String connectionId, String adId) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("workspaceId", workspaceId);
body.put("connectionId", connectionId);
body.put("adId", adId);
return body;
}

// ─── Lead forms ───────────────────────────────────────────────────────────

public List<LeadFormSource> leadForms() {
Expand Down
117 changes: 117 additions & 0 deletions src/test/java/com/fopost/sdk/AdsTikTokTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package com.fopost.sdk;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.fopost.sdk.model.AdBusinessCenter;
import com.fopost.sdk.model.AdCommentsPage;
import com.fopost.sdk.model.AdIdentity;
import com.fopost.sdk.model.SparkPost;
import com.fopost.sdk.param.AdBudgetParams;
import com.fopost.sdk.param.AdTargetingParams;
import com.fopost.sdk.param.CreateAdCampaignParams;
import com.fopost.sdk.param.CreateAdParams;
import com.fopost.sdk.param.UploadConversionsParams;
import java.util.List;
import org.junit.jupiter.api.Test;

class AdsTikTokTest {

@Test
void identitiesAndSparkPostsReadTheRightPaths() {
FakeTransport transport = new FakeTransport()
.enqueue(200, "{\"data\":[{\"id\":\"bc1\",\"name\":\"Brand HQ\",\"role\":\"ADMIN\"}]}")
.enqueue(200, "{\"data\":[{\"id\":\"idt_1\",\"type\":\"CUSTOMIZED_USER\",\"name\":\"Your Brand\","
+ "\"avatarUrl\":null}]}")
.enqueue(200, "{\"data\":[{\"id\":\"item_99\",\"identityId\":\"idt_1\",\"caption\":null,"
+ "\"thumbnailUrl\":null,\"createdAt\":null,\"views\":48213}]}");

FoPost client = TestSupport.client(transport);

List<AdBusinessCenter> centers = client.ads().tiktokBusinessCenters("c1", "w1");
assertEquals("Brand HQ", centers.get(0).name());
assertTrue(transport.last().url().contains("/v1/ads/tiktok/business-centers"));

List<AdIdentity> identities = client.ads().tiktokIdentities("c1", "7011", "w1");
assertEquals("CUSTOMIZED_USER", identities.get(0).type());

List<SparkPost> posts = client.ads().sparkPosts("c1", "7011", "idt_1", "w1");
assertEquals(48213L, posts.get(0).views());
assertTrue(transport.last().url().contains("identity_id=idt_1"));
}

@Test
void sparkPostIdAndSmartPlusTravelInTheBody() {
FakeTransport transport = new FakeTransport()
.enqueue(201, "{\"data\":{\"id\":\"ad1\",\"workspaceId\":\"w1\",\"kind\":\"ad\",\"name\":\"Spark\","
+ "\"goal\":\"traffic\",\"status\":\"paused\"}}")
.enqueue(201, "{\"data\":{\"id\":\"c1\",\"name\":\"Smart\",\"status\":\"PAUSED\"}}");

FoPost client = TestSupport.client(transport);

client.ads()
.create(CreateAdParams.of(
"w1",
"c1",
"7011",
"idt_1",
"Spark",
"traffic",
AdBudgetParams.daily(2000),
AdTargetingParams.create(List.of("US"), 18, 44, "all"),
"")
.sparkPostId("item_99"));
assertTrue(transport.lastBody().contains("\"sparkPostId\":\"item_99\""));

client.ads()
.createCampaign(CreateAdCampaignParams.of("w1", "c1", "7011", "Smart", "traffic")
.smartPlus(true));
assertTrue(transport.lastBody().contains("\"smartPlus\":true"));
}

@Test
void conversionsReportWhatTheNetworkAccepted() {
FakeTransport transport = new FakeTransport().enqueue(202, "{\"data\":{\"accepted\":2}}");

long accepted = TestSupport.client(transport)
.ads()
.uploadConversions(UploadConversionsParams.of("w1", "c1", "7011", "px_1")
.event("CompletePayment", "2026-09-18T10:04:00Z")
.with("valueMinor", 4999)
.event("CompletePayment", "2026-09-18T11:04:00Z"));

assertEquals(2L, accepted);
assertTrue(transport.lastBody().contains("\"pixelId\":\"px_1\""));
assertTrue(transport.lastBody().contains("\"valueMinor\":4999"));
}

@Test
void commentsPageAndTheThreeWrites() {
FakeTransport transport = new FakeTransport()
.enqueue(200, "{\"data\":{\"comments\":[{\"id\":\"cm1\",\"adId\":\"ad1\",\"text\":\"nice\","
+ "\"authorName\":null,\"authorAvatarUrl\":null,\"createdAt\":null,\"likes\":3,"
+ "\"replyCount\":0,\"hidden\":true,\"parentId\":null}],\"nextCursor\":\"2\"}}")
.enqueue(201, "{\"data\":{\"replyId\":\"cm2\"}}")
.enqueue(200, "{\"message\":\"Comment hidden\"}")
.enqueue(200, "{\"message\":\"Comment deleted\"}");

FoPost client = TestSupport.client(transport);

AdCommentsPage page = client.ads().comments("c1", "ad1", null, "w1");
assertEquals("2", page.nextCursor());
assertTrue(page.comments().get(0).hidden());
assertEquals(3L, page.comments().get(0).likes());

assertEquals("cm2", client.ads().replyToComment("cm1", "w1", "c1", "ad1", "Friday!"));
assertTrue(transport.last().url().endsWith("/v1/ads/comments/cm1/reply"));
assertTrue(transport.lastBody().contains("\"adId\":\"ad1\""));

client.ads().setCommentHidden("cm1", "w1", "c1", "ad1", true);
assertTrue(transport.lastBody().contains("\"hidden\":true"));

client.ads().deleteComment("cm1", "w1", "c1", "ad1");
// The ad travels in the body, because the path already carries the comment.
assertEquals("DELETE", transport.last().method());
assertTrue(transport.lastBody().contains("\"adId\":\"ad1\""));
}
}
Loading