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]>
199 lines
7.9 KiB
PHP
199 lines
7.9 KiB
PHP
<?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];
|
|
}
|
|
}
|