547 lines
21 KiB
PHP
547 lines
21 KiB
PHP
<?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);
|
|
});
|