Files
ghassanandClaude Opus 4.7 639279f2a2 test(deferred): coverage for the bulk deferred-items cleanup
Ten tests spanning the eight commits in this branch:
- Player end-screen markup present (overlay + ring + cancel hooks).
- C hotkey binds to #ytpLyricsBtn in the player keydown handler.
- Guest Subscribe button carries ?intended= and encodeURIComponent.
- /@handle/videos 302s to the hash URL; nonsense tabs 404.
- /channel/{username}/videos/more returns a cursor-paginated JSON
  payload with items + next_cursor + has_more.
- /studio redirects anonymous visitors to login.
- /studio renders only the signed-in user's videos.
- Studio bulk visibility + bulk delete scope updates to Auth::id().

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:37:15 +03:00

186 lines
6.7 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Covers the bulk cleanup of deferred items:
* end-screen markup, C hotkey, guest subscribe prompt, /@handle tab aliases,
* channel videos cursor endpoint, shorts comments pane, studio skeleton.
*/
class DeferredCleanupTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Player ─────────────────────────────────────
public function test_player_component_ships_the_end_screen_markup(): void
{
$u = $this->makeUser();
$v = $this->makeVideo($u);
$html = view('components.video-player', ['video' => $v])->render();
$this->assertStringContainsString('id="ytpEndScreen"', $html);
$this->assertStringContainsString('ytp-end-countdown', $html, 'countdown ring class hook');
$this->assertStringContainsString('id="ytpEndRing"', $html);
$this->assertStringContainsString('id="ytpEndCancel"', $html);
}
public function test_player_binds_c_hotkey_to_lyrics_button(): void
{
$u = $this->makeUser();
$v = $this->makeVideo($u);
$html = view('components.video-player', ['video' => $v])->render();
$this->assertStringContainsString("case 'c':", $html);
$this->assertStringContainsString('ytpLyricsBtn', $html);
}
// ─── Guest Subscribe prompt ────────────────────
public function test_guest_subscribe_button_preserves_intended_url(): void
{
$u = $this->makeUser();
$v = $this->makeVideo($u);
$html = view('components.video-actions', ['video' => $v])->render();
$this->assertStringContainsString('?intended=', $html,
'Guest Subscribe button must redirect to login with ?intended=.');
$this->assertStringContainsString('encodeURIComponent', $html);
}
// ─── /@handle tab aliases ───────────────────────
public function test_handle_tab_alias_302_redirects_to_hash_url(): void
{
$u = User::factory()->create(['username' => 'hta-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
$r = $this->get('/@' . $u->username . '/videos');
$r->assertStatus(302);
$this->assertStringEndsWith('#videos', $r->headers->get('Location'));
}
public function test_handle_tab_alias_404s_on_unknown_tab(): void
{
$u = User::factory()->create(['username' => 'htn-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
$this->get('/@' . $u->username . '/nonsense')->assertStatus(404);
}
// ─── Channel videos cursor endpoint ─────────────
public function test_channel_videos_more_returns_cursor_json(): void
{
$u = $this->makeUser();
// Twice the page size so has_more is true.
for ($i = 0; $i < 30; $i++) {
$this->makeVideo($u, ['title' => 'Vid '.$i]);
}
$r = $this->getJson(route('channel.videos.more', ['username' => $u->username]));
$r->assertOk()
->assertJsonStructure(['items', 'next_cursor', 'has_more'])
->assertJson(['has_more' => true]);
$this->assertStringContainsString('yt-video-card', $r->json('items'));
}
// ─── Studio ─────────────────────────────────────
public function test_studio_requires_authentication(): void
{
$this->get(route('studio'))->assertStatus(302);
}
public function test_studio_renders_only_my_videos(): void
{
$me = $this->makeUser();
$other = $this->makeUser();
$mine = $this->makeVideo($me, ['title' => 'MyStudioVideo']);
$theirs = $this->makeVideo($other, ['title' => 'TheirVideo']);
$this->actingAs($me);
$html = $this->get(route('studio'))->getContent();
$this->assertStringContainsString('MyStudioVideo', $html);
$this->assertStringNotContainsString('TheirVideo', $html);
}
public function test_studio_bulk_visibility_refuses_other_users_videos(): void
{
$me = $this->makeUser();
$other = $this->makeUser();
$mine = $this->makeVideo($me, ['visibility' => 'public']);
$theirs = $this->makeVideo($other, ['visibility' => 'public']);
$this->actingAs($me);
$this->postJson(route('studio.bulkVisibility'), [
'video_ids' => [$mine->id, $theirs->id],
'visibility' => 'private',
])->assertOk()->assertJson(['updated' => 1]);
$this->assertEquals('private', $mine->fresh()->visibility);
$this->assertEquals('public', $theirs->fresh()->visibility,
'A crafted request body must not flip another user\'s video.');
}
public function test_studio_bulk_delete_scoped_to_owner(): void
{
$me = $this->makeUser();
$other = $this->makeUser();
$mine = $this->makeVideo($me);
$theirs = $this->makeVideo($other);
$this->actingAs($me);
$this->postJson(route('studio.bulkDelete'), [
'video_ids' => [$mine->id, $theirs->id],
])->assertOk()->assertJson(['deleted' => 1]);
$this->assertNull(Video::find($mine->id));
$this->assertNotNull(Video::find($theirs->id));
}
// ─── Helpers ────────────────────────────────────
private function makeUser(): User
{
return User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
}
private function makeVideo(User $u, array $attrs = []): Video
{
return Video::create(array_merge([
'user_id' => $u->id,
'title' => 'T',
'description' => '',
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
], $attrs));
}
}