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)
|
||||
<div class="recommended-videos-list" id="recList">
|
||||
@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) }}"
|
||||
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;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
@@ -1016,7 +1020,7 @@
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -3144,9 +3144,13 @@
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list" id="recList">
|
||||
@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) }}"
|
||||
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;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
@@ -3181,7 +3185,7 @@
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -972,9 +972,13 @@
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list" id="recList">
|
||||
@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) }}"
|
||||
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;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
@@ -1008,7 +1012,7 @@
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@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