Save workspace changes
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
<?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 App\Policies\ConversationPolicy;
|
||||
use App\Events\ConversationUpdated;
|
||||
use App\Events\MessageCreated;
|
||||
use App\Events\MessageRead;
|
||||
use App\Services\Messaging\MessagingPresenceService;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
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('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');
|
||||
});
|
||||
Reference in New Issue
Block a user