From 6ea3f922da9f2c72386abec30467ff5a5b1bc24f Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 13 Aug 2026 20:46:06 +0200 Subject: [PATCH] 138 - Add multipart upload support to LemmyRequest --- app/Modules/Lemmy/LemmyRequest.php | 24 ++++++++ tests/Unit/Modules/Lemmy/LemmyRequestTest.php | 55 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/app/Modules/Lemmy/LemmyRequest.php b/app/Modules/Lemmy/LemmyRequest.php index d64d2de9..316b68a0 100644 --- a/app/Modules/Lemmy/LemmyRequest.php +++ b/app/Modules/Lemmy/LemmyRequest.php @@ -7,6 +7,9 @@ class LemmyRequest { + // Uploads carry an image payload; the 30s used for JSON calls is not enough. + private const UPLOAD_TIMEOUT_SECONDS = 60; + private string $instance; private ?string $token; @@ -83,6 +86,27 @@ public function post(string $endpoint, array $data = []): Response return $request->post($url, $data); } + /** + * pict-rs is mounted outside the /api/v3 prefix, so this takes a root-relative path. + * + * @param string $path Root-relative, e.g. 'pictrs/image' + * @param string $name Multipart field name, e.g. 'images[]' + * @param string $contents Raw file bytes + * @param string $filename Filename sent with the part + */ + public function postMultipart(string $path, string $name, string $contents, string $filename): Response + { + $url = sprintf('%s://%s/%s', $this->scheme, $this->instance, ltrim($path, '/')); + + $request = Http::timeout(self::UPLOAD_TIMEOUT_SECONDS); + + if ($this->token) { + $request = $request->withToken($this->token); + } + + return $request->attach($name, $contents, $filename)->post($url); + } + public function withToken(string $token): self { $this->token = $token; diff --git a/tests/Unit/Modules/Lemmy/LemmyRequestTest.php b/tests/Unit/Modules/Lemmy/LemmyRequestTest.php index b8404a2f..2613a02b 100644 --- a/tests/Unit/Modules/Lemmy/LemmyRequestTest.php +++ b/tests/Unit/Modules/Lemmy/LemmyRequestTest.php @@ -263,6 +263,61 @@ public function test_chaining_methods(): void }); } + public function test_post_multipart_targets_a_root_relative_path(): void + { + Http::fake(['*' => Http::response(['files' => []])]); + + $request = new LemmyRequest('lemmy.world', 'test-token'); + $response = $request->postMultipart('pictrs/image', 'images[]', 'binary-data', 'thumb.jpg'); + + $this->assertInstanceOf(Response::class, $response); + + Http::assertSent(function ($httpRequest) { + return $httpRequest->url() === 'https://lemmy.world/pictrs/image' + && $httpRequest->header('Authorization')[0] === 'Bearer test-token'; + }); + } + + public function test_post_multipart_does_not_use_the_api_prefix(): void + { + Http::fake(['*' => Http::response(['files' => []])]); + + (new LemmyRequest('lemmy.world'))->postMultipart('/pictrs/image', 'images[]', 'data', 'thumb.jpg'); + + Http::assertSent(fn ($httpRequest) => ! str_contains($httpRequest->url(), '/api/v3/')); + } + + public function test_post_multipart_sends_the_file_as_multipart_data(): void + { + Http::fake(['*' => Http::response(['files' => []])]); + + (new LemmyRequest('lemmy.world'))->postMultipart('pictrs/image', 'images[]', 'binary-data', 'thumb.jpg'); + + Http::assertSent(function ($httpRequest) { + return str_contains($httpRequest->header('Content-Type')[0], 'multipart/form-data') + && str_contains($httpRequest->body(), 'binary-data') + && str_contains($httpRequest->body(), 'thumb.jpg'); + }); + } + + public function test_post_multipart_omits_authorization_without_a_token(): void + { + Http::fake(['*' => Http::response(['files' => []])]); + + (new LemmyRequest('lemmy.world'))->postMultipart('pictrs/image', 'images[]', 'data', 'thumb.jpg'); + + Http::assertSent(fn ($httpRequest) => ! $httpRequest->hasHeader('Authorization')); + } + + public function test_post_multipart_respects_the_scheme(): void + { + Http::fake(['*' => Http::response(['files' => []])]); + + (new LemmyRequest('http://lemmy.world'))->postMultipart('pictrs/image', 'images[]', 'data', 'thumb.jpg'); + + Http::assertSent(fn ($httpRequest) => $httpRequest->url() === 'http://lemmy.world/pictrs/image'); + } + private function getPrivateProperty(object $object, string $property): mixed { $reflection = new \ReflectionClass($object);