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.
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Livewire\AdminEpisodeForm;
|
||||
use App\Models\Downloads;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Hentai;
|
||||
use App\Models\Studios;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
use Tests\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminEpisodeFormTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Queue::fake();
|
||||
Cache::flush();
|
||||
Storage::fake('public');
|
||||
|
||||
config([
|
||||
'hstream.download_domain' => ['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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user