diff --git a/tests/Feature/Phase3FeedsTest.php b/tests/Feature/Phase3FeedsTest.php new file mode 100644 index 0000000..dad6c34 --- /dev/null +++ b/tests/Feature/Phase3FeedsTest.php @@ -0,0 +1,190 @@ +withoutMiddleware([ + VerifyCsrfToken::class, + \Illuminate\Session\Middleware\AuthenticateSession::class, + ]); + } + + // ─── Trending window ───────────────────────────── + + public function test_trending_accepts_24_168_720_and_snaps_others_to_24(): void + { + $this->get(route('videos.trending', ['hours' => 24]))->assertOk(); + $this->get(route('videos.trending', ['hours' => 168]))->assertOk(); + $this->get(route('videos.trending', ['hours' => 720]))->assertOk(); + + // Garbage input must not crash; it snaps to 24. + $this->get(route('videos.trending', ['hours' => 'abc']))->assertOk(); + $this->get(route('videos.trending', ['hours' => 99999]))->assertOk(); + } + + public function test_trending_ranks_by_in_window_views_then_likes(): void + { + $owner = $this->makeUser(); + + // Three videos — "A" has most views in last 24h, "B" has most likes + // but no views, "C" has nothing. A should be first; B should NOT + // appear (no in-window views → filtered out); C should not appear. + $a = $this->makeVideo($owner, 'A'); + $b = $this->makeVideo($owner, 'B'); + $c = $this->makeVideo($owner, 'C'); + + $this->seedViews($a, 10); + $this->seedViews($b, 0); + $this->seedLikes($b, 5); + $this->seedViews($c, 0); + + $r = $this->get(route('videos.trending', ['hours' => 24])); + $r->assertOk(); + + $html = $r->getContent(); + $this->assertStringContainsString('A', $html, 'Video A must appear — it has in-window views.'); + $this->assertStringNotContainsString('>B<', $html, 'Video B must not appear — zero in-window views.'); + } + + // ─── Music feed newest-first ───────────────────── + + public function test_music_feed_opens_on_newest_video(): void + { + $owner = $this->makeUser(); + // created_at isn't in Video::$fillable (Eloquent auto-manages it), + // so set it after the row exists. + $old = $this->makeVideo($owner, 'OLD MUSIC', ['type' => 'music']); + $old->created_at = now()->subDays(5); $old->save(); + $new = $this->makeVideo($owner, 'NEW MUSIC', ['type' => 'music']); + $new->created_at = now()->subMinutes(5); $new->save(); + + $html = $this->get(route('videos.index', ['filter' => 'music']))->assertOk()->getContent(); + + $posNew = strpos($html, 'NEW MUSIC'); + $posOld = strpos($html, 'OLD MUSIC'); + $this->assertNotFalse($posNew); + $this->assertNotFalse($posOld); + $this->assertLessThan($posOld, $posNew, 'Newer music video must appear before the older one.'); + } + + // ─── /videos/search → /search (301) ────────────── + + public function test_old_videos_search_url_301_redirects_to_search(): void + { + $this->get('/videos/search?q=hello') + ->assertStatus(301) + ->assertRedirect('/search?q=hello'); + } + + // ─── /search returns channel + playlist buckets ── + + public function test_search_matches_video_channel_and_playlist(): void + { + $owner = User::factory()->create([ + 'name' => 'Searchable Owner', + 'username' => 'searchable-channel-'.substr(bin2hex(random_bytes(4)), 0, 6), + ]); + $video = $this->makeVideo($owner, 'SearchableVideoTitle', ['description' => 'nothing special']); + $pl = Playlist::create([ + 'user_id' => $owner->id, + 'name' => 'SearchablePlaylistName', + 'visibility' => 'public', + 'share_token'=> bin2hex(random_bytes(8)), + ]); + + // 1. Query that matches a video title + $html = $this->get(route('search', ['q' => 'SearchableVideo']))->assertOk()->getContent(); + $this->assertStringContainsString('SearchableVideoTitle', $html); + + // 2. Query that matches a channel name + $html = $this->get(route('search', ['q' => 'Searchable Owner']))->assertOk()->getContent(); + $this->assertStringContainsString('Channels', $html); + $this->assertStringContainsString('Searchable Owner', $html); + + // 3. Query that matches a playlist name + $html = $this->get(route('search', ['q' => 'SearchablePlaylist']))->assertOk()->getContent(); + $this->assertStringContainsString('Playlists', $html); + $this->assertStringContainsString('SearchablePlaylistName', $html); + } + + public function test_empty_search_redirects_to_browse(): void + { + $this->get(route('search', ['q' => ''])) + ->assertRedirect(route('videos.index')); + } + + // ─── Helpers ───────────────────────────────────── + + private function makeUser(): User + { + return User::factory()->create([ + 'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8), + ]); + } + + private function makeVideo(User $u, string $title, array $attrs = []): Video + { + return Video::create(array_merge([ + 'user_id' => $u->id, + 'title' => $title, + '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)); + } + + private function seedViews(Video $v, int $n): void + { + for ($i = 0; $i < $n; $i++) { + DB::table('video_views')->insert([ + 'video_id' => $v->id, + 'user_id' => null, + 'device_id' => 'dev-'.$i, + 'watched_at' => now()->subMinutes(10), + ]); + } + } + + private function seedLikes(Video $v, int $n): void + { + for ($i = 0; $i < $n; $i++) { + $u = $this->makeUser(); + DB::table('video_likes')->insert([ + 'video_id' => $v->id, + 'user_id' => $u->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } +}