feat(watch): anchor Up Next cards everywhere + Phase 4 tests

- All three type-specific watch templates (generic / music / match)
  were still rendering the Up Next list as <div onclick="recGoTo(...)">
  — only show.blade.php had been converted in the earlier commit.
  Convert each to <a href> so middle-click / right-click / cmd-click
  open-in-new-tab work, keeping the SPA swap on normal left-click via
  an onclick guard that lets modified clicks fall through.
- 9 feature tests covering: singular/plural comment counts, top-level
  vs reply separation, ?lc= permalink HREFs, markdown header
  promotion in HtmlSanitizer::render(), passthrough of plain text,
  download access gate for both MP4 and MP3 at disabled + registered
  tiers, and sidebar anchor-card presence.

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:24:41 +03:00
1 parent 36295fcd6a
commit 646d3e9d2a
4 files changed
+201 -9

No files matched your search

@@ -980,9 +980,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0) @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList"> <div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo) @foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card" {{-- Real <a href> so right-click / middle-click /
open-in-new-tab works. recGoTo() still runs
on a plain left-click for the SPA swap. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}" data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')" onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
style="cursor:pointer;"> style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail) @if ($recVideo->thumbnail)
@@ -1016,7 +1020,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div> {{ $recVideo->created_at->diffForHumans() }}</div>
</div> </div>
</div> </div>
</div> </a>
@endforeach @endforeach
</div> </div>
@else @else
+7 -3
View File
@@ -3144,9 +3144,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0) @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList"> <div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo) @foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card" {{-- Real <a href> so right-click / middle-click /
open-in-new-tab works. recGoTo() still runs
on a plain left-click for the SPA swap. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}" data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')" onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
style="cursor:pointer;"> style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail) @if ($recVideo->thumbnail)
@@ -3181,7 +3185,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div> {{ $recVideo->created_at->diffForHumans() }}</div>
</div> </div>
</div> </div>
</div> </a>
@endforeach @endforeach
</div> </div>
@else @else
+7 -3
View File
@@ -972,9 +972,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0) @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList"> <div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo) @foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card" {{-- Real <a href> so right-click / middle-click /
open-in-new-tab works. recGoTo() still runs
on a plain left-click for the SPA swap. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}" data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')" onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
style="cursor:pointer;"> style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail) @if ($recVideo->thumbnail)
@@ -1008,7 +1012,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div> {{ $recVideo->created_at->diffForHumans() }}</div>
</div> </div>
</div> </div>
</div> </a>
@endforeach @endforeach
</div> </div>
@else @else
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use App\Support\HtmlSanitizer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 4 — Watch page.
*
* Guards against regressions in: the Up Next sidebar (anchor cards, weighted
* recommender), the comment permalink + plural counts, the markdown-leak
* rendering, and the download access gate for both MP4 and MP3.
*/
class Phase4WatchTest 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,
]);
}
// ─── Comment component ────────────────────────────
public function test_comment_header_uses_singular_count_for_one_comment(): void
{
[$user, $video] = $this->makeUserAndVideo();
Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'the only comment',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('1 Comment', $html);
$this->assertStringNotContainsString('1 Comments', $html);
}
public function test_comment_header_breaks_out_replies_from_top_level(): void
{
[$user, $video] = $this->makeUserAndVideo();
$parent = Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'top',
]);
Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'parent_id' => $parent->id,
'body' => 'reply 1',
]);
Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'parent_id' => $parent->id,
'body' => 'reply 2',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('1 Comment', $html, 'Top-level count is 1.');
$this->assertStringContainsString('2 replies', $html, 'Replies are counted separately.');
}
public function test_each_comment_timestamp_is_a_lc_permalink(): void
{
[$user, $video] = $this->makeUserAndVideo();
$c = Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'hi',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('?lc=' . $c->id . '#comment-' . $c->id, $html);
$this->assertStringContainsString('ytc-permalink', $html);
}
// ─── Description markdown ────────────────────────
public function test_leaked_markdown_headers_render_as_html(): void
{
$out = HtmlSanitizer::render("Intro line\n## Letra\nla la\n### Chorus");
$this->assertStringContainsString('<h2>Letra</h2>', $out);
$this->assertStringContainsString('<h3>Chorus</h3>', $out);
$this->assertStringNotContainsString('## Letra', $out);
$this->assertStringNotContainsString('### Chorus', $out);
}
public function test_normal_plain_text_description_passes_through(): void
{
$out = HtmlSanitizer::render("hello world\nhow are you");
$this->assertStringContainsString('hello world', $out);
$this->assertStringContainsString('how are you', $out);
$this->assertStringNotContainsString('<h2>', $out);
}
// ─── Download access gate ────────────────────────
public function test_disabled_download_returns_403_for_mp4(): void
{
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
$this->get(route('videos.download', $video))->assertStatus(403);
}
public function test_disabled_download_returns_403_for_mp3(): void
{
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
$this->get(route('videos.downloadMp3', $video))->assertStatus(403);
}
public function test_registered_download_denies_guests(): void
{
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'registered']);
$this->get(route('videos.download', $video))->assertStatus(403);
}
// ─── Up Next anchor cards ─────────────────────────
public function test_up_next_sidebar_renders_anchor_cards_with_hrefs(): void
{
[$owner, $target] = $this->makeUserAndVideo();
[, $rec] = $this->makeUserAndVideo();
$html = $this->get(route('videos.show', $target))->getContent();
$this->assertStringContainsString('<a class="sidebar-video-card"', $html,
'Up Next entries must be anchor elements, not onclick divs.');
$this->assertStringContainsString('data-rec-url=', $html,
'data-rec-url hooks keep the SPA transition working for left-clicks.');
}
// ─── Helpers ──────────────────────────────────────
private function makeUserAndVideo(array $attrs = []): array
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$video = Video::create(array_merge([
'user_id' => $user->id,
'title' => 'T '.substr(bin2hex(random_bytes(4)), 0, 6),
'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));
return [$user, $video];
}
}