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