diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 42356e1e3..4df2e4e59 100644 --- a/app/Services/Social/InstagramAnalytics.php +++ b/app/Services/Social/InstagramAnalytics.php @@ -34,6 +34,9 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul }); } + /** + * @return array|array{unsupported: true, reason: string} + */ public function fetchPostMetrics(PostPlatform $postPlatform): array { $account = $postPlatform->socialAccount; @@ -50,39 +53,68 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array $this->accessToken = $account->access_token; - // Per-post metric set differs by content type. Reels/Stories expose - // different fields than feed posts. Pick the right set per type. - $metrics = match ($postPlatform->content_type) { - ContentType::InstagramReel => 'reach,likes,comments,shares,saved,plays', - ContentType::InstagramStory => 'reach,impressions,replies', - default => 'reach,likes,comments,shares,saved,total_interactions', - }; + $metrics = $this->postMetricsFor($postPlatform); $response = $this->socialHttp() ->get("{$this->baseUrl}/{$postPlatform->platform_post_id}/insights", [ - 'metric' => $metrics, + 'metric' => implode(',', array_keys($metrics)), 'access_token' => $this->accessToken, ]); if ($response->failed()) { Log::warning('Instagram post metrics fetch failed', [ + 'content_type' => $postPlatform->content_type?->value, 'body' => $this->redactResponseBody($response->body()), ]); return ['unsupported' => true, 'reason' => 'api_error']; } - $data = data_get($response->json(), 'data', []); - - return collect($data) - ->map(fn (array $item) => [ - 'label' => ucfirst(str_replace('_', ' ', data_get($item, 'name', ''))), + return collect(data_get($response->json(), 'data', [])) + ->filter(fn (array $item): bool => isset($metrics[data_get($item, 'name')])) + ->map(fn (array $item): array => [ + 'label' => __($metrics[data_get($item, 'name')]), 'value' => (int) data_get($item, 'values.0.value', 0), ]) ->values() ->all(); } + /** + * Meta retired `plays` (Reels) and `impressions` (Stories created after + * 2024-07-02) from Instagram media insights. Either name fails the whole + * request, so the card comes back empty. `views` is the replacement on the + * same `/insights` edge for Reels, Stories, and feed. + * + * @return array + */ + private function postMetricsFor(PostPlatform $postPlatform): array + { + return match ($postPlatform->content_type) { + ContentType::InstagramReel => [ + 'reach' => 'analytics.metrics.reach', + 'likes' => 'analytics.metrics.likes', + 'comments' => 'analytics.metrics.comments', + 'shares' => 'analytics.metrics.shares', + 'saved' => 'analytics.metrics.saves', + 'views' => 'analytics.metrics.views', + ], + ContentType::InstagramStory => [ + 'reach' => 'analytics.metrics.reach', + 'views' => 'analytics.metrics.views', + 'replies' => 'analytics.metrics.replies', + ], + default => [ + 'reach' => 'analytics.metrics.reach', + 'likes' => 'analytics.metrics.likes', + 'comments' => 'analytics.metrics.comments', + 'shares' => 'analytics.metrics.shares', + 'saved' => 'analytics.metrics.saves', + 'total_interactions' => 'analytics.metrics.interactions', + ], + }; + } + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { $this->baseUrl = $account->platform->instagramGraphBaseUrl(); diff --git a/tests/Feature/Services/Social/InstagramAnalyticsTest.php b/tests/Feature/Services/Social/InstagramAnalyticsTest.php new file mode 100644 index 000000000..f2da4f247 --- /dev/null +++ b/tests/Feature/Services/Social/InstagramAnalyticsTest.php @@ -0,0 +1,179 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $this->account = SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'ig_123', + ]); + $this->graph = config('trypost.platforms.instagram.graph_api'); +}); + +/** + * @param array $metrics + * @return array + */ +function instagramInsightsResponse(array $metrics): array +{ + return [ + 'data' => array_map(fn (array $metric): array => [ + 'name' => $metric['name'], + 'values' => [['value' => $metric['value']]], + ], $metrics), + ]; +} + +function instagramPostPlatform(ContentType $contentType, ?string $platformPostId = 'media_123'): PostPlatform +{ + return PostPlatform::factory()->create([ + 'post_id' => test()->post->id, + 'social_account_id' => test()->account->id, + 'platform' => Platform::Instagram, + 'content_type' => $contentType, + 'platform_post_id' => $platformPostId, + ]); +} + +test('instagram analytics reads feed post metrics from the media insights edge', function () { + Http::fake([ + "{$this->graph}/media_123/insights*" => Http::response(instagramInsightsResponse([ + ['name' => 'reach', 'value' => 90], + ['name' => 'likes', 'value' => 7], + ['name' => 'comments', 'value' => 2], + ['name' => 'shares', 'value' => 1], + ['name' => 'saved', 'value' => 4], + ['name' => 'total_interactions', 'value' => 14], + ])), + ]); + + $metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramFeed)); + + expect($metrics)->toBe([ + ['label' => __('analytics.metrics.reach'), 'value' => 90], + ['label' => __('analytics.metrics.likes'), 'value' => 7], + ['label' => __('analytics.metrics.comments'), 'value' => 2], + ['label' => __('analytics.metrics.shares'), 'value' => 1], + ['label' => __('analytics.metrics.saves'), 'value' => 4], + ['label' => __('analytics.metrics.interactions'), 'value' => 14], + ]); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/media_123/insights") + && $request['metric'] === 'reach,likes,comments,shares,saved,total_interactions'); +}); + +test('instagram analytics reads reel metrics with views instead of retired plays', function () { + Http::fake([ + "{$this->graph}/media_123/insights*" => Http::response(instagramInsightsResponse([ + ['name' => 'reach', 'value' => 40], + ['name' => 'likes', 'value' => 5], + ['name' => 'comments', 'value' => 1], + ['name' => 'shares', 'value' => 2], + ['name' => 'saved', 'value' => 3], + ['name' => 'views', 'value' => 120], + ])), + ]); + + $metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramReel)); + + expect($metrics)->toBe([ + ['label' => __('analytics.metrics.reach'), 'value' => 40], + ['label' => __('analytics.metrics.likes'), 'value' => 5], + ['label' => __('analytics.metrics.comments'), 'value' => 1], + ['label' => __('analytics.metrics.shares'), 'value' => 2], + ['label' => __('analytics.metrics.saves'), 'value' => 3], + ['label' => __('analytics.metrics.views'), 'value' => 120], + ]); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/media_123/insights") + && $request['metric'] === 'reach,likes,comments,shares,saved,views'); +}); + +test('instagram analytics reads story metrics with views instead of retired impressions', function () { + Http::fake([ + "{$this->graph}/media_123/insights*" => Http::response(instagramInsightsResponse([ + ['name' => 'reach', 'value' => 35], + ['name' => 'views', 'value' => 50], + ['name' => 'replies', 'value' => 2], + ])), + ]); + + $metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramStory)); + + expect($metrics)->toBe([ + ['label' => __('analytics.metrics.reach'), 'value' => 35], + ['label' => __('analytics.metrics.views'), 'value' => 50], + ['label' => __('analytics.metrics.replies'), 'value' => 2], + ]); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/media_123/insights") + && $request['metric'] === 'reach,views,replies'); +}); + +test('instagram analytics does not request the retired plays or impressions metrics', function (ContentType $contentType) { + Http::fake(); + + (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform($contentType)); + + Http::assertNotSent(fn ($request) => str_contains($request['metric'] ?? '', 'plays') + || str_contains($request['metric'] ?? '', 'impressions')); +})->with([ + 'feed' => ContentType::InstagramFeed, + 'reel' => ContentType::InstagramReel, + 'story' => ContentType::InstagramStory, +]); + +test('instagram analytics ignores metrics it did not ask for', function () { + Http::fake([ + "{$this->graph}/media_123/insights*" => Http::response(instagramInsightsResponse([ + ['name' => 'reach', 'value' => 12], + ['name' => 'plays', 'value' => 99], + ['name' => 'likes', 'value' => 1], + ])), + ]); + + $metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramFeed)); + + expect($metrics)->toBe([ + ['label' => __('analytics.metrics.reach'), 'value' => 12], + ['label' => __('analytics.metrics.likes'), 'value' => 1], + ]); +}); + +test('instagram analytics reports an api rejection as unsupported', function () { + Http::fake([ + "{$this->graph}/media_123/insights*" => Http::response([ + 'error' => ['message' => '(#100) plays is not a valid metric', 'code' => 100], + ], 400), + ]); + + $metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramReel)); + + expect($metrics)->toBe(['unsupported' => true, 'reason' => 'api_error']); +}); + +test('instagram analytics reports a missing platform post id as unsupported', function () { + Http::fake(); + + $metrics = (new InstagramAnalytics)->fetchPostMetrics(instagramPostPlatform(ContentType::InstagramFeed, null)); + + expect($metrics)->toBe(['unsupported' => true, 'reason' => 'missing_post_id']); + + Http::assertNothingSent(); +});