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); });