viewtrender/php-youtube-testkit-laravel

Laravel integration for php-youtube-testkit

Maintainers

Package info

github.com/viewtrender/php-youtube-testkit-laravel

pkg:composer/viewtrender/php-youtube-testkit-laravel

Statistics

Installs: 23

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.7.0 2026-03-19 15:56 UTC

This package is auto-updated.

Last update: 2026-03-19 16:00:48 UTC


README

Laravel integration for mocking YouTube Data, Analytics, and Reporting APIs in tests.

Tests Latest Version PHP 8.3+ License: MIT

Overview

Laravel service provider, facades, and automatic container swaps for viewtrender/php-youtube-testkit-core. Supports three YouTube APIs:

  • YouTube Data API — videos, channels, playlists, search, comments
  • YouTube Analytics API — on-demand metrics queries
  • YouTube Reporting API — bulk data exports and scheduled jobs

When you call fake() on any API facade, the service provider replaces the Google Service container binding with a fake instance — controllers that type-hint the service receive the fake automatically.

Installation

composer require --dev viewtrender/php-youtube-testkit-laravel

The service provider is auto-discovered. To publish the config file:

php artisan vendor:publish --tag=youtube-testkit-config

Requirements

  • PHP 8.3+
  • Laravel 10, 11, or 12
  • google/apiclient ^2.15

Setup

Register the real Google services in your AppServiceProvider:

use Google\Client as GoogleClient;
use Google\Service\YouTube;
use Google\Service\YouTubeAnalytics;
use Google\Service\YouTubeReporting;

public function register(): void
{
    // Shared Google Client (configure once)
    $this->app->singleton(GoogleClient::class, function () {
        $client = new GoogleClient();
        $client->setApplicationName(config('services.youtube.application_name', 'My App'));
        $client->setDeveloperKey(config('services.youtube.api_key'));
        // For Analytics/Reporting, also set OAuth credentials
        return $client;
    });

    // YouTube Data API
    $this->app->singleton(YouTube::class, function ($app) {
        return new YouTube($app->make(GoogleClient::class));
    });

    // YouTube Analytics API
    $this->app->singleton(YouTubeAnalytics::class, function ($app) {
        return new YouTubeAnalytics($app->make(GoogleClient::class));
    });

    // YouTube Reporting API
    $this->app->singleton(YouTubeReporting::class, function ($app) {
        return new YouTubeReporting($app->make(GoogleClient::class));
    });
}

Controllers can then type-hint any service:

use Google\Service\YouTube;
use Google\Service\YouTubeAnalytics;

class DashboardController extends Controller
{
    public function index(YouTube $youtube, YouTubeAnalytics $analytics)
    {
        $videos = $youtube->videos->listVideos('snippet', ['chart' => 'mostPopular']);
        $stats = $analytics->reports->query([...]);
        
        return view('dashboard', compact('videos', 'stats'));
    }
}

Testing with Pest

Base Setup

Create a base test file or add to tests/Pest.php:

use Viewtrender\Youtube\YoutubeAnalyticsApi;
use Viewtrender\Youtube\YoutubeDataApi;
use Viewtrender\Youtube\YoutubeReportingApi;

afterEach(function () {
    YoutubeDataApi::reset();
    YoutubeAnalyticsApi::reset();
    YoutubeReportingApi::reset();
});

Import Factories

use Viewtrender\Youtube\Factories\YoutubeVideo;
use Viewtrender\Youtube\Factories\YoutubeChannel;
use Viewtrender\Youtube\Factories\YoutubePlaylist;
use Viewtrender\Youtube\Factories\YoutubePlaylistItems;
use Viewtrender\Youtube\Factories\YoutubeSearchResult;
use Viewtrender\Youtube\Factories\YoutubeSubscriptions;
use Viewtrender\Youtube\Factories\YoutubeComments;
use Viewtrender\Youtube\Factories\YoutubeCommentThreads;
use Viewtrender\Youtube\Factories\YoutubeActivities;
use Viewtrender\Youtube\Factories\YoutubeCaptions;
use Viewtrender\Youtube\Factories\YoutubeChannelSections;
use Viewtrender\Youtube\Factories\YoutubeMembers;
use Viewtrender\Youtube\Factories\YoutubeMembershipsLevels;
use Viewtrender\Youtube\Factories\YoutubeI18nLanguages;
use Viewtrender\Youtube\Factories\YoutubeI18nRegions;
use Viewtrender\Youtube\Factories\YoutubeVideoCategories;
use Viewtrender\Youtube\Factories\YoutubeVideoAbuseReportReasons;
use Viewtrender\Youtube\Factories\YoutubeGuideCategories;
use Viewtrender\Youtube\Factories\YoutubeThumbnails;
use Viewtrender\Youtube\Factories\YoutubeWatermarks;
use Viewtrender\Youtube\YoutubeDataApi;

Factory Examples — Pest

Videos

