test(phase-3): feeds/search/trending regression coverage

Six feature tests:
- Trending accepts hours=24|168|720, snaps garbage input to 24.
- Trending ranks by in-window views then likes; videos with zero
  in-window views are dropped.
- Music feed opens on the newest video (regression test for the
  skipped-->latest() call in VideoController@index).
- Old /videos/search?q=... returns a 301 to /search?q=...
- /search matches on video title, channel name, and playlist name
  separately; each surfaces in its own Section.
- Empty /search?q= redirects to /videos browse.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 10:11:01 +03:00
1 parent 45b5f586ba
commit a2752643e9
1 file changed
+190
+190
View File
@@ -0,0 +1,190 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Phase 3 — Feeds, search, trending.
*
* Guards against: trending window regressions, music feed opening on oldest,
* search returning the wrong buckets, and the /videos/search → /search 301.
*/
class Phase3FeedsTest 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,
]);
}
// ─── 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(),
]);
}
}
}