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('sending a message dispatches realtime events and preserves client temp id', function () { Event::fake([MessageCreated::class, ConversationUpdated::class]); $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $response = $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [ 'body' => 'Realtime hello', 'client_temp_id' => 'tmp_feature_test_123', ]); $response->assertStatus(201) ->assertJsonFragment(['client_temp_id' => 'tmp_feature_test_123']) ->assertJsonFragment(['body' => 'Realtime hello']); Event::assertDispatched(MessageCreated::class); Event::assertDispatched(ConversationUpdated::class); }); test('retrying a send with the same client temp id reuses the existing message', function () { Event::fake([MessageCreated::class]); $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $first = $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [ 'body' => 'Retry safe hello', 'client_temp_id' => 'tmp_retry_safe_001', ]); $second = $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [ 'body' => 'Retry safe hello', 'client_temp_id' => 'tmp_retry_safe_001', ]); $first->assertStatus(201); $second->assertStatus(201); expect(Message::query()->count())->toBe(1) ->and($second->json('id'))->toBe($first->json('id')) ->and($second->json('uuid'))->toBe($first->json('uuid')) ->and($second->json('client_temp_id'))->toBe('tmp_retry_safe_001'); Event::assertDispatchedTimes(MessageCreated::class, 1); }); test('client temp id dedupe is scoped to sender', function () { Event::fake([MessageCreated::class]); $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $first = $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [ 'body' => 'Sender A', 'client_temp_id' => 'tmp_sender_scope_001', ]); $second = $this->actingAs($userB)->postJson("/api/messages/{$conv->id}", [ 'body' => 'Sender B', 'client_temp_id' => 'tmp_sender_scope_001', ]); $first->assertStatus(201); $second->assertStatus(201); expect(Message::query()->count())->toBe(2) ->and($second->json('id'))->not->toBe($first->json('id')); Event::assertDispatchedTimes(MessageCreated::class, 2); }); test('database enforces sender scoped client temp id uniqueness', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userA->id, 'body' => 'Stored once', 'client_temp_id' => 'tmp_db_guard_001', ]); expect(fn () => Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userA->id, 'body' => 'Stored twice', 'client_temp_id' => 'tmp_db_guard_001', ]))->toThrow(QueryException::class); }); test('non participant cannot send a message', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $outsider = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $this->actingAs($outsider)->postJson("/api/messages/{$conv->id}", [ 'body' => 'intrusion', ])->assertStatus(403); }); test('channel authorization denies non participant and allows participant', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $outsider = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $policy = app(ConversationPolicy::class); expect($policy->view($outsider, $conv))->toBeFalse() ->and($policy->view($userA, $conv))->toBeTrue() ->and($policy->joinPresence($outsider, $conv))->toBeFalse() ->and($policy->joinPresence($userB, $conv))->toBeTrue(); }); test('mark read updates last read message id and dispatches read event', function () { Event::fake([MessageRead::class, ConversationUpdated::class]); $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $first = Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'One', ]); $last = Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Two', ]); $this->actingAs($userA) ->postJson("/api/messages/{$conv->id}/read", ['message_id' => $last->id]) ->assertStatus(200) ->assertJsonFragment(['last_read_message_id' => $last->id]); $participant = ConversationParticipant::query() ->where('conversation_id', $conv->id) ->where('user_id', $userA->id) ->firstOrFail(); expect($participant->last_read_message_id)->toBe($last->id); $this->assertDatabaseHas('message_reads', [ 'message_id' => $first->id, 'user_id' => $userA->id, ]); $this->assertDatabaseHas('message_reads', [ 'message_id' => $last->id, 'user_id' => $userA->id, ]); Event::assertDispatched(MessageRead::class); }); test('typing endpoints reject non participants', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $outsider = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $this->actingAs($outsider) ->postJson("/api/messages/{$conv->id}/typing") ->assertStatus(403); $this->actingAs($outsider) ->postJson("/api/messages/{$conv->id}/typing/stop") ->assertStatus(403); }); test('conversation list includes unread summary total', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Unread one', ]); Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Unread two', ]); $response = $this->actingAs($userA)->getJson('/api/messages/conversations'); $response->assertStatus(200) ->assertJsonPath('summary.unread_total', 2); }); test('conversation updated broadcast includes unread summary total', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Unread one', ]); Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Unread two', ]); $payload = (new ConversationUpdated($userA->id, $conv->fresh(), 'message.created'))->broadcastWith(); expect($payload['reason'])->toBe('message.created') ->and((int) data_get($payload, 'conversation.id'))->toBe($conv->id) ->and((int) data_get($payload, 'conversation.unread_count'))->toBe(2) ->and((int) data_get($payload, 'summary.unread_total'))->toBe(2); }); test('delta endpoint returns only messages after requested id in ascending order', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $first = Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'First', ]); $second = Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userB->id, 'body' => 'Second', ]); $third = Message::create([ 'conversation_id' => $conv->id, 'sender_id' => $userA->id, 'body' => 'Third', ]); $response = $this->actingAs($userA)->getJson("/api/messages/{$conv->id}/delta?after_message_id={$first->id}"); $response->assertStatus(200) ->assertJsonPath('conversation.id', $conv->id) ->assertJsonPath('conversation.latest_message.id', $third->id) ->assertJsonPath('summary.unread_total', 2) ->assertJsonPath('data.0.id', $second->id) ->assertJsonPath('data.1.id', $third->id); }); test('presence heartbeat marks user online and viewing a conversation', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $this->actingAs($userA) ->postJson('/api/messages/presence/heartbeat', ['conversation_id' => $conv->id]) ->assertStatus(200) ->assertJsonFragment(['conversation_id' => $conv->id]); $presence = app(MessagingPresenceService::class); expect($presence->isUserOnline($userA->id))->toBeTrue() ->and($presence->isViewingConversation($conv->id, $userA->id))->toBeTrue(); }); test('offline fallback notifications are skipped for online recipients', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); app(MessagingPresenceService::class)->touch($userB); $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [ 'body' => 'Presence-aware hello', ])->assertStatus(201); expect(DB::table('notifications')->count())->toBe(0); }); test('offline fallback notifications are stored for offline recipients', function () { $userA = makeMessagingUser(); $userB = makeMessagingUser(); $conv = makeDirectConversation($userA, $userB); $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [ 'body' => 'Offline hello', ])->assertStatus(201); $notification = DB::table('notifications')->first(); expect($notification)->not->toBeNull() ->and((int) $notification->user_id)->toBe($userB->id) ->and((string) $notification->type)->toBe('message'); }); 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'); });