d08e280ce0
Replace the server-rendered episode upload form with a Livewire component, introducing an interactive admin experience that validates the new episode's stream path against all configured mirrors before saving. The controller-based `store` method and its route are removed, and episode creation now flows through the `AdminEpisodeForm` component with frontend upload handling and dynamic quality checks. Key changes: - Add `AdminEpisodeForm` Livewire component with file uploads, tag handling, and debounced stream/base-url validation. - Extend `CdnPathValidator::validateStream` to accept an episode number so the manifest path can point to the specific episode being added. - Remove the `store` method from `EpisodeController` and its POST route. - Delete the legacy `createEpisode` method from `EpisodeService`. - Replace the Blade upload modal with the Livewire view, including progress indicators and validation badges. - Add feature tests for the Livewire component and update unit tests for the CDN validator's episode-specific manifest check.
175 lines
7.3 KiB
PHP
175 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Services\CdnPathValidator;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Tests\TestCase;
|
|
|
|
class CdnPathValidatorTest extends TestCase
|
|
{
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Cache::flush();
|
|
|
|
config([
|
|
'hstream.download_domain_4k' => ['https://dl4k-a.test', 'https://dl4k-b.test'],
|
|
]);
|
|
}
|
|
|
|
public function test_fhd_uses_4k_domains_and_hentai_1080p_prefix(): void
|
|
{
|
|
Http::fake([
|
|
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
|
|
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
|
|
]);
|
|
|
|
$result = app(CdnPathValidator::class)->validateDownload('FHD', '2026/Title/E01.mkv');
|
|
|
|
$this->assertTrue($result['valid']);
|
|
$this->assertSame(12345, $result['size']);
|
|
$this->assertSame([
|
|
'https://dl4k-a.test' => ['hentai-1080p/2026/Title/E01.mkv' => true],
|
|
'https://dl4k-b.test' => ['hentai-1080p/2026/Title/E01.mkv' => true],
|
|
], $result['mirrors']);
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'https://dl4k-a.test/check/'));
|
|
}
|
|
|
|
public function test_uhd_uses_download_domain_4k_and_hentai_prefix(): void
|
|
{
|
|
Http::fake([
|
|
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 777]),
|
|
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 777]),
|
|
]);
|
|
|
|
$result = app(CdnPathValidator::class)->validateDownload('UHD', '2026/Title/E01.mkv');
|
|
|
|
$this->assertTrue($result['valid']);
|
|
$this->assertArrayHasKey('hentai/2026/Title/E01.mkv', $result['mirrors']['https://dl4k-a.test']);
|
|
$this->assertArrayNotHasKey('hentai-1080p/2026/Title/E01.mkv', $result['mirrors']['https://dl4k-a.test']);
|
|
}
|
|
|
|
public function test_missing_on_one_mirror_is_invalid(): void
|
|
{
|
|
Http::fake([
|
|
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
|
|
'https://dl4k-b.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404),
|
|
]);
|
|
|
|
$result = app(CdnPathValidator::class)->validateDownload('FHD', '2026/Title/E01.mkv');
|
|
|
|
$this->assertFalse($result['valid']);
|
|
$this->assertNull($result['size']);
|
|
$this->assertTrue($result['mirrors']['https://dl4k-a.test']['hentai-1080p/2026/Title/E01.mkv']);
|
|
$this->assertFalse($result['mirrors']['https://dl4k-b.test']['hentai-1080p/2026/Title/E01.mkv']);
|
|
}
|
|
|
|
public function test_connection_error_is_invalid(): void
|
|
{
|
|
Http::fake(function ($request) {
|
|
if (str_starts_with($request->url(), 'https://dl4k-a.test/')) {
|
|
return Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]);
|
|
}
|
|
|
|
throw new ConnectionException('Connection timed out', 0);
|
|
});
|
|
|
|
$result = app(CdnPathValidator::class)->validateDownload('FHD', '2026/Title/E01.mkv');
|
|
|
|
$this->assertFalse($result['valid']);
|
|
$this->assertNull($result['size']);
|
|
$this->assertFalse($result['mirrors']['https://dl4k-b.test']['hentai-1080p/2026/Title/E01.mkv']);
|
|
}
|
|
|
|
public function test_stream_checks_base_directory_and_first_manifest(): void
|
|
{
|
|
Http::fake([
|
|
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
|
|
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
|
|
]);
|
|
|
|
$result = app(CdnPathValidator::class)->validateStream('2026/Title', 3);
|
|
|
|
$this->assertTrue($result['valid']);
|
|
$this->assertArrayHasKey('hentai-stream/2026/Title', $result['mirrors']['https://dl4k-a.test']);
|
|
$this->assertArrayHasKey('hentai-stream/2026/Title/E01/720/manifest.mpd', $result['mirrors']['https://dl4k-a.test']);
|
|
|
|
Http::assertSentCount(4);
|
|
}
|
|
|
|
public function test_stream_can_target_specific_episode_manifest(): void
|
|
{
|
|
Http::fake([
|
|
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
|
|
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
|
|
]);
|
|
|
|
$result = app(CdnPathValidator::class)->validateStream('2026/Title', 1, false, 2);
|
|
|
|
$this->assertTrue($result['valid']);
|
|
$this->assertArrayHasKey('hentai-stream/2026/Title', $result['mirrors']['https://dl4k-a.test']);
|
|
$this->assertArrayHasKey('hentai-stream/2026/Title/E02/720/manifest.mpd', $result['mirrors']['https://dl4k-a.test']);
|
|
$this->assertArrayHasKey('hentai-stream/2026/Title/E02/720/manifest.mpd', $result['mirrors']['https://dl4k-b.test']);
|
|
$this->assertArrayNotHasKey('hentai-stream/2026/Title/E01/720/manifest.mpd', $result['mirrors']['https://dl4k-a.test']);
|
|
$this->assertArrayNotHasKey('hentai-stream/2026/Title/E01/720/manifest.mpd', $result['mirrors']['https://dl4k-b.test']);
|
|
$this->assertSame(4, count(Http::recorded()));
|
|
}
|
|
|
|
public function test_results_are_cached_for_short_period(): void
|
|
{
|
|
Http::fake([
|
|
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
|
|
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]),
|
|
]);
|
|
|
|
$validator = app(CdnPathValidator::class);
|
|
$first = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
|
|
$second = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
|
|
|
|
$this->assertTrue($first['valid']);
|
|
$this->assertTrue($second['valid']);
|
|
Http::assertSentCount(2);
|
|
}
|
|
|
|
public function test_fresh_check_bypasses_cache(): void
|
|
{
|
|
$valid = true;
|
|
|
|
Http::fake(function ($request) use (&$valid) {
|
|
return Http::response(
|
|
$valid ? ['valid' => true, 'type' => 'file', 'size' => 12345] : ['valid' => false, 'error' => 'File not found!'],
|
|
$valid ? 200 : 404
|
|
);
|
|
});
|
|
|
|
$validator = app(CdnPathValidator::class);
|
|
|
|
$cached = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
|
|
$this->assertTrue($cached['valid']);
|
|
$this->assertCount(2, Http::recorded());
|
|
|
|
// The CDN result changes within the cache window, but a non-fresh
|
|
// check still serves the cached valid result.
|
|
$valid = false;
|
|
|
|
$stale = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
|
|
$this->assertTrue($stale['valid']);
|
|
$this->assertCount(2, Http::recorded());
|
|
|
|
// A fresh check bypasses the cache and overwrites the stored result.
|
|
$fresh = $validator->validateDownload('FHD', '2026/Title/E01.mkv', true);
|
|
$this->assertFalse($fresh['valid']);
|
|
$this->assertCount(4, Http::recorded());
|
|
|
|
// The fresh (invalid) result is now what subsequent cached checks serve.
|
|
$after = $validator->validateDownload('FHD', '2026/Title/E01.mkv');
|
|
$this->assertFalse($after['valid']);
|
|
$this->assertCount(4, Http::recorded());
|
|
}
|
|
}
|