Files
hstream/tests/Feature/Livewire/AdminReleaseFormTest.php
w33b 30a6b080eb feat(admin): rebuild release upload form with Livewire and CDN validation
Replace the server-rendered release upload form with a Livewire component,
introducing an interactive admin release form that supports dynamic episode
management, real‑time CDN path validation, and direct download file checks.

Key changes:
- New `AdminReleaseForm` Livewire component with dynamic episode fields,
  Tagify integration, and upload handling via Livewire's file uploads.
- Added `CdnPathValidator` service to verify download and stream paths across
  all configured mirrors, caching results and capturing file sizes.
- Extended `EpisodeService` and `GalleryService` to support array‑based data
  and temporary uploaded files from Livewire.
- Persist validated download sizes and store the validation timestamp on the
  `downloads` table via a new migration; also add a unique constraint on
  `hentais.slug` to prevent duplicates.
- Remove the old POST `/admin/release/upload` route and controller logic,
  along with the legacy `upload.js` script.
- Add comprehensive feature and unit tests covering the new component and the
  CDN validation service.
2026-08-08 13:18:57 +02:00

239 lines
9.1 KiB
PHP

<?php
namespace Tests\Feature\Livewire;
use App\Enums\UserRole;
use App\Livewire\AdminReleaseForm;
use App\Models\Downloads;
use App\Models\Episode;
use App\Models\Hentai;
use App\Models\User;
use Illuminate\Http\UploadedFile;
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 AdminReleaseFormTest extends TestCase
{
use RefreshDatabase;
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'],
]);
}
private function makeForm(): Testable
{
return Livewire::test(AdminReleaseForm::class)
->set('title', 'Amazing Title 2026')
->set('titleJpn', '素晴らしいタイトル')
->set('studio', 'Test Studio')
->set('tags', ['Action', 'Romance'])
->set('releasedate', '2026-08-04')
->set('baseurl', '2026/AmazingTitle')
->set('episodes.0.description', 'A lovely description');
}
public function test_valid_release_is_persisted_with_sizes(): void
{
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
$this->makeForm()
->set('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.gallery', [
UploadedFile::fake()->image('g1.jpg'),
UploadedFile::fake()->image('g2.jpg'),
])
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhdi', '2026/AmazingTitle/E01.mkv')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('home.index'));
$this->assertDatabaseCount('hentais', 1);
$this->assertDatabaseHas('hentais', ['slug' => 'amazing-title-2026']);
$hentai = Hentai::where('slug', 'amazing-title-2026')->firstOrFail();
$this->assertDatabaseCount('episodes', 1);
$episode = Episode::where('hentai_id', $hentai->id)->firstOrFail();
$this->assertSame('2026/AmazingTitle/E01', $episode->url);
$this->assertSame(1, $episode->interpolated);
$this->assertSame(1, $episode->interpolated_uhd);
$this->assertDatabaseCount('gallery', 2);
$this->assertDatabaseCount('downloads', 3);
$fhd = Downloads::where('episode_id', $episode->id)->where('type', 'FHD')->firstOrFail();
$this->assertSame('2026/AmazingTitle/E01.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-1.webp'));
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-1-0.webp'));
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-1-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('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->call('save')
->assertHasErrors('episodes.0.downloads.fhd');
$this->assertDatabaseCount('hentais', 0);
$this->assertDatabaseCount('episodes', 0);
}
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('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->set('overrideValidation', true)
->call('save')
->assertHasNoErrors()
->assertRedirect(route('home.index'));
$this->assertDatabaseCount('hentais', 1);
$hentai = Hentai::where('slug', 'amazing-title-2026')->firstOrFail();
$episode = Episode::where('hentai_id', $hentai->id)->firstOrFail();
$uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail();
$this->assertSame('2026/AmazingTitle/E01.mkv', $uhd->url);
$this->assertNull($uhd->size);
}
public function test_existing_release_created_today_is_blocked(): void
{
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
Hentai::create(['slug' => 'amazing-title-2026', 'description' => 'existing']);
$this->makeForm()
->set('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->call('save')
->assertHasErrors('title');
$this->assertDatabaseCount('hentais', 1);
$this->assertDatabaseCount('episodes', 0);
}
public function test_validation_requires_required_fields(): void
{
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
Livewire::test(AdminReleaseForm::class)
->call('save')
->assertHasErrors([
'title' => 'required',
'titleJpn' => 'required',
'studio' => 'required',
'tags' => 'required',
'releasedate' => 'required',
'baseurl' => 'required',
'episodes.0.description' => 'required',
'episodes.0.cover' => 'required',
'episodes.0.downloads.fhd' => 'required',
'episodes.0.downloads.uhd' => 'required',
]);
$this->assertDatabaseCount('hentais', 0);
}
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('episodes.0.cover', UploadedFile::fake()->image('cover.jpg'))
->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv')
->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv');
// Live typing validation caches the invalid CDN result.
$validation = $component->get('validation');
$this->assertSame('invalid', $validation['episodes.0.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('home.index'));
$this->assertDatabaseCount('hentais', 1);
$this->assertDatabaseCount('downloads', 2);
}
public function test_admin_release_page_renders(): void
{
$user = User::factory()->create();
$user->addRole(UserRole::ADMINISTRATOR);
$this->actingAs($user)
->get(route('admin.upload.index'))
->assertOk()
->assertSee('admin-release-form');
}
public function test_tagify_inputs_are_excluded_from_livewire_morphing(): void
{
$component = Livewire::test(AdminReleaseForm::class);
// Tagify inputs must live inside wire:ignore scopes so Livewire's
// DOM morphing never replaces the Tagify-generated markup.
$component
->assertSeeHtml('wire:ignore x-data="adminReleaseTags"')
->assertSeeHtml('wire:ignore x-data="adminReleaseStudio"');
// The wrappers must survive a server round-trip (e.g. a debounced
// download/baseurl update) untouched.
$component
->set('title', 'Changed Title')
->assertSeeHtml('wire:ignore x-data="adminReleaseTags"')
->assertSeeHtml('wire:ignore x-data="adminReleaseStudio"');
}
}