From d08e280ce0317019382c31a689846b11087770d3 Mon Sep 17 00:00:00 2001 From: w33b Date: Sat, 8 Aug 2026 14:29:05 +0200 Subject: [PATCH] refactor(episode): migrate episode upload to Livewire with real-time CDN verification 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. --- .../Controllers/Admin/EpisodeController.php | 28 -- app/Livewire/AdminEpisodeForm.php | 284 ++++++++++++++++++ app/Services/CdnPathValidator.php | 6 +- app/Services/EpisodeService.php | 33 -- .../admin/modals/upload-episode.blade.php | 70 +---- .../livewire/admin-episode-form.blade.php | 185 ++++++++++++ routes/admin.php | 3 - .../Feature/Livewire/AdminEpisodeFormTest.php | 283 +++++++++++++++++ tests/Unit/CdnPathValidatorTest.php | 18 ++ 9 files changed, 774 insertions(+), 136 deletions(-) create mode 100644 app/Livewire/AdminEpisodeForm.php create mode 100644 resources/views/livewire/admin-episode-form.blade.php create mode 100644 tests/Feature/Livewire/AdminEpisodeFormTest.php diff --git a/app/Http/Controllers/Admin/EpisodeController.php b/app/Http/Controllers/Admin/EpisodeController.php index 101c8b9..317ed82 100644 --- a/app/Http/Controllers/Admin/EpisodeController.php +++ b/app/Http/Controllers/Admin/EpisodeController.php @@ -30,34 +30,6 @@ class EpisodeController extends Controller $this->downloadService = $downloadService; } - /** - * Add Episode to existing series - */ - public function store(Request $request): RedirectResponse - { - $referenceEpisode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail(); - $episodeNumber = $referenceEpisode->hentai->episodes()->count() + 1; - - // Create Episode - $episode = $this->episodeService->createEpisode($request, $referenceEpisode->hentai, $episodeNumber, null, $referenceEpisode); - $this->episodeService->createOrUpdateCover($request, $episode, $referenceEpisode->hentai->slug, 1); - $this->downloadService->createOrUpdateDownloads($request, $episode, 1); - $this->galleryService->createOrUpdateGallery($request, $referenceEpisode->hentai, $episode, $episodeNumber, true); - - // Discord Alert - if ($request->has('censored')) { - DiscordReleaseNotification::dispatch($referenceEpisode->title.' - '.$episodeNumber, 'release-censored'); - } else { - DiscordReleaseNotification::dispatch($episode->slug, 'release'); - } - - cache()->flush(); - - return to_route('hentai.index', [ - 'title' => $episode->slug, - ]); - } - /** * Edit Episode */ diff --git a/app/Livewire/AdminEpisodeForm.php b/app/Livewire/AdminEpisodeForm.php new file mode 100644 index 0000000..acd2e35 --- /dev/null +++ b/app/Livewire/AdminEpisodeForm.php @@ -0,0 +1,284 @@ + '', 'fhdi' => '', 'uhd' => '', 'uhdi' => '']; + + public bool $censored = false; + + /** @var array */ + public array $validation = []; + + public bool $overrideValidation = false; + + public bool $saving = false; + + public function mount(int $episodeId): void + { + abort_unless(auth()->user()?->hasRole(UserRole::ADMINISTRATOR), 403); + + $reference = Episode::with(['hentai', 'studio'])->findOrFail($episodeId); + + $this->referenceEpisodeId = $reference->id; + $this->episodeNumber = $reference->hentai->episodes()->count() + 1; + + $this->title = $reference->title; + $this->titleJpn = $reference->title_jpn; + $this->studio = $reference->studio?->name ?? ''; + $this->tags = $reference->tags->pluck('name')->all(); + $this->releasedate = $reference->release_date + ? Carbon::parse($reference->release_date)->format('Y-m-d') + : now()->format('Y-m-d'); + + $this->baseurl = preg_replace('#/E\d+$#', '', $reference->url) ?? ''; + $this->description = $reference->description; + } + + public function getHasInvalidValidationProperty(): bool + { + foreach ($this->validation as $status) { + if (($status['state'] ?? '') === 'invalid') { + return true; + } + } + + return false; + } + + public function updatedBaseurl(): void + { + $this->validateStream(); + } + + public function updatedDownloads(mixed $value, string $key): void + { + if (preg_match('/^(fhd|fhdi|uhd|uhdi)$/', $key)) { + $this->validateDownload($key); + } + } + + public function validateStream(): void + { + $baseurl = trim($this->baseurl); + + if ($baseurl === '') { + $this->validation['stream'] = ['state' => 'idle', 'message' => '', 'mirrors' => []]; + + return; + } + + $this->validation['stream'] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []]; + + $result = app(CdnPathValidator::class)->validateStream($baseurl, 1, false, $this->episodeNumber); + + $this->validation['stream'] = [ + 'state' => $result['valid'] ? 'valid' : 'invalid', + 'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors', + 'mirrors' => $result['mirrors'], + ]; + } + + public function validateDownload(string $quality): void + { + $key = "downloads.{$quality}"; + $url = $this->downloads[$quality] ?? ''; + + if (trim($url) === '') { + $this->validation[$key] = ['state' => 'idle', 'message' => '', 'mirrors' => []]; + + return; + } + + $this->validation[$key] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []]; + + $result = app(CdnPathValidator::class)->validateDownload($this->qualityToType($quality), $url); + + $this->validation[$key] = [ + 'state' => $result['valid'] ? 'valid' : 'invalid', + 'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors', + 'mirrors' => $result['mirrors'], + ]; + } + + public function save(): void + { + abort_unless(auth()->user()?->hasRole(UserRole::ADMINISTRATOR), 403); + + $this->validate([ + 'baseurl' => ['required', 'string', 'regex:/^[A-Za-z0-9_.\-]+\/[A-Za-z0-9_.\-]+/'], + 'description' => 'required|string', + 'cover' => 'required|image|max:20480', + 'gallery.*' => 'nullable|image|max:20480', + 'downloads.fhd' => 'required|string', + 'downloads.uhd' => 'required|string', + 'downloads.fhdi' => 'nullable|string', + 'downloads.uhdi' => 'nullable|string', + ]); + + $this->saving = true; + + try { + // Re-load the reference episode fresh so the episode number reflects + // the current episode count rather than the mount-time value. + $referenceEpisode = Episode::with(['hentai', 'studio'])->findOrFail($this->referenceEpisodeId); + $hentai = $referenceEpisode->hentai; + $this->episodeNumber = $hentai->episodes()->count() + 1; + + // Re-validate every non-empty download path + stream against the CDN. + $validator = app(CdnPathValidator::class); + $sizes = []; + + foreach ($this->downloads as $quality => $url) { + if (trim($url) === '') { + continue; + } + + $type = $this->qualityToType($quality); + $key = "downloads.{$quality}"; + $result = $validator->validateDownload($type, $url, true); + + $this->validation[$key] = [ + 'state' => $result['valid'] ? 'valid' : 'invalid', + 'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors', + 'mirrors' => $result['mirrors'], + ]; + $sizes[$type] = $result['size']; + + if (! $result['valid'] && ! $this->overrideValidation) { + $this->addError($key, 'Path not found on all CDN mirrors.'); + $this->saving = false; + + return; + } + } + + $result = $validator->validateStream($this->baseurl, 1, true, $this->episodeNumber); + + $this->validation['stream'] = [ + 'state' => $result['valid'] ? 'valid' : 'invalid', + 'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors', + 'mirrors' => $result['mirrors'], + ]; + + if (! $result['valid'] && ! $this->overrideValidation) { + $this->addError('baseurl', 'Stream path not found on all CDN mirrors.'); + $this->saving = false; + + return; + } + + // Persist. + $episodeService = app(EpisodeService::class); + $studio = $episodeService->getOrCreateStudio(trim($this->studio)); + + $episodeModel = $episodeService->createEpisodeFromArray([ + 'title' => $this->title, + 'title_jpn' => $this->titleJpn, + 'baseurl' => $this->baseurl, + 'description' => $this->description, + 'releasedate' => $this->releasedate, + 'tags' => $this->tags, + 'interpolated_uhd' => trim($this->downloads['uhdi'] ?? '') !== '', + ], $hentai, $this->episodeNumber, $studio); + + if ($this->cover) { + $episodeService->saveCoverFromFile($episodeModel, $hentai->slug, $this->episodeNumber, $this->cover); + } + + if (! empty($this->gallery)) { + app(GalleryService::class)->saveGalleryFiles($hentai, $episodeModel, $this->episodeNumber, $this->gallery); + } + + $downloads = []; + foreach ($this->downloads as $quality => $url) { + if (trim($url) === '') { + continue; + } + + $type = $this->qualityToType($quality); + $downloads[$type] = [ + 'url' => trim($url), + 'size' => $sizes[$type] ?? null, + ]; + } + app(DownloadService::class)->createOrUpdateDownloadsFromArray($episodeModel, $downloads); + + // Discord notifications + cache flush. + if ($this->censored) { + DiscordReleaseNotification::dispatch($this->title.' - '.$this->episodeNumber, 'release-censored'); + } else { + DiscordReleaseNotification::dispatch($episodeModel->slug, 'release'); + } + + cache()->flush(); + } catch (\Throwable $e) { + Log::error('Failed to create episode from Livewire form: '.$e->getMessage(), [ + 'exception' => $e, + 'referenceEpisodeId' => $this->referenceEpisodeId, + ]); + $this->saving = false; + $this->addError('description', 'Something went wrong while saving. Please try again.'); + + return; + } + + $this->redirectRoute('hentai.index', ['title' => $episodeModel->slug]); + } + + private function qualityToType(string $quality): string + { + return match ($quality) { + 'fhd' => 'FHD', + 'fhdi' => 'FHDi', + 'uhd' => 'UHD', + 'uhdi' => 'UHDi', + }; + } + + public function render() + { + return view('livewire.admin-episode-form'); + } +} diff --git a/app/Services/CdnPathValidator.php b/app/Services/CdnPathValidator.php index fd533a5..0a5ba59 100644 --- a/app/Services/CdnPathValidator.php +++ b/app/Services/CdnPathValidator.php @@ -28,14 +28,14 @@ class CdnPathValidator return $this->checkMirrors($domains, [$path], $fresh); } - public function validateStream(string $baseurl, int $episodeCount, bool $fresh = false): array + public function validateStream(string $baseurl, int $episodeCount, bool $fresh = false, int $episodeNumber = 1): array { $domains = config('hstream.download_domain_4k'); - // Validate the base directory, plus the first episode manifest as a sanity check. + // Validate the base directory, plus the episode manifest as a sanity check. $paths = [self::STREAM_PREFIX.rtrim($baseurl, '/')]; if ($episodeCount >= 1) { - $paths[] = self::STREAM_PREFIX.rtrim($baseurl, '/').'/E'.str_pad(1, 2, '0', STR_PAD_LEFT).'/720/manifest.mpd'; + $paths[] = self::STREAM_PREFIX.rtrim($baseurl, '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT).'/720/manifest.mpd'; } return $this->checkMirrors($domains, $paths, $fresh); diff --git a/app/Services/EpisodeService.php b/app/Services/EpisodeService.php index 2506169..5c7a4ff 100644 --- a/app/Services/EpisodeService.php +++ b/app/Services/EpisodeService.php @@ -32,39 +32,6 @@ class EpisodeService return $slug; } - public function createEpisode( - Request $request, - Hentai $hentai, - int $episodeNumber, - ?Studios $studio = null, - ?Episode $referenceEpisode = null - ): Episode { - $episode = new Episode; - $episode->title = $referenceEpisode->title ?? $request->input('title'); - $episode->title_search = preg_replace('/[^A-Za-z0-9 ]/', '', $episode->title); - $episode->title_jpn = $referenceEpisode->title_jpn ?? $request->input('title_jpn'); - $episode->slug = "{$hentai->slug}-{$episodeNumber}"; - $episode->hentai_id = $hentai->id; - $episode->studios_id = $referenceEpisode->studio->id ?? $studio->id; - $episode->episode = $episodeNumber; - $episode->description = $referenceEpisode ? $request->input('description') : $request->input("description{$episodeNumber}"); - $episode->url = $referenceEpisode ? $request->input('baseurl') : rtrim($request->input('baseurl'), '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT); - $episode->view_count = 0; - $episode->interpolated = true; - $episode->is_dvd_aspect = false; - $episode->release_date = $referenceEpisode->release_date ?? Carbon::parse($request->input('releasedate'))->format('Y-m-d'); - $episode->cover_url = "/images/hentai/{$hentai->slug}/cover-ep-{$episodeNumber}.webp"; - $episode->save(); - - // Tagging - $tags = $referenceEpisode ? $referenceEpisode->tags : json_decode($request->input('tags')); - foreach ($tags as $t) { - $episode->tag($referenceEpisode ? $t->name : $t->value); - } - - return $episode; - } - private function applyTags(Request $request, Episode $episode): void { $tags = json_decode($request->input('tags')); diff --git a/resources/views/admin/modals/upload-episode.blade.php b/resources/views/admin/modals/upload-episode.blade.php index 7664aba..37cd404 100644 --- a/resources/views/admin/modals/upload-episode.blade.php +++ b/resources/views/admin/modals/upload-episode.blade.php @@ -6,76 +6,8 @@
-
- @csrf - - - - -
- -
- - -
- -
- - -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
-
- - -
- - -
-
+ @livewire('admin-episode-form', ['episodeId' => $episode->id])
- - diff --git a/resources/views/livewire/admin-episode-form.blade.php b/resources/views/livewire/admin-episode-form.blade.php new file mode 100644 index 0000000..1cb7247 --- /dev/null +++ b/resources/views/livewire/admin-episode-form.blade.php @@ -0,0 +1,185 @@ +
+ {{-- Info card --}} +
+

Adding Episode {{ $episodeNumber }} to {{ $title }}

+ +
+
+ +
{{ $studio }}
+
+ +
+ +
{{ $releasedate }}
+
+ +
+ +
{{ $titleJpn }}
+
+
+ +
+ +
{{ implode(', ', $tags) }}
+
+
+ + {{-- Stream card --}} +
+

Stream

+
+ +
+ + @include('livewire.partials.validation-badge', ['status' => $validation['stream'] ?? null]) +
+ @error('baseurl') {{ $message }} @enderror +
+
+ + {{-- Files card --}} +
+ +
+ {{-- Cover --}} +
+ +
+
+ + @error('cover') {{ $message }} @enderror + +
+
+
+
+ +
+
+ + @if ($cover) + Cover preview + @endif +
+
+ + {{-- Gallery --}} +
+ + + @error('gallery') {{ $message }} @enderror + +
+
+
+
+ +
+ + @if (! empty($gallery)) +
+ @foreach ($gallery as $galleryImage) + Gallery preview + @endforeach +
+ @endif +
+
+ +
+ + + @error('description') {{ $message }} @enderror +
+ +
+ @php + $downloadFields = [ + 'fhd' => 'Download 1080p *', + 'fhdi' => 'Download 1080p 48fps', + 'uhd' => 'Download 4k *', + 'uhdi' => 'Download 4k 48fps', + ]; + @endphp + @foreach ($downloadFields as $quality => $label) +
+ +
+ + @include('livewire.partials.validation-badge', ['status' => $validation['downloads.' . $quality] ?? null]) +
+ @error("downloads.{$quality}") {{ $message }} @enderror +
+ @endforeach +
+
+ + {{-- Action bar --}} +
+ + + @if ($this->hasInvalidValidation) + + @endif + + + + +
+
diff --git a/routes/admin.php b/routes/admin.php index e2c9360..af21115 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -42,9 +42,6 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () { // Release Route::get('/admin/release', [ReleaseController::class, 'index'])->name('admin.upload.index'); - // Episode - Route::post('/admin/episode/upload', [EpisodeController::class, 'store'])->name('admin.upload.episode'); - // Get Tags used for Upload Form Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags'); Route::get('/admin/studios', [AdminApiController::class, 'getStudios'])->name('admin.studios'); diff --git a/tests/Feature/Livewire/AdminEpisodeFormTest.php b/tests/Feature/Livewire/AdminEpisodeFormTest.php new file mode 100644 index 0000000..ecd1b3d --- /dev/null +++ b/tests/Feature/Livewire/AdminEpisodeFormTest.php @@ -0,0 +1,283 @@ + ['https://dl-a.test'], + 'hstream.download_domain_4k' => ['https://dl4k-a.test'], + 'hstream.stream_domain' => ['https://stream-a.test'], + ]); + + $this->admin = User::factory()->create(); + $this->admin->addRole(UserRole::ADMINISTRATOR); + $this->actingAs($this->admin); + } + + private function makeReferenceEpisode(string $slug = 'amazing-title-2026', array $attributes = []): Episode + { + $hentai = Hentai::factory()->create([ + 'slug' => $slug, + 'description' => 'An existing series', + ]); + $studio = Studios::factory()->create([ + 'name' => 'Test Studio', + 'slug' => 'test-studio', + ]); + $episode = Episode::factory()->create(array_merge([ + 'hentai_id' => $hentai->id, + 'studios_id' => $studio->id, + 'title' => 'Amazing Title 2026', + 'title_search' => 'Amazing Title 2026', + 'title_jpn' => '素晴らしいタイトル', + 'slug' => $slug.'-1', + 'episode' => 1, + 'description' => 'A lovely description', + 'url' => '2026/AmazingTitle/E01', + 'cover_url' => '/images/hentai/'.$slug.'/cover-ep-1.webp', + 'interpolated' => 1, + 'interpolated_uhd' => 1, + 'release_date' => '2026-08-04', + 'view_count' => 0, + ], $attributes)); + $episode->tag('Action'); + + return $episode; + } + + private function makeForm(?Episode $episode = null): Testable + { + $episode ??= $this->makeReferenceEpisode(); + + return Livewire::test(AdminEpisodeForm::class, ['episodeId' => $episode->id]); + } + + public function test_valid_episode_is_persisted(): void + { + Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]); + + $this->makeForm() + ->set('cover', UploadedFile::fake()->image('cover.jpg')) + ->set('gallery', [ + UploadedFile::fake()->image('g1.jpg'), + UploadedFile::fake()->image('g2.jpg'), + ]) + ->set('downloads.fhd', '2026/AmazingTitle/E02.mkv') + ->set('downloads.uhd', '2026/AmazingTitle/E02.mkv') + ->set('downloads.uhdi', '2026/AmazingTitle/E02.mkv') + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('hentai.index', 'amazing-title-2026-2')); + + $this->assertDatabaseCount('episodes', 2); + + $episode = Episode::where('slug', 'amazing-title-2026-2')->firstOrFail(); + $this->assertSame('Amazing Title 2026', $episode->title); + $this->assertSame('素晴らしいタイトル', $episode->title_jpn); + $this->assertSame('Test Studio', $episode->studio->name); + $this->assertSame(2, $episode->episode); + $this->assertSame('2026/AmazingTitle/E02', $episode->url); + $this->assertSame('2026-08-04', $episode->release_date); + $this->assertSame(1, $episode->interpolated); + $this->assertSame(1, $episode->interpolated_uhd); + $this->assertTrue($episode->tags->contains('name', 'Action')); + + $this->assertDatabaseCount('gallery', 2); + $this->assertDatabaseCount('downloads', 3); + + $fhd = Downloads::where('episode_id', $episode->id)->where('type', 'FHD')->firstOrFail(); + $this->assertSame('2026/AmazingTitle/E02.mkv', $fhd->url); + $this->assertEquals(9999, $fhd->size); + $this->assertNotNull($fhd->validated_at); + + $uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail(); + $this->assertEquals(9999, $uhd->size); + + $uhdi = Downloads::where('episode_id', $episode->id)->where('type', 'UHDi')->firstOrFail(); + $this->assertEquals(9999, $uhdi->size); + + $this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/cover-ep-2.webp')); + $this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-2-0.webp')); + $this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-2-1.webp')); + } + + public function test_invalid_cdn_path_blocks_save_with_error(): void + { + Http::fake([ + 'https://dl4k-a.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404), + ]); + + $this->makeForm() + ->set('cover', UploadedFile::fake()->image('cover.jpg')) + ->set('downloads.fhd', '2026/AmazingTitle/E02.mkv') + ->set('downloads.uhd', '2026/AmazingTitle/E02.mkv') + ->call('save') + ->assertHasErrors('downloads.fhd'); + + $this->assertDatabaseCount('episodes', 1); + } + + public function test_override_validation_allows_saving_invalid_paths(): void + { + Http::fake([ + 'https://dl4k-a.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404), + ]); + + $this->makeForm() + ->set('cover', UploadedFile::fake()->image('cover.jpg')) + ->set('downloads.fhd', '2026/AmazingTitle/E02.mkv') + ->set('downloads.uhd', '2026/AmazingTitle/E02.mkv') + ->set('overrideValidation', true) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('hentai.index', 'amazing-title-2026-2')); + + $this->assertDatabaseCount('episodes', 2); + + $episode = Episode::where('slug', 'amazing-title-2026-2')->firstOrFail(); + $uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail(); + $this->assertSame('2026/AmazingTitle/E02.mkv', $uhd->url); + $this->assertNull($uhd->size); + } + + public function test_validation_requires_required_fields(): void + { + Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]); + + $component = $this->makeForm() + ->set('baseurl', '') + ->set('description', '') + ->set('downloads.fhd', '') + ->set('downloads.uhd', '') + ->call('save') + ->assertHasErrors([ + 'baseurl' => 'required', + 'description' => 'required', + 'cover' => 'required', + 'downloads.fhd' => 'required', + 'downloads.uhd' => 'required', + ]); + + $this->assertDatabaseCount('episodes', 1); + + // The base URL must match the directory pattern (2026/Title). + $component + ->set('baseurl', 'no-slash') + ->set('description', 'A lovely description') + ->set('cover', UploadedFile::fake()->image('cover.jpg')) + ->set('downloads.fhd', '2026/AmazingTitle/E02.mkv') + ->set('downloads.uhd', '2026/AmazingTitle/E02.mkv') + ->call('save') + ->assertHasErrors(['baseurl' => 'regex']); + + $this->assertDatabaseCount('episodes', 1); + } + + public function test_save_uses_fresh_cdn_check_ignoring_cached_results(): void + { + $valid = false; + + Http::fake(function ($request) use (&$valid) { + return Http::response(['valid' => $valid, 'type' => 'file', 'size' => 9999]); + }); + + $component = $this->makeForm() + ->set('cover', UploadedFile::fake()->image('cover.jpg')) + ->set('downloads.uhd', '2026/AmazingTitle/E02.mkv') + ->set('downloads.fhd', '2026/AmazingTitle/E02.mkv'); + + // Live typing validation caches the invalid CDN result. + $validation = $component->get('validation'); + $this->assertSame('invalid', $validation['downloads.fhd']['state'] ?? null); + + // The file gets "fixed" on the CDN within the cache window. + $valid = true; + + // Saving must re-check the CDN without using the cached invalid result. + $component + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('hentai.index', 'amazing-title-2026-2')); + + $this->assertDatabaseCount('episodes', 2); + $this->assertDatabaseCount('downloads', 2); + } + + public function test_mount_and_save_require_admin_role(): void + { + $episode = $this->makeReferenceEpisode(); + + // Guests are rejected when the component mounts. + Auth::logout(); + + Livewire::test(AdminEpisodeForm::class, ['episodeId' => $episode->id]) + ->assertStatus(403); + + // Authenticated non-admins are rejected when the component mounts. + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(AdminEpisodeForm::class, ['episodeId' => $episode->id]) + ->assertStatus(403); + + // Administrators can mount and save. + $admin = User::factory()->create(); + $admin->addRole(UserRole::ADMINISTRATOR); + $this->actingAs($admin); + + Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]); + + $this->makeForm($episode) + ->set('cover', UploadedFile::fake()->image('cover.jpg')) + ->set('downloads.fhd', '2026/AmazingTitle/E02.mkv') + ->set('downloads.uhd', '2026/AmazingTitle/E02.mkv') + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('hentai.index', 'amazing-title-2026-2')); + + $this->assertDatabaseCount('episodes', 2); + } + + public function test_stream_page_renders_episode_modal_for_admin(): void + { + $episode = $this->makeReferenceEpisode('amazing-title-2026', [ + 'interpolated' => 0, + 'interpolated_uhd' => 0, + ]); + + $this->get(route('hentai.index', $episode->slug)) + ->assertOk() + ->assertSee('admin-episode-form'); + } +} diff --git a/tests/Unit/CdnPathValidatorTest.php b/tests/Unit/CdnPathValidatorTest.php index 831d4cf..8531fc7 100644 --- a/tests/Unit/CdnPathValidatorTest.php +++ b/tests/Unit/CdnPathValidatorTest.php @@ -102,6 +102,24 @@ class CdnPathValidatorTest extends TestCase 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([