diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 268c248..b3d8a7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.4 extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite - name: Install dependencies @@ -30,7 +30,7 @@ jobs: run: vendor/bin/phpunit - name: Run PHPStan - run: vendor/bin/phpstan analyse + run: vendor/bin/phpstan analyse --memory-limit=1G - name: Run Pint run: vendor/bin/pint --test @@ -69,7 +69,7 @@ jobs: fi - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: name: Release ${{ steps.tag.outputs.version }} body_path: changelog_extract.md diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e333cfc..bf1b352 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,11 +18,21 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3'] - laravel: ['^10.0', '^11.0'] - exclude: - - php: '8.1' - laravel: '^11.0' + # Officially supported PHP versions per Laravel release + include: + - { php: '8.1', laravel: '^10.0' } + - { php: '8.2', laravel: '^10.0' } + - { php: '8.3', laravel: '^10.0' } + - { php: '8.2', laravel: '^11.0' } + - { php: '8.3', laravel: '^11.0' } + - { php: '8.4', laravel: '^11.0' } + - { php: '8.2', laravel: '^12.0' } + - { php: '8.3', laravel: '^12.0' } + - { php: '8.4', laravel: '^12.0' } + - { php: '8.5', laravel: '^12.0' } + - { php: '8.3', laravel: '^13.0' } + - { php: '8.4', laravel: '^13.0' } + - { php: '8.5', laravel: '^13.0' } steps: - name: Checkout code @@ -43,7 +53,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -57,12 +67,12 @@ jobs: - name: Run PHPUnit tests run: vendor/bin/phpunit --testdox - - name: Generate coverage report (PHP 8.3, Laravel 11 only) - if: matrix.php == '8.3' && matrix.laravel == '^11.0' + - name: Generate coverage report (PHP 8.4, Laravel 12 only) + if: matrix.php == '8.4' && matrix.laravel == '^12.0' run: vendor/bin/phpunit --coverage-clover coverage.xml - - name: Upload coverage to Codecov (PHP 8.3, Laravel 11 only) - if: matrix.php == '8.3' && matrix.laravel == '^11.0' + - name: Upload coverage to Codecov (PHP 8.4, Laravel 12 only) + if: matrix.php == '8.4' && matrix.laravel == '^12.0' uses: codecov/codecov-action@v5 with: files: ./coverage.xml @@ -80,7 +90,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.4 extensions: dom, curl, libxml, mbstring, zip - name: Install dependencies @@ -100,11 +110,11 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.4 extensions: dom, curl, libxml, mbstring, zip - name: Install dependencies run: composer update --prefer-dist --no-interaction --no-progress - name: Run PHPStan - run: vendor/bin/phpstan analyse --error-format=github + run: vendor/bin/phpstan analyse --memory-limit=1G --error-format=github diff --git a/CHANGELOG.md b/CHANGELOG.md index fcca886..a3115d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-08-02 + ### Added -- Initial package structure -- Project documentation and planning +- Laravel 12 and Laravel 13 support (`illuminate/*` `^12.0|^13.0`) +- PHP 8.4 and PHP 8.5 support +- `RestreamDto::fromUrl()` to decompose RTMP(S) publishing URLs (e.g. Facebook's + `secure_stream_url`) into `dest_addr`/`dest_port`/`dest_app`/`dest_stream`/`ssl` +- `RestreamService::stats()` for `GET /manage/rtmp/republish/stats` +- `RestreamStatsDto` for republishing connection statistics +- CI test matrix covering Laravel 10–13 on PHP 8.1–8.5 (runs on pull requests to main) + +### Changed +- **Breaking**: `RestreamService` now targets Nimble's real RTMP republishing API + (`/manage/rtmp/republish`); the previous `/manage/restream/*` endpoints do not + exist in Nimble's native API +- **Breaking**: `RestreamDto` now carries the real republish rule fields + (`src_app`, `src_stream`, `dest_addr`, `dest_port`, `dest_app`, `dest_stream`, + plus optional `ssl`, `auth_schema`, `dest_login`, `dest_password`, + `keep_src_stream_params`, `dest_app_params`, `dest_stream_params`) instead of + `streamId`/`targetUrl`/`protocol` +- **Breaking**: `RestreamService::add()` was replaced by `create(RestreamDto)`, + which returns the created rule with its assigned id +- **Breaking**: `RestreamService::update()` was removed — Nimble has no update + endpoint; delete the rule and create a new one instead +- Tests migrated from `/** @test */` annotations to `#[Test]` attributes + (doc-comment metadata is removed in PHPUnit 12+) +- GitHub Actions updated to Node 24 runtimes (`actions/cache@v6`, + `softprops/action-gh-release@v3`) + +### Fixed +- `nimble:status` no longer crashes on byte values above the TB range ## [0.1.0] - 2025-10-10 diff --git a/README.md b/README.md index 4484bae..556cf77 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ A comprehensive Laravel package for seamless integration with Nimble Streamer AP ## Requirements -- PHP 8.1 or higher -- Laravel 10 or 11 +- PHP 8.1 – 8.5 +- Laravel 10, 11, 12 or 13 - Nimble Streamer with API enabled - Composer @@ -282,46 +282,59 @@ if (Nimble::dvr()->configure([ } ``` -### Restream Management +### Restream Management (RTMP Republishing) + +Restreaming uses Nimble's native RTMP republishing API (`/manage/rtmp/republish`). ```php +use AlexHackney\LaraNimble\DTOs\RestreamDto; use AlexHackney\LaraNimble\Facades\Nimble; -// List all restream targets -$restreams = Nimble::restream()->list(); -foreach ($restreams as $restream) { - echo "Target: {$restream->targetUrl}\n"; - echo "Protocol: {$restream->protocol}\n"; - echo "Status: {$restream->status}\n"; - echo "Enabled: " . ($restream->enabled ? 'Yes' : 'No') . "\n"; +// List republishing rules created through this API +$rules = Nimble::restream()->list(); +foreach ($rules as $rule) { + echo "Rule {$rule->id}: {$rule->srcApp}/{$rule->srcStream}"; + echo " -> {$rule->destAddr}:{$rule->destPort}/{$rule->destApp}/{$rule->destStream}\n"; } -// Get a specific restream target -$restream = Nimble::restream()->get('restream-123'); -echo "Target URL: {$restream->targetUrl}"; - -// Add a new restream target -if (Nimble::restream()->add('stream-123', [ - 'target_url' => 'rtmp://live.youtube.com/stream/key123', - 'protocol' => 'rtmp', - 'enabled' => true, -])) { - echo "Restream target added successfully!"; +// Get a specific rule (null when it does not exist) +$rule = Nimble::restream()->get(42); + +// Create a rule from explicit fields +$created = Nimble::restream()->create(new RestreamDto( + srcApp: 'live', + srcStream: 'stream1', + destAddr: 'a.rtmp.youtube.com', + destPort: 1935, + destApp: 'live2', + destStream: 'your-stream-key', +)); +echo "Created rule {$created->id}"; + +// Or decompose an RTMP(S) publishing URL, e.g. Facebook's secure_stream_url +$created = Nimble::restream()->create(RestreamDto::fromUrl( + 'live', + 'stream1', + 'rtmps://live-api-s.facebook.com:443/rtmp/your-stream-key' +)); + +// Delete a rule +if (Nimble::restream()->delete(42)) { + echo "Rule deleted!"; } -// Update a restream target -if (Nimble::restream()->update('restream-123', [ - 'enabled' => false, -])) { - echo "Restream target updated!"; -} - -// Delete a restream target -if (Nimble::restream()->delete('restream-123')) { - echo "Restream target deleted!"; +// Connection statistics for all rules +foreach (Nimble::restream()->stats() as $stat) { + echo "Rule {$stat->id}: {$stat->state}, {$stat->bandwidth} bandwidth\n"; } ``` +Notes from the Nimble API docs: + +- `src_stream` is optional in the raw API, but omitting it republishes **every** stream in the source application, so `RestreamDto` refuses to build a create payload without it. +- `list()` only returns rules created through the native API — rules defined in WMSPanel do not appear. +- Rules created through the native API are not persisted across a Nimble config reload or restart; recreate them as needed. + ### Stream Pulling ```php @@ -652,8 +665,8 @@ The MIT License (MIT). See [LICENSE](LICENSE) for details. **Developed by:** Alex Hackney **Built with:** -- Laravel 10 & 11 -- PHP 8.1+ +- Laravel 10 – 13 +- PHP 8.1 – 8.5 - Nimble Streamer API ## Artisan Commands diff --git a/composer.json b/composer.json index 47a94be..29d046c 100644 --- a/composer.json +++ b/composer.json @@ -34,18 +34,18 @@ "docs": "https://github.com/alexhackney/lara-nimble#readme" }, "require": { - "php": "^8.1|^8.2|^8.3", - "illuminate/support": "^10.0|^11.0", - "illuminate/http": "^10.0|^11.0", + "php": "^8.1|^8.2|^8.3|^8.4|^8.5", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", "guzzlehttp/guzzle": "^7.5" }, "require-dev": { - "orchestra/testbench": "^8.0|^9.0", - "phpunit/phpunit": "^10.5|^11.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^10.5|^11.0|^12.0|^13.0", "mockery/mockery": "^1.6", "laravel/pint": "^1.13", - "phpstan/phpstan": "^1.10", - "larastan/larastan": "^2.9" + "phpstan/phpstan": "^1.10|^2.0", + "larastan/larastan": "^2.9|^3.0" }, "autoload": { "psr-4": { @@ -71,7 +71,7 @@ "test": "vendor/bin/phpunit", "test-coverage": "vendor/bin/phpunit --coverage-html coverage", "format": "vendor/bin/pint", - "analyse": "vendor/bin/phpstan analyse" + "analyse": "vendor/bin/phpstan analyse --memory-limit=1G" }, "config": { "sort-packages": true, diff --git a/phpstan.neon b/phpstan.neon index 98c6372..3cade64 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,5 +6,4 @@ parameters: - src level: 8 ignoreErrors: - excludePaths: - checkMissingIterableValueType: false + - identifier: missingType.iterableValue diff --git a/phpunit.xml b/phpunit.xml index 019fc98..c32bfdd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,6 +1,6 @@ tests/Feature - - - - - - src - - src/Console - diff --git a/src/Client/NimbleClient.php b/src/Client/NimbleClient.php index 776d00e..41cdc15 100644 --- a/src/Client/NimbleClient.php +++ b/src/Client/NimbleClient.php @@ -398,7 +398,7 @@ private function validateConfig(array $config): void throw new InvalidConfigurationException('Protocol must be either http or https'); } - if (isset($config['token']) && ! is_string($config['token']) && $config['token'] !== null) { + if (isset($config['token']) && ! is_string($config['token'])) { throw new InvalidConfigurationException('Token must be a string or null'); } } diff --git a/src/Commands/NimbleStatusCommand.php b/src/Commands/NimbleStatusCommand.php index 2c81417..398dcce 100644 --- a/src/Commands/NimbleStatusCommand.php +++ b/src/Commands/NimbleStatusCommand.php @@ -98,7 +98,7 @@ private function formatUptime(int $seconds): string private function formatBytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $factor = floor((strlen((string) $bytes) - 1) / 3); + $factor = min((int) floor((strlen((string) $bytes) - 1) / 3), count($units) - 1); return sprintf('%.2f %s', $bytes / (1024 ** $factor), $units[$factor]); } diff --git a/src/DTOs/RestreamDto.php b/src/DTOs/RestreamDto.php index da7284c..a45a8e0 100644 --- a/src/DTOs/RestreamDto.php +++ b/src/DTOs/RestreamDto.php @@ -4,18 +4,35 @@ namespace AlexHackney\LaraNimble\DTOs; +use InvalidArgumentException; + /** - * Data Transfer Object for Restream target information. + * Data Transfer Object for a Nimble RTMP republishing rule. + * + * Maps to the native API at /manage/rtmp/republish. The Nimble API treats + * src_stream as optional, but omitting it republishes every stream in the + * source application, so toCreatePayload() refuses to send a rule without it. */ class RestreamDto { + /** + * @param string|null $authSchema One of NONE, NIMBLE, AKAMAI, LIMELIGHT, PERISCOPE + */ public function __construct( - public readonly string $id, - public readonly string $streamId, - public readonly string $targetUrl, - public readonly string $protocol, - public readonly ?bool $enabled = null, - public readonly ?string $status = null, + public readonly string $srcApp, + public readonly string $srcStream, + public readonly string $destAddr, + public readonly int $destPort, + public readonly string $destApp, + public readonly string $destStream, + public readonly ?bool $ssl = null, + public readonly ?string $authSchema = null, + public readonly ?string $destLogin = null, + public readonly ?string $destPassword = null, + public readonly ?bool $keepSrcStreamParams = null, + public readonly ?string $destAppParams = null, + public readonly ?string $destStreamParams = null, + public readonly int|string|null $id = null, ) {} /** @@ -24,12 +41,88 @@ public function __construct( public static function fromArray(array $data): self { return new self( - id: $data['id'], - streamId: $data['stream_id'], - targetUrl: $data['target_url'], - protocol: $data['protocol'], - enabled: $data['enabled'] ?? null, - status: $data['status'] ?? null, + srcApp: $data['src_app'], + srcStream: $data['src_stream'] ?? '', + destAddr: $data['dest_addr'], + destPort: (int) $data['dest_port'], + destApp: $data['dest_app'], + destStream: $data['dest_stream'], + ssl: isset($data['ssl']) ? (bool) $data['ssl'] : null, + authSchema: $data['auth_schema'] ?? null, + destLogin: $data['dest_login'] ?? null, + destPassword: $data['dest_password'] ?? null, + keepSrcStreamParams: isset($data['keep_src_stream_params']) ? (bool) $data['keep_src_stream_params'] : null, + destAppParams: $data['dest_app_params'] ?? null, + destStreamParams: $data['dest_stream_params'] ?? null, + id: $data['id'] ?? null, + ); + } + + /** + * Create a RestreamDto from an RTMP(S) publishing URL. + * + * Decomposes e.g. rtmps://live-api-s.facebook.com:443/rtmp/{key} into + * destAddr, destPort, destApp, destStream and ssl. Query strings are kept + * as part of the stream key, as platforms like Facebook require them. + * + * @throws InvalidArgumentException + */ + public static function fromUrl( + string $srcApp, + string $srcStream, + string $url, + ?string $authSchema = null, + ?string $destLogin = null, + ?string $destPassword = null, + ?bool $keepSrcStreamParams = null, + ): self { + $parts = parse_url(trim($url)); + + if ($parts === false) { + throw new InvalidArgumentException("Could not parse URL: {$url}"); + } + + $scheme = strtolower($parts['scheme'] ?? ''); + + if (! in_array($scheme, ['rtmp', 'rtmps'], true)) { + throw new InvalidArgumentException("URL must use the rtmp or rtmps scheme, got: {$url}"); + } + + $host = $parts['host'] ?? ''; + + if ($host === '') { + throw new InvalidArgumentException("URL is missing a host: {$url}"); + } + + $path = trim($parts['path'] ?? '', '/'); + $segments = $path === '' ? [] : explode('/', $path); + + if (count($segments) < 2) { + throw new InvalidArgumentException( + "URL must contain an application and a stream key (rtmp://host/app/stream), got: {$url}" + ); + } + + $destStream = array_pop($segments); + + if (isset($parts['query']) && $parts['query'] !== '') { + $destStream .= '?'.$parts['query']; + } + + $ssl = $scheme === 'rtmps'; + + return new self( + srcApp: $srcApp, + srcStream: $srcStream, + destAddr: $host, + destPort: $parts['port'] ?? ($ssl ? 443 : 1935), + destApp: implode('/', $segments), + destStream: $destStream, + ssl: $ssl ? true : null, + authSchema: $authSchema, + destLogin: $destLogin, + destPassword: $destPassword, + keepSrcStreamParams: $keepSrcStreamParams, ); } @@ -40,11 +133,41 @@ public function toArray(): array { return [ 'id' => $this->id, - 'stream_id' => $this->streamId, - 'target_url' => $this->targetUrl, - 'protocol' => $this->protocol, - 'enabled' => $this->enabled, - 'status' => $this->status, + 'src_app' => $this->srcApp, + 'src_stream' => $this->srcStream, + 'dest_addr' => $this->destAddr, + 'dest_port' => $this->destPort, + 'dest_app' => $this->destApp, + 'dest_stream' => $this->destStream, + 'ssl' => $this->ssl, + 'auth_schema' => $this->authSchema, + 'dest_login' => $this->destLogin, + 'dest_password' => $this->destPassword, + 'keep_src_stream_params' => $this->keepSrcStreamParams, + 'dest_app_params' => $this->destAppParams, + 'dest_stream_params' => $this->destStreamParams, ]; } + + /** + * Build the request body for POST /manage/rtmp/republish. + * + * Omits the id and any unset optional fields. + * + * @throws InvalidArgumentException when src_stream is empty, because + * Nimble would republish every stream in the source application + */ + public function toCreatePayload(): array + { + if ($this->srcStream === '') { + throw new InvalidArgumentException( + 'src_stream must be set: omitting it makes Nimble republish every stream in the source application' + ); + } + + $payload = $this->toArray(); + unset($payload['id']); + + return array_filter($payload, fn (mixed $value): bool => $value !== null); + } } diff --git a/src/DTOs/RestreamStatsDto.php b/src/DTOs/RestreamStatsDto.php new file mode 100644 index 0000000..0af5f53 --- /dev/null +++ b/src/DTOs/RestreamStatsDto.php @@ -0,0 +1,75 @@ + $this->id, + 'src_app' => $this->srcApp, + 'src_stream' => $this->srcStream, + 'dest_addr' => $this->destAddr, + 'dest_port' => $this->destPort, + 'dest_app' => $this->destApp, + 'dest_stream' => $this->destStream, + 'state' => $this->state, + 'session_duration' => $this->sessionDuration, + 'bandwidth' => $this->bandwidth, + 'bytes_recv' => $this->bytesRecv, + 'bytes_sent' => $this->bytesSent, + 'retry_count' => $this->retryCount, + ]; + } +} diff --git a/src/Services/RestreamService.php b/src/Services/RestreamService.php index 12635a8..2751a14 100644 --- a/src/Services/RestreamService.php +++ b/src/Services/RestreamService.php @@ -6,10 +6,17 @@ use AlexHackney\LaraNimble\Client\NimbleClient; use AlexHackney\LaraNimble\DTOs\RestreamDto; +use AlexHackney\LaraNimble\DTOs\RestreamStatsDto; +use AlexHackney\LaraNimble\Exceptions\NimbleApiException; use Illuminate\Support\Collection; /** - * Service for managing Nimble restream targets. + * Service for managing Nimble RTMP republishing rules. + * + * Uses the native API at /manage/rtmp/republish. Two caveats from the + * Nimble docs: only rules created through this API appear in list(), so + * rules made in WMSPanel are invisible here, and API-created rules are + * not persisted across a Nimble config reload or restart. */ class RestreamService { @@ -18,62 +25,82 @@ public function __construct( ) {} /** - * List all restream targets. + * List all republishing rules created through the native API. * * @return Collection */ public function list(): Collection { - $response = $this->client->get('/manage/restream/targets'); + $response = $this->client->get('/manage/rtmp/republish'); - /** @var array> $restreams */ - $restreams = $response->get('restreams', []); + /** @var array> $rules */ + $rules = $response->get('rules', []); - return collect($restreams)->map(function (array $restreamData) { - return RestreamDto::fromArray($restreamData); + return collect($rules)->map(function (array $rule) { + return RestreamDto::fromArray($rule); }); } /** - * Get details of a specific restream target. + * Get a specific republishing rule, or null when it does not exist. */ - public function get(string $restreamId): RestreamDto + public function get(int|string $ruleId): ?RestreamDto { - $response = $this->client->get("/manage/restream/target/{$restreamId}"); + $response = $this->client->get("/manage/rtmp/republish/{$ruleId}"); - return RestreamDto::fromArray($response->data()); + /** @var array|null $rule */ + $rule = $response->get('rule') ?? $response->get('rules.0'); + + return is_array($rule) ? RestreamDto::fromArray($rule) : null; } /** - * Add a new restream target. + * Create a new republishing rule and return it with its assigned id. + * + * @throws NimbleApiException when Nimble rejects the rule */ - public function add(string $streamId, array $config): bool + public function create(RestreamDto $rule): RestreamDto { - $response = $this->client->post('/manage/restream/target', array_merge( - ['stream_id' => $streamId], - $config - )); + $response = $this->client->post('/manage/rtmp/republish', $rule->toCreatePayload()); + + /** @var array|null $created */ + $created = $response->get('rule'); + + if (! is_array($created)) { + throw new NimbleApiException( + 'Nimble did not return the created republishing rule', + $response->statusCode(), + $response->data() + ); + } - return $response->get('success', false) === true; + return RestreamDto::fromArray($created); } /** - * Update an existing restream target. + * Delete a republishing rule. */ - public function update(string $restreamId, array $config): bool + public function delete(int|string $ruleId): bool { - $response = $this->client->put("/manage/restream/target/{$restreamId}", $config); + $response = $this->client->delete("/manage/rtmp/republish/{$ruleId}"); - return $response->get('success', false) === true; + return strcasecmp((string) $response->get('status', ''), 'ok') === 0; } /** - * Delete a restream target. + * Get connection statistics for all republishing rules. + * + * @return Collection */ - public function delete(string $restreamId): bool + public function stats(): Collection { - $response = $this->client->delete("/manage/restream/target/{$restreamId}"); + $response = $this->client->get('/manage/rtmp/republish/stats'); + + /** @var array> $stats */ + $stats = $response->get('stats', []); - return $response->get('success', false) === true; + return collect($stats)->map(function (array $entry) { + return RestreamStatsDto::fromArray($entry); + }); } } diff --git a/tests/Feature/FacadeTest.php b/tests/Feature/FacadeTest.php index f7bd1c1..71df843 100644 --- a/tests/Feature/FacadeTest.php +++ b/tests/Feature/FacadeTest.php @@ -8,10 +8,11 @@ use AlexHackney\LaraNimble\Facades\Nimble as NimbleFacade; use AlexHackney\LaraNimble\Nimble; use AlexHackney\LaraNimble\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class FacadeTest extends TestCase { - /** @test */ + #[Test] public function it_resolves_to_nimble_manager_instance(): void { $instance = NimbleFacade::getFacadeRoot(); @@ -19,7 +20,7 @@ public function it_resolves_to_nimble_manager_instance(): void $this->assertInstanceOf(Nimble::class, $instance); } - /** @test */ + #[Test] public function it_can_access_nimble_client_through_facade(): void { $client = NimbleFacade::client(); @@ -27,7 +28,7 @@ public function it_can_access_nimble_client_through_facade(): void $this->assertInstanceOf(NimbleClient::class, $client); } - /** @test */ + #[Test] public function facade_returns_same_instance_on_multiple_calls(): void { $instance1 = NimbleFacade::getFacadeRoot(); @@ -36,7 +37,7 @@ public function facade_returns_same_instance_on_multiple_calls(): void $this->assertSame($instance1, $instance2); } - /** @test */ + #[Test] public function it_can_get_base_url_through_facade(): void { $baseUrl = NimbleFacade::getBaseUrl(); diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php index a591ea9..7023974 100644 --- a/tests/Feature/ServiceProviderTest.php +++ b/tests/Feature/ServiceProviderTest.php @@ -8,10 +8,11 @@ use AlexHackney\LaraNimble\Nimble; use AlexHackney\LaraNimble\NimbleServiceProvider; use AlexHackney\LaraNimble\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class ServiceProviderTest extends TestCase { - /** @test */ + #[Test] public function it_registers_the_service_provider(): void { $providers = $this->app->getLoadedProviders(); @@ -19,7 +20,7 @@ public function it_registers_the_service_provider(): void $this->assertArrayHasKey(NimbleServiceProvider::class, $providers); } - /** @test */ + #[Test] public function it_registers_nimble_client_as_singleton(): void { $client1 = $this->app->make(NimbleClient::class); @@ -29,7 +30,7 @@ public function it_registers_nimble_client_as_singleton(): void $this->assertSame($client1, $client2); } - /** @test */ + #[Test] public function it_registers_nimble_manager_as_singleton(): void { $nimble1 = $this->app->make(Nimble::class); @@ -39,7 +40,7 @@ public function it_registers_nimble_manager_as_singleton(): void $this->assertSame($nimble1, $nimble2); } - /** @test */ + #[Test] public function it_loads_configuration_from_config_file(): void { $this->assertEquals('localhost', config('nimble.host')); @@ -47,7 +48,7 @@ public function it_loads_configuration_from_config_file(): void $this->assertEquals('http', config('nimble.protocol')); } - /** @test */ + #[Test] public function it_can_publish_configuration_file(): void { $configPath = config_path('nimble.php'); @@ -59,7 +60,7 @@ public function it_can_publish_configuration_file(): void $this->assertInstanceOf(NimbleServiceProvider::class, $provider); } - /** @test */ + #[Test] public function nimble_client_uses_config_values(): void { config(['nimble.host' => 'custom.example.com']); diff --git a/tests/Feature/StreamServiceIntegrationTest.php b/tests/Feature/StreamServiceIntegrationTest.php index d7a7548..bc6b312 100644 --- a/tests/Feature/StreamServiceIntegrationTest.php +++ b/tests/Feature/StreamServiceIntegrationTest.php @@ -5,21 +5,23 @@ namespace AlexHackney\LaraNimble\Tests\Feature; use AlexHackney\LaraNimble\Facades\Nimble as NimbleFacade; +use AlexHackney\LaraNimble\Nimble; use AlexHackney\LaraNimble\Services\StreamService; use AlexHackney\LaraNimble\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class StreamServiceIntegrationTest extends TestCase { - /** @test */ + #[Test] public function it_can_access_stream_service_through_nimble_manager(): void { - $nimble = $this->app->make(\AlexHackney\LaraNimble\Nimble::class); + $nimble = $this->app->make(Nimble::class); $streamService = $nimble->streams(); $this->assertInstanceOf(StreamService::class, $streamService); } - /** @test */ + #[Test] public function it_can_access_stream_service_through_facade(): void { $streamService = NimbleFacade::streams(); @@ -27,7 +29,7 @@ public function it_can_access_stream_service_through_facade(): void $this->assertInstanceOf(StreamService::class, $streamService); } - /** @test */ + #[Test] public function stream_service_has_access_to_configured_client(): void { $streamService = NimbleFacade::streams(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 6a7fe6d..9c78cac 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,6 +4,8 @@ namespace AlexHackney\LaraNimble\Tests; +use AlexHackney\LaraNimble\Facades\Nimble; +use AlexHackney\LaraNimble\NimbleServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; abstract class TestCase extends Orchestra @@ -11,14 +13,14 @@ abstract class TestCase extends Orchestra protected function getPackageProviders($app): array { return [ - \AlexHackney\LaraNimble\NimbleServiceProvider::class, + NimbleServiceProvider::class, ]; } protected function getPackageAliases($app): array { return [ - 'Nimble' => \AlexHackney\LaraNimble\Facades\Nimble::class, + 'Nimble' => Nimble::class, ]; } diff --git a/tests/Unit/Client/NimbleClientTest.php b/tests/Unit/Client/NimbleClientTest.php index 037b9b0..a6a41f8 100644 --- a/tests/Unit/Client/NimbleClientTest.php +++ b/tests/Unit/Client/NimbleClientTest.php @@ -15,11 +15,12 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class NimbleClientTest extends TestCase { - /** @test */ + #[Test] public function it_can_be_instantiated_with_valid_config(): void { $config = [ @@ -33,7 +34,7 @@ public function it_can_be_instantiated_with_valid_config(): void $this->assertInstanceOf(NimbleClient::class, $client); } - /** @test */ + #[Test] public function it_throws_exception_when_host_is_missing(): void { $this->expectException(InvalidConfigurationException::class); @@ -42,7 +43,7 @@ public function it_throws_exception_when_host_is_missing(): void new NimbleClient([]); } - /** @test */ + #[Test] public function it_builds_correct_base_url(): void { $config = [ @@ -57,7 +58,7 @@ public function it_builds_correct_base_url(): void $this->assertEquals('https://nimble.example.com:8082', $baseUrl); } - /** @test */ + #[Test] public function it_can_make_get_request(): void { $mock = new MockHandler([ @@ -79,7 +80,7 @@ public function it_can_make_get_request(): void $this->assertEquals(['status' => 'online'], $response->data()); } - /** @test */ + #[Test] public function it_can_make_post_request(): void { $mock = new MockHandler([ @@ -101,7 +102,7 @@ public function it_can_make_post_request(): void $this->assertEquals(['success' => true], $response->data()); } - /** @test */ + #[Test] public function it_can_make_delete_request(): void { $mock = new MockHandler([ @@ -123,7 +124,7 @@ public function it_can_make_delete_request(): void $this->assertEquals(['success' => true], $response->data()); } - /** @test */ + #[Test] public function it_adds_authentication_when_token_is_provided(): void { $mock = new MockHandler([ @@ -150,7 +151,7 @@ public function it_adds_authentication_when_token_is_provided(): void $this->assertNotNull($lastRequest); } - /** @test */ + #[Test] public function it_throws_connection_exception_on_network_error(): void { $mock = new MockHandler([ @@ -173,7 +174,7 @@ public function it_throws_connection_exception_on_network_error(): void $client->get('/manage/status'); } - /** @test */ + #[Test] public function it_throws_authentication_exception_on_401(): void { $mock = new MockHandler([ @@ -195,7 +196,7 @@ public function it_throws_authentication_exception_on_401(): void $client->get('/manage/status'); } - /** @test */ + #[Test] public function it_throws_api_exception_on_4xx_errors(): void { $mock = new MockHandler([ @@ -217,7 +218,7 @@ public function it_throws_api_exception_on_4xx_errors(): void $client->get('/manage/status'); } - /** @test */ + #[Test] public function it_throws_api_exception_on_5xx_errors(): void { $mock = new MockHandler([ diff --git a/tests/Unit/DTOs/ArchiveDtoTest.php b/tests/Unit/DTOs/ArchiveDtoTest.php index 4b4bee2..0200943 100644 --- a/tests/Unit/DTOs/ArchiveDtoTest.php +++ b/tests/Unit/DTOs/ArchiveDtoTest.php @@ -5,11 +5,12 @@ namespace AlexHackney\LaraNimble\Tests\Unit\DTOs; use AlexHackney\LaraNimble\DTOs\ArchiveDto; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class ArchiveDtoTest extends TestCase { - /** @test */ + #[Test] public function it_can_be_created_from_array(): void { $data = [ @@ -33,7 +34,7 @@ public function it_can_be_created_from_array(): void $this->assertEquals(3600, $dto->duration); } - /** @test */ + #[Test] public function it_can_handle_optional_fields(): void { $data = [ @@ -49,7 +50,7 @@ public function it_can_handle_optional_fields(): void $this->assertNull($dto->path); } - /** @test */ + #[Test] public function it_can_be_converted_to_array(): void { $data = [ diff --git a/tests/Unit/DTOs/PullDtoTest.php b/tests/Unit/DTOs/PullDtoTest.php index 30ab1b9..3780e20 100644 --- a/tests/Unit/DTOs/PullDtoTest.php +++ b/tests/Unit/DTOs/PullDtoTest.php @@ -5,11 +5,12 @@ namespace AlexHackney\LaraNimble\Tests\Unit\DTOs; use AlexHackney\LaraNimble\DTOs\PullDto; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class PullDtoTest extends TestCase { - /** @test */ + #[Test] public function it_can_be_created_from_array(): void { $data = [ @@ -34,7 +35,7 @@ public function it_can_be_created_from_array(): void $this->assertEquals('active', $dto->status); } - /** @test */ + #[Test] public function it_can_handle_optional_fields(): void { $data = [ @@ -51,7 +52,7 @@ public function it_can_handle_optional_fields(): void $this->assertNull($dto->status); } - /** @test */ + #[Test] public function it_can_be_converted_to_array(): void { $data = [ diff --git a/tests/Unit/DTOs/RestreamDtoTest.php b/tests/Unit/DTOs/RestreamDtoTest.php index e620da6..c25b78f 100644 --- a/tests/Unit/DTOs/RestreamDtoTest.php +++ b/tests/Unit/DTOs/RestreamDtoTest.php @@ -5,67 +5,280 @@ namespace AlexHackney\LaraNimble\Tests\Unit\DTOs; use AlexHackney\LaraNimble\DTOs\RestreamDto; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class RestreamDtoTest extends TestCase { - /** @test */ - public function it_can_be_created_from_array(): void + private function fullData(): array { - $data = [ - 'id' => 'restream-123', - 'stream_id' => 'stream-456', - 'target_url' => 'rtmp://live.youtube.com/stream/key123', - 'protocol' => 'rtmp', - 'enabled' => true, - 'status' => 'active', + return [ + 'id' => 42, + 'src_app' => 'live', + 'src_stream' => 'stream1', + 'dest_addr' => 'live-api-s.facebook.com', + 'dest_port' => 443, + 'dest_app' => 'rtmp', + 'dest_stream' => 'fb-key-123', + 'ssl' => true, + 'auth_schema' => 'NIMBLE', + 'dest_login' => 'user', + 'dest_password' => 'secret', + 'keep_src_stream_params' => true, + 'dest_app_params' => 'appParam=1', + 'dest_stream_params' => 'streamParam=1', ]; + } - $dto = RestreamDto::fromArray($data); + #[Test] + public function it_can_be_created_from_array(): void + { + $dto = RestreamDto::fromArray($this->fullData()); $this->assertInstanceOf(RestreamDto::class, $dto); - $this->assertEquals('restream-123', $dto->id); - $this->assertEquals('stream-456', $dto->streamId); - $this->assertEquals('rtmp://live.youtube.com/stream/key123', $dto->targetUrl); - $this->assertEquals('rtmp', $dto->protocol); - $this->assertTrue($dto->enabled); - $this->assertEquals('active', $dto->status); + $this->assertSame(42, $dto->id); + $this->assertSame('live', $dto->srcApp); + $this->assertSame('stream1', $dto->srcStream); + $this->assertSame('live-api-s.facebook.com', $dto->destAddr); + $this->assertSame(443, $dto->destPort); + $this->assertSame('rtmp', $dto->destApp); + $this->assertSame('fb-key-123', $dto->destStream); + $this->assertTrue($dto->ssl); + $this->assertSame('NIMBLE', $dto->authSchema); + $this->assertSame('user', $dto->destLogin); + $this->assertSame('secret', $dto->destPassword); + $this->assertTrue($dto->keepSrcStreamParams); + $this->assertSame('appParam=1', $dto->destAppParams); + $this->assertSame('streamParam=1', $dto->destStreamParams); } - /** @test */ + #[Test] public function it_can_handle_optional_fields(): void { - $data = [ - 'id' => 'restream-123', - 'stream_id' => 'stream-456', - 'target_url' => 'rtmp://example.com/live', - 'protocol' => 'rtmp', - ]; + $dto = RestreamDto::fromArray([ + 'src_app' => 'live', + 'src_stream' => 'stream1', + 'dest_addr' => 'example.com', + 'dest_port' => 1935, + 'dest_app' => 'live', + 'dest_stream' => 'key', + ]); + + $this->assertNull($dto->id); + $this->assertNull($dto->ssl); + $this->assertNull($dto->authSchema); + $this->assertNull($dto->destLogin); + $this->assertNull($dto->destPassword); + $this->assertNull($dto->keepSrcStreamParams); + $this->assertNull($dto->destAppParams); + $this->assertNull($dto->destStreamParams); + } + + #[Test] + public function it_casts_a_string_dest_port_to_int(): void + { + // The Nimble docs themselves show "dest_port":"1999" as a string + $dto = RestreamDto::fromArray([ + 'src_app' => 'live', + 'src_stream' => 'stream1', + 'dest_addr' => 'example.com', + 'dest_port' => '1999', + 'dest_app' => 'live-re', + 'dest_stream' => 'key', + ]); + + $this->assertSame(1999, $dto->destPort); + } - $dto = RestreamDto::fromArray($data); + #[Test] + public function it_defaults_a_missing_src_stream_to_an_empty_string(): void + { + $dto = RestreamDto::fromArray([ + 'src_app' => 'live', + 'dest_addr' => 'example.com', + 'dest_port' => 1935, + 'dest_app' => 'live', + 'dest_stream' => 'key', + ]); - $this->assertNull($dto->enabled); - $this->assertNull($dto->status); + $this->assertSame('', $dto->srcStream); } - /** @test */ + #[Test] public function it_can_be_converted_to_array(): void { - $data = [ - 'id' => 'restream-123', - 'stream_id' => 'stream-456', - 'target_url' => 'rtmp://live.youtube.com/stream/key123', - 'protocol' => 'rtmp', - 'enabled' => true, - 'status' => 'active', - ]; + $array = RestreamDto::fromArray($this->fullData())->toArray(); + + $this->assertSame(42, $array['id']); + $this->assertSame('live', $array['src_app']); + $this->assertSame('stream1', $array['src_stream']); + $this->assertSame('live-api-s.facebook.com', $array['dest_addr']); + $this->assertSame(443, $array['dest_port']); + $this->assertSame('rtmp', $array['dest_app']); + $this->assertSame('fb-key-123', $array['dest_stream']); + $this->assertTrue($array['ssl']); + $this->assertSame('NIMBLE', $array['auth_schema']); + $this->assertTrue($array['keep_src_stream_params']); + } - $dto = RestreamDto::fromArray($data); - $array = $dto->toArray(); + #[Test] + public function it_builds_a_create_payload_without_id_or_unset_fields(): void + { + $dto = new RestreamDto( + srcApp: 'live', + srcStream: 'stream1', + destAddr: 'example.com', + destPort: 1935, + destApp: 'live', + destStream: 'key', + id: 42, + ); + + $this->assertSame([ + 'src_app' => 'live', + 'src_stream' => 'stream1', + 'dest_addr' => 'example.com', + 'dest_port' => 1935, + 'dest_app' => 'live', + 'dest_stream' => 'key', + ], $dto->toCreatePayload()); + } + + #[Test] + public function it_includes_optional_fields_in_the_create_payload_when_set(): void + { + $payload = RestreamDto::fromArray($this->fullData())->toCreatePayload(); + + $this->assertArrayNotHasKey('id', $payload); + $this->assertTrue($payload['ssl']); + $this->assertSame('NIMBLE', $payload['auth_schema']); + $this->assertSame('user', $payload['dest_login']); + $this->assertSame('secret', $payload['dest_password']); + $this->assertTrue($payload['keep_src_stream_params']); + $this->assertSame('appParam=1', $payload['dest_app_params']); + $this->assertSame('streamParam=1', $payload['dest_stream_params']); + } + + #[Test] + public function it_refuses_to_build_a_create_payload_without_a_src_stream(): void + { + $dto = new RestreamDto( + srcApp: 'live', + srcStream: '', + destAddr: 'example.com', + destPort: 1935, + destApp: 'live', + destStream: 'key', + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('src_stream'); + + $dto->toCreatePayload(); + } + + #[Test] + public function it_decomposes_a_facebook_secure_stream_url(): void + { + $dto = RestreamDto::fromUrl( + 'live', + 'stream1', + 'rtmps://live-api-s.facebook.com:443/rtmp/FB-1234567890' + ); + + $this->assertSame('live', $dto->srcApp); + $this->assertSame('stream1', $dto->srcStream); + $this->assertSame('live-api-s.facebook.com', $dto->destAddr); + $this->assertSame(443, $dto->destPort); + $this->assertSame('rtmp', $dto->destApp); + $this->assertSame('FB-1234567890', $dto->destStream); + $this->assertTrue($dto->ssl); + } + + #[Test] + public function it_defaults_rtmps_urls_to_port_443(): void + { + $dto = RestreamDto::fromUrl('live', 'stream1', 'rtmps://live-api-s.facebook.com/rtmp/FB-KEY'); + + $this->assertSame(443, $dto->destPort); + $this->assertTrue($dto->ssl); + } + + #[Test] + public function it_defaults_rtmp_urls_to_port_1935_without_ssl(): void + { + $dto = RestreamDto::fromUrl('live', 'stream1', 'rtmp://a.rtmp.youtube.com/live2/yt-key'); + + $this->assertSame('a.rtmp.youtube.com', $dto->destAddr); + $this->assertSame(1935, $dto->destPort); + $this->assertSame('live2', $dto->destApp); + $this->assertSame('yt-key', $dto->destStream); + $this->assertNull($dto->ssl); + } + + #[Test] + public function it_keeps_query_strings_as_part_of_the_stream_key(): void + { + $dto = RestreamDto::fromUrl( + 'live', + 'stream1', + 'rtmps://live-api-s.facebook.com:443/rtmp/FB-KEY?s_bl=1&s_psm=1' + ); + + $this->assertSame('FB-KEY?s_bl=1&s_psm=1', $dto->destStream); + } + + #[Test] + public function it_treats_all_leading_path_segments_as_the_application(): void + { + $dto = RestreamDto::fromUrl('live', 'stream1', 'rtmp://example.com/live/nested/key'); + + $this->assertSame('live/nested', $dto->destApp); + $this->assertSame('key', $dto->destStream); + } + + #[Test] + public function it_passes_optional_auth_settings_through_from_url(): void + { + $dto = RestreamDto::fromUrl( + 'live', + 'stream1', + 'rtmp://example.com/live/key', + authSchema: 'NIMBLE', + destLogin: 'user', + destPassword: 'secret', + keepSrcStreamParams: true, + ); + + $this->assertSame('NIMBLE', $dto->authSchema); + $this->assertSame('user', $dto->destLogin); + $this->assertSame('secret', $dto->destPassword); + $this->assertTrue($dto->keepSrcStreamParams); + } + + #[Test] + public function it_rejects_non_rtmp_urls(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('rtmp or rtmps'); + + RestreamDto::fromUrl('live', 'stream1', 'https://example.com/live/key'); + } + + #[Test] + public function it_rejects_urls_without_a_stream_key_segment(): void + { + $this->expectException(InvalidArgumentException::class); + + RestreamDto::fromUrl('live', 'stream1', 'rtmp://example.com/apponly'); + } + + #[Test] + public function it_rejects_urls_without_a_host(): void + { + $this->expectException(InvalidArgumentException::class); - $this->assertIsArray($array); - $this->assertEquals('restream-123', $array['id']); - $this->assertEquals('rtmp://live.youtube.com/stream/key123', $array['target_url']); - $this->assertTrue($array['enabled']); + RestreamDto::fromUrl('live', 'stream1', 'rtmp:///live/key'); } } diff --git a/tests/Unit/DTOs/RestreamStatsDtoTest.php b/tests/Unit/DTOs/RestreamStatsDtoTest.php new file mode 100644 index 0000000..ee23305 --- /dev/null +++ b/tests/Unit/DTOs/RestreamStatsDtoTest.php @@ -0,0 +1,95 @@ + 42, + 'src_app' => 'live', + 'src_stream' => 'stream1', + 'dest_addr' => 'live-api-s.facebook.com', + 'dest_port' => 443, + 'dest_app' => 'rtmp', + 'dest_stream' => 'fb-key-123', + 'state' => 'connected', + 'session_duration' => 120, + 'bandwidth' => 2500, + 'bytes_recv' => 1000, + 'bytes_sent' => 900, + 'retry_count' => 3, + ]); + + $this->assertSame(42, $dto->id); + $this->assertSame('live', $dto->srcApp); + $this->assertSame('stream1', $dto->srcStream); + $this->assertSame('live-api-s.facebook.com', $dto->destAddr); + $this->assertSame(443, $dto->destPort); + $this->assertSame('rtmp', $dto->destApp); + $this->assertSame('fb-key-123', $dto->destStream); + $this->assertSame('connected', $dto->state); + $this->assertSame(120, $dto->sessionDuration); + $this->assertSame(2500, $dto->bandwidth); + $this->assertSame(1000, $dto->bytesRecv); + $this->assertSame(900, $dto->bytesSent); + $this->assertSame(3, $dto->retryCount); + } + + #[Test] + public function it_tolerates_missing_fields(): void + { + $dto = RestreamStatsDto::fromArray(['id' => 1, 'state' => 'reconnecting']); + + $this->assertSame(1, $dto->id); + $this->assertSame('reconnecting', $dto->state); + $this->assertNull($dto->srcApp); + $this->assertNull($dto->destPort); + $this->assertNull($dto->sessionDuration); + $this->assertNull($dto->bandwidth); + $this->assertNull($dto->bytesRecv); + $this->assertNull($dto->bytesSent); + $this->assertNull($dto->retryCount); + } + + #[Test] + public function it_casts_numeric_strings_to_int(): void + { + $dto = RestreamStatsDto::fromArray([ + 'dest_port' => '1935', + 'session_duration' => '60', + 'retry_count' => '2', + ]); + + $this->assertSame(1935, $dto->destPort); + $this->assertSame(60, $dto->sessionDuration); + $this->assertSame(2, $dto->retryCount); + } + + #[Test] + public function it_can_be_converted_to_array(): void + { + $dto = RestreamStatsDto::fromArray([ + 'id' => 42, + 'src_app' => 'live', + 'state' => 'connected', + 'bytes_sent' => 900, + ]); + + $array = $dto->toArray(); + + $this->assertSame(42, $array['id']); + $this->assertSame('live', $array['src_app']); + $this->assertSame('connected', $array['state']); + $this->assertSame(900, $array['bytes_sent']); + $this->assertNull($array['dest_addr']); + } +} diff --git a/tests/Unit/DTOs/ServerStatusDtoTest.php b/tests/Unit/DTOs/ServerStatusDtoTest.php index 5c00bd1..a7157a4 100644 --- a/tests/Unit/DTOs/ServerStatusDtoTest.php +++ b/tests/Unit/DTOs/ServerStatusDtoTest.php @@ -5,11 +5,12 @@ namespace AlexHackney\LaraNimble\Tests\Unit\DTOs; use AlexHackney\LaraNimble\DTOs\ServerStatusDto; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class ServerStatusDtoTest extends TestCase { - /** @test */ + #[Test] public function it_can_be_created_from_array(): void { $data = [ @@ -34,7 +35,7 @@ public function it_can_be_created_from_array(): void $this->assertEquals(1234567, $dto->bandwidth['in']); } - /** @test */ + #[Test] public function it_can_handle_optional_fields(): void { $data = [ @@ -49,7 +50,7 @@ public function it_can_handle_optional_fields(): void $this->assertNull($dto->bandwidth); } - /** @test */ + #[Test] public function it_can_be_converted_to_array(): void { $data = [ diff --git a/tests/Unit/DTOs/SessionDtoTest.php b/tests/Unit/DTOs/SessionDtoTest.php index 8fa765d..c0afa86 100644 --- a/tests/Unit/DTOs/SessionDtoTest.php +++ b/tests/Unit/DTOs/SessionDtoTest.php @@ -5,11 +5,12 @@ namespace AlexHackney\LaraNimble\Tests\Unit\DTOs; use AlexHackney\LaraNimble\DTOs\SessionDto; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class SessionDtoTest extends TestCase { - /** @test */ + #[Test] public function it_can_be_created_from_array(): void { $data = [ @@ -31,7 +32,7 @@ public function it_can_be_created_from_array(): void $this->assertEquals('rtmp', $dto->protocol); } - /** @test */ + #[Test] public function it_can_handle_optional_fields(): void { $data = [ @@ -48,7 +49,7 @@ public function it_can_handle_optional_fields(): void $this->assertNull($dto->bytesTransferred); } - /** @test */ + #[Test] public function it_can_be_converted_to_array(): void { $data = [ diff --git a/tests/Unit/DTOs/StreamDtoTest.php b/tests/Unit/DTOs/StreamDtoTest.php index 014cf1f..8deeccb 100644 --- a/tests/Unit/DTOs/StreamDtoTest.php +++ b/tests/Unit/DTOs/StreamDtoTest.php @@ -7,11 +7,12 @@ use AlexHackney\LaraNimble\DTOs\StreamDto; use AlexHackney\LaraNimble\Enums\StreamProtocol; use AlexHackney\LaraNimble\Enums\StreamStatus; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class StreamDtoTest extends TestCase { - /** @test */ + #[Test] public function it_can_be_created_from_array(): void { $data = [ @@ -30,7 +31,7 @@ public function it_can_be_created_from_array(): void $this->assertEquals(StreamProtocol::RTMP, $dto->protocol); } - /** @test */ + #[Test] public function it_can_handle_optional_fields(): void { $data = [ @@ -50,7 +51,7 @@ public function it_can_handle_optional_fields(): void $this->assertEquals(42, $dto->viewers); } - /** @test */ + #[Test] public function it_can_be_converted_to_array(): void { $data = [ @@ -73,7 +74,7 @@ public function it_can_be_converted_to_array(): void $this->assertEquals('rtmp', $array['protocol']); } - /** @test */ + #[Test] public function it_handles_null_optional_fields(): void { $data = [ @@ -90,7 +91,7 @@ public function it_handles_null_optional_fields(): void $this->assertNull($dto->viewers); } - /** @test */ + #[Test] public function it_can_handle_different_protocols(): void { $protocols = ['rtmp', 'mpegts', 'srt', 'ndi', 'hls', 'rtsp']; @@ -108,7 +109,7 @@ public function it_can_handle_different_protocols(): void } } - /** @test */ + #[Test] public function it_can_handle_different_statuses(): void { $statuses = ['active', 'inactive', 'error']; diff --git a/tests/Unit/DTOs/StreamStatsDtoTest.php b/tests/Unit/DTOs/StreamStatsDtoTest.php index d606600..4d18591 100644 --- a/tests/Unit/DTOs/StreamStatsDtoTest.php +++ b/tests/Unit/DTOs/StreamStatsDtoTest.php @@ -5,11 +5,12 @@ namespace AlexHackney\LaraNimble\Tests\Unit\DTOs; use AlexHackney\LaraNimble\DTOs\StreamStatsDto; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class StreamStatsDtoTest extends TestCase { - /** @test */ + #[Test] public function it_can_be_created_from_array(): void { $data = [ @@ -49,7 +50,7 @@ public function it_can_be_created_from_array(): void $this->assertEquals('2024-01-01 12:00:00', $dto->startTime); } - /** @test */ + #[Test] public function it_can_handle_optional_fields(): void { $data = [ @@ -67,7 +68,7 @@ public function it_can_handle_optional_fields(): void $this->assertNull($dto->protocol); } - /** @test */ + #[Test] public function it_can_be_converted_to_array(): void { $dto = new StreamStatsDto( @@ -107,7 +108,7 @@ public function it_can_be_converted_to_array(): void $this->assertEquals('2024-01-01 12:00:00', $array['start_time']); } - /** @test */ + #[Test] public function it_handles_alternative_field_names_from_api(): void { // Test with 'name' instead of 'stream_name' diff --git a/tests/Unit/Enums/EnumTest.php b/tests/Unit/Enums/EnumTest.php index 1b0ada6..c3d59a4 100644 --- a/tests/Unit/Enums/EnumTest.php +++ b/tests/Unit/Enums/EnumTest.php @@ -7,11 +7,12 @@ use AlexHackney\LaraNimble\Enums\PublishAction; use AlexHackney\LaraNimble\Enums\StreamProtocol; use AlexHackney\LaraNimble\Enums\StreamStatus; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class EnumTest extends TestCase { - /** @test */ + #[Test] public function stream_protocol_enum_has_expected_cases(): void { $this->assertEquals('rtmp', StreamProtocol::RTMP->value); @@ -22,7 +23,7 @@ public function stream_protocol_enum_has_expected_cases(): void $this->assertEquals('rtsp', StreamProtocol::RTSP->value); } - /** @test */ + #[Test] public function stream_protocol_enum_has_all_cases(): void { $cases = StreamProtocol::cases(); @@ -31,7 +32,7 @@ public function stream_protocol_enum_has_all_cases(): void $this->assertContainsOnlyInstancesOf(StreamProtocol::class, $cases); } - /** @test */ + #[Test] public function stream_status_enum_has_expected_cases(): void { $this->assertEquals('active', StreamStatus::ACTIVE->value); @@ -39,7 +40,7 @@ public function stream_status_enum_has_expected_cases(): void $this->assertEquals('error', StreamStatus::ERROR->value); } - /** @test */ + #[Test] public function stream_status_enum_has_all_cases(): void { $cases = StreamStatus::cases(); @@ -48,14 +49,14 @@ public function stream_status_enum_has_all_cases(): void $this->assertContainsOnlyInstancesOf(StreamStatus::class, $cases); } - /** @test */ + #[Test] public function publish_action_enum_has_expected_cases(): void { $this->assertEquals('publish', PublishAction::PUBLISH->value); $this->assertEquals('unpublish', PublishAction::UNPUBLISH->value); } - /** @test */ + #[Test] public function publish_action_enum_has_all_cases(): void { $cases = PublishAction::cases(); @@ -64,7 +65,7 @@ public function publish_action_enum_has_all_cases(): void $this->assertContainsOnlyInstancesOf(PublishAction::class, $cases); } - /** @test */ + #[Test] public function stream_protocol_can_be_created_from_string(): void { $protocol = StreamProtocol::from('rtmp'); @@ -72,7 +73,7 @@ public function stream_protocol_can_be_created_from_string(): void $this->assertEquals(StreamProtocol::RTMP, $protocol); } - /** @test */ + #[Test] public function stream_status_can_be_created_from_string(): void { $status = StreamStatus::from('active'); @@ -80,7 +81,7 @@ public function stream_status_can_be_created_from_string(): void $this->assertEquals(StreamStatus::ACTIVE, $status); } - /** @test */ + #[Test] public function publish_action_can_be_created_from_string(): void { $action = PublishAction::from('publish'); diff --git a/tests/Unit/Exceptions/ExceptionTest.php b/tests/Unit/Exceptions/ExceptionTest.php index 9410564..4d7d4c5 100644 --- a/tests/Unit/Exceptions/ExceptionTest.php +++ b/tests/Unit/Exceptions/ExceptionTest.php @@ -10,11 +10,12 @@ use AlexHackney\LaraNimble\Exceptions\NimbleConnectionException; use AlexHackney\LaraNimble\Exceptions\NimbleException; use AlexHackney\LaraNimble\Exceptions\StreamNotFoundException; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class ExceptionTest extends TestCase { - /** @test */ + #[Test] public function nimble_exception_can_be_instantiated(): void { $exception = new NimbleException('Test error'); @@ -23,7 +24,7 @@ public function nimble_exception_can_be_instantiated(): void $this->assertEquals('Test error', $exception->getMessage()); } - /** @test */ + #[Test] public function nimble_connection_exception_extends_nimble_exception(): void { $exception = new NimbleConnectionException('Connection failed'); @@ -32,7 +33,7 @@ public function nimble_connection_exception_extends_nimble_exception(): void $this->assertEquals('Connection failed', $exception->getMessage()); } - /** @test */ + #[Test] public function nimble_authentication_exception_extends_nimble_exception(): void { $exception = new NimbleAuthenticationException('Auth failed'); @@ -41,7 +42,7 @@ public function nimble_authentication_exception_extends_nimble_exception(): void $this->assertEquals('Auth failed', $exception->getMessage()); } - /** @test */ + #[Test] public function nimble_api_exception_extends_nimble_exception(): void { $exception = new NimbleApiException('API error', 500); @@ -51,7 +52,7 @@ public function nimble_api_exception_extends_nimble_exception(): void $this->assertEquals(500, $exception->getCode()); } - /** @test */ + #[Test] public function stream_not_found_exception_extends_nimble_exception(): void { $exception = new StreamNotFoundException('Stream not found'); @@ -60,7 +61,7 @@ public function stream_not_found_exception_extends_nimble_exception(): void $this->assertEquals('Stream not found', $exception->getMessage()); } - /** @test */ + #[Test] public function invalid_configuration_exception_extends_nimble_exception(): void { $exception = new InvalidConfigurationException('Invalid config'); @@ -69,7 +70,7 @@ public function invalid_configuration_exception_extends_nimble_exception(): void $this->assertEquals('Invalid config', $exception->getMessage()); } - /** @test */ + #[Test] public function exceptions_can_have_previous_exception(): void { $previous = new \RuntimeException('Previous error'); @@ -78,7 +79,7 @@ public function exceptions_can_have_previous_exception(): void $this->assertSame($previous, $exception->getPrevious()); } - /** @test */ + #[Test] public function exceptions_can_have_custom_codes(): void { $exception = new NimbleException('Error with code', 404); diff --git a/tests/Unit/Services/CacheServiceTest.php b/tests/Unit/Services/CacheServiceTest.php index 12a58ca..4ae7b0a 100644 --- a/tests/Unit/Services/CacheServiceTest.php +++ b/tests/Unit/Services/CacheServiceTest.php @@ -10,6 +10,7 @@ use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class CacheServiceTest extends TestCase @@ -30,7 +31,7 @@ private function createService(MockHandler $mockHandler): CacheService return new CacheService($nimbleClient); } - /** @test */ + #[Test] public function it_can_clear_cache(): void { $mock = new MockHandler([ @@ -46,7 +47,7 @@ public function it_can_clear_cache(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_can_clear_cache_with_type(): void { $mock = new MockHandler([ @@ -62,7 +63,7 @@ public function it_can_clear_cache_with_type(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_returns_false_when_clear_fails(): void { $mock = new MockHandler([ @@ -78,7 +79,7 @@ public function it_returns_false_when_clear_fails(): void $this->assertFalse($result); } - /** @test */ + #[Test] public function it_can_get_cache_statistics(): void { $mock = new MockHandler([ @@ -99,7 +100,7 @@ public function it_can_get_cache_statistics(): void $this->assertEquals(0.85, $stats['hit_rate']); } - /** @test */ + #[Test] public function it_can_configure_cache_settings(): void { $mock = new MockHandler([ @@ -118,7 +119,7 @@ public function it_can_configure_cache_settings(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_returns_false_when_configure_fails(): void { $mock = new MockHandler([ diff --git a/tests/Unit/Services/DvrServiceTest.php b/tests/Unit/Services/DvrServiceTest.php index 205ba41..19a3895 100644 --- a/tests/Unit/Services/DvrServiceTest.php +++ b/tests/Unit/Services/DvrServiceTest.php @@ -12,6 +12,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use Illuminate\Support\Collection; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class DvrServiceTest extends TestCase @@ -32,7 +33,7 @@ private function createService(MockHandler $mockHandler): DvrService return new DvrService($nimbleClient); } - /** @test */ + #[Test] public function it_can_list_archives(): void { $mock = new MockHandler([ @@ -65,7 +66,7 @@ public function it_can_list_archives(): void $this->assertEquals('archive-1', $archives->first()->id); } - /** @test */ + #[Test] public function it_can_get_a_specific_archive(): void { $mock = new MockHandler([ @@ -86,7 +87,7 @@ public function it_can_get_a_specific_archive(): void $this->assertEquals('stream-123', $archive->streamId); } - /** @test */ + #[Test] public function it_can_delete_an_archive(): void { $mock = new MockHandler([ @@ -102,7 +103,7 @@ public function it_can_delete_an_archive(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_returns_false_when_delete_fails(): void { $mock = new MockHandler([ @@ -118,7 +119,7 @@ public function it_returns_false_when_delete_fails(): void $this->assertFalse($result); } - /** @test */ + #[Test] public function it_can_configure_dvr_settings(): void { $mock = new MockHandler([ diff --git a/tests/Unit/Services/PullServiceTest.php b/tests/Unit/Services/PullServiceTest.php index 31cef83..d9176ff 100644 --- a/tests/Unit/Services/PullServiceTest.php +++ b/tests/Unit/Services/PullServiceTest.php @@ -12,6 +12,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use Illuminate\Support\Collection; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class PullServiceTest extends TestCase @@ -32,7 +33,7 @@ private function createService(MockHandler $mockHandler): PullService return new PullService($nimbleClient); } - /** @test */ + #[Test] public function it_can_list_pull_configurations(): void { $mock = new MockHandler([ @@ -69,7 +70,7 @@ public function it_can_list_pull_configurations(): void $this->assertEquals('pull-1', $pulls->first()->id); } - /** @test */ + #[Test] public function it_can_get_a_specific_pull_configuration(): void { $mock = new MockHandler([ @@ -92,7 +93,7 @@ public function it_can_get_a_specific_pull_configuration(): void $this->assertEquals('live', $pull->localApp); } - /** @test */ + #[Test] public function it_can_add_a_pull_configuration(): void { $mock = new MockHandler([ @@ -114,7 +115,7 @@ public function it_can_add_a_pull_configuration(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_can_update_a_pull_configuration(): void { $mock = new MockHandler([ @@ -131,7 +132,7 @@ public function it_can_update_a_pull_configuration(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_can_delete_a_pull_configuration(): void { $mock = new MockHandler([ @@ -147,7 +148,7 @@ public function it_can_delete_a_pull_configuration(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_returns_false_when_delete_fails(): void { $mock = new MockHandler([ @@ -163,7 +164,7 @@ public function it_returns_false_when_delete_fails(): void $this->assertFalse($result); } - /** @test */ + #[Test] public function it_can_get_pull_status(): void { $mock = new MockHandler([ diff --git a/tests/Unit/Services/RestreamServiceTest.php b/tests/Unit/Services/RestreamServiceTest.php index 2ffa650..4eeda5b 100644 --- a/tests/Unit/Services/RestreamServiceTest.php +++ b/tests/Unit/Services/RestreamServiceTest.php @@ -6,19 +6,30 @@ use AlexHackney\LaraNimble\Client\NimbleClient; use AlexHackney\LaraNimble\DTOs\RestreamDto; +use AlexHackney\LaraNimble\DTOs\RestreamStatsDto; +use AlexHackney\LaraNimble\Exceptions\NimbleApiException; use AlexHackney\LaraNimble\Services\RestreamService; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Response; use Illuminate\Support\Collection; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; class RestreamServiceTest extends TestCase { + /** @var array */ + private array $history = []; + private function createService(MockHandler $mockHandler): RestreamService { + $this->history = []; + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push(Middleware::history($this->history)); $httpClient = new Client(['handler' => $handlerStack]); $config = [ @@ -32,129 +43,249 @@ private function createService(MockHandler $mockHandler): RestreamService return new RestreamService($nimbleClient); } - /** @test */ - public function it_can_list_restream_targets(): void + private function lastRequest(): RequestInterface + { + $this->assertNotEmpty($this->history); + + return $this->history[count($this->history) - 1]['request']; + } + + private function sampleRule(array $overrides = []): array + { + return array_merge([ + 'id' => 1, + 'src_app' => 'live', + 'src_stream' => 'stream1', + 'dest_addr' => 'live-api-s.facebook.com', + 'dest_port' => 443, + 'dest_app' => 'rtmp', + 'dest_stream' => 'fb-key-123', + 'ssl' => true, + ], $overrides); + } + + #[Test] + public function it_lists_republish_rules_from_the_native_endpoint(): void { $mock = new MockHandler([ new Response(200, [], json_encode([ - 'restreams' => [ - [ - 'id' => 'restream-1', - 'stream_id' => 'stream-123', - 'target_url' => 'rtmp://live.youtube.com/stream/key1', - 'protocol' => 'rtmp', - 'enabled' => true, - 'status' => 'active', - ], - [ - 'id' => 'restream-2', - 'stream_id' => 'stream-123', - 'target_url' => 'rtmp://live.facebook.com/stream/key2', - 'protocol' => 'rtmp', - 'enabled' => false, - 'status' => 'inactive', - ], + 'status' => 'Ok', + 'rules' => [ + $this->sampleRule(), + $this->sampleRule([ + 'id' => 2, + 'dest_addr' => 'a.rtmp.youtube.com', + 'dest_port' => 1935, + 'dest_app' => 'live2', + 'dest_stream' => 'yt-key', + 'ssl' => false, + ]), ], ])), ]); $service = $this->createService($mock); - $restreams = $service->list(); + $rules = $service->list(); + + $this->assertInstanceOf(Collection::class, $rules); + $this->assertCount(2, $rules); + $this->assertContainsOnlyInstancesOf(RestreamDto::class, $rules); + $this->assertSame(1, $rules->first()->id); + $this->assertSame('live-api-s.facebook.com', $rules->first()->destAddr); + + $request = $this->lastRequest(); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/manage/rtmp/republish', $request->getUri()->getPath()); + } + + #[Test] + public function it_returns_an_empty_collection_when_there_are_no_rules(): void + { + $mock = new MockHandler([ + new Response(200, [], json_encode(['status' => 'Ok', 'rules' => []])), + ]); + + $service = $this->createService($mock); - $this->assertInstanceOf(Collection::class, $restreams); - $this->assertCount(2, $restreams); - $this->assertContainsOnlyInstancesOf(RestreamDto::class, $restreams); - $this->assertEquals('restream-1', $restreams->first()->id); + $this->assertTrue($service->list()->isEmpty()); } - /** @test */ - public function it_can_get_a_specific_restream_target(): void + #[Test] + public function it_gets_a_single_rule_by_id(): void { $mock = new MockHandler([ new Response(200, [], json_encode([ - 'id' => 'restream-789', - 'stream_id' => 'stream-123', - 'target_url' => 'rtmp://live.youtube.com/stream/key789', - 'protocol' => 'rtmp', - 'enabled' => true, - 'status' => 'active', + 'status' => 'Ok', + 'rules' => [$this->sampleRule(['id' => 42])], ])), ]); $service = $this->createService($mock); - $restream = $service->get('restream-789'); + $rule = $service->get(42); + + $this->assertInstanceOf(RestreamDto::class, $rule); + $this->assertSame(42, $rule->id); + $this->assertSame('live', $rule->srcApp); + $this->assertSame('stream1', $rule->srcStream); - $this->assertInstanceOf(RestreamDto::class, $restream); - $this->assertEquals('restream-789', $restream->id); - $this->assertEquals('stream-123', $restream->streamId); + $request = $this->lastRequest(); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/manage/rtmp/republish/42', $request->getUri()->getPath()); } - /** @test */ - public function it_can_add_a_restream_target(): void + #[Test] + public function it_gets_a_single_rule_when_nimble_returns_a_bare_rule_object(): void { $mock = new MockHandler([ new Response(200, [], json_encode([ - 'success' => true, - 'id' => 'restream-new', + 'status' => 'Ok', + 'rule' => $this->sampleRule(['id' => 7]), ])), ]); $service = $this->createService($mock); - $result = $service->add('stream-123', [ - 'target_url' => 'rtmp://live.youtube.com/stream/newkey', - 'protocol' => 'rtmp', - 'enabled' => true, + $rule = $service->get(7); + + $this->assertInstanceOf(RestreamDto::class, $rule); + $this->assertSame(7, $rule->id); + } + + #[Test] + public function it_returns_null_when_a_rule_does_not_exist(): void + { + $mock = new MockHandler([ + new Response(200, [], json_encode(['status' => 'NotFound', 'rules' => []])), ]); - $this->assertTrue($result); + $service = $this->createService($mock); + + $this->assertNull($service->get(999)); } - /** @test */ - public function it_can_update_a_restream_target(): void + #[Test] + public function it_creates_a_rule_and_returns_it_with_the_assigned_id(): void { $mock = new MockHandler([ new Response(200, [], json_encode([ - 'success' => true, + 'status' => 'Ok', + 'rule' => $this->sampleRule(['id' => 11]), ])), ]); $service = $this->createService($mock); - $result = $service->update('restream-789', [ - 'enabled' => false, + + $created = $service->create(new RestreamDto( + srcApp: 'live', + srcStream: 'stream1', + destAddr: 'live-api-s.facebook.com', + destPort: 443, + destApp: 'rtmp', + destStream: 'fb-key-123', + ssl: true, + )); + + $this->assertInstanceOf(RestreamDto::class, $created); + $this->assertSame(11, $created->id); + + $request = $this->lastRequest(); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('/manage/rtmp/republish', $request->getUri()->getPath()); + + $body = json_decode((string) $request->getBody(), true); + $this->assertSame([ + 'src_app' => 'live', + 'src_stream' => 'stream1', + 'dest_addr' => 'live-api-s.facebook.com', + 'dest_port' => 443, + 'dest_app' => 'rtmp', + 'dest_stream' => 'fb-key-123', + 'ssl' => true, + ], $body); + } + + #[Test] + public function it_throws_when_creation_does_not_return_a_rule(): void + { + $mock = new MockHandler([ + new Response(200, [], json_encode(['status' => 'Error'])), ]); - $this->assertTrue($result); + $service = $this->createService($mock); + + $this->expectException(NimbleApiException::class); + + $service->create(new RestreamDto( + srcApp: 'live', + srcStream: 'stream1', + destAddr: 'example.com', + destPort: 1935, + destApp: 'live', + destStream: 'key', + )); } - /** @test */ - public function it_can_delete_a_restream_target(): void + #[Test] + public function it_deletes_a_rule(): void { $mock = new MockHandler([ - new Response(200, [], json_encode([ - 'success' => true, - 'message' => 'Restream target deleted', - ])), + new Response(200, [], json_encode(['status' => 'Ok'])), ]); $service = $this->createService($mock); - $result = $service->delete('restream-789'); - $this->assertTrue($result); + $this->assertTrue($service->delete(42)); + + $request = $this->lastRequest(); + $this->assertSame('DELETE', $request->getMethod()); + $this->assertSame('/manage/rtmp/republish/42', $request->getUri()->getPath()); + } + + #[Test] + public function it_returns_false_when_deleting_a_missing_rule(): void + { + $mock = new MockHandler([ + new Response(200, [], json_encode(['status' => 'NotFound'])), + ]); + + $service = $this->createService($mock); + + $this->assertFalse($service->delete(999)); } - /** @test */ - public function it_returns_false_when_delete_fails(): void + #[Test] + public function it_fetches_republish_stats(): void { $mock = new MockHandler([ new Response(200, [], json_encode([ - 'success' => false, - 'error' => 'Restream target not found', + 'status' => 'Ok', + 'stats' => [ + array_merge($this->sampleRule(), [ + 'state' => 'connected', + 'session_duration' => 120, + 'bandwidth' => 2500, + 'bytes_recv' => 1000, + 'bytes_sent' => 900, + 'retry_count' => 0, + ]), + ], ])), ]); $service = $this->createService($mock); - $result = $service->delete('restream-789'); + $stats = $service->stats(); + + $this->assertInstanceOf(Collection::class, $stats); + $this->assertCount(1, $stats); + $this->assertContainsOnlyInstancesOf(RestreamStatsDto::class, $stats); + + $entry = $stats->first(); + $this->assertSame('connected', $entry->state); + $this->assertSame(120, $entry->sessionDuration); + $this->assertSame(0, $entry->retryCount); + $this->assertSame('live-api-s.facebook.com', $entry->destAddr); - $this->assertFalse($result); + $request = $this->lastRequest(); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/manage/rtmp/republish/stats', $request->getUri()->getPath()); } } diff --git a/tests/Unit/Services/ServerServiceTest.php b/tests/Unit/Services/ServerServiceTest.php index d31b598..af78382 100644 --- a/tests/Unit/Services/ServerServiceTest.php +++ b/tests/Unit/Services/ServerServiceTest.php @@ -11,6 +11,7 @@ use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class ServerServiceTest extends TestCase @@ -31,7 +32,7 @@ private function createService(MockHandler $mockHandler): ServerService return new ServerService($nimbleClient); } - /** @test */ + #[Test] public function it_can_get_server_status(): void { $mock = new MockHandler([ @@ -56,7 +57,7 @@ public function it_can_get_server_status(): void $this->assertEquals(42, $status->connections); } - /** @test */ + #[Test] public function it_can_reload_configuration(): void { $mock = new MockHandler([ @@ -72,7 +73,7 @@ public function it_can_reload_configuration(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_returns_false_when_reload_fails(): void { $mock = new MockHandler([ @@ -88,7 +89,7 @@ public function it_returns_false_when_reload_fails(): void $this->assertFalse($result); } - /** @test */ + #[Test] public function it_can_sync_with_wms_panel(): void { $mock = new MockHandler([ @@ -104,7 +105,7 @@ public function it_can_sync_with_wms_panel(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_returns_false_when_sync_fails(): void { $mock = new MockHandler([ diff --git a/tests/Unit/Services/SessionServiceTest.php b/tests/Unit/Services/SessionServiceTest.php index 06b56d1..8e4031b 100644 --- a/tests/Unit/Services/SessionServiceTest.php +++ b/tests/Unit/Services/SessionServiceTest.php @@ -12,6 +12,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use Illuminate\Support\Collection; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class SessionServiceTest extends TestCase @@ -32,7 +33,7 @@ private function createService(MockHandler $mockHandler): SessionService return new SessionService($nimbleClient); } - /** @test */ + #[Test] public function it_can_list_sessions(): void { $mock = new MockHandler([ @@ -65,7 +66,7 @@ public function it_can_list_sessions(): void $this->assertEquals('session-1', $sessions->first()->id); } - /** @test */ + #[Test] public function it_can_get_a_specific_session(): void { $mock = new MockHandler([ @@ -86,7 +87,7 @@ public function it_can_get_a_specific_session(): void $this->assertEquals('stream-123', $session->streamId); } - /** @test */ + #[Test] public function it_can_terminate_a_session(): void { $mock = new MockHandler([ @@ -102,7 +103,7 @@ public function it_can_terminate_a_session(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_returns_false_when_terminate_fails(): void { $mock = new MockHandler([ @@ -118,7 +119,7 @@ public function it_returns_false_when_terminate_fails(): void $this->assertFalse($result); } - /** @test */ + #[Test] public function it_can_get_session_statistics(): void { $mock = new MockHandler([ diff --git a/tests/Unit/Services/StreamServiceTest.php b/tests/Unit/Services/StreamServiceTest.php index 6b278f6..2f3f480 100644 --- a/tests/Unit/Services/StreamServiceTest.php +++ b/tests/Unit/Services/StreamServiceTest.php @@ -13,6 +13,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use Illuminate\Support\Collection; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class StreamServiceTest extends TestCase @@ -33,7 +34,7 @@ private function createService(MockHandler $mockHandler): StreamService return new StreamService($nimbleClient); } - /** @test */ + #[Test] public function it_can_list_streams(): void { $mock = new MockHandler([ @@ -65,7 +66,7 @@ public function it_can_list_streams(): void $this->assertEquals('live-stream', $streams->first()->name); } - /** @test */ + #[Test] public function it_can_get_a_specific_stream(): void { $mock = new MockHandler([ @@ -86,7 +87,7 @@ public function it_can_get_a_specific_stream(): void $this->assertEquals('active', $stream->status->value); } - /** @test */ + #[Test] public function it_can_publish_a_stream(): void { $mock = new MockHandler([ @@ -102,7 +103,7 @@ public function it_can_publish_a_stream(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_can_unpublish_a_stream(): void { $mock = new MockHandler([ @@ -117,7 +118,7 @@ public function it_can_unpublish_a_stream(): void $this->assertTrue($result); } - /** @test */ + #[Test] public function it_returns_false_when_publish_fails(): void { $mock = new MockHandler([ @@ -133,7 +134,7 @@ public function it_returns_false_when_publish_fails(): void $this->assertFalse($result); } - /** @test */ + #[Test] public function it_can_get_stream_statistics(): void { $mock = new MockHandler([ @@ -154,7 +155,7 @@ public function it_can_get_stream_statistics(): void $this->assertEquals(42, $stats['viewers']); } - /** @test */ + #[Test] public function it_returns_empty_collection_when_no_streams(): void { $mock = new MockHandler([ @@ -170,7 +171,7 @@ public function it_returns_empty_collection_when_no_streams(): void $this->assertCount(0, $streams); } - /** @test */ + #[Test] public function it_can_get_live_status_for_specific_stream(): void { $mock = new MockHandler([ @@ -218,7 +219,7 @@ public function it_can_get_live_status_for_specific_stream(): void $this->assertEquals(30.0, $stats->fps); } - /** @test */ + #[Test] public function it_returns_null_when_stream_is_not_live(): void { $mock = new MockHandler([ @@ -238,7 +239,7 @@ public function it_returns_null_when_stream_is_not_live(): void $this->assertNull($stats); } - /** @test */ + #[Test] public function it_can_get_all_live_streams(): void { $mock = new MockHandler([ @@ -287,7 +288,7 @@ public function it_can_get_all_live_streams(): void $this->assertEquals('ndi', $streams->get(2)->protocol); } - /** @test */ + #[Test] public function it_returns_empty_collection_when_no_live_streams(): void { $mock = new MockHandler([