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]>
191 lines
7.0 KiB
PHP
191 lines
7.0 KiB
PHP
<?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(),
|
|
]);
|
|
}
|
|
}
|
|
}
|