From 4d9bc0f501c9224240829d9af4806c734120dc13 Mon Sep 17 00:00:00 2001 From: ghassan Date: Wed, 21 Oct 2026 12:18:17 +0300 Subject: [PATCH] feat(engagement): video dislike + creator heart + creator pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three features that depend on the two additive migrations shipped in the same commit (safe defaults, cascading foreign keys, no data loss possible). ## Dislike button - New video_dislikes table (migration 2026_10_21_000001) with the same shape as video_likes. - Video model: dislikes() relation + isDislikedBy() / getDislikeCount Attribute() helpers mirroring the like pair. - UserController@toggleDislike: endpoint behind POST /videos/{video}/toggle-dislike. Mutually exclusive with like — the toggle*Like / toggle*Dislike controllers detach the opposite state so a user is in exactly one of {liked, disliked, neither}. YouTube parity. - video-actions component: a second thumbs-down button next to the existing like. Both reconcile in parallel on either response so the UI pair always reflects the authoritative server state. ## Creator heart + pin - comments table gains hearted + pinned boolean columns (migration 2026_10_21_000002, both default false, pinned is indexed). - CommentController@heart + @pin: 403 for anyone but the video owner; pin() also rejects nested replies (422) and auto-unpins the previously-pinned top-level comment so the "at most one pinned" invariant is enforced at the DB level. - Comment component: - Pinned comments render ahead of everything else; reorder() clears the comments() relation's default ->latest() so pinned DESC becomes the PRIMARY sort instead of a tiebreaker. - Creator sees heart + pin toggle buttons in the action bar. - Hearted comments carry a small red heart badge in the meta row. - Pinned comments carry a "Pinned by {creator}" header and a red left accent so they're visually distinct from the stream. ## Tests (9 new, 63 total) - Dislike toggle adds/removes the row. - Disliking clears an existing like and vice versa. - Owner can heart; non-owner gets 403. - Only one comment pinned at a time; replies can't be pinned (422); non-owner gets 403. - Pinned comments render before newer unpinned ones. ## Deploy note Running the migrations against the live DB needs: php artisan migrate The two migration files are additive and reversible via `migrate:rollback`. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Http/Controllers/CommentController.php | 52 +++++ app/Http/Controllers/UserController.php | 32 ++- app/Models/Comment.php | 7 + app/Models/Video.php | 19 ++ ..._21_000001_create_video_dislikes_table.php | 33 +++ ...002_add_hearted_and_pinned_to_comments.php | 36 ++++ .../views/components/video-actions.blade.php | 60 +++++- .../views/components/video-comments.blade.php | 138 +++++++++++- routes/web.php | 3 + tests/Feature/DislikeHeartPinTest.php | 198 ++++++++++++++++++ 10 files changed, 570 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_10_21_000001_create_video_dislikes_table.php create mode 100644 database/migrations/2026_10_21_000002_add_hearted_and_pinned_to_comments.php create mode 100644 tests/Feature/DislikeHeartPinTest.php diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index b51ca21..2f5cc7b 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -206,4 +206,56 @@ class CommentController extends Controller return response()->json(['liked' => $liked, 'count' => $count]); } + + /** + * Toggle the "creator heart" on a comment. Only the video owner can + * drop a heart; anyone else gets a 403. + */ + public function heart(Comment $comment) + { + $video = $comment->video; + if (! $video || $video->user_id !== Auth::id()) { + return response()->json(['error' => 'Only the video owner can heart a comment.'], 403); + } + + $comment->hearted = ! $comment->hearted; + $comment->save(); + + return response()->json([ + 'hearted' => (bool) $comment->hearted, + ]); + } + + /** + * Toggle "pinned" on a top-level comment. Only the video owner can + * pin, and pinned comments sort ahead of everything else in the list. + * At most one comment is pinned at a time — pinning a new one unpins + * the previous. + */ + public function pin(Comment $comment) + { + $video = $comment->video; + if (! $video || $video->user_id !== Auth::id()) { + return response()->json(['error' => 'Only the video owner can pin a comment.'], 403); + } + if ($comment->parent_id) { + return response()->json(['error' => 'Only top-level comments can be pinned.'], 422); + } + + $wasPinned = (bool) $comment->pinned; + if (! $wasPinned) { + // Unpin any previously-pinned comment on this video first so + // the UI invariant "at most one pinned" is maintained at the + // DB level, not just in the view. + Comment::where('video_id', $video->id) + ->where('pinned', true) + ->update(['pinned' => false]); + } + $comment->pinned = ! $wasPinned; + $comment->save(); + + return response()->json([ + 'pinned' => (bool) $comment->pinned, + ]); + } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 73edc0d..80fea93 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -453,14 +453,42 @@ class UserController extends Controller } else { $video->likes()->attach($user->id); $liked = true; + // Toggling to like clears any existing dislike — mirrors YT's + // behavior where a video can be liked OR disliked, not both. + $video->dislikes()->detach($user->id); if ($video->user_id && $video->user_id !== $user->id) { try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {} } } return response()->json([ - 'liked' => $liked, - 'like_count' => $video->like_count, + 'liked' => $liked, + 'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it + 'like_count' => $video->like_count, + 'dislike_count' => $video->dislike_count, + ]); + } + + // Toggle dislike (API). Mutually exclusive with the like state. + public function toggleDislike(Video $video) + { + $user = Auth::user(); + + if ($video->isDislikedBy($user)) { + $video->dislikes()->detach($user->id); + $disliked = false; + } else { + $video->dislikes()->attach($user->id); + $disliked = true; + // Clear any existing like. + $video->likes()->detach($user->id); + } + + return response()->json([ + 'disliked' => $disliked, + 'liked' => $video->isLikedBy($user), // guaranteed false here when disliked just became true + 'like_count' => $video->like_count, + 'dislike_count' => $video->dislike_count, ]); } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 32bbdd3..63df92f 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -11,6 +11,13 @@ class Comment extends Model 'user_id', 'parent_id', 'body', + 'hearted', + 'pinned', + ]; + + protected $casts = [ + 'hearted' => 'boolean', + 'pinned' => 'boolean', ]; // Relationships diff --git a/app/Models/Video.php b/app/Models/Video.php index 5897017..4410b9a 100644 --- a/app/Models/Video.php +++ b/app/Models/Video.php @@ -53,6 +53,11 @@ class Video extends Model return $this->belongsToMany(User::class, 'video_likes')->withTimestamps(); } + public function dislikes() + { + return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps(); + } + public function viewers() { return $this->belongsToMany(User::class, 'video_views') @@ -177,6 +182,20 @@ class Video extends Model return $this->likes()->count(); } + // Dislike helpers — same shape as the like pair above. + public function isDislikedBy($user) + { + if (! $user) { + return false; + } + return $this->dislikes()->where('user_id', $user->id)->exists(); + } + + public function getDislikeCountAttribute() + { + return $this->dislikes()->count(); + } + // Get view count - use direct query to avoid timestamp issues public function getViewCountAttribute() { diff --git a/database/migrations/2026_10_21_000001_create_video_dislikes_table.php b/database/migrations/2026_10_21_000001_create_video_dislikes_table.php new file mode 100644 index 0000000..fff266f --- /dev/null +++ b/database/migrations/2026_10_21_000001_create_video_dislikes_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('video_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['video_id', 'user_id']); + $table->index('video_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('video_dislikes'); + } +}; diff --git a/database/migrations/2026_10_21_000002_add_hearted_and_pinned_to_comments.php b/database/migrations/2026_10_21_000002_add_hearted_and_pinned_to_comments.php new file mode 100644 index 0000000..d2215b2 --- /dev/null +++ b/database/migrations/2026_10_21_000002_add_hearted_and_pinned_to_comments.php @@ -0,0 +1,36 @@ +boolean('hearted')->default(false)->after('body'); + $table->boolean('pinned')->default(false)->after('hearted'); + $table->index('pinned'); + }); + } + + public function down(): void + { + Schema::table('comments', function (Blueprint $table) { + $table->dropIndex(['pinned']); + $table->dropColumn(['hearted', 'pinned']); + }); + } +}; diff --git a/resources/views/components/video-actions.blade.php b/resources/views/components/video-actions.blade.php index fde7e0c..3de8a69 100644 --- a/resources/views/components/video-actions.blade.php +++ b/resources/views/components/video-actions.blade.php @@ -164,19 +164,33 @@ @endauth @auth + {{-- Like + Dislike pair. Mutually exclusive on the server; clicking + one clears the other via the controllers' detach() call. --}} + @else + @endauth @if ($video->isShareable()) @@ -451,6 +465,46 @@ if (typeof toast === 'function') toast('Could not update like. Try again.', 'error'); }); }); + + // ── Dislike button ───────────────────────────────────────────── + // Dislike is mutually exclusive with like on the server side; whenever + // the server returns a new state, we reconcile BOTH buttons in one + // sweep so the pair always reflects the authoritative truth. + document.addEventListener('click', function (e) { + var btn = e.target.closest('.dislike-toggle-btn'); + if (!btn) return; + + var url = btn.dataset.toggleUrl; + var videoId = btn.dataset.videoId; + + fetch(url, { + method: 'POST', + headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }, + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + // Reconcile dislike pair. + document.querySelectorAll('.dislike-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) { + var icon = b.querySelector('i'); + b.dataset.disliked = data.disliked ? 'true' : 'false'; + b.classList.toggle('disliked', data.disliked); + if (icon) icon.className = 'bi ' + (data.disliked ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down'); + b.setAttribute('aria-label', data.disliked ? 'Remove dislike' : 'Dislike'); + }); + // Reconcile like pair in parallel — may have flipped off. + document.querySelectorAll('.like-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) { + var icon = b.querySelector('i'); + var label = b.querySelector('.like-count-label'); + b.dataset.liked = data.liked ? 'true' : 'false'; + b.classList.toggle('liked', data.liked); + if (icon) icon.className = 'bi ' + (data.liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up'); + if (label) label.textContent = data.like_count > 0 ? data.like_count.toLocaleString() : 'Like'; + }); + }) + .catch(function () { + if (typeof toast === 'function') toast('Could not update dislike. Try again.', 'error'); + }); + }); })(); // ── Slideshow video generation progress ────────────────────────────── diff --git a/resources/views/components/video-comments.blade.php b/resources/views/components/video-comments.blade.php index 436c305..b492302 100644 --- a/resources/views/components/video-comments.blade.php +++ b/resources/views/components/video-comments.blade.php @@ -10,7 +10,17 @@ // user's nationality. Batched so the comments list stays a single query. $commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all(); $commenterCountry = \App\Models\User::topCountriesFor($commenterIds); - $topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get(); + // Pinned comments bubble to the top of the list regardless of recency, + // then everything else sorts newest-first. reorder() wipes the + // ->latest() default the comments() relation already applied, so + // pinned DESC becomes the PRIMARY sort instead of a tiebreaker. + $topComments = $video->comments() + ->whereNull('parent_id') + ->with('user', 'replies.user') + ->reorder() + ->orderByDesc('pinned') + ->latest() + ->get(); $topLevelCount = $topComments->count(); $replyCount = $topComments->sum(fn($c) => $c->replies->count()); @@ -105,8 +115,16 @@ {{-- ── Comments list ── --}}
@forelse($topComments as $comment) - @php $isOwn = $comment->user_id === $currentUserId; @endphp -
+ @php + $isOwn = $comment->user_id === $currentUserId; + $isVideoOwner = $currentUserId === $video->user_id; + @endphp +
+ @if($comment->pinned) +
+ Pinned by {{ $video->user->name ?? 'the creator' }} +
+ @endif {{ $comment->user->name ?? 'User' }} @@ -119,6 +137,13 @@ href="?lc={{ $comment->id }}#comment-{{ $comment->id }}" title="Copy link to this comment" aria-label="Permalink">{{ ytcTime($comment->created_at) }} + @if($comment->hearted) + + + + @endif
{{ $comment->body }}
@@ -149,6 +174,23 @@ @auth + {{-- Creator-only heart + pin toggles. The server + rejects anyone else, but hiding the buttons + keeps the UI uncluttered for regular viewers. --}} + @if($isVideoOwner) + + + @endif @endauth @if($llmEnabled) @@ -328,6 +370,49 @@ margin-left: 4px; } +/* ── Creator engagement (heart + pin) ── */ +.ytc-pinned-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 4px; +} +.ytc-pinned-badge i { color: #ef4444; } +.ytc-comment.ytc-pinned { + background: rgba(239, 68, 68, 0.03); + border-left: 2px solid rgba(239, 68, 68, 0.5); + padding-left: 10px; + margin-left: -12px; +} +.ytc-heart-badge { + display: inline-flex; + align-items: center; + margin-left: 4px; + color: #ef4444; + font-size: 13px; +} +.ytc-creator-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 15px; + cursor: pointer; + padding: 4px; + border-radius: 50%; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background .15s, color .15s; +} +.ytc-creator-btn:hover { background: rgba(255,255,255,.08); color: var(--text-primary); } +.ytc-creator-btn.active { color: #ef4444; } +.ytc-creator-btn.active:hover { background: rgba(239, 68, 68, 0.15); } + /* Comment permalink highlight — brief glow on the targeted row when a ?lc= link lands on this page. Fades on its own after ~2.8s. */ .ytc-comment.ytc-highlight, @@ -1081,6 +1166,53 @@ document.getElementById('ytcList')?.addEventListener('click', e => { const db = e.target.closest('.ytc-dislike-btn'); if (db) { e.stopPropagation(); toggleDislike(db); return; } + // Creator heart — owner-only, server rejects anyone else + const hb = e.target.closest('.ytc-heart-toggle'); + if (hb) { + e.stopPropagation(); + const id = hb.dataset.commentId; + fetch('/comments/' + id + '/heart', { + method: 'POST', + headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' }, + }).then(r => r.json()).then(d => { + if (d.hearted !== undefined) { + hb.classList.toggle('active', d.hearted); + hb.setAttribute('aria-pressed', d.hearted ? 'true' : 'false'); + hb.querySelector('i').className = 'bi ' + (d.hearted ? 'bi-heart-fill' : 'bi-heart'); + // Also toggle the visible heart badge in the comment meta. + const meta = hb.closest('.ytc-comment').querySelector('.ytc-meta'); + let badge = meta?.querySelector('.ytc-heart-badge'); + if (d.hearted && !badge && meta) { + badge = document.createElement('span'); + badge.className = 'ytc-heart-badge'; + badge.innerHTML = ''; + meta.appendChild(badge); + } else if (!d.hearted && badge) { + badge.remove(); + } + } + }).catch(() => {}); + return; + } + + // Creator pin — owner-only, pins top-level comments to the list head + const pb = e.target.closest('.ytc-pin-toggle'); + if (pb) { + e.stopPropagation(); + const id = pb.dataset.commentId; + fetch('/comments/' + id + '/pin', { + method: 'POST', + headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' }, + }).then(r => r.json()).then(d => { + if (d.pinned !== undefined) { + // Reload to re-sort — pin changes the order of top-level + // comments and the DOM move is cleaner server-side than + // trying to shuffle the live list. + location.reload(); + } + }).catch(() => {}); + return; + } }); // Translate — bound at `document` level once, dispatches to the LATEST diff --git a/routes/web.php b/routes/web.php index 6a490e0..ec5d52c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -131,6 +131,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like'); Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike'); Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike'); + Route::post('/videos/{video}/toggle-dislike', [UserController::class, 'toggleDislike'])->name('videos.toggleDislike'); }); // Subscribe + notifications — auth but NOT email verification required @@ -155,6 +156,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update'); Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy'); Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like'); + Route::post('/comments/{comment}/heart', [CommentController::class, 'heart'])->name('comments.heart'); + Route::post('/comments/{comment}/pin', [CommentController::class, 'pin'])->name('comments.pin'); }); // User routes diff --git a/tests/Feature/DislikeHeartPinTest.php b/tests/Feature/DislikeHeartPinTest.php new file mode 100644 index 0000000..41720a7 --- /dev/null +++ b/tests/Feature/DislikeHeartPinTest.php @@ -0,0 +1,198 @@ +withoutMiddleware([ + VerifyCsrfToken::class, + \Illuminate\Session\Middleware\AuthenticateSession::class, + ]); + } + + // ─── Dislike toggle ────────────────────────────── + + public function test_toggle_dislike_adds_then_removes_the_row(): void + { + [$owner, $viewer, $video] = $this->makeOwnerViewerVideo(); + $this->actingAs($viewer); + + $this->postJson(route('videos.toggleDislike', $video)) + ->assertOk() + ->assertJson(['disliked' => true, 'dislike_count' => 1]); + $this->assertDatabaseHas('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]); + + $this->postJson(route('videos.toggleDislike', $video)) + ->assertOk() + ->assertJson(['disliked' => false, 'dislike_count' => 0]); + $this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]); + } + + public function test_disliking_clears_an_existing_like(): void + { + [$owner, $viewer, $video] = $this->makeOwnerViewerVideo(); + $this->actingAs($viewer); + $video->likes()->attach($viewer->id); + + $this->postJson(route('videos.toggleDislike', $video)) + ->assertOk() + ->assertJson(['disliked' => true, 'liked' => false, 'like_count' => 0]); + $this->assertDatabaseMissing('video_likes', ['video_id' => $video->id, 'user_id' => $viewer->id]); + } + + public function test_liking_clears_an_existing_dislike(): void + { + [$owner, $viewer, $video] = $this->makeOwnerViewerVideo(); + $this->actingAs($viewer); + $video->dislikes()->attach($viewer->id); + + $this->postJson(route('videos.toggleLike', $video)) + ->assertOk() + ->assertJson(['liked' => true, 'disliked' => false, 'like_count' => 1, 'dislike_count' => 0]); + $this->assertDatabaseMissing('video_dislikes', ['video_id' => $video->id, 'user_id' => $viewer->id]); + } + + // ─── Heart ────────────────────────────────────── + + public function test_owner_can_heart_a_comment(): void + { + [$owner, $viewer, $video] = $this->makeOwnerViewerVideo(); + $this->actingAs($owner); + $comment = Comment::create([ + 'user_id' => $viewer->id, + 'video_id' => $video->id, + 'body' => 'nice video!', + ]); + + $this->postJson(route('comments.heart', $comment)) + ->assertOk() + ->assertJson(['hearted' => true]); + $this->assertEquals(true, $comment->fresh()->hearted); + + $this->postJson(route('comments.heart', $comment)) + ->assertOk() + ->assertJson(['hearted' => false]); + } + + public function test_non_owner_cannot_heart_a_comment(): void + { + [$owner, $viewer, $video] = $this->makeOwnerViewerVideo(); + $comment = Comment::create([ + 'user_id' => $viewer->id, + 'video_id' => $video->id, + 'body' => 'nice video!', + ]); + $this->actingAs($viewer); + + $this->postJson(route('comments.heart', $comment))->assertStatus(403); + $this->assertEquals(false, $comment->fresh()->hearted); + } + + // ─── Pin ──────────────────────────────────────── + + public function test_owner_pins_at_most_one_comment_at_a_time(): void + { + [$owner, $viewer, $video] = $this->makeOwnerViewerVideo(); + $this->actingAs($owner); + $a = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'A']); + $b = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'B']); + + $this->postJson(route('comments.pin', $a))->assertOk()->assertJson(['pinned' => true]); + $this->assertTrue((bool) $a->fresh()->pinned); + $this->assertFalse((bool) $b->fresh()->pinned); + + // Pinning B must unpin A. + $this->postJson(route('comments.pin', $b))->assertOk()->assertJson(['pinned' => true]); + $this->assertFalse((bool) $a->fresh()->pinned); + $this->assertTrue((bool) $b->fresh()->pinned); + } + + public function test_cannot_pin_a_reply(): void + { + [$owner, $viewer, $video] = $this->makeOwnerViewerVideo(); + $this->actingAs($owner); + $parent = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'top']); + $reply = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'parent_id' => $parent->id, 'body' => 'r']); + + $this->postJson(route('comments.pin', $reply))->assertStatus(422); + $this->assertFalse((bool) $reply->fresh()->pinned); + } + + public function test_non_owner_cannot_pin_a_comment(): void + { + [$owner, $viewer, $video] = $this->makeOwnerViewerVideo(); + $this->actingAs($viewer); + $comment = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'self']); + + $this->postJson(route('comments.pin', $comment))->assertStatus(403); + } + + // ─── Pinned comments sort first in the component ── + + public function test_pinned_comments_render_before_newer_unpinned_ones(): void + { + [$owner, $viewer, $video] = $this->makeOwnerViewerVideo(); + $this->actingAs($owner); + + $pinned = Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'OLDPINNED', 'pinned' => true]); + // Date it older than the second one so sort would normally send it to the bottom. + $pinned->created_at = now()->subDays(5); $pinned->save(); + Comment::create(['user_id' => $viewer->id, 'video_id' => $video->id, 'body' => 'NEWFRESH']); + + $html = view('components.video-comments', ['video' => $video])->render(); + // Match on the unique comment-id markup so count/header text can't + // interfere with the order check. + $pOld = strpos($html, 'id="comment-' . $pinned->id . '"'); + $pNew = strpos($html, 'id="comment-' . ($pinned->id + 1) . '"'); + + $this->assertNotFalse($pOld); + $this->assertNotFalse($pNew); + $this->assertLessThan($pNew, $pOld, 'Pinned comment must render before a newer unpinned one.'); + } + + // ─── Helpers ──────────────────────────────────── + + private function makeOwnerViewerVideo(): array + { + $owner = User::factory()->create(['username' => 'own-'.substr(bin2hex(random_bytes(4)), 0, 6)]); + $viewer = User::factory()->create(['username' => 'vwr-'.substr(bin2hex(random_bytes(4)), 0, 6)]); + $video = Video::create([ + 'user_id' => $owner->id, + 'title' => 'test', + 'description' => '', + 'filename' => 'users/x/videos/y/video.mp4', + 'path' => 'users/x/videos/y/video.mp4', + 'type' => 'generic', + 'visibility' => 'public', + 'status' => 'ready', + 'share_token' => bin2hex(random_bytes(8)), + ]); + return [$owner, $viewer, $video]; + } +}