feat(engagement): video dislike + creator heart + creator pin

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) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 12:18:17 +03:00
1 parent 73de5bc97e
commit 4d9bc0f501
10 files changed
+568 -6

No files matched your search

@@ -206,4 +206,56 @@ class CommentController extends Controller
return response()->json(['liked' => $liked, 'count' => $count]); 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,
]);
}
} }
+28
View File
@@ -453,6 +453,9 @@ class UserController extends Controller
} else { } else {
$video->likes()->attach($user->id); $video->likes()->attach($user->id);
$liked = true; $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) { if ($video->user_id && $video->user_id !== $user->id) {
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {} try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
} }
@@ -460,7 +463,32 @@ class UserController extends Controller
return response()->json([ return response()->json([
'liked' => $liked, 'liked' => $liked,
'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it
'like_count' => $video->like_count, '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,
]); ]);
} }
+7
View File
@@ -11,6 +11,13 @@ class Comment extends Model
'user_id', 'user_id',
'parent_id', 'parent_id',
'body', 'body',
'hearted',
'pinned',
];
protected $casts = [
'hearted' => 'boolean',
'pinned' => 'boolean',
]; ];
// Relationships // Relationships
+19
View File
@@ -53,6 +53,11 @@ class Video extends Model
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps(); return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
} }
public function dislikes()
{
return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps();
}
public function viewers() public function viewers()
{ {
return $this->belongsToMany(User::class, 'video_views') return $this->belongsToMany(User::class, 'video_views')
@@ -177,6 +182,20 @@ class Video extends Model
return $this->likes()->count(); 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 // Get view count - use direct query to avoid timestamp issues
public function getViewCountAttribute() public function getViewCountAttribute()
{ {
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Dislike counterpart to video_likes.
*
* One row per (user, video) pair. The unique index on the pair prevents a
* user from racking up multiple dislikes against the same video, and the
* foreign keys cascade so cleaning up a user or video drops their dislikes
* for free.
*/
return new class extends Migration {
public function up(): void
{
Schema::create('video_dislikes', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Creator-engagement flags on comments.
*
* - hearted: the video owner has acknowledged the comment with a red
* heart (YouTube's "creator heart"). Visible to everyone.
* - pinned: the owner has anchored the comment to the top of the list.
* The comment component sorts pinned comments ahead of everything
* else, newest-pinned first.
*
* Both default to false and are indexed only on `pinned` because that's the
* one the comments query sorts by.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -164,19 +164,33 @@
@endauth @endauth
@auth @auth
{{-- Like + Dislike pair. Mutually exclusive on the server; clicking
one clears the other via the controllers' detach() call. --}}
<button type="button" <button type="button"
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}" class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
data-video-id="{{ $video->id }}" data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleLike', $video) }}" data-toggle-url="{{ route('videos.toggleLike', $video) }}"
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"> data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i> aria-label="{{ $video->isLikedBy(Auth::user()) ? 'Remove like' : 'Like' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}" aria-hidden="true"></i>
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span> <span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button> </button>
<button type="button"
class="action-btn desktop-action dislike-toggle-btn {{ $video->isDislikedBy(Auth::user()) ? 'disliked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleDislike', $video) }}"
data-disliked="{{ $video->isDislikedBy(Auth::user()) ? 'true' : 'false' }}"
aria-label="{{ $video->isDislikedBy(Auth::user()) ? 'Remove dislike' : 'Dislike' }}">
<i class="bi {{ $video->isDislikedBy(Auth::user()) ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down' }}" aria-hidden="true"></i>
</button>
@else @else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action"> <button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
<i class="bi bi-hand-thumbs-up"></i> <i class="bi bi-hand-thumbs-up" aria-hidden="true"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }} {{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button> </button>
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action" aria-label="Dislike">
<i class="bi bi-hand-thumbs-down" aria-hidden="true"></i>
</button>
@endauth @endauth
@if ($video->isShareable()) @if ($video->isShareable())
@@ -451,6 +465,46 @@
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error'); 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 ────────────────────────────── // ── Slideshow video generation progress ──────────────────────────────
@@ -10,7 +10,17 @@
// user's nationality. Batched so the comments list stays a single query. // user's nationality. Batched so the comments list stays a single query.
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all(); $commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds); $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(); $topLevelCount = $topComments->count();
$replyCount = $topComments->sum(fn($c) => $c->replies->count()); $replyCount = $topComments->sum(fn($c) => $c->replies->count());
@@ -105,8 +115,16 @@
{{-- ── Comments list ── --}} {{-- ── Comments list ── --}}
<div id="ytcList"> <div id="ytcList">
@forelse($topComments as $comment) @forelse($topComments as $comment)
@php $isOwn = $comment->user_id === $currentUserId; @endphp @php
<div class="ytc-comment" id="comment-{{ $comment->id }}"> $isOwn = $comment->user_id === $currentUserId;
$isVideoOwner = $currentUserId === $video->user_id;
@endphp
<div class="ytc-comment{{ $comment->pinned ? ' ytc-pinned' : '' }}" id="comment-{{ $comment->id }}">
@if($comment->pinned)
<div class="ytc-pinned-badge" aria-label="Pinned by the creator">
<i class="bi bi-pin-fill" aria-hidden="true"></i> Pinned by {{ $video->user->name ?? 'the creator' }}
</div>
@endif
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link"> <a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link">
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}"> <img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
</a> </a>
@@ -119,6 +137,13 @@
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}" href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
title="Copy link to this comment" title="Copy link to this comment"
aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a> aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a>
@if($comment->hearted)
<span class="ytc-heart-badge"
aria-label="Hearted by the creator"
title="Hearted by {{ $video->user->name ?? 'the creator' }}">
<i class="bi bi-heart-fill" aria-hidden="true"></i>
</span>
@endif
</div> </div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div> <div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
@@ -149,6 +174,23 @@
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg> <svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button> </button>
@auth @auth
{{-- Creator-only heart + pin toggles. The server
rejects anyone else, but hiding the buttons
keeps the UI uncluttered for regular viewers. --}}
@if($isVideoOwner)
<button class="ytc-creator-btn ytc-heart-toggle{{ $comment->hearted ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->hearted ? 'true' : 'false' }}"
title="{{ $comment->hearted ? 'Remove heart' : 'Heart this comment' }}">
<i class="bi {{ $comment->hearted ? 'bi-heart-fill' : 'bi-heart' }}" aria-hidden="true"></i>
</button>
<button class="ytc-creator-btn ytc-pin-toggle{{ $comment->pinned ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->pinned ? 'true' : 'false' }}"
title="{{ $comment->pinned ? 'Unpin' : 'Pin to top' }}">
<i class="bi {{ $comment->pinned ? 'bi-pin-fill' : 'bi-pin' }}" aria-hidden="true"></i>
</button>
@endif
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button> <button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
@endauth @endauth
@if($llmEnabled) @if($llmEnabled)
@@ -328,6 +370,49 @@
margin-left: 4px; 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 /* Comment permalink highlight — brief glow on the targeted row when a
?lc=<id> link lands on this page. Fades on its own after ~2.8s. */ ?lc=<id> link lands on this page. Fades on its own after ~2.8s. */
.ytc-comment.ytc-highlight, .ytc-comment.ytc-highlight,
@@ -1081,6 +1166,53 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
const db = e.target.closest('.ytc-dislike-btn'); const db = e.target.closest('.ytc-dislike-btn');
if (db) { e.stopPropagation(); toggleDislike(db); return; } 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 = '<i class="bi bi-heart-fill" aria-hidden="true"></i>';
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 // Translate — bound at `document` level once, dispatches to the LATEST
+3
View File
@@ -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}/like', [UserController::class, 'like'])->name('videos.like');
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike'); 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-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 // 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::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy'); 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}/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 // User routes
+198
View File
@@ -0,0 +1,198 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Video dislike toggle + creator heart + creator pin.
*
* The in-memory DB these tests run against gets the two post-audit migrations
* applied automatically by RefreshDatabase, so no manual migrate step is
* required to exercise the full feature.
*/
class DislikeHeartPinTest 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,
]);
}
// ─── 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];
}
}