diff --git a/tests/Feature/DeferredCleanupTest.php b/tests/Feature/DeferredCleanupTest.php new file mode 100644 index 0000000..73c3a62 --- /dev/null +++ b/tests/Feature/DeferredCleanupTest.php @@ -0,0 +1,185 @@ +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)); + } +}