mirror of https://github.com/pixelfed/pixelfed
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
377 lines
15 KiB
PHP
377 lines
15 KiB
PHP
<?php
|
|
|
|
use App\Models\DmConversation;
|
|
use App\Models\DmConversationParticipant;
|
|
use App\Models\DmMessage;
|
|
use App\Models\Media;
|
|
use App\Models\Report;
|
|
use App\Services\DirectMessageService;
|
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Routing\Middleware\ThrottleRequests;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Redis;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Laravel\Passport\Passport;
|
|
|
|
require_once __DIR__.'/helpers.php';
|
|
|
|
uses(LazilyRefreshDatabase::class);
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Legacy thread endpoints and the Mastodon conversations API
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| The Blade UI and the older mobile app address a thread by the other
|
|
| person's profile id. Those endpoints keep their request and response
|
|
| shapes and now read and write conversations.
|
|
|
|
|
*/
|
|
|
|
beforeEach(function () {
|
|
Redis::spy();
|
|
Queue::fake();
|
|
Http::fake();
|
|
|
|
// Ids minted in the same millisecond only sort by creation order when the
|
|
// worker bits are fixed. Left unset they are random for every id.
|
|
config(['snowflake.datacenter_id' => 1, 'snowflake.worker_id' => 1]);
|
|
|
|
$this->withoutMiddleware(ThrottleRequests::class);
|
|
|
|
// Ids minted in the same millisecond only sort by creation order when the
|
|
// worker bits are fixed. Left unset they are random for every id.
|
|
config(['snowflake.datacenter_id' => 1, 'snowflake.worker_id' => 1]);
|
|
|
|
config([
|
|
'instance.enable_cc' => false,
|
|
'federation.activitypub.enabled' => true,
|
|
]);
|
|
});
|
|
|
|
describe('thread endpoints', function () {
|
|
it('sends through thread/send and reads it back through thread', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
|
|
$sent = $this->postJson('/api/v1.1/direct/thread/send', [
|
|
'to_id' => $bob->profile_id,
|
|
'message' => 'hello bob',
|
|
'type' => 'text',
|
|
]);
|
|
|
|
$sent->assertOk()
|
|
->assertJsonPath('isAuthor', true)
|
|
->assertJsonPath('type', 'text')
|
|
->assertJsonPath('text', 'hello bob');
|
|
|
|
expect($sent->json('reportId'))->toBe($sent->json('id'))
|
|
->and(DmConversation::count())->toBe(1);
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
|
|
$thread = $this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id);
|
|
|
|
$thread->assertOk()
|
|
->assertJsonPath('id', (string) $alice->profile_id)
|
|
->assertJsonCount(1, 'messages')
|
|
->assertJsonPath('messages.0.text', 'hello bob')
|
|
->assertJsonPath('messages.0.isAuthor', false)
|
|
->assertJsonPath('messages.0.seen', false);
|
|
|
|
expect($thread->json('conversation_id'))->toBe((string) DmConversation::first()->id);
|
|
});
|
|
|
|
it('returns an empty thread for someone never messaged', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
|
|
$this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id)
|
|
->assertOk()
|
|
->assertJsonCount(0, 'messages')
|
|
->assertJsonPath('conversation_id', null);
|
|
});
|
|
|
|
it('uploads media with text as a single message', function () {
|
|
Storage::fake(config('filesystems.default'));
|
|
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
|
|
$this->post('/api/v1.1/direct/thread/media', [
|
|
'to_id' => $bob->profile_id,
|
|
'file' => UploadedFile::fake()->image('cat.jpg', 100, 100),
|
|
'message' => 'my cat',
|
|
], ['Accept' => 'application/json'])->assertOk()->assertJsonPath('type', 'photo');
|
|
|
|
$message = DmMessage::with('media')->first();
|
|
|
|
expect(DmMessage::count())->toBe(1)
|
|
->and($message->body)->toBe('my cat')
|
|
->and($message->media)->toHaveCount(1)
|
|
->and($message->media[0]->status_id)->toBeNull();
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
|
|
$this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id)
|
|
->assertJsonPath('messages.0.text', 'my cat')
|
|
->assertJsonPath('messages.0.type', 'photo')
|
|
->assertJsonCount(1, 'messages.0.carousel');
|
|
|
|
expect($this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id)->json('messages.0.media'))->not->toBeNull();
|
|
});
|
|
|
|
it('marks a thread read and reports it as seen to the sender', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
$id = $this->postJson('/api/v1.1/direct/thread/send', ['to_id' => $bob->profile_id, 'message' => 'one', 'type' => 'text'])->json('id');
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
$this->postJson('/api/v1.1/direct/thread/read', ['pid' => $alice->profile_id, 'sid' => $id])
|
|
->assertOk()
|
|
->assertExactJson([$id]);
|
|
|
|
expect(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('unread_count'))->toBe(0);
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
$this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id)->assertJsonPath('messages.0.seen', true);
|
|
});
|
|
|
|
it('deletes by the id the thread handed out', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
$id = $this->postJson('/api/v1.1/direct/thread/send', ['to_id' => $bob->profile_id, 'message' => 'oops', 'type' => 'text'])->json('reportId');
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
$this->deleteJson('/api/v1.1/direct/thread/message', ['id' => $id])->assertNotFound();
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
$this->deleteJson('/api/v1.1/direct/thread/message', ['id' => $id])->assertOk();
|
|
|
|
expect(DmMessage::count())->toBe(0);
|
|
});
|
|
|
|
it('mutes and unmutes a thread', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
|
|
$this->postJson('/api/v1.1/direct/thread/mute', ['id' => $bob->profile_id])->assertOk();
|
|
expect(DmConversationParticipant::where('profile_id', $alice->profile_id)->value('muted_at'))->not->toBeNull();
|
|
|
|
$this->postJson('/api/v1.1/direct/thread/unmute', ['id' => $bob->profile_id])->assertOk();
|
|
expect(DmConversationParticipant::where('profile_id', $alice->profile_id)->value('muted_at'))->toBeNull();
|
|
});
|
|
|
|
it('lets an older client report a message as a post', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
$id = $this->postJson('/api/v1.1/direct/thread/send', ['to_id' => $bob->profile_id, 'message' => 'spam', 'type' => 'text'])->json('reportId');
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
$this->postJson('/api/v1.1/report', ['report_type' => 'spam', 'object_type' => 'post', 'object_id' => $id])->assertOk();
|
|
|
|
expect(Report::first()->object_type)->toBe(DmMessage::class);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/v1/conversations', function () {
|
|
it('lists one to one conversations with a status shaped last message', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
$service = app(DirectMessageService::class);
|
|
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
|
$message = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']);
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
|
|
$response = $this->getJson('/api/v1/conversations')
|
|
->assertOk()
|
|
->assertJsonCount(1)
|
|
->assertJsonPath('0.unread', true)
|
|
->assertJsonPath('0.accounts.0.id', (string) $alice->profile_id)
|
|
->assertJsonPath('0.last_status.id', (string) $message->id)
|
|
->assertJsonPath('0.last_status.visibility', 'direct')
|
|
->assertJsonPath('0.last_status.content', '<p>hello</p>')
|
|
->assertJsonPath('0.last_status.account.id', (string) $alice->profile_id)
|
|
->assertJsonPath('0.last_status.mentions.0.id', (string) $bob->profile_id);
|
|
|
|
// Pixelix stores this id as a 32-bit int, as the old endpoint allowed
|
|
$id = $response->json('0.id');
|
|
expect((int) $id)->toBeLessThan(2 ** 31)
|
|
->and((int) $id)->toBeGreaterThan(0)
|
|
->and((int) $id)->toBe(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('id'));
|
|
|
|
$this->postJson("/api/v1/conversations/{$id}/read")->assertOk()->assertJsonPath('unread', false);
|
|
|
|
// The real conversation id still works for clients written against it
|
|
$this->postJson("/api/v1/conversations/{$conversation->id}/read")->assertOk();
|
|
|
|
$this->deleteJson("/api/v1/conversations/{$id}")->assertOk();
|
|
$this->getJson('/api/v1/conversations')->assertJsonCount(0);
|
|
});
|
|
|
|
it('does not let one person use another persons conversation id', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
$eve = dmLocalUser();
|
|
|
|
$service = app(DirectMessageService::class);
|
|
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
|
$service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']);
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
$id = $this->getJson('/api/v1/conversations')->json('0.id');
|
|
|
|
Passport::actingAs($eve, ['read', 'write']);
|
|
$this->postJson("/api/v1/conversations/{$id}/read")->assertNotFound();
|
|
$this->deleteJson("/api/v1/conversations/{$id}")->assertNotFound();
|
|
$this->deleteJson("/api/v1/conversations/{$conversation->id}")->assertNotFound();
|
|
});
|
|
|
|
it('only includes groups when asked', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
$dave = dmLocalUser();
|
|
|
|
$service = app(DirectMessageService::class);
|
|
$group = $service->findOrCreateConversation(dmProfile($alice), collect([dmProfile($bob), dmProfile($dave)]));
|
|
$service->sendMessage($group, dmProfile($alice), ['body' => 'hi all']);
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
|
|
$this->getJson('/api/v1/conversations')->assertJsonCount(0);
|
|
$this->getJson('/api/v1/conversations?include_groups=1')
|
|
->assertJsonCount(1)
|
|
->assertJsonCount(2, '0.accounts');
|
|
});
|
|
|
|
it('keeps requests out of the inbox', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser(publicDm: false);
|
|
|
|
$service = app(DirectMessageService::class);
|
|
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
|
$service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']);
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
|
|
$this->getJson('/api/v1/conversations')->assertJsonCount(0);
|
|
$this->getJson('/api/v1/conversations?scope=requests')->assertJsonCount(1);
|
|
});
|
|
});
|
|
|
|
describe('mastodon status endpoints', function () {
|
|
it('serves a message as a status to people in the conversation and nobody else', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
$eve = dmLocalUser();
|
|
|
|
$service = app(DirectMessageService::class);
|
|
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
|
$message = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']);
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
|
|
$this->getJson("/api/v1/statuses/{$message->id}")
|
|
->assertOk()
|
|
->assertJsonPath('id', (string) $message->id)
|
|
->assertJsonPath('visibility', 'direct')
|
|
->assertJsonPath('content', '<p>hello</p>')
|
|
->assertJsonPath('account.id', (string) $alice->profile_id)
|
|
->assertJsonPath('mentions.0.id', (string) $bob->profile_id);
|
|
|
|
Passport::actingAs($eve, ['read', 'write']);
|
|
|
|
$this->getJson("/api/v1/statuses/{$message->id}")->assertNotFound();
|
|
});
|
|
|
|
it('returns the rest of the conversation as the context of a message', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
$service = app(DirectMessageService::class);
|
|
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
|
$one = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'one']);
|
|
$two = $service->sendMessage($conversation, dmProfile($bob), ['body' => 'two']);
|
|
$three = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'three']);
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
|
|
$this->getJson("/api/v1/statuses/{$two->id}/context")
|
|
->assertOk()
|
|
->assertJsonCount(1, 'ancestors')
|
|
->assertJsonPath('ancestors.0.id', (string) $one->id)
|
|
->assertJsonCount(1, 'descendants')
|
|
->assertJsonPath('descendants.0.id', (string) $three->id)
|
|
->assertJsonPath('descendants.0.visibility', 'direct');
|
|
|
|
// The usual client flow: open the conversation from its last status
|
|
$this->getJson("/api/v1/statuses/{$three->id}/context")
|
|
->assertOk()
|
|
->assertJsonCount(2, 'ancestors')
|
|
->assertJsonCount(0, 'descendants');
|
|
});
|
|
|
|
it('lets the author delete a message through the status endpoint', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
$service = app(DirectMessageService::class);
|
|
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
|
$message = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'oops']);
|
|
|
|
Passport::actingAs($bob, ['read', 'write']);
|
|
$this->deleteJson("/api/v1/statuses/{$message->id}")->assertNotFound();
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
$this->deleteJson("/api/v1/statuses/{$message->id}")
|
|
->assertOk()
|
|
->assertJsonPath('text', 'oops');
|
|
|
|
expect(DmMessage::count())->toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('media housekeeping', function () {
|
|
it('does not let direct message media be attached to a post', function () {
|
|
$alice = dmLocalUser();
|
|
$bob = dmLocalUser();
|
|
|
|
$media = Media::create([
|
|
'status_id' => null,
|
|
'profile_id' => $alice->profile_id,
|
|
'user_id' => $alice->id,
|
|
'media_path' => 'public/m/test.jpg',
|
|
'mime' => 'image/jpeg',
|
|
'size' => 1000,
|
|
]);
|
|
|
|
$service = app(DirectMessageService::class);
|
|
$conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob));
|
|
$service->sendMessage($conversation, dmProfile($alice), ['media' => collect([$media])]);
|
|
|
|
Passport::actingAs($alice, ['read', 'write']);
|
|
|
|
$this->postJson('/api/v1/statuses', ['media_ids' => [$media->id], 'status' => 'now public'])->assertStatus(400);
|
|
|
|
expect($media->fresh()->status_id)->toBeNull();
|
|
});
|
|
});
|