175 lines
5.7 KiB
PHP
175 lines
5.7 KiB
PHP
<?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');
|
|
});
|