From 495273ca8af7b489a67ca59dd96d42bd24afcea5 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:46:08 +0200 Subject: [PATCH] feat(ads): TikTok identities, Spark ads, conversions and ad comments The ads resource gains tiktokBusinessCenters, tiktokIdentities, sparkPosts, uploadConversions and the four ad-comment calls, plus sparkPostId on an ad and smartPlus on a campaign. --- src/Model/AdBusinessCenter.php | 30 +++++++ src/Model/AdComment.php | 45 ++++++++++ src/Model/AdCommentsPage.php | 31 +++++++ src/Model/AdIdentity.php | 33 ++++++++ src/Model/SparkPost.php | 36 ++++++++ src/Resource/AdsResource.php | 149 +++++++++++++++++++++++++++++++++ tests/AdsTikTokTest.php | 94 +++++++++++++++++++++ 7 files changed, 418 insertions(+) create mode 100644 src/Model/AdBusinessCenter.php create mode 100644 src/Model/AdComment.php create mode 100644 src/Model/AdCommentsPage.php create mode 100644 src/Model/AdIdentity.php create mode 100644 src/Model/SparkPost.php create mode 100644 tests/AdsTikTokTest.php diff --git a/src/Model/AdBusinessCenter.php b/src/Model/AdBusinessCenter.php new file mode 100644 index 0000000..9a67d8e --- /dev/null +++ b/src/Model/AdBusinessCenter.php @@ -0,0 +1,30 @@ + $comments + */ + private function __construct( + array $raw, + public readonly array $comments, + public readonly ?string $nextCursor, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + AdComment::listFrom(self::seq($data, 'comments')), + self::str($data, 'next_cursor'), + ); + } +} diff --git a/src/Model/AdIdentity.php b/src/Model/AdIdentity.php new file mode 100644 index 0000000..a77601c --- /dev/null +++ b/src/Model/AdIdentity.php @@ -0,0 +1,33 @@ + $workspaceId, @@ -175,6 +180,7 @@ public function create( 'budget' => $budget, 'targeting' => $targeting, 'text' => $text, + 'sparkPostId' => $sparkPostId, 'headline' => $headline, 'destinationUrl' => $destinationUrl, 'mediaUrl' => $mediaUrl, @@ -312,6 +318,147 @@ public function leads( ]))); } + /** + * TikTok's Business Centers, the one network-named read in this resource. + * + * @return array + */ + public function tiktokBusinessCenters(string $connectionId, ?string $workspaceId = null): array + { + return AdBusinessCenter::listFrom(self::unwrap($this->http->get( + '/ads/tiktok/business-centers', + self::scope($workspaceId, $connectionId), + ))); + } + + /** + * The accounts an ad can run as; an identity id is a $pageId. + * + * @return array + */ + public function tiktokIdentities( + string $connectionId, + string $adAccountId, + ?string $workspaceId = null, + ): array { + return AdIdentity::listFrom(self::unwrap($this->http->get('/ads/tiktok/identities', [ + 'workspace_id' => $workspaceId, + 'connection_id' => $connectionId, + 'ad_account_id' => $adAccountId, + ]))); + } + + /** + * Posts already live under an identity, each a candidate Spark ad. + * + * @return array + */ + public function sparkPosts( + string $connectionId, + string $adAccountId, + string $identityId, + ?string $workspaceId = null, + ): array { + return SparkPost::listFrom(self::unwrap($this->http->get('/ads/spark-posts', [ + 'workspace_id' => $workspaceId, + 'connection_id' => $connectionId, + 'ad_account_id' => $adAccountId, + 'identity_id' => $identityId, + ]))); + } + + /** + * Offline conversions. Identifiers are hashed before they leave FoPost. + * + * @param array> $events up to 1000 per call + * + * @return int the number the network accepted + */ + public function uploadConversions( + string $workspaceId, + string $connectionId, + string $adAccountId, + string $pixelId, + array $events, + ): int { + $result = self::unwrap($this->http->post('/ads/conversions', [ + 'workspaceId' => $workspaceId, + 'connectionId' => $connectionId, + 'adAccountId' => $adAccountId, + 'pixelId' => $pixelId, + 'events' => $events, + ])); + + return is_array($result) && isset($result['accepted']) ? (int) $result['accepted'] : 0; + } + + /** One page of an ad's comments; pass `nextCursor` back as $after. */ + public function comments( + string $connectionId, + string $adId, + ?string $after = null, + ?string $workspaceId = null, + ): AdCommentsPage { + return AdCommentsPage::fromArray(self::unwrap($this->http->get('/ads/comments', [ + 'workspace_id' => $workspaceId, + 'connection_id' => $connectionId, + 'ad_id' => $adId, + 'after' => $after, + ]))); + } + + /** + * Needs the `publish` scope as well as `ads`. + * + * @return string the reply's id on the network + */ + public function replyToComment( + string $commentId, + string $workspaceId, + string $connectionId, + string $adId, + string $text, + ): string { + $result = self::unwrap($this->http->post("/ads/comments/{$commentId}/reply", [ + 'workspaceId' => $workspaceId, + 'connectionId' => $connectionId, + 'adId' => $adId, + 'text' => $text, + ])); + + return is_array($result) && isset($result['replyId']) ? (string) $result['replyId'] : ''; + } + + /** Needs the `publish` scope as well as `ads`. */ + public function setCommentHidden( + string $commentId, + string $workspaceId, + string $connectionId, + string $adId, + bool $hidden, + ): void { + $this->http->post("/ads/comments/{$commentId}/hide", [ + 'workspaceId' => $workspaceId, + 'connectionId' => $connectionId, + 'adId' => $adId, + 'hidden' => $hidden, + ]); + } + + /** One already gone on the network succeeds. Needs `publish` as well as `ads`. */ + public function deleteComment( + string $commentId, + string $workspaceId, + string $connectionId, + string $adId, + ): void { + $this->http->request('DELETE', "/ads/comments/{$commentId}", [ + 'workspaceId' => $workspaceId, + 'connectionId' => $connectionId, + 'adId' => $adId, + ]); + } + /** Every campaign on the ad account with its ad sets and ads, read live. */ public function accountTree(string $adAccountId, string $connectionId, ?string $workspaceId = null): AdAccountTree { @@ -332,6 +479,7 @@ public function createCampaign( string $name, string $goal, ?bool $paused = null, + ?bool $smartPlus = null, ): AdCampaign { $body = self::compact([ 'workspaceId' => $workspaceId, @@ -340,6 +488,7 @@ public function createCampaign( 'name' => $name, 'goal' => $goal, 'paused' => $paused, + 'smartPlus' => $smartPlus, ]); return AdCampaign::fromArray(self::unwrap($this->http->post('/ads/campaigns', $body))); diff --git a/tests/AdsTikTokTest.php b/tests/AdsTikTokTest.php new file mode 100644 index 0000000..8885b37 --- /dev/null +++ b/tests/AdsTikTokTest.php @@ -0,0 +1,94 @@ +transport->push(200, ['data' => [['id' => 'bc1', 'name' => 'Brand HQ', 'role' => 'ADMIN']]]); + $centers = $this->client()->ads()->tiktokBusinessCenters('conn_1', 'w_1'); + $this->assertSame('Brand HQ', $centers[0]->name); + $this->assertSame( + 'https://api.fopost.com/v1/ads/tiktok/business-centers?workspace_id=w_1&connection_id=conn_1', + $this->transport->last()['url'], + ); + + $this->transport->push(200, ['data' => [ + ['id' => 'idt_1', 'type' => 'CUSTOMIZED_USER', 'name' => 'Your Brand'], + ]]); + $identities = $this->client()->ads()->tiktokIdentities('conn_1', '7011', 'w_1'); + $this->assertSame('CUSTOMIZED_USER', $identities[0]->type); + + $this->transport->push(200, ['data' => [ + ['id' => 'item_99', 'identityId' => 'idt_1', 'views' => 48213], + ]]); + $posts = $this->client()->ads()->sparkPosts('conn_1', '7011', 'idt_1', 'w_1'); + $this->assertSame(48213, $posts[0]->views); + $this->assertStringContainsString('identity_id=idt_1', $this->transport->last()['url']); + } + + public function testSparkPostIdAndSmartPlusTravelInTheBody(): void + { + $this->transport->push(201, ['data' => ['id' => 'ad_1', 'workspaceId' => 'w_1', 'kind' => 'ad', 'name' => 'Spark', 'goal' => 'traffic', 'status' => 'paused']]); + $this->client()->ads()->create( + 'w_1', + 'conn_1', + '7011', + 'idt_1', + 'Spark', + 'traffic', + ['minor' => 2000, 'type' => 'daily'], + ['countries' => ['US'], 'ageMin' => 18, 'ageMax' => 44, 'gender' => 'all'], + '', + sparkPostId: 'item_99', + ); + $this->assertSame('item_99', $this->transport->lastJson()['sparkPostId']); + + $this->transport->push(201, ['data' => ['id' => 'c1', 'name' => 'Smart', 'status' => 'PAUSED']]); + $this->client()->ads()->createCampaign('w_1', 'conn_1', '7011', 'Smart', 'traffic', smartPlus: true); + $this->assertTrue($this->transport->lastJson()['smartPlus']); + } + + public function testConversionsReportWhatTheNetworkAccepted(): void + { + $this->transport->push(202, ['data' => ['accepted' => 2]]); + + $accepted = $this->client()->ads()->uploadConversions('w_1', 'conn_1', '7011', 'px_1', [ + ['eventName' => 'CompletePayment', 'occurredAt' => '2026-09-18T10:04:00Z'], + ['eventName' => 'CompletePayment', 'occurredAt' => '2026-09-18T11:04:00Z'], + ]); + + $this->assertSame(2, $accepted); + $this->assertSame('https://api.fopost.com/v1/ads/conversions', $this->transport->last()['url']); + $this->assertSame('px_1', $this->transport->lastJson()['pixelId']); + } + + public function testCommentsPageAndTheThreeWrites(): void + { + $this->transport->push(200, ['data' => [ + 'comments' => [['id' => 'cm_1', 'text' => 'nice', 'likes' => 3, 'hidden' => true]], + 'nextCursor' => '2', + ]]); + $page = $this->client()->ads()->comments('conn_1', 'ad_1', null, 'w_1'); + $this->assertSame('2', $page->nextCursor); + $this->assertTrue($page->comments[0]->hidden); + $this->assertSame(3, $page->comments[0]->likes); + + $this->transport->push(201, ['data' => ['replyId' => 'cm_2']]); + $this->assertSame('cm_2', $this->client()->ads()->replyToComment('cm_1', 'w_1', 'conn_1', 'ad_1', 'Friday!')); + $this->assertSame('ad_1', $this->transport->lastJson()['adId']); + + $this->transport->push(200, ['message' => 'Comment hidden']); + $this->client()->ads()->setCommentHidden('cm_1', 'w_1', 'conn_1', 'ad_1', true); + $this->assertTrue($this->transport->lastJson()['hidden']); + + $this->transport->push(200, ['message' => 'Comment deleted']); + $this->client()->ads()->deleteComment('cm_1', 'w_1', 'conn_1', 'ad_1'); + // The ad travels in the body, because the path already carries the comment. + $this->assertSame('DELETE', $this->transport->last()['method']); + $this->assertSame('ad_1', $this->transport->lastJson()['adId']); + } +}