messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

@@ -0,0 +1,156 @@
<?php
use App\Enums\ReactionType;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// ── Comment CRUD ──────────────────────────────────────────────────────────────
test('authenticated user can post a comment', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/comments", [
'content' => 'Great work! Really love the **colours**.',
])
->assertStatus(201)
->assertJsonPath('data.user.id', $user->id)
->assertJsonStructure(['data' => ['id', 'raw_content', 'rendered_content', 'user']]);
});
test('guest cannot post a comment', function () {
$artwork = Artwork::factory()->create();
$this->postJson("/api/artworks/{$artwork->id}/comments", ['content' => 'Nice!'])
->assertStatus(401);
});
test('comment with raw HTML is rejected via validation', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/comments", [
'content' => '<script>alert("xss")</script>',
])
->assertStatus(422);
});
test('user can view comments on public artwork', function () {
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create(['artwork_id' => $artwork->id]);
$this->getJson("/api/artworks/{$artwork->id}/comments")
->assertStatus(200)
->assertJsonStructure(['data', 'meta'])
->assertJsonCount(1, 'data');
});
// ── Reactions ─────────────────────────────────────────────────────────────────
test('authenticated user can add an artwork reaction', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => ReactionType::Heart->value,
])
->assertStatus(200)
->assertJsonPath('reaction', ReactionType::Heart->value)
->assertJsonPath('active', true);
});
test('reaction is toggled off when posted twice', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
// First toggle — on
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => ReactionType::ThumbsUp->value,
])
->assertJsonPath('active', true);
// Second toggle — off
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => ReactionType::ThumbsUp->value,
])
->assertJsonPath('active', false);
});
test('guest cannot add a reaction', function () {
$artwork = Artwork::factory()->create();
$this->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => ReactionType::Fire->value,
])->assertStatus(401);
});
test('invalid reaction slug is rejected', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$this->actingAs($user)
->postJson("/api/artworks/{$artwork->id}/reactions", [
'reaction' => 'not_valid_slug',
])
->assertStatus(422);
});
test('reaction totals are returned for public artworks', function () {
$artwork = Artwork::factory()->create();
$user = User::factory()->create();
// Insert a reaction directly
\Illuminate\Support\Facades\DB::table('artwork_reactions')->insert([
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'reaction' => ReactionType::Clap->value,
'created_at' => now(),
]);
$this->getJson("/api/artworks/{$artwork->id}/reactions")
->assertStatus(200)
->assertJsonPath('totals.' . ReactionType::Clap->value . '.count', 1)
->assertJsonPath('totals.' . ReactionType::Clap->value . '.emoji', '👏');
});
test('user can react to a comment', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$comment = ArtworkComment::factory()->create(['artwork_id' => $artwork->id]);
$this->actingAs($user)
->postJson("/api/comments/{$comment->id}/reactions", [
'reaction' => ReactionType::Laugh->value,
])
->assertStatus(200)
->assertJsonPath('active', true)
->assertJsonPath('entity_type', 'comment');
});
test('reaction uniqueness per user per slug', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$slug = ReactionType::Wow->value;
// Toggle on
$this->actingAs($user)->postJson("/api/artworks/{$artwork->id}/reactions", ['reaction' => $slug]);
// DB should have exactly 1 row
$this->assertDatabaseCount('artwork_reactions', 1);
// Toggle off
$this->actingAs($user)->postJson("/api/artworks/{$artwork->id}/reactions", ['reaction' => $slug]);
// DB should have 0 rows
$this->assertDatabaseCount('artwork_reactions', 0);
});

View File

