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:
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user