it('fetches video details', function () {
    YoutubeDataApi::fake([
        YoutubeVideo::listWithVideos([
            [
                'id' => 'dQw4w9WgXcQ',
                'snippet' => [
                    'title' => 'Never Gonna Give You Up',
                    'description' => 'Official music video',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'channelTitle' => 'Rick Astley',
                    'publishedAt' => '2009-10-25T06:57:33Z',
                    'thumbnails' => [
                        'default' => ['url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg'],
                        'high' => ['url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg'],
                    ],
                ],
                'statistics' => [
                    'viewCount' => '1500000000',
                    'likeCount' => '15000000',
                    'commentCount' => '3000000',
                ],
                'contentDetails' => [
                    'duration' => 'PT3M33S',
                    'dimension' => '2d',
                    'definition' => 'hd',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/videos/dQw4w9WgXcQ');

    $response->assertOk()
        ->assertJsonPath('title', 'Never Gonna Give You Up')
        ->assertJsonPath('statistics.viewCount', '1500000000');
    
    YoutubeDataApi::assertListedVideos();
    YoutubeDataApi::assertSentCount(1);
});

it('handles video not found', function () {
    YoutubeDataApi::fake([
        YoutubeVideo::empty(),
    ]);

    $response = $this->getJson('/api/videos/nonexistent');

    $response->assertNotFound();
});

Channels

it('fetches channel details', function () {
    YoutubeDataApi::fake([
        YoutubeChannel::listWithChannels([
            [
                'id' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                'snippet' => [
                    'title' => 'Rick Astley',
                    'description' => 'Official channel',
                    'customUrl' => '@RickAstleyYT',
                    'publishedAt' => '2006-09-19T01:03:26Z',
                    'thumbnails' => [
                        'default' => ['url' => 'https://yt3.ggpht.com/example/default.jpg'],
                    ],
                    'country' => 'GB',
                ],
                'statistics' => [
                    'viewCount' => '2000000000',
                    'subscriberCount' => '4500000',
                    'videoCount' => '150',
                ],
                'brandingSettings' => [
                    'channel' => [
                        'title' => 'Rick Astley',
                        'keywords' => 'music pop 80s',
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw');

    $response->assertOk()
        ->assertJsonPath('snippet.title', 'Rick Astley')
        ->assertJsonPath('statistics.subscriberCount', '4500000');
    
    YoutubeDataApi::assertListedChannels();
});

Playlists

it('fetches user playlists', function () {
    YoutubeDataApi::fake([
        YoutubePlaylist::listWithPlaylists([
            [
                'id' => 'PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
                'snippet' => [
                    'title' => 'My Favorite Videos',
                    'description' => 'A collection of favorites',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'publishedAt' => '2020-01-15T12:00:00Z',
                    'thumbnails' => [
                        'default' => ['url' => 'https://i.ytimg.com/vi/example/default.jpg'],
                    ],
                ],
                'contentDetails' => [
                    'itemCount' => 25,
                ],
                'status' => [
                    'privacyStatus' => 'public',
                ],
            ],
            [
                'id' => 'PLrAXtmErZgOeiKm4sgNOknGvNjby9efde',
                'snippet' => [
                    'title' => 'Watch Later',
                    'description' => 'Videos to watch',
                ],
                'contentDetails' => [
                    'itemCount' => 100,
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/playlists');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.snippet.title', 'My Favorite Videos');
    
    YoutubeDataApi::assertListedPlaylists();
});

Playlist Items

it('fetches videos in a playlist', function () {
    YoutubeDataApi::fake([
        YoutubePlaylistItems::listWithPlaylistItems([
            [
                'id' => 'UExmWEZ...',
                'snippet' => [
                    'title' => 'First Video',
                    'description' => 'Description of first video',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'playlistId' => 'PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf',
                    'position' => 0,
                    'resourceId' => [
                        'kind' => 'youtube#video',
                        'videoId' => 'dQw4w9WgXcQ',
                    ],
                    'thumbnails' => [
                        'default' => ['url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg'],
                    ],
                ],
                'contentDetails' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'videoPublishedAt' => '2009-10-25T06:57:33Z',
                ],
            ],
            [
                'snippet' => [
                    'title' => 'Second Video',
                    'position' => 1,
                    'resourceId' => [
                        'kind' => 'youtube#video',
                        'videoId' => 'abc123xyz',
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/playlists/PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf/items');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.snippet.position', 0);
});

Search Results

it('searches for videos', function () {
    YoutubeDataApi::fake([
        YoutubeSearchResult::listWithResults([
            [
                'id' => [
                    'kind' => 'youtube#video',
                    'videoId' => 'dQw4w9WgXcQ',
                ],
                'snippet' => [
                    'title' => 'Never Gonna Give You Up',
                    'description' => 'Official music video',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'channelTitle' => 'Rick Astley',
                    'publishedAt' => '2009-10-25T06:57:33Z',
                    'liveBroadcastContent' => 'none',
                ],
            ],
            [
                'id' => [
                    'kind' => 'youtube#channel',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                ],
                'snippet' => [
                    'title' => 'Rick Astley',
                    'description' => 'Official channel',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/search?q=rick+astley');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.id.videoId', 'dQw4w9WgXcQ');
    
    YoutubeDataApi::assertSearched();
});

Subscriptions

it('fetches channel subscriptions', function () {
    YoutubeDataApi::fake([
        YoutubeSubscriptions::listWithSubscriptions([
            [
                'id' => 'subscription123',
                'snippet' => [
                    'title' => 'PewDiePie',
                    'description' => 'Gaming and entertainment',
                    'channelId' => 'UC-lHJZR3Gqxm24_Vd_AJ5Yw',
                    'resourceId' => [
                        'kind' => 'youtube#channel',
                        'channelId' => 'UC-lHJZR3Gqxm24_Vd_AJ5Yw',
                    ],
                    'thumbnails' => [
                        'default' => ['url' => 'https://yt3.ggpht.com/pewdiepie/default.jpg'],
                    ],
                ],
                'contentDetails' => [
                    'totalItemCount' => 4500,
                    'newItemCount' => 3,
                ],
            ],
            [
                'snippet' => [
                    'title' => 'MrBeast',
                    'resourceId' => [
                        'kind' => 'youtube#channel',
                        'channelId' => 'UCX6OQ3DkcsbYNE6H8uQQuVA',
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/subscriptions');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.snippet.title', 'PewDiePie');
});

Comments

it('fetches video comments', function () {
    YoutubeDataApi::fake([
        YoutubeComments::listWithComments([
            [
                'id' => 'comment123',
                'snippet' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'textDisplay' => 'This song is a masterpiece!',
                    'textOriginal' => 'This song is a masterpiece!',
                    'authorDisplayName' => 'MusicFan123',
                    'authorChannelId' => ['value' => 'UCxxx'],
                    'authorProfileImageUrl' => 'https://yt3.ggpht.com/user/default.jpg',
                    'likeCount' => 1500,
                    'publishedAt' => '2023-01-15T10:30:00Z',
                    'updatedAt' => '2023-01-15T10:30:00Z',
                ],
            ],
            [
                'snippet' => [
                    'textDisplay' => 'Classic!',
                    'authorDisplayName' => 'RetroLover',
                    'likeCount' => 500,
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/videos/dQw4w9WgXcQ/comments');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.textDisplay', 'This song is a masterpiece!');
});

Comment Threads

it('fetches comment threads with replies', function () {
    YoutubeDataApi::fake([
        YoutubeCommentThreads::listWithCommentThreads([
            [
                'id' => 'thread123',
                'snippet' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'topLevelComment' => [
                        'id' => 'comment123',
                        'snippet' => [
                            'textDisplay' => 'Best song ever!',
                            'authorDisplayName' => 'TopCommenter',
                            'likeCount' => 5000,
                        ],
                    ],
                    'canReply' => true,
                    'totalReplyCount' => 50,
                    'isPublic' => true,
                ],
                'replies' => [
                    'comments' => [
                        [
                            'snippet' => [
                                'textDisplay' => 'I agree!',
                                'authorDisplayName' => 'Replier1',
                            ],
                        ],
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/videos/dQw4w9WgXcQ/comment-threads');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.totalReplyCount', 50);
});

Activities

it('fetches channel activity feed', function () {
    YoutubeDataApi::fake([
        YoutubeActivities::listWithActivities([
            [
                'id' => 'activity123',
                'snippet' => [
                    'title' => 'Uploaded: New Music Video',
                    'description' => 'Check out my new video',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'channelTitle' => 'Rick Astley',
                    'type' => 'upload',
                    'publishedAt' => '2024-01-15T12:00:00Z',
                    'thumbnails' => [
                        'default' => ['url' => 'https://i.ytimg.com/vi/newvideo/default.jpg'],
                    ],
                ],
                'contentDetails' => [
                    'upload' => [
                        'videoId' => 'newvideo123',
                    ],
                ],
            ],
            [
                'snippet' => [
                    'title' => 'Liked: Amazing Cover',
                    'type' => 'like',
                ],
                'contentDetails' => [
                    'like' => [
                        'resourceId' => [
                            'kind' => 'youtube#video',
                            'videoId' => 'cover123',
                        ],
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw/activities');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.type', 'upload');
});

Captions

it('fetches video captions', function () {
    YoutubeDataApi::fake([
        YoutubeCaptions::listWithCaptions([
            [
                'id' => 'caption123',
                'snippet' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'language' => 'en',
                    'name' => 'English',
                    'audioTrackType' => 'primary',
                    'trackKind' => 'standard',
                    'isDraft' => false,
                    'isAutoSynced' => false,
                    'isCC' => false,
                    'status' => 'serving',
                ],
            ],
            [
                'snippet' => [
                    'videoId' => 'dQw4w9WgXcQ',
                    'language' => 'es',
                    'name' => 'Spanish',
                    'trackKind' => 'standard',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/videos/dQw4w9WgXcQ/captions');

    $response->assertOk()
        ->assertJsonCount(2, 'items')
        ->assertJsonPath('items.0.snippet.language', 'en');
});

Channel Sections

it('fetches channel sections', function () {
    YoutubeDataApi::fake([
        YoutubeChannelSections::listWithChannelSections([
            [
                'id' => 'section123',
                'snippet' => [
                    'type' => 'singlePlaylist',
                    'channelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'title' => 'Popular Uploads',
                    'position' => 0,
                ],
                'contentDetails' => [
                    'playlists' => ['PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'],
                ],
            ],
            [
                'snippet' => [
                    'type' => 'recentActivity',
                    'title' => 'Recent Activity',
                    'position' => 1,
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw/sections');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.type', 'singlePlaylist');
});

Members (OAuth Required)

it('fetches channel members', function () {
    YoutubeDataApi::fake([
        YoutubeMembers::listWithMembers([
            [
                'id' => 'member123',
                'snippet' => [
                    'creatorChannelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'memberDetails' => [
                        'channelId' => 'UCfan123',
                        'channelUrl' => 'https://youtube.com/channel/UCfan123',
                        'displayName' => 'SuperFan',
                        'profileImageUrl' => 'https://yt3.ggpht.com/fan/default.jpg',
                    ],
                    'membershipsDetails' => [
                        'highestAccessibleLevel' => 'level1',
                        'highestAccessibleLevelDisplayName' => 'Bronze Member',
                        'memberSince' => '2023-06-01T00:00:00Z',
                        'memberTotalDurationMonths' => 8,
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channel/members');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.memberDetails.displayName', 'SuperFan');
});

Membership Levels (OAuth Required)

it('fetches membership levels', function () {
    YoutubeDataApi::fake([
        YoutubeMembershipsLevels::listWithLevels([
            [
                'id' => 'level1',
                'snippet' => [
                    'creatorChannelId' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'levelDetails' => [
                        'displayName' => 'Bronze Member',
                    ],
                ],
            ],
            [
                'id' => 'level2',
                'snippet' => [
                    'levelDetails' => [
                        'displayName' => 'Silver Member',
                    ],
                ],
            ],
            [
                'id' => 'level3',
                'snippet' => [
                    'levelDetails' => [
                        'displayName' => 'Gold Member',
                    ],
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channel/membership-levels');

    $response->assertOk()
        ->assertJsonCount(3, 'items');
});

I18n Languages

it('fetches supported languages', function () {
    YoutubeDataApi::fake([
        YoutubeI18nLanguages::listWithLanguages([
            [
                'id' => 'en',
                'snippet' => [
                    'hl' => 'en',
                    'name' => 'English',
                ],
            ],
            [
                'id' => 'es',
                'snippet' => [
                    'hl' => 'es',
                    'name' => 'Spanish',
                ],
            ],
            [
                'id' => 'ja',
                'snippet' => [
                    'hl' => 'ja',
                    'name' => 'Japanese',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/languages');

    $response->assertOk()
        ->assertJsonCount(3, 'items')
        ->assertJsonPath('items.0.snippet.name', 'English');
});

I18n Regions

it('fetches supported regions', function () {
    YoutubeDataApi::fake([
        YoutubeI18nRegions::listWithRegions([
            [
                'id' => 'US',
                'snippet' => [
                    'gl' => 'US',
                    'name' => 'United States',
                ],
            ],
            [
                'id' => 'GB',
                'snippet' => [
                    'gl' => 'GB',
                    'name' => 'United Kingdom',
                ],
            ],
            [
                'id' => 'JP',
                'snippet' => [
                    'gl' => 'JP',
                    'name' => 'Japan',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/regions');

    $response->assertOk()
        ->assertJsonCount(3, 'items')
        ->assertJsonPath('items.0.snippet.name', 'United States');
});

Video Categories

it('fetches video categories', function () {
    YoutubeDataApi::fake([
        YoutubeVideoCategories::listWithVideoCategories([
            [
                'id' => '10',
                'snippet' => [
                    'channelId' => 'UCBR8-60-B28hp2BmDPdntcQ',
                    'title' => 'Music',
                    'assignable' => true,
                ],
            ],
            [
                'id' => '20',
                'snippet' => [
                    'title' => 'Gaming',
                    'assignable' => true,
                ],
            ],
            [
                'id' => '22',
                'snippet' => [
                    'title' => 'People & Blogs',
                    'assignable' => true,
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/video-categories?regionCode=US');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.title', 'Music');
});

Video Abuse Report Reasons

it('fetches abuse report reasons', function () {
    YoutubeDataApi::fake([
        YoutubeVideoAbuseReportReasons::listWithReasons([
            [
                'id' => 'S',
                'snippet' => [
                    'label' => 'Spam or misleading',
                    'secondaryReasons' => [
                        ['id' => 'S.1', 'label' => 'Mass advertising'],
                        ['id' => 'S.2', 'label' => 'Misleading thumbnail'],
                    ],
                ],
            ],
            [
                'id' => 'V',
                'snippet' => [
                    'label' => 'Violent or repulsive content',
                ],
            ],
        ]),
    ]);

    $response = $this->getJson('/api/abuse-report-reasons');

    $response->assertOk()
        ->assertJsonPath('items.0.snippet.label', 'Spam or misleading');
});

Thumbnails (Write-Only)

it('uploads a video thumbnail', function () {
    YoutubeDataApi::fake([
        YoutubeThumbnails::setWithThumbnail([
            'default' => [
                'url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg',
                'width' => 120,
                'height' => 90,
            ],
            'medium' => [
                'url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg',
                'width' => 320,
                'height' => 180,
            ],
            'high' => [
                'url' => 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
                'width' => 480,
                'height' => 360,
            ],
        ]),
    ]);

    $response = $this->postJson('/api/videos/dQw4w9WgXcQ/thumbnail', [
        'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg'),
    ]);

    $response->assertOk()
        ->assertJsonPath('items.0.default.url', 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg');
});

Watermarks (Write-Only)

it('sets channel watermark', function () {
    YoutubeDataApi::fake([
        YoutubeWatermarks::setWithWatermark([
            'timing' => [
                'type' => 'offsetFromStart',
                'offsetMs' => 15000,
                'durationMs' => 0,
            ],
            'position' => [
                'type' => 'corner',
                'cornerPosition' => 'topRight',
            ],
            'imageUrl' => 'https://example.com/watermark.png',
            'imageBytes' => 'base64data...',
        ]),
    ]);

    $response = $this->postJson('/api/channel/watermark', [
        'image' => UploadedFile::fake()->image('watermark.png'),
    ]);

    $response->assertOk();
});

Pagination

Two factories support multi-page responses via the HasPagination trait: YoutubePlaylistItems and YoutubeActivities. These are the Data API endpoints that return nextPageToken for iterating through large result sets.

Both expose two static methods that return array<FakeResponse> — spread them into fake([]) to queue all pages at once:

Method Purpose
paginated(pages, perPage) Auto-generate items with sensible defaults
pages(array) Explicit control over each page's items

Each factory also provides a named constructor for building individual items:

  • YoutubePlaylistItems::playlistItem(array $overrides = [])
  • YoutubeActivities::activity(array $overrides = [])

paginated() — auto-generated items

Generate multiple pages of fake items with a single call. Each page includes a nextPageToken except the last, and pageInfo reflects the total count.

it('syncs all playlist items across multiple pages', function () {
    YoutubeDataApi::fake([
        ...YoutubePlaylistItems::paginated(pages: 3, perPage: 5),
    ]);

    // Your service will make 3 requests, each returning 5 items.
    // The first two responses include nextPageToken; the last does not.
    $response = $this->postJson('/api/playlists/PLrAXtmErZgOe/sync');

    $response->assertOk();
    YoutubeDataApi::assertSentCount(3);
});

Manual pagination loop

If your code paginates through results directly (e.g., in a job or service class), you can test the full loop:

it('collects all items across pages', function () {
    YoutubeDataApi::fake([
        ...YoutubePlaylistItems::paginated(pages: 3, perPage: 5),
    ]);

    $youtube = YoutubeDataApi::youtube();
    $pageToken = null;
    $allItems = [];

    do {
        $response = $youtube->playlistItems->listPlaylistItems(
            'snippet',
            ['playlistId' => 'PLxxx', 'pageToken' => $pageToken]
        );
        $allItems = array_merge($allItems, $response->getItems());
        $pageToken = $response->getNextPageToken();
    } while ($pageToken !== null);

    expect($allItems)->toHaveCount(15);
});

pages() — explicit items per page

For full control over each page's contents, pass an array of pages, where each page is an array of item overrides. Use the named constructors (::playlistItem(), ::activity()) or pass raw override arrays.

it('handles paginated activity feed with specific items', function () {
    YoutubeDataApi::fake([
        ...YoutubeActivities::pages([
            // Page 1 — has nextPageToken
            [
                YoutubeActivities::activity([
                    'snippet' => ['title' => 'Uploaded: First Video', 'type' => 'upload'],
                    'contentDetails' => ['upload' => ['videoId' => 'vid1']],
                ]),
            ],
            // Page 2 — last page, no nextPageToken
            [
                YoutubeActivities::activity([
                    'snippet' => ['title' => 'Liked: Some Video', 'type' => 'like'],
                ]),
            ],
        ]),
    ]);

    $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw/activities/all');

    $response->assertOk();
    YoutubeDataApi::assertSentCount(2);
});

You can also pass raw arrays to pages() — they'll be merged with fixture defaults:

YoutubeDataApi::fake([
    ...YoutubePlaylistItems::pages([
        // Page 1
        [
            ['snippet' => ['title' => 'First Video', 'resourceId' => ['videoId' => 'vid1']]],
            ['snippet' => ['title' => 'Second Video', 'resourceId' => ['videoId' => 'vid2']]],
        ],
        // Page 2
        [
            ['snippet' => ['title' => 'Third Video', 'resourceId' => ['videoId' => 'vid3']]],
        ],
    ]),
]);

Single page (no nextPageToken)

Passing one sub-array to pages() produces a single response with no nextPageToken — useful when you want explicit item control without multi-page behavior:

YoutubeDataApi::fake([
    ...YoutubePlaylistItems::pages([
        [YoutubePlaylistItems::playlistItem(['snippet' => ['title' => 'Only Video']])],
    ]),
]);

Testing pagination in Laravel jobs

A common pattern is testing a Laravel job that syncs all items from a paginated endpoint:

it('syncs video library across paginated playlist items', function () {
    // Seed the channel
    $channel = Channel::factory()->create(['uploads_playlist_id' => 'UUxxx']);

    // Queue 2 pages of playlist items
    YoutubeDataApi::fake([
        ...YoutubePlaylistItems::paginated(pages: 2, perPage: 50),
    ]);

    // Dispatch the job
    SyncVideoLibraryJob::dispatchSync($channel);

    // Verify all 100 items were persisted
    expect($channel->videos()->count())->toBe(100);
    YoutubeDataApi::assertSentCount(2);
});

Testing with PHPUnit

Base Test Case

<?php

namespace Tests;

use Orchestra\Testbench\TestCase as BaseTestCase;
use Viewtrender\Youtube\YoutubeAnalyticsApi;
use Viewtrender\Youtube\YoutubeDataApi;
use Viewtrender\Youtube\YoutubeReportingApi;

abstract class TestCase extends BaseTestCase
{
    protected function tearDown(): void
    {
        YoutubeDataApi::reset();
        YoutubeAnalyticsApi::reset();
        YoutubeReportingApi::reset();
        parent::tearDown();
    }
    
    protected function getPackageProviders($app): array
    {
        return [
            \Viewtrender\Youtube\Laravel\YoutubeDataApiServiceProvider::class,
        ];
    }
}

Video Tests

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Viewtrender\Youtube\Factories\YoutubeVideo;
use Viewtrender\Youtube\YoutubeDataApi;

class VideoControllerTest extends TestCase
{
    public function test_show_returns_video_details(): void
    {
        YoutubeDataApi::fake([
            YoutubeVideo::listWithVideos([
                [
                    'id' => 'dQw4w9WgXcQ',
                    'snippet' => [
                        'title' => 'Never Gonna Give You Up',
                        'description' => 'Official music video',
                        'channelTitle' => 'Rick Astley',
                    ],
                    'statistics' => [
                        'viewCount' => '1500000000',
                        'likeCount' => '15000000',
                    ],
                ],
            ]),
        ]);

        $response = $this->getJson('/api/videos/dQw4w9WgXcQ');

        $response->assertOk();
        $response->assertJsonPath('title', 'Never Gonna Give You Up');
        $response->assertJsonPath('statistics.viewCount', '1500000000');
        
        YoutubeDataApi::assertListedVideos();
    }

    public function test_show_returns_404_when_video_not_found(): void
    {
        YoutubeDataApi::fake([
            YoutubeVideo::empty(),
        ]);

        $response = $this->getJson('/api/videos/nonexistent');

        $response->assertNotFound();
    }

    public function test_index_returns_multiple_videos(): void
    {
        YoutubeDataApi::fake([
            YoutubeVideo::listWithVideos([
                ['id' => 'video1', 'snippet' => ['title' => 'First Video']],
                ['id' => 'video2', 'snippet' => ['title' => 'Second Video']],
                ['id' => 'video3', 'snippet' => ['title' => 'Third Video']],
            ]),
        ]);

        $response = $this->getJson('/api/videos?ids=video1,video2,video3');

        $response->assertOk();
        $response->assertJsonCount(3, 'items');
    }
}

Channel Tests

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Viewtrender\Youtube\Factories\YoutubeChannel;
use Viewtrender\Youtube\YoutubeDataApi;

class ChannelControllerTest extends TestCase
{
    public function test_show_returns_channel_details(): void
    {
        YoutubeDataApi::fake([
            YoutubeChannel::listWithChannels([
                [
                    'id' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'snippet' => [
                        'title' => 'Rick Astley',
                        'customUrl' => '@RickAstleyYT',
                        'country' => 'GB',
                    ],
                    'statistics' => [
                        'subscriberCount' => '4500000',
                        'videoCount' => '150',
                    ],
                ],
            ]),
        ]);

        $response = $this->getJson('/api/channels/UCuAXFkgsw1L7xaCfnd5JJOw');

        $response->assertOk();
        $response->assertJsonPath('snippet.title', 'Rick Astley');
        
        YoutubeDataApi::assertListedChannels();
    }
}

Search Tests

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Viewtrender\Youtube\Factories\YoutubeSearchResult;
use Viewtrender\Youtube\YoutubeDataApi;

class SearchControllerTest extends TestCase
{
    public function test_search_returns_results(): void
    {
        YoutubeDataApi::fake([
            YoutubeSearchResult::listWithResults([
                [
                    'id' => ['kind' => 'youtube#video', 'videoId' => 'abc123'],
                    'snippet' => ['title' => 'Search Result 1'],
                ],
                [
                    'id' => ['kind' => 'youtube#video', 'videoId' => 'def456'],
                    'snippet' => ['title' => 'Search Result 2'],
                ],
            ]),
        ]);

        $response = $this->getJson('/api/search?q=test+query');

        $response->assertOk();
        $response->assertJsonCount(2, 'items');
        
        YoutubeDataApi::assertSearched();
    }

    public function test_search_returns_empty_for_no_results(): void
    {
        YoutubeDataApi::fake([
            YoutubeSearchResult::empty(),
        ]);

        $response = $this->getJson('/api/search?q=gibberish+nonsense+xyz');

        $response->assertOk();
        $response->assertJsonCount(0, 'items');
    }
}

Subscription Tests

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Viewtrender\Youtube\Factories\YoutubeSubscriptions;
use Viewtrender\Youtube\YoutubeDataApi;

class SubscriptionControllerTest extends TestCase
{
    public function test_index_returns_user_subscriptions(): void
    {
        YoutubeDataApi::fake([
            YoutubeSubscriptions::listWithSubscriptions([
                [
                    'snippet' => [
                        'title' => 'PewDiePie',
                        'resourceId' => [
                            'channelId' => 'UC-lHJZR3Gqxm24_Vd_AJ5Yw',
                        ],
                    ],
                ],
                [
                    'snippet' => [
                        'title' => 'MrBeast',
                        'resourceId' => [
                            'channelId' => 'UCX6OQ3DkcsbYNE6H8uQQuVA',
                        ],
                    ],
                ],
            ]),
        ]);

        $response = $this->getJson('/api/subscriptions');

        $response->assertOk();
        $response->assertJsonCount(2, 'items');
    }
}

Complete Feature Test Example

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Viewtrender\Youtube\Factories\YoutubeVideo;
use Viewtrender\Youtube\Factories\YoutubeChannel;
use Viewtrender\Youtube\Factories\YoutubePlaylist;
use Viewtrender\Youtube\Factories\YoutubePlaylistItems;
use Viewtrender\Youtube\Responses\ErrorResponse;
use Viewtrender\Youtube\YoutubeDataApi;

class YouTubeIntegrationTest extends TestCase
{
    public function test_dashboard_loads_channel_and_recent_videos(): void
    {
        // Queue multiple responses for a complex page load
        YoutubeDataApi::fake([
            // First call: Get channel details
            YoutubeChannel::listWithChannels([
                [
                    'id' => 'UCuAXFkgsw1L7xaCfnd5JJOw',
                    'snippet' => ['title' => 'My Channel'],
                    'statistics' => ['subscriberCount' => '10000'],
                ],
            ]),
            // Second call: Get recent uploads playlist
            YoutubePlaylist::listWithPlaylists([
                [
                    'id' => 'UUuAXFkgsw1L7xaCfnd5JJOw',
                    'snippet' => ['title' => 'Uploads'],
                ],
            ]),
            // Third call: Get playlist items
            YoutubePlaylistItems::listWithPlaylistItems([
                ['snippet' => ['title' => 'Latest Video', 'position' => 0]],
                ['snippet' => ['title' => 'Previous Video', 'position' => 1]],
            ]),
            // Fourth call: Get video statistics
            YoutubeVideo::listWithVideos([
                ['id' => 'vid1', 'statistics' => ['viewCount' => '5000']],
                ['id' => 'vid2', 'statistics' => ['viewCount' => '3000']],
            ]),
        ]);

        $response = $this->get('/dashboard');

        $response->assertOk();
        $response->assertSee('My Channel');
        $response->assertSee('Latest Video');
        
        YoutubeDataApi::assertSentCount(4);
    }

    public function test_handles_api_quota_exceeded(): void
    {
        YoutubeDataApi::fake([
            ErrorResponse::quotaExceeded('Daily quota exhausted'),
        ]);

        $response = $this->getJson('/api/videos/any');

        $response->assertStatus(500);
        $response->assertJsonPath('error', 'YouTube API quota exceeded');
    }

    public function test_handles_unauthorized_access(): void
    {
        YoutubeDataApi::fake([
            ErrorResponse::unauthorized('Invalid API key'),
        ]);

        $response = $this->getJson('/api/videos/any');

        $response->assertStatus(401);
    }

    public function test_prevents_stray_requests(): void
    {
        $fake = YoutubeDataApi::fake([
            YoutubeVideo::list(),
        ]);
        $fake->preventStrayRequests();

        // First request succeeds
        $this->getJson('/api/videos/abc');

        // Second request throws because no more responses queued
        $this->expectException(\Viewtrender\Youtube\Exceptions\StrayRequestException::class);
        $this->getJson('/api/videos/xyz');
    }
}

YouTube Analytics API

For on-demand metrics queries — dashboards, real-time stats, custom date ranges.

Base Test Case (Analytics)

<?php

namespace Tests;

use Orchestra\Testbench\TestCase as BaseTestCase;
use Viewtrender\Youtube\YoutubeAnalyticsApi;

abstract class AnalyticsTestCase extends BaseTestCase
{
    protected function tearDown(): void
    {
        YoutubeAnalyticsApi::reset();
        parent::tearDown();
    }
    
    protected function getPackageProviders($app): array
    {
        return [
            \Viewtrender\Youtube\Laravel\YoutubeDataApiServiceProvider::class,
        ];
    }
}

Channel Analytics Tests

<?php

namespace Tests\Feature;

use Tests\AnalyticsTestCase;
use Viewtrender\Youtube\Factories\AnalyticsQueryResponse;
use Viewtrender\Youtube\YoutubeAnalyticsApi;

class ChannelAnalyticsTest extends AnalyticsTestCase
{
    public function test_fetches_channel_overview_metrics(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::channelOverview([
                'views' => 500000,
                'estimatedMinutesWatched' => 1500000,
                'averageViewDuration' => 180,
                'subscribersGained' => 1000,
                'subscribersLost' => 50,
            ]),
        ]);

        $response = $this->getJson('/api/analytics/overview?startDate=2024-01-01&endDate=2024-01-31');

        $response->assertOk();
        $response->assertJsonPath('views', 500000);
        $response->assertJsonPath('subscribersGained', 1000);

        YoutubeAnalyticsApi::assertSentCount(1);
    }

    public function test_fetches_daily_metrics(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::dailyMetrics([
                ['day' => '2024-01-01', 'views' => 10000, 'estimatedMinutesWatched' => 30000],
                ['day' => '2024-01-02', 'views' => 12000, 'estimatedMinutesWatched' => 36000],
                ['day' => '2024-01-03', 'views' => 11000, 'estimatedMinutesWatched' => 33000],
            ]),
        ]);

        $response = $this->getJson('/api/analytics/daily');

        $response->assertOk();
        $response->assertJsonCount(3, 'rows');
    }

    public function test_fetches_top_videos(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::topVideos([
                ['video' => 'dQw4w9WgXcQ', 'views' => 50000, 'estimatedMinutesWatched' => 150000],
                ['video' => 'abc123xyz', 'views' => 30000, 'estimatedMinutesWatched' => 90000],
            ]),
        ]);

        $response = $this->getJson('/api/analytics/top-videos');

        $response->assertOk();
        $response->assertJsonPath('rows.0.0', 'dQw4w9WgXcQ');
    }

    public function test_fetches_traffic_sources(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::trafficSources([
                ['source' => 'RELATED_VIDEO', 'views' => 200000],
                ['source' => 'YT_SEARCH', 'views' => 150000],
                ['source' => 'EXT_URL', 'views' => 50000],
                ['source' => 'SUBSCRIBER', 'views' => 30000],
            ]),
        ]);

        $response = $this->getJson('/api/analytics/traffic-sources');

        $response->assertOk();
        $response->assertJsonCount(4, 'rows');
    }

    public function test_fetches_demographics(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::demographics([
                ['ageGroup' => 'age18-24', 'gender' => 'male', 'viewerPercentage' => 25.5],
                ['ageGroup' => 'age18-24', 'gender' => 'female', 'viewerPercentage' => 15.2],
                ['ageGroup' => 'age25-34', 'gender' => 'male', 'viewerPercentage' => 22.1],
                ['ageGroup' => 'age25-34', 'gender' => 'female', 'viewerPercentage' => 18.3],
            ]),
        ]);

        $response = $this->getJson('/api/analytics/demographics');

        $response->assertOk();
        $response->assertJsonPath('rows.0.2', 25.5);
    }

    public function test_fetches_geography(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::geography([
                ['country' => 'US', 'views' => 200000],
                ['country' => 'GB', 'views' => 80000],
                ['country' => 'CA', 'views' => 50000],
            ]),
        ]);

        $response = $this->getJson('/api/analytics/geography');

        $response->assertOk();
        $response->assertJsonPath('rows.0.0', 'US');
    }

    public function test_fetches_content_type_breakdown(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::videoTypes([
                ['video' => 'abc123', 'creatorContentType' => 'VIDEO_ON_DEMAND', 'views' => 180000],
                ['video' => 'def456', 'creatorContentType' => 'SHORTS', 'views' => 120000],
                ['video' => 'ghi789', 'creatorContentType' => 'LIVE_STREAM', 'views' => 50000],
            ]),
        ]);

        $response = $this->getJson('/api/analytics/content-types');

        $response->assertOk();
        $response->assertJsonCount(3, 'rows');
    }

    public function test_fetches_playback_locations(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::playbackLocations(),
        ]);

        $response = $this->getJson('/api/analytics/playback-locations');

        $response->assertOk();
        $response->assertJsonPath('rows.0.0', 'WATCH');
    }

    public function test_fetches_playback_locations_filtered(): void
    {
        // Column filtering: only request views metric
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::playbackLocations(metrics: ['views']),
        ]);

        $response = $this->getJson('/api/analytics/playback-locations?metrics=views');

        $response->assertOk();
    }

    public function test_fetches_operating_systems(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::operatingSystems(),
        ]);

        $response = $this->getJson('/api/analytics/operating-systems');

        $response->assertOk();
        $response->assertJsonPath('rows.0.0', 'WINDOWS');
    }

    public function test_fetches_sharing_service(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::sharingService(),
        ]);

        $response = $this->getJson('/api/analytics/sharing');

        $response->assertOk();
        $response->assertJsonPath('rows.0.0', 'COPY_PASTE');
    }

    public function test_fetches_device_os_combo(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::deviceOperatingSystem(),
        ]);

        $response = $this->getJson('/api/analytics/device-os');

        $response->assertOk();
        $response->assertJsonPath('rows.0.0', 'DESKTOP');
        $response->assertJsonPath('rows.0.1', 'WINDOWS');
    }

    public function test_fetches_audience_retention(): void
    {
        YoutubeAnalyticsApi::fake([
            AnalyticsQueryResponse::audienceRetention(),
        ]);

        $response = $this->getJson('/api/analytics/retention?video=abc123');

        $response->assertOk();
        $response->assertJsonCount(100, 'rows');
    }
}

YouTube Reporting API

For bulk data exports — background jobs, historical data pipelines, scheduled reports.

Workflow: Create job → Poll for reports → Download CSV → Parse & upsert

Base Test Case (Reporting)

<?php

namespace Tests;

use Orchestra\Testbench\TestCase as BaseTestCase;
use Viewtrender\Youtube\YoutubeReportingApi;

abstract class ReportingTestCase extends BaseTestCase
{
    protected function tearDown(): void
    {
        YoutubeReportingApi::reset();
        parent::tearDown();
    }
    
    protected function getPackageProviders($app): array
    {
        return [
            \Viewtrender\Youtube\Laravel\YoutubeDataApiServiceProvider::class,
        ];
    }
}

Reporting Job Tests

<?php

namespace Tests\Feature;

use Tests\ReportingTestCase;
use Viewtrender\Youtube\Factories\ReportingJob;
use Viewtrender\Youtube\Factories\ReportingReport;
use Viewtrender\Youtube\Factories\ReportingReportType;
use Viewtrender\Youtube\Factories\ReportingMedia;
use Viewtrender\Youtube\YoutubeReportingApi;

class ReportingPipelineTest extends ReportingTestCase
{
    public function test_creates_reporting_job(): void
    {
        YoutubeReportingApi::fake([
            ReportingJob::create([
                'id' => 'job-123',
                'reportTypeId' => 'channel_basic_a2',
                'name' => 'Daily Channel Stats',
            ]),
        ]);

        $response = $this->postJson('/api/reporting/jobs', [
            'reportTypeId' => 'channel_basic_a2',
            'name' => 'Daily Channel Stats',
        ]);

        $response->assertCreated();
        $response->assertJsonPath('id', 'job-123');

        YoutubeReportingApi::assertSentCount(1);
    }

    public function test_lists_reporting_jobs(): void
    {
        YoutubeReportingApi::fake([
            ReportingJob::list([
                ['id' => 'job-1', 'reportTypeId' => 'channel_basic_a2', 'name' => 'Daily Stats'],
                ['id' => 'job-2', 'reportTypeId' => 'channel_demographics_a1', 'name' => 'Demographics'],
                ['id' => 'job-3', 'reportTypeId' => 'channel_traffic_source_a2', 'name' => 'Traffic'],
            ]),
        ]);

        $response = $this->getJson('/api/reporting/jobs');

        $response->assertOk();
        $response->assertJsonCount(3, 'jobs');
        $response->assertJsonPath('jobs.0.reportTypeId', 'channel_basic_a2');

        YoutubeReportingApi::assertSentCount(1);
    }

    public function test_lists_available_reports(): void
    {
        YoutubeReportingApi::fake([
            ReportingReport::list([
                [
                    'id' => 'report-1',
                    'jobId' => 'job-123',
                    'startTime' => '2024-01-01T00:00:00Z',
                    'endTime' => '2024-01-02T00:00:00Z',
                    'createTime' => '2024-01-02T06:00:00Z',
                    'downloadUrl' => 'https://youtubereporting.googleapis.com/v1/media/report-1',
                ],
                [
                    'id' => 'report-2',
                    'jobId' => 'job-123',
                    'startTime' => '2024-01-02T00:00:00Z',
                    'endTime' => '2024-01-03T00:00:00Z',
                    'createTime' => '2024-01-03T06:00:00Z',
                    'downloadUrl' => 'https://youtubereporting.googleapis.com/v1/media/report-2',
                ],
            ]),
        ]);

        $response = $this->getJson('/api/reporting/jobs/job-123/reports');

        $response->assertOk();
        $response->assertJsonCount(2, 'reports');
    }

    public function test_downloads_report_csv(): void
    {
        $csvContent = "date,channel_id,views,watch_time_minutes,average_view_duration_seconds\n" .
                      "2024-01-01,UC123,10000,50000,300\n" .
                      "2024-01-02,UC123,12000,60000,300\n" .
                      "2024-01-03,UC123,11000,55000,300\n";

        YoutubeReportingApi::fake([
            ReportingMedia::download($csvContent),
        ]);

        $response = $this->get('/api/reporting/download/report-1');

        $response->assertOk();
        $response->assertHeader('Content-Type', 'text/csv');

        YoutubeReportingApi::assertSentCount(1);
    }

    public function test_lists_report_types(): void
    {
        YoutubeReportingApi::fake([
            ReportingReportType::list([
                ['id' => 'channel_basic_a2', 'name' => 'Channel Basic'],
                ['id' => 'channel_demographics_a1', 'name' => 'Channel Demographics'],
                ['id' => 'channel_device_os_a2', 'name' => 'Channel Device/OS'],
                ['id' => 'channel_traffic_source_a2', 'name' => 'Channel Traffic Source'],
            ]),
        ]);

        $response = $this->getJson('/api/reporting/report-types');

        $response->assertOk();
        $response->assertJsonCount(4, 'reportTypes');
    }

    public function test_deletes_reporting_job(): void
    {
        YoutubeReportingApi::fake([
            ReportingJob::delete(),
        ]);

        $response = $this->deleteJson('/api/reporting/jobs/job-123');

        $response->assertNoContent();

        YoutubeReportingApi::assertSentCount(1);
    }
}

Complete Pipeline Test

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Viewtrender\Youtube\Factories\ReportingJob;
use Viewtrender\Youtube\Factories\ReportingReport;
use Viewtrender\Youtube\Factories\ReportingMedia;
use Viewtrender\Youtube\YoutubeReportingApi;

class ReportingSyncJobTest extends TestCase
{
    protected function tearDown(): void
    {
        YoutubeReportingApi::reset();
        parent::tearDown();
    }

    public function test_full_reporting_pipeline(): void
    {
        // Queue responses for the entire pipeline
        YoutubeReportingApi::fake([
            // 1. List jobs to find our job
            ReportingJob::list([
                ['id' => 'job-123', 'reportTypeId' => 'channel_basic_a2'],
            ]),
            // 2. List available reports for the job
            ReportingReport::list([
                [
                    'id' => 'report-today',
                    'jobId' => 'job-123',
                    'startTime' => '2024-01-01T00:00:00Z',
                    'endTime' => '2024-01-02T00:00:00Z',
                    'downloadUrl' => 'https://youtubereporting.googleapis.com/v1/media/report-today',
                ],
            ]),
            // 3. Download the report CSV
            ReportingMedia::download(
                "date,channel_id,views,watch_time_minutes\n" .
                "2024-01-01,UC123,10000,50000\n"
            ),
        ]);

        // Dispatch the sync job
        $this->artisan('youtube:sync-reports')
            ->assertSuccessful();

        // Verify all three API calls were made
        YoutubeReportingApi::assertSentCount(3);

        // Verify data was stored
        $this->assertDatabaseHas('channel_daily_stats', [
            'date' => '2024-01-01',
            'views' => 10000,
        ]);
    }
}

Error Responses

Simulate API errors in your tests:

use Viewtrender\Youtube\Responses\ErrorResponse;

// 404 Not Found
YoutubeDataApi::fake([ErrorResponse::notFound()]);
YoutubeDataApi::fake([ErrorResponse::notFound('Video not found')]);

// 403 Forbidden
YoutubeDataApi::fake([ErrorResponse::forbidden()]);
YoutubeDataApi::fake([ErrorResponse::forbidden('Access denied')]);

// 401 Unauthorized
YoutubeDataApi::fake([ErrorResponse::unauthorized()]);
YoutubeDataApi::fake([ErrorResponse::unauthorized('Invalid credentials')]);

// 403 Quota Exceeded
YoutubeDataApi::fake([ErrorResponse::quotaExceeded()]);
YoutubeDataApi::fake([ErrorResponse::quotaExceeded('Daily limit reached')]);

// 400 Bad Request
YoutubeDataApi::fake([ErrorResponse::badRequest()]);
YoutubeDataApi::fake([ErrorResponse::badRequest('Invalid parameter')]);

Assertions

// Assert a request was sent matching the callback
YoutubeDataApi::assertSent(function ($request) {
    return str_contains($request->getUri()->getPath(), '/videos');
});

// Assert no request matched the callback
YoutubeDataApi::assertNotSent(function ($request) {
    return str_contains($request->getUri()->getPath(), '/channels');
});

// Assert no requests were sent
YoutubeDataApi::assertNothingSent();

// Assert exact number of requests
YoutubeDataApi::assertSentCount(3);

// Endpoint-specific assertions
YoutubeDataApi::assertListedVideos();
YoutubeDataApi::assertListedChannels();
YoutubeDataApi::assertListedPlaylists();
YoutubeDataApi::assertSearched();

Configuration

// config/youtube-testkit.php
return [
    // Custom fixture path (null = package defaults)
    'fixtures_path' => null,

    // Throw exception on unqueued requests
    'prevent_stray_requests' => false,
];

License

MIT