@@ -6,7 +6,6 @@ use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DashboardFavoritesTest extends TestCase
@@ -22,30 +21,12 @@ class DashboardFavoritesTest extends TestCase
$user = User::factory()->create();
$art = Artwork::factory()->create(['user_id' => $user->id, 'title' => 'Fav Artwork']);
$favTable = Schema::hasTable('user_favorites') ? 'user_favorites' : (Schema::hasTable('favourites') ? 'favourites' : null);
if (! $favTable) {
$this->markTestSkipped('No favorites table available in schema');
return;
}
// insert using whichever timestamp column exists on the fav table
$col = null;
foreach (['datum', 'created_at', 'created', 'date'] as $c) {
if (Schema::hasColumn($favTable, $c)) {
$col = $c;
break;
}
}
$insert = [
'user_id' => $user->id,
DB::table('artwork_favourites')->insert([
'user_id' => $user->id,
'artwork_id' => $art->id,
];
if ($col) {
$insert[$col] = now();
}
DB::table($favTable)->insert($insert);
'created_at' => now(),
'updated_at' => now(),
]);
$response = $this->actingAs($user)
->get(route('dashboard.favorites'))
@@ -58,12 +39,12 @@ class DashboardFavoritesTest extends TestCase
$this->assertStringContainsString('data-blur-preview', $html);
$this->assertStringContainsString('loading="lazy"', $html);
$this->assertStringContainsString('decoding="async"', $html);
$this->assertMatchesRegularExpression('/<img[^>]*data-blur-preview[^>]*width="\d+"[^>]*height="\d+"/i', $html);
$this->assertMatchesRegularExpression('/<img[^>]*data-blur-preview[^>]*/i', $html);
$this->actingAs($user)
->delete(route('dashboard.favorites.destroy', ['artwork' => $art->id]))
->assertRedirect(route('dashboard.favorites'));
$this->assertDatabaseMissing($favTable, ['user_id' => $user->id, 'artwork_id' => $art->id]);
$this->assertDatabaseMissing('artwork_favourites', ['user_id' => $user->id, 'artwork_id' => $art->id]);
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\Report;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
function makeMessagingUser(array $attrs = []): User
{
return User::factory()->create(array_merge(['is_active' => 1], $attrs));
}
function makeDirectConversation(User $a, User $b): Conversation
{
$conv = Conversation::create(['type' => 'direct', 'created_by' => $a->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $a->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],
['conversation_id' => $conv->id, 'user_id' => $b->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => null],
]);
return $conv;
}
test('message search is membership scoped', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$userC = makeMessagingUser();
$convAllowed = makeDirectConversation($userA, $userB);
$convDenied = makeDirectConversation($userB, $userC);
Message::create(['conversation_id' => $convAllowed->id, 'sender_id' => $userB->id, 'body' => 'phase3 searchable hello']);
Message::create(['conversation_id' => $convDenied->id, 'sender_id' => $userB->id, 'body' => 'phase3 searchable secret']);
$response = $this->actingAs($userA)->getJson('/api/messages/search?q=phase3+searchable');
$response->assertStatus(200);
$conversations = collect($response->json('data'))->pluck('conversation_id')->unique()->values()->all();
expect($conversations)->toContain($convAllowed->id)
->and($conversations)->not->toContain($convDenied->id);
});
test('typing endpoints store and clear typing state', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/typing")
->assertStatus(200);
$typing = $this->actingAs($userB)->getJson("/api/messages/{$conv->id}/typing");
$typing->assertStatus(200)
->assertJsonFragment(['user_id' => $userA->id]);
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/typing/stop")
->assertStatus(200);
$typingAfter = $this->actingAs($userB)->getJson("/api/messages/{$conv->id}/typing");
expect($typingAfter->json('typing'))->toBeArray()->toHaveCount(0);
});
test('message reactions use whitelist and toggle semantics', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$message = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userA->id,
'body' => 'React me',
]);
$first = $this->actingAs($userB)->postJson("/api/messages/{$message->id}/reactions", [
'reaction' => '🔥',
]);
$first->assertStatus(200)
->assertJsonFragment(['🔥' => 1]);
$second = $this->actingAs($userB)->postJson("/api/messages/{$message->id}/reactions", [
'reaction' => '🔥',
]);
$second->assertStatus(200);
expect($second->json('🔥') ?? 0)->toBe(0);
$this->actingAs($userB)->postJson("/api/messages/{$message->id}/reactions", [
'reaction' => '🤯',
])->assertStatus(422);
});
test('message attachments are protected by conversation membership', function () {
Storage::fake(config('messaging.attachments.disk', 'local'));
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$userC = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$image = UploadedFile::fake()->image('phase3.png', 200, 200)->size(200);
$send = $this->actingAs($userA)->post("/api/messages/{$conv->id}", [
'body' => 'with file',
'attachments' => [$image],
]);
$send->assertStatus(201);
$attachment = MessageAttachment::query()->latest('id')->first();
expect($attachment)->not->toBeNull();
$this->actingAs($userB)
->get("/messages/attachments/{$attachment->id}")
->assertStatus(200);
$this->actingAs($userC)
->get("/messages/attachments/{$attachment->id}")
->assertStatus(403);
});
test('pin and unpin endpoints toggle participant pin state', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/pin")
->assertStatus(200)
->assertJsonFragment(['is_pinned' => true]);
expect(
(bool) ConversationParticipant::query()
->where('conversation_id', $conv->id)
->where('user_id', $userA->id)
->value('is_pinned')
)->toBeTrue();
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/unpin")
->assertStatus(200)
->assertJsonFragment(['is_pinned' => false]);
});
test('report endpoint creates moderation report entry', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$message = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'Reportable',
]);
$response = $this->actingAs($userA)->postJson('/api/reports', [
'target_type' => 'message',
'target_id' => $message->id,
'reason' => 'abuse',
'details' => 'phase3 test',
]);
$response->assertStatus(201);
expect(Report::query()->count())->toBe(1)
->and(Report::query()->first()->target_type)->toBe('message');
});

View File

