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
+570 -8

No files matched your search

+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];
}
}