false, 'federation.activitypub.enabled' => true, 'snowflake.datacenter_id' => 1, 'snowflake.worker_id' => 1, ]); }); describe('one to one', function () { it('stores a direct note as a message in a conversation with its author', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); $message = DmMessage::first(); expect($message)->not->toBeNull() ->and($message->body)->toBe('hello there') ->and($message->profile_id)->toBe($alice->id) ->and($message->ap_object_uri)->toBe($alice->remote_url.'/statuses/1'); $conversation = DmConversation::find($message->conversation_id); expect($conversation->type)->toBe('dm') ->and($conversation->participants_hash)->toBe(DmConversation::dmHash($alice->id, $bob->id)) ->and($conversation->last_message_id)->toBe($message->id); expect(Status::count())->toBe(0); }); it('keeps the thread identifiers the remote server sent', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); $conversation = DmConversation::first(); expect($conversation->context_uri)->toBe('https://remote.example/contexts/1') ->and($conversation->conversation_uri)->toBe('tag:remote.example,2026-09-21:objectId=1:objectType=Conversation'); }); it('counts the message as unread and notifies the recipient', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); $participant = DmConversationParticipant::where('profile_id', $bob->id)->first(); expect($participant->state)->toBe('active') ->and($participant->unread_count)->toBe(1) ->and($participant->last_activity_at)->not->toBeNull(); $notification = Notification::where('profile_id', $bob->id)->first(); expect($notification)->not->toBeNull() ->and($notification->action)->toBe('dm') ->and($notification->item_type)->toBe(DmMessage::class); }); it('resolves a recipient addressed by an id based actor url', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob], ['to' => [url('/users/'.$bob->id)]])); expect(DmMessage::count())->toBe(1); }); it('stores a redelivered message once', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); dmDeliver($alice, dmNote($alice, '1', [$bob])); expect(DmMessage::count())->toBe(1) ->and(DmConversationParticipant::where('profile_id', $bob->id)->value('unread_count'))->toBe(1); }); it('does not store a message that already exists as a legacy direct status', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); Status::factory()->create([ 'profile_id' => $alice->id, 'caption' => 'old', 'scope' => 'direct', 'visibility' => 'direct', 'uri' => $alice->remote_url.'/statuses/1', ]); dmDeliver($alice, dmNote($alice, '1', [$bob])); expect(DmMessage::count())->toBe(0); }); it('turns the conversation into a request when the recipient only takes messages from people they follow', function () { $bob = dmProfile(dmLocalUser(publicDm: false)); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); expect(DmConversationParticipant::where('profile_id', $bob->id)->value('state'))->toBe('request') ->and(Notification::where('profile_id', $bob->id)->count())->toBe(0); }); it('skips the request when the recipient follows the sender', function () { $bob = dmProfile(dmLocalUser(publicDm: false)); $alice = dmRemoteProfile(); dmFollow($bob, $alice); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); expect(DmConversationParticipant::where('profile_id', $bob->id)->value('state'))->toBe('active'); }); it('stops storing messages once a pending request hits the inbound limit', function () { config(['dm.requests.inbound_limit' => 2]); $bob = dmProfile(dmLocalUser(publicDm: false)); $alice = dmRemoteProfile(); dmSeedHosts(); foreach (['1', '2', '3'] as $path) { dmDeliver($alice, dmNote($alice, $path, [$bob])); } expect(DmMessage::count())->toBe(2); }); it('drops a message from someone the recipient blocks', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); UserFilter::create([ 'user_id' => $bob->id, 'filterable_id' => $alice->id, 'filterable_type' => 'App\Models\Profile', 'filter_type' => 'block', ]); dmDeliver($alice, dmNote($alice, '1', [$bob])); expect(DmMessage::count())->toBe(0) ->and(DmConversation::count())->toBe(0); }); it('drops a message from a domain the recipient blocks', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); UserDomainBlock::create(['profile_id' => $bob->id, 'domain' => 'remote.example']); dmDeliver($alice, dmNote($alice, '1', [$bob])); expect(DmMessage::count())->toBe(0); }); it('does not store a message attributed to someone other than the sender', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); $mallory = dmRemoteProfile('mallory', 'other.example'); dmSeedHosts(); dmDeliver($mallory, dmNote($alice, '1', [$bob])); expect(DmMessage::count())->toBe(0) ->and(Status::count())->toBe(0); }); }); describe('text and media', function () { it('keeps the text of a message that also has media', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob], [ 'content' => '

look at this

', 'attachment' => [[ 'type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg', 'name' => 'a cat', 'blurhash' => 'UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH', 'width' => 1200, 'height' => 800, ]], ])); $message = DmMessage::with('media')->first(); expect($message->body)->toBe('look at this') ->and($message->type)->toBe('photo') ->and($message->media)->toHaveCount(1) ->and($message->media[0]->remote_url)->toBe('https://remote.example/media/1.jpg') ->and($message->media[0]->status_id)->toBeNull() ->and($message->media[0]->caption)->toBe('a cat'); }); it('stores a message that is only media', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob], [ 'content' => '', 'attachment' => [ ['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg'], ['type' => 'Document', 'mediaType' => 'image/png', 'url' => 'https://remote.example/media/2.png'], ], ])); $message = DmMessage::with('media')->first(); expect($message->body)->toBeNull() ->and($message->type)->toBe('photos') ->and($message->media)->toHaveCount(2); }); it('ignores attachments of a type this server does not accept', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob], [ 'attachment' => [ ['type' => 'Document', 'mediaType' => 'application/x-msdownload', 'url' => 'https://remote.example/media/1.exe'], ], ])); $message = DmMessage::with('media')->first(); expect($message->type)->toBe('text') ->and($message->media)->toHaveCount(0); }); it('keeps direct message media out of the unattached media queries', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob], [ 'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']], ])); expect(Media::whereNull('status_id')->count())->toBe(1) ->and(Media::whereNull('status_id')->notInDirectMessage()->count())->toBe(0); }); }); describe('plain text', function () { it('drops the leading mentions and keeps paragraphs and line breaks', function () { $html = '

@bob ' .'@carol first line
second line

' .'

new paragraph & more

'; expect(DirectMessageHandler::plainText($html))->toBe("first line\nsecond line\n\nnew paragraph & more"); }); it('leaves a mention in the middle of the text alone', function () { expect(DirectMessageHandler::plainText('

@bob have you met @carol@other.example yet

')) ->toBe('have you met @carol@other.example yet'); }); it('does not keep markup or script content', function () { expect(DirectMessageHandler::plainText('

hi there

'))->toBe('hi there'); }); it('returns null when nothing is left', function () { expect(DirectMessageHandler::plainText('

@bob

'))->toBeNull(); }); }); describe('groups', function () { it('stores a note addressed to several people as one group conversation', function () { $bob = dmProfile(dmLocalUser()); $dave = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); $carol = dmRemoteProfile('carol', 'other.example'); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob, $dave, $carol])); $conversation = DmConversation::first(); expect(DmConversation::count())->toBe(1) ->and($conversation->type)->toBe('group') ->and($conversation->participants_hash)->toBe(DmConversation::participantsHash([$alice->id, $bob->id, $dave->id, $carol->id])) ->and(DmConversationParticipant::where('conversation_id', $conversation->id)->count())->toBe(4) ->and(DmMessage::count())->toBe(1); }); it('reads recipients from cc as well as to', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); $carol = dmRemoteProfile('carol', 'other.example'); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob], ['cc' => [$carol->remote_url]])); expect(DmConversation::first()->type)->toBe('group'); }); it('never stores a group message as a followers-only post', function () { $bob = dmProfile(dmLocalUser()); $dave = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmFollow($bob, $alice); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob, $dave], [ 'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']], ])); expect(Status::count())->toBe(0) ->and(DmMessage::count())->toBe(1); }); it('never stores a group message that replies to a local post as a comment', function () { $bobUser = dmLocalUser(); $bob = dmProfile($bobUser); $dave = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); $post = Status::factory()->photo()->create(['profile_id' => $bob->id]); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob, $dave], ['inReplyTo' => $post->url()])); expect(Status::where('in_reply_to_id', $post->id)->count())->toBe(0) ->and(DmMessage::count())->toBe(1); }); it('drops a direct note that names nobody on this server instead of storing it as a post', function () { dmLocalUser(); $alice = dmRemoteProfile(); $carol = dmRemoteProfile('carol', 'other.example'); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$carol], [ 'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']], ])); expect(Status::count())->toBe(0) ->and(DmMessage::count())->toBe(0); }); it('drops a note addressed to more people than a group allows', function () { config(['dm.groups.max_participants' => 3]); $bob = dmProfile(dmLocalUser()); $dave = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); $carol = dmRemoteProfile('carol', 'other.example'); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob, $dave, $carol])); expect(DmMessage::count())->toBe(0) ->and(Status::count())->toBe(0); }); it('hides the message from a member who blocks the sender without telling anyone', function () { $bob = dmProfile(dmLocalUser()); $dave = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); UserFilter::create([ 'user_id' => $dave->id, 'filterable_id' => $alice->id, 'filterable_type' => 'App\Models\Profile', 'filter_type' => 'block', ]); dmDeliver($alice, dmNote($alice, '1', [$bob, $dave])); expect(DmMessage::count())->toBe(1) ->and(DmConversationParticipant::where('profile_id', $bob->id)->value('unread_count'))->toBe(1) ->and(DmConversationParticipant::where('profile_id', $dave->id)->value('unread_count'))->toBe(0) ->and(DmConversationParticipant::where('profile_id', $dave->id)->value('last_activity_at'))->toBeNull() ->and(Notification::where('profile_id', $dave->id)->count())->toBe(0); }); it('drops a message when the only person it could reach here has left the group', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); $carol = dmNeighbour($alice, dmRemoteProfile('carol', 'other.example')); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob, $carol])); DmConversationParticipant::where('profile_id', $bob->id)->update(['state' => 'left']); dmDeliver($alice, dmNote($alice, '2', [$bob, $carol])); expect(DmMessage::count())->toBe(1); }); it('starts a separate conversation when the set of people changes', function () { $bob = dmProfile(dmLocalUser()); $dave = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); dmDeliver($alice, dmNote($alice, '2', [$bob, $dave])); expect(DmConversation::count())->toBe(2); }); }); describe('conversation identity', function () { it('hashes the same people to the same conversation whatever order they come in', function () { $a = 1007571621538590723; $b = $a + 1; $c = $a + 2; expect(DmConversation::dmHash($a, $b))->toBe(DmConversation::dmHash($b, $a))->and(DmConversation::participantsHash([$a, $b, $c]))->toBe(DmConversation::participantsHash([$c, $a, $b]))->and(DmConversation::participantsHash([$a, $b, $c]))->toBe(DmConversation::participantsHash([(string) $b, $c, $a, $a]))->and(DmConversation::dmHash($a, $b))->not->toBe(DmConversation::dmHash($a, $c)); }); it('keeps one conversation when two participants have neighbouring ids', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); $carol = dmNeighbour($alice, dmRemoteProfile('carol', 'other.example')); dmSeedHosts(); dmDeliver($carol, dmNote($carol, '1', [$alice, $bob])); dmDeliver($alice, dmNote($alice, '2', [$bob, $carol])); $mine = app(DirectMessageService::class)->findOrCreateConversation($bob, collect([$alice, $carol])); expect(DmConversation::count())->toBe(1) ->and(DmMessage::where('conversation_id', $mine->id)->count())->toBe(2); }); }); describe('audience', function () { it('does not treat public, unlisted or followers-only notes as direct', function () { $alice = dmRemoteProfile(); $bobUrl = 'https://pixelfed.test/users/bob'; expect(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'cc' => [$bobUrl]], $alice))->toBeFalse() ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => [$bobUrl], 'cc' => ['as:Public']], $alice))->toBeFalse() ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => [$alice->remote_url.'/followers'], 'cc' => [$bobUrl]], $alice))->toBeFalse() ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => ['https://friendica.example/followers/alice'], 'cc' => []], $alice))->toBeFalse() ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => $bobUrl], $alice))->toBeTrue() ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => [$bobUrl, 'https://other.example/users/carol']], $alice))->toBeTrue() ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => []], $alice))->toBeFalse(); }); it('still counts a poll vote as a vote', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, [ 'id' => $alice->remote_url.'/votes/1', 'type' => 'Note', 'attributedTo' => $alice->remote_url, 'name' => 'Option A', 'inReplyTo' => 'https://remote.example/polls/1', 'to' => [$bob->permalink()], ]); expect(DmMessage::count())->toBe(0); }); }); describe('threading', function () { it('links a reply to the message it answers', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); dmDeliver($alice, dmNote($alice, '2', [$bob], ['inReplyTo' => $alice->remote_url.'/statuses/1'])); $first = DmMessage::whereObjectUri($alice->remote_url.'/statuses/1')->first(); $second = DmMessage::whereObjectUri($alice->remote_url.'/statuses/2')->first(); expect($second->in_reply_to_id)->toBe($first->id) ->and($second->conversation_id)->toBe($first->conversation_id); }); it('follows the remote thread when it moves to a new context', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); dmDeliver($alice, dmNote($alice, '2', [$bob], [ 'context' => 'https://remote.example/contexts/2', 'conversation' => 'tag:remote.example,2026-09-21:objectId=2:objectType=Conversation', ])); expect(DmConversation::count())->toBe(1) ->and(DmConversation::first()->context_uri)->toBe('https://remote.example/contexts/2'); }); }); describe('edits', function () { it('updates the text when the author edits a message', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); dmDeliver($alice, dmNote($alice, '1', [$bob], ['content' => '

fixed typo

']), 'Update'); $message = DmMessage::first(); expect(DmMessage::count())->toBe(1) ->and($message->body)->toBe('fixed typo') ->and($message->edited_at)->not->toBeNull(); }); it('ignores an edit from someone who did not write the message', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); $mallory = dmRemoteProfile('mallory', 'remote.example'); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); dmDeliver($mallory, dmNote($alice, '1', [$bob], ['content' => '

hijacked

']), 'Update'); expect(DmMessage::first()->body)->toBe('hello there'); }); }); describe('deletes', function () { it('removes a message when its author deletes it', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); dmDeliver($alice, dmNote($alice, '2', [$bob])); dmDeliver($alice, ['id' => $alice->remote_url.'/statuses/2', 'type' => 'Tombstone'], 'Delete'); $conversation = DmConversation::first(); $first = DmMessage::whereObjectUri($alice->remote_url.'/statuses/1')->first(); expect(DmMessage::count())->toBe(1) ->and($conversation->last_message_id)->toBe($first->id) ->and(DmConversationParticipant::where('profile_id', $bob->id)->value('unread_count'))->toBe(1) ->and(DB::table('dm_message_media')->count())->toBe(0); }); it('removes the media of a deleted message', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob], [ 'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']], ])); expect(Media::count())->toBe(1); dmDeliver($alice, ['id' => $alice->remote_url.'/statuses/1', 'type' => 'Tombstone'], 'Delete'); expect(DmMessage::count())->toBe(0) ->and(DB::table('dm_message_media')->count())->toBe(0); Queue::assertPushed(MediaDeletePipeline::class, 1); }); it('ignores a delete from someone who did not write the message', function () { $bob = dmProfile(dmLocalUser()); $alice = dmRemoteProfile(); $mallory = dmRemoteProfile('mallory', 'remote.example'); dmSeedHosts(); dmDeliver($alice, dmNote($alice, '1', [$bob])); dmDeliver($mallory, ['id' => $alice->remote_url.'/statuses/1', 'type' => 'Tombstone'], 'Delete'); expect(DmMessage::count())->toBe(1); }); });