@@ -0,0 +1,546 @@
<?php
declare(strict_types=1);
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeUser(array $attrs = []): User
{
return User::factory()->create(array_merge(['is_active' => 1], $attrs));
}
function actingAs(User $user): User
{
return $user; // alias — call $this->actingAs() in HTTP tests
}
// ── 1. Direct conversation creation ──────────────────────────────────────────
test('user can create a direct conversation', function () {
$userA = makeUser();
$userB = makeUser();
$response = $this->actingAs($userA)->postJson('/api/messages/conversation', [
'type' => 'direct',
'recipient_id' => $userB->id,
'body' => 'Hello there!',
]);
$response->assertStatus(201);
expect(Conversation::count())->toBe(1)
->and(ConversationParticipant::count())->toBe(2)
->and(Message::count())->toBe(1);
$conv = Conversation::first();
expect($conv->type)->toBe('direct')
->and($conv->last_message_at)->not->toBeNull();
});
test('sending a second message to same user reuses existing conversation', function () {
$userA = makeUser();
$userB = makeUser();
$this->actingAs($userA)->postJson('/api/messages/conversation', [
'type' => 'direct',
'recipient_id' => $userB->id,
'body' => 'Message 1',
])->assertStatus(201);
$this->actingAs($userA)->postJson('/api/messages/conversation', [
'type' => 'direct',
'recipient_id' => $userB->id,
'body' => 'Message 2',
])->assertStatus(201);
expect(Conversation::count())->toBe(1)
->and(Message::count())->toBe(2);
});
test('user cannot message themselves', function () {
$user = makeUser();
$this->actingAs($user)->postJson('/api/messages/conversation', [
'type' => 'direct',
'recipient_id' => $user->id,
'body' => 'Hello me!',
])->assertStatus(422);
});
// ── 2. Group conversation creation ───────────────────────────────────────────
test('user can create a group conversation', function () {
$creator = makeUser();
$memberA = makeUser();
$memberB = makeUser();
$response = $this->actingAs($creator)->postJson('/api/messages/conversation', [
'type' => 'group',
'title' => 'Test Group',
'participant_ids' => [$memberA->id, $memberB->id],
'body' => 'Welcome everyone!',
]);
$response->assertStatus(201);
expect(Conversation::count())->toBe(1)
->and(ConversationParticipant::count())->toBe(3) // creator + 2
->and(Message::count())->toBe(1);
$conv = Conversation::first();
expect($conv->type)->toBe('group')
->and($conv->title)->toBe('Test Group');
});
// ── 3. Add / remove participant ───────────────────────────────────────────────
test('group admin can add a participant', function () {
$creator = makeUser();
$existing = makeUser();
$newGuy = makeUser();
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $existing->id, 'role' => 'member', 'joined_at' => now()],
]);
$response = $this->actingAs($creator)->postJson("/api/messages/{$conv->id}/add-user", [
'user_id' => $newGuy->id,
]);
$response->assertStatus(200);
expect(ConversationParticipant::where('conversation_id', $conv->id)->whereNull('left_at')->count())->toBe(3);
});
test('non-admin cannot add a participant', function () {
$creator = makeUser();
$member = makeUser();
$newGuy = makeUser();
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $member->id, 'role' => 'member', 'joined_at' => now()],
]);
$this->actingAs($member)->postJson("/api/messages/{$conv->id}/add-user", [
'user_id' => $newGuy->id,
])->assertStatus(403);
});
test('admin can remove a participant', function () {
$creator = makeUser();
$member = makeUser();
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $member->id, 'role' => 'member', 'joined_at' => now()],
]);
$this->actingAs($creator)->deleteJson("/api/messages/{$conv->id}/remove-user", [
'user_id' => $member->id,
])->assertStatus(200);
expect(
ConversationParticipant::where('conversation_id', $conv->id)
->where('user_id', $member->id)
->whereNotNull('left_at')
->exists()
)->toBeTrue();
});
// ── 4. Leave conversation ─────────────────────────────────────────────────────
test('member can leave a group', function () {
$creator = makeUser();
$member = makeUser();
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $member->id, 'role' => 'member', 'joined_at' => now()],
]);
$this->actingAs($member)->deleteJson("/api/messages/{$conv->id}/leave")
->assertStatus(200);
expect(
ConversationParticipant::where('conversation_id', $conv->id)
->where('user_id', $member->id)
->whereNotNull('left_at')
->exists()
)->toBeTrue();
});
test('last admin leaving promotes another member to admin', function () {
$creator = makeUser();
$member = makeUser();
$conv = Conversation::create(['type' => 'group', 'title' => 'G', 'created_by' => $creator->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $creator->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $member->id, 'role' => 'member', 'joined_at' => now()],
]);
$this->actingAs($creator)->deleteJson("/api/messages/{$conv->id}/leave")
->assertStatus(200);
$promoted = ConversationParticipant::where('conversation_id', $conv->id)
->where('user_id', $member->id)
->first();
expect($promoted->role)->toBe('admin');
});
// ── 5. Unread logic ───────────────────────────────────────────────────────────
test('unread count is computed correctly', function () {
$userA = makeUser();
$userB = makeUser();
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => now()],
]);
// userB sends 3 messages
Message::insert([
['conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Hi', 'created_at' => now(), 'updated_at' => now()],
['conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Hey', 'created_at' => now(), 'updated_at' => now()],
['conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => '!!', 'created_at' => now(), 'updated_at' => now()],
]);
// userA has last_read_at = null → all 3 messages are unread
expect($conv->unreadCountFor($userA->id))->toBe(3);
// userB sent the messages; unread for userB = 0
expect($conv->unreadCountFor($userB->id))->toBe(0);
});
test('mark-as-read endpoint sets last_read_at', function () {
$userA = makeUser();
$userB = makeUser();
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => null],
]);
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/read")
->assertStatus(200);
expect(
ConversationParticipant::where('conversation_id', $conv->id)
->where('user_id', $userA->id)
->whereNotNull('last_read_at')
->exists()
)->toBeTrue();
});
// ── 6. Privacy rule enforcement ───────────────────────────────────────────────
test('user with privacy=nobody cannot be messaged', function () {
$sender = makeUser();
$recipient = makeUser(['allow_messages_from' => 'nobody']);
$this->actingAs($sender)->postJson('/api/messages/conversation', [
'type' => 'direct',
'recipient_id' => $recipient->id,
'body' => 'Can I message you?',
])->assertStatus(403);
});
test('user with privacy=everyone can be messaged by anyone', function () {
$sender = makeUser();
$recipient = makeUser(['allow_messages_from' => 'everyone']);
$this->actingAs($sender)->postJson('/api/messages/conversation', [
'type' => 'direct',
'recipient_id' => $recipient->id,
'body' => 'Hello!',
])->assertStatus(201);
});
// ── 7. Message pagination ─────────────────────────────────────────────────────
test('message list is cursor paginated', function () {
$userA = makeUser();
$userB = makeUser();
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now()],
]);
// Insert 50 messages
$rows = [];
for ($i = 1; $i <= 50; $i++) {
$rows[] = [
'conversation_id' => $conv->id,
'sender_id' => $userA->id,
'body' => "Message {$i}",
'created_at' => now()->addSeconds($i),
'updated_at' => now()->addSeconds($i),
];
}
Message::insert($rows);
$response = $this->actingAs($userA)->getJson("/api/messages/{$conv->id}");
$response->assertStatus(200);
$data = $response->json();
expect($data['data'])->toHaveCount(30)
->and($data['next_cursor'])->not->toBeNull();
$cursorResponse = $this->actingAs($userA)->getJson("/api/messages/{$conv->id}?cursor={$data['next_cursor']}");
$cursorResponse->assertStatus(200);
$olderData = $cursorResponse->json();
expect($olderData['data'])->toHaveCount(20)
->and($olderData['next_cursor'])->toBeNull();
});
test('sending a message creates notifications for active unmuted participants', function () {
$sender = makeUser();
$recipient = makeUser();
$conv = Conversation::create(['type' => 'direct', 'created_by' => $sender->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $sender->id, 'role' => 'admin', 'joined_at' => now(), 'is_muted' => false, 'is_archived' => false],
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now(), 'is_muted' => false, 'is_archived' => false],
]);
$this->actingAs($sender)->postJson("/api/messages/{$conv->id}", [
'body' => 'Hello with notify',
])->assertStatus(201);
$row = DB::table('notifications')
->where('user_id', $recipient->id)
->where('type', 'message')
->latest('id')
->first();
expect($row)->not->toBeNull();
});
test('sending a message does not notify recipient who disallows sender by privacy setting', function () {
$sender = makeUser();
$recipient = makeUser(['allow_messages_from' => 'nobody']);
$conv = Conversation::create(['type' => 'direct', 'created_by' => $sender->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $sender->id, 'role' => 'admin', 'joined_at' => now(), 'is_muted' => false, 'is_archived' => false],
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now(), 'is_muted' => false, 'is_archived' => false],
]);
$this->actingAs($sender)->postJson("/api/messages/{$conv->id}", [
'body' => 'Privacy should block notification',
])->assertStatus(201);
$count = DB::table('notifications')
->where('user_id', $recipient->id)
->where('type', 'message')
->count();
expect($count)->toBe(0);
});
// ── 8. Message editing ────────────────────────────────────────────────────────
test('user can edit their own message', function () {
$userA = makeUser();
$userB = makeUser();
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now()],
]);
$message = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userA->id,
'body' => 'Original body',
]);
$response = $this->actingAs($userA)->patchJson("/api/messages/message/{$message->id}", [
'body' => 'Updated body',
]);
$response->assertStatus(200)
->assertJsonFragment(['body' => 'Updated body']);
expect($message->fresh()->edited_at)->not->toBeNull();
});
test('user cannot edit another users message', function () {
$userA = makeUser();
$userB = makeUser();
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now()],
]);
$message = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userA->id,
'body' => 'Original body',
]);
$this->actingAs($userB)->patchJson("/api/messages/message/{$message->id}", [
'body' => 'Hacked!',
])->assertStatus(403);
});
// ── 9. Message soft-delete ────────────────────────────────────────────────────
test('user can delete their own message', function () {
$userA = makeUser();
$userB = makeUser();
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now()],
]);
$message = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userA->id,
'body' => 'Delete me',
]);
$this->actingAs($userA)->deleteJson("/api/messages/message/{$message->id}")
->assertStatus(200);
expect(Message::withTrashed()->find($message->id)->deleted_at)->not->toBeNull();
});
// ── 10. N+1 query check ──────────────────────────────────────────────────────
test('conversation list does not produce N+1 queries', function () {
$user = makeUser();
$other = makeUser();
// Create 5 conversations
for ($i = 0; $i < 5; $i++) {
$conv = Conversation::create(['type' => 'direct', 'created_by' => $user->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $other->id, 'role' => 'member', 'joined_at' => now()],
]);
Message::create(['conversation_id' => $conv->id, 'sender_id' => $other->id, 'body' => "Hi #{$i}"]);
$conv->update(['last_message_at' => now()]);
}
$queryCount = 0;
\Illuminate\Support\Facades\DB::listen(function () use (&$queryCount) { $queryCount++; });
$response = $this->actingAs($user)->getJson('/api/messages/conversations');
$response->assertStatus(200);
// Expect a small fixed number of queries regardless of conversation count.
// A naive implementation would run 1 + N*3 queries (N+1). Reasonable bound: <= 25.
expect($queryCount)->toBeLessThanOrEqual(25);
});
// ── 11. Messaging settings ────────────────────────────────────────────────────
test('user can update their messaging privacy setting', function () {
$user = makeUser(['allow_messages_from' => 'everyone']);
$this->actingAs($user)->patchJson('/api/messages/settings', [
'allow_messages_from' => 'followers',
])->assertStatus(200)
->assertJsonFragment(['allow_messages_from' => 'followers']);
expect($user->fresh()->allow_messages_from)->toBe('followers');
});
test('invalid privacy value is rejected', function () {
$user = makeUser();
$this->actingAs($user)->patchJson('/api/messages/settings', [
'allow_messages_from' => 'friends_only',
])->assertStatus(422);
});
test('mark-as-read invalidates cached conversation list unread count', function () {
$userA = makeUser();
$userB = makeUser();
$conv = Conversation::create(['type' => 'direct', 'created_by' => $userA->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $userA->id, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],
['conversation_id' => $conv->id, 'user_id' => $userB->id, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => now()],
]);
Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'Unread for userA',
]);
$firstList = $this->actingAs($userA)->getJson('/api/messages/conversations');
$firstList->assertStatus(200);
expect((int) $firstList->json('data.0.unread_count'))->toBe(1);
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}/read")
->assertStatus(200);
$secondList = $this->actingAs($userA)->getJson('/api/messages/conversations');
$secondList->assertStatus(200);
expect((int) $secondList->json('data.0.unread_count'))->toBe(0);
});
test('message send endpoint enforces per-user rate limit', function () {
$sender = makeUser();
$recipient = makeUser();
$conv = Conversation::create(['type' => 'direct', 'created_by' => $sender->id]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $sender->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()],
]);
for ($i = 1; $i <= 20; $i++) {
$this->actingAs($sender)->postJson("/api/messages/{$conv->id}", [
'body' => "Burst {$i}",
])->assertStatus(201);
}
$this->actingAs($sender)->postJson("/api/messages/{$conv->id}", [
'body' => 'Burst 21',
])->assertStatus(429);
});
// ── 12. Unauthenticated access is blocked ────────────────────────────────────
test('conversations list requires authentication', function () {
$this->getJson('/api/messages/conversations')
->assertStatus(401);
});
test('sending a message requires authentication', function () {
$this->postJson('/api/messages/conversation', [
'type' => 'direct',
'recipient_id' => 1,
'body' => 'Hello',
])->assertStatus(401);
});

View File

@@ -0,0 +1,518 @@
<?php
declare(strict_types=1);
use App\Console\Commands\RecomputeUserStatsCommand;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkComment;
use App\Models\ArtworkFavourite;
use App\Models\ArtworkReaction;
use App\Models\User;
use App\Services\ArtworkAwardService;
use App\Services\UserStatsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeCreator(): User
{
return User::factory()->create(['is_active' => true]);
}
function makeArtworkFor(User $user): Artwork
{
return Artwork::factory()->create([
'user_id' => $user->id,
'is_public' => true,
'is_approved'=> true,
]);
}
function statsRow(int $userId): object
{
return DB::table('user_statistics')->where('user_id', $userId)->first();
}
// ─── 1. Schema ───────────────────────────────────────────────────────────────
test('user_statistics v2 schema has all expected columns', function () {
$columns = DB::getSchemaBuilder()->getColumnListing('user_statistics');
$expected = [
'user_id',
'uploads_count',
'downloads_received_count',
'artwork_views_received_count',
'awards_received_count',
'favorites_received_count',
'comments_received_count',
'reactions_received_count',
'profile_views_count',
'followers_count',
'following_count',
'last_upload_at',
'last_active_at',
'created_at',
'updated_at',
];
foreach ($expected as $col) {
expect(in_array($col, $columns, true))->toBeTrue("Column '{$col}' is missing from user_statistics");
}
// Old column names must NOT exist
foreach (['uploads', 'downloads', 'pageviews', 'awards', 'profile_views'] as $old) {
expect(in_array($old, $columns, true))->toBeFalse("Old column '{$old}' still present in user_statistics");
}
});
// ─── 2. UserStatsService ensureRow ─────────────────────────────────────────
test('ensureRow creates a stats row if none exists', function () {
$user = makeCreator();
DB::table('user_statistics')->where('user_id', $user->id)->delete();
app(UserStatsService::class)->ensureRow($user->id);
expect(DB::table('user_statistics')->where('user_id', $user->id)->exists())->toBeTrue();
});
test('ensureRow does not throw if row already exists', function () {
$user = makeCreator();
app(UserStatsService::class)->ensureRow($user->id);
app(UserStatsService::class)->ensureRow($user->id); // second call should not fail
expect(DB::table('user_statistics')->where('user_id', $user->id)->count())->toBe(1);
});
// ─── 3. UserStatsService increment / decrement ────────────────────────────
test('incrementUploads increments uploads_count atomically', function () {
Queue::fake(); // prevent Meilisearch reindex job
$user = makeCreator();
$svc = app(UserStatsService::class);
$svc->incrementUploads($user->id);
$svc->incrementUploads($user->id, 4);
expect((int) statsRow($user->id)->uploads_count)->toBe(5);
});
test('decrementUploads does not go below zero', function () {
Queue::fake();
$user = makeCreator();
$svc = app(UserStatsService::class);
// Ensure row exists with default 0, then try to decrement
$svc->ensureRow($user->id);
$svc->decrementUploads($user->id, 10);
expect((int) statsRow($user->id)->uploads_count)->toBe(0);
});
test('incrementFavoritesReceived increments the counter', function () {
Queue::fake();
$user = makeCreator();
$svc = app(UserStatsService::class);
$svc->incrementFavoritesReceived($user->id);
$svc->incrementFavoritesReceived($user->id);
expect((int) statsRow($user->id)->favorites_received_count)->toBe(2);
});
test('decrementFavoritesReceived does not go below zero', function () {
Queue::fake();
$user = makeCreator();
$svc = app(UserStatsService::class);
// Ensure row exists with default 0, then try to decrement
$svc->ensureRow($user->id);
$svc->decrementFavoritesReceived($user->id);
expect((int) statsRow($user->id)->favorites_received_count)->toBe(0);
});
test('incrementCommentsReceived and decrementCommentsReceived work symmetrically', function () {
Queue::fake();
$user = makeCreator();
$svc = app(UserStatsService::class);
$svc->incrementCommentsReceived($user->id);
$svc->incrementCommentsReceived($user->id);
$svc->decrementCommentsReceived($user->id);
expect((int) statsRow($user->id)->comments_received_count)->toBe(1);
});
test('incrementReactionsReceived and decrementReactionsReceived work symmetrically', function () {
Queue::fake();
$user = makeCreator();
$svc = app(UserStatsService::class);
$svc->incrementReactionsReceived($user->id, 3);
$svc->decrementReactionsReceived($user->id, 2);
expect((int) statsRow($user->id)->reactions_received_count)->toBe(1);
});
test('incrementAwardsReceived and decrementAwardsReceived work symmetrically', function () {
Queue::fake();
$user = makeCreator();
$svc = app(UserStatsService::class);
$svc->incrementAwardsReceived($user->id);
$svc->decrementAwardsReceived($user->id);
expect((int) statsRow($user->id)->awards_received_count)->toBe(0);
});
test('incrementProfileViews increments profile_views_count', function () {
Queue::fake();
$user = makeCreator();
app(UserStatsService::class)->incrementProfileViews($user->id, 5);
expect((int) statsRow($user->id)->profile_views_count)->toBe(5);
});
// ─── 4. Timestamps ───────────────────────────────────────────────────────────
test('setLastUploadAt writes the timestamp', function () {
Queue::fake();
$user = makeCreator();
$ts = now()->subHours(3);
app(UserStatsService::class)->setLastUploadAt($user->id, $ts);
$row = statsRow($user->id);
expect($row->last_upload_at)->not->toBeNull();
});
test('setLastActiveAt writes the timestamp', function () {
Queue::fake();
$user = makeCreator();
app(UserStatsService::class)->setLastActiveAt($user->id);
expect(statsRow($user->id)->last_active_at)->not->toBeNull();
});
// ─── 5. Observer wiring Artwork created ────────────────────────────────────
test('creating an artwork increments uploads_count for its owner', function () {
Queue::fake();
$creator = makeCreator();
DB::table('user_statistics')->where('user_id', $creator->id)->delete();
makeArtworkFor($creator);
expect((int) statsRow($creator->id)->uploads_count)->toBe(1);
});
test('soft-deleting an artwork decrements uploads_count', function () {
Queue::fake();
$creator = makeCreator();
$artwork = makeArtworkFor($creator);
$before = (int) statsRow($creator->id)->uploads_count;
$artwork->delete();
expect((int) statsRow($creator->id)->uploads_count)->toBe(max(0, $before - 1));
});
// ─── 6. Observer wiring Favourites ─────────────────────────────────────────
test('adding a favourite increments creator favorites_received_count', function () {
Queue::fake();
$creator = makeCreator();
$liker = makeCreator();
$artwork = makeArtworkFor($creator);
DB::table('user_statistics')->where('user_id', $creator->id)
->update(['favorites_received_count' => 0]);
ArtworkFavourite::create(['user_id' => $liker->id, 'artwork_id' => $artwork->id]);
expect((int) statsRow($creator->id)->favorites_received_count)->toBe(1);
});
test('removing a favourite decrements creator favorites_received_count', function () {
Queue::fake();
$creator = makeCreator();
$liker = makeCreator();
$artwork = makeArtworkFor($creator);
$fav = ArtworkFavourite::create(['user_id' => $liker->id, 'artwork_id' => $artwork->id]);
$after = (int) statsRow($creator->id)->favorites_received_count;
$fav->delete();
expect((int) statsRow($creator->id)->favorites_received_count)->toBe(max(0, $after - 1));
});
// ─── 7. Observer wiring Comments ───────────────────────────────────────────
test('adding a comment increments creator comments_received_count', function () {
Queue::fake();
$creator = makeCreator();
$commenter = makeCreator();
$artwork = makeArtworkFor($creator);
DB::table('user_statistics')->where('user_id', $creator->id)
->update(['comments_received_count' => 0]);
ArtworkComment::create([
'artwork_id' => $artwork->id,
'user_id' => $commenter->id,
'content' => 'Nice work!',
'is_approved'=> true,
]);
expect((int) statsRow($creator->id)->comments_received_count)->toBe(1);
});
test('soft-deleting a comment decrements creator comments_received_count', function () {
Queue::fake();
$creator = makeCreator();
$commenter = makeCreator();
$artwork = makeArtworkFor($creator);
$comment = ArtworkComment::create([
'artwork_id' => $artwork->id,
'user_id' => $commenter->id,
'content' => 'Hi',
'is_approved'=> true,
]);
$before = (int) statsRow($creator->id)->comments_received_count;
$comment->delete();
expect((int) statsRow($creator->id)->comments_received_count)->toBe(max(0, $before - 1));
});
// ─── 8. Observer wiring Reactions ──────────────────────────────────────────
test('adding a reaction increments creator reactions_received_count', function () {
Queue::fake();
$creator = makeCreator();
$reactor = makeCreator();
$artwork = makeArtworkFor($creator);
DB::table('user_statistics')->where('user_id', $creator->id)
->update(['reactions_received_count' => 0]);
ArtworkReaction::create([
'artwork_id' => $artwork->id,
'user_id' => $reactor->id,
'reaction' => 'heart',
]);
expect((int) statsRow($creator->id)->reactions_received_count)->toBe(1);
});
test('removing a reaction decrements creator reactions_received_count', function () {
Queue::fake();
$creator = makeCreator();
$reactor = makeCreator();
$artwork = makeArtworkFor($creator);
$reaction = ArtworkReaction::create([
'artwork_id' => $artwork->id,
'user_id' => $reactor->id,
'reaction' => 'thumbs_up',
]);
$before = (int) statsRow($creator->id)->reactions_received_count;
$reaction->delete();
expect((int) statsRow($creator->id)->reactions_received_count)->toBe(max(0, $before - 1));
});
// ─── 9. Observer wiring Awards ────────────────────────────────────────────
test('giving an award increments creator awards_received_count', function () {
Queue::fake();
$creator = makeCreator();
$awarder = makeCreator();
$artwork = makeArtworkFor($creator);
DB::table('user_statistics')->where('user_id', $creator->id)
->update(['awards_received_count' => 0]);
$svc = app(ArtworkAwardService::class);
$svc->award($artwork, $awarder, 'gold');
expect((int) statsRow($creator->id)->awards_received_count)->toBe(1);
});
test('removing an award decrements creator awards_received_count', function () {
Queue::fake();
$creator = makeCreator();
$awarder = makeCreator();
$artwork = makeArtworkFor($creator);
$svc = app(ArtworkAwardService::class);
$svc->award($artwork, $awarder, 'gold');
$before = (int) statsRow($creator->id)->awards_received_count;
$svc->removeAward($artwork, $awarder);
expect((int) statsRow($creator->id)->awards_received_count)->toBe(max(0, $before - 1));
});
// ─── 10. Recompute single user ─────────────────────────────────────────────
test('recomputeUser rebuilds counters from source tables', function () {
Queue::fake();
$creator = makeCreator();
$fanA = makeCreator();
$fanB = makeCreator();
$art1 = makeArtworkFor($creator);
$art2 = makeArtworkFor($creator);
// Add 2 favourites
ArtworkFavourite::create(['user_id' => $fanA->id, 'artwork_id' => $art1->id]);
ArtworkFavourite::create(['user_id' => $fanB->id, 'artwork_id' => $art2->id]);
// Add 1 comment
ArtworkComment::create([
'artwork_id' => $art1->id,
'user_id' => $fanA->id,
'content' => 'Nice',
'is_approved'=> true,
]);
// Corrupt the stored counters to simulate drift
DB::table('user_statistics')->where('user_id', $creator->id)->update([
'uploads_count' => 99,
'favorites_received_count'=> 99,
'comments_received_count' => 99,
]);
// Recompute should restore correct values
$svc = app(UserStatsService::class);
$svc->recomputeUser($creator->id);
$row = statsRow($creator->id);
expect((int) $row->uploads_count)->toBe(2)
->and((int) $row->favorites_received_count)->toBe(2)
->and((int) $row->comments_received_count)->toBe(1);
});
test('recomputeUser dry-run does not write to database', function () {
Queue::fake();
$creator = makeCreator();
makeArtworkFor($creator);
// Corrupt the counter
DB::table('user_statistics')->where('user_id', $creator->id)
->update(['uploads_count' => 99]);
$svc = app(UserStatsService::class);
$result = $svc->recomputeUser($creator->id, dryRun: true);
// Returned value should be correct
expect($result['uploads_count'])->toBe(1);
// Nothing should have been written
expect((int) statsRow($creator->id)->uploads_count)->toBe(99);
});
// ─── 11. Recompute command ────────────────────────────────────────────────────
test('recompute command dry-run does not write changes', function () {
Queue::fake();
$creator = makeCreator();
makeArtworkFor($creator);
DB::table('user_statistics')->where('user_id', $creator->id)
->update(['uploads_count' => 99]);
$this->artisan('skinbase:recompute-user-stats', [
'user_id' => $creator->id,
'--dry-run'=> true,
])->assertSuccessful();
expect((int) statsRow($creator->id)->uploads_count)->toBe(99);
});
test('recompute command live applies correct values', function () {
Queue::fake();
$creator = makeCreator();
makeArtworkFor($creator);
makeArtworkFor($creator);
DB::table('user_statistics')->where('user_id', $creator->id)
->update(['uploads_count' => 0]);
$this->artisan('skinbase:recompute-user-stats', [
'user_id' => $creator->id,
])->assertSuccessful();
expect((int) statsRow($creator->id)->uploads_count)->toBe(2);
});
test('recompute command --all processes all users', function () {
Queue::fake();
$userA = makeCreator();
$userB = makeCreator();
makeArtworkFor($userA);
DB::table('user_statistics')
->whereIn('user_id', [$userA->id, $userB->id])
->update(['uploads_count' => 0]);
$this->artisan('skinbase:recompute-user-stats', ['--all' => true])
->assertSuccessful();
expect((int) statsRow($userA->id)->uploads_count)->toBe(1)
->and((int) statsRow($userB->id)->uploads_count)->toBe(0);
});
// ─── 12. Meilisearch toSearchableArray ─────────────────────────────────────
test('User toSearchableArray contains v2 stat fields', function () {
Queue::fake();
$user = makeCreator();
// Ensure stats row exists before updating
app(UserStatsService::class)->ensureRow($user->id);
DB::table('user_statistics')->where('user_id', $user->id)->update([
'uploads_count' => 10,
'downloads_received_count' => 20,
'artwork_views_received_count' => 30,
'awards_received_count' => 4,
'favorites_received_count' => 5,
'comments_received_count' => 6,
'reactions_received_count' => 7,
'followers_count' => 100,
'following_count' => 50,
]);
$user->load('statistics');
$arr = $user->toSearchableArray();
expect($arr)->toHaveKey('uploads_count', 10)
->and($arr)->toHaveKey('downloads_received_count', 20)
->and($arr)->toHaveKey('artwork_views_received_count', 30)
->and($arr)->toHaveKey('awards_received_count', 4)
->and($arr)->toHaveKey('favorites_received_count', 5)
->and($arr)->toHaveKey('comments_received_count', 6)
->and($arr)->toHaveKey('reactions_received_count', 7)
->and($arr)->toHaveKey('followers_count', 100)
->and($arr)->toHaveKey('following_count', 50);
// Old key must not be present
expect(array_key_exists('uploads', $arr))->toBeFalse();
});