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:
1 parent
45b5f586ba
commit
a2752643e9
1 file changed
+190
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user