diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 9a0f5fd9f..385288ced 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -437,6 +437,7 @@ A signed request sees what the web shows you, such as your own pending commits. - read your account, logs, replies, notifications, and saved commits; - commit, reply, and quote, with images, and delete your commits; +- preview commit drafts without publishing them; - follow or unfollow, mute or unmute, and block or unblock, and list who you muted and blocked; - star or unstar, and save or unsave; - create polls on commits that are neither replies nor quotes, and vote on polls; diff --git a/app/Http/Controllers/Api/CommitPreviewController.php b/app/Http/Controllers/Api/CommitPreviewController.php new file mode 100644 index 000000000..bc74dddb4 --- /dev/null +++ b/app/Http/Controllers/Api/CommitPreviewController.php @@ -0,0 +1,30 @@ +replace($request->string('body')->value()); + + return response()->json(['data' => [ + 'body' => $body, + ...$commitBodies->segments($body), + 'mentions' => $mentions->positions($body), + ]]); + } +} diff --git a/app/Http/Requests/CreateApiCommitPreviewRequest.php b/app/Http/Requests/CreateApiCommitPreviewRequest.php new file mode 100644 index 000000000..849c11962 --- /dev/null +++ b/app/Http/Requests/CreateApiCommitPreviewRequest.php @@ -0,0 +1,34 @@ +> + */ + public function rules(): array + { + return [ + 'body' => CreateCommitRequest::bodyRules(), + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 48652d8c2..78e9d0f13 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\CommitController as ApiCommitController; use App\Http\Controllers\Api\CommitPastelController; use App\Http\Controllers\Api\CommitPollVoteController; +use App\Http\Controllers\Api\CommitPreviewController; use App\Http\Controllers\Api\CommitReplyController; use App\Http\Controllers\Api\CommitReportController; use App\Http\Controllers\Api\CommitStarController; @@ -121,6 +122,9 @@ ->name('api-commit.store'); Route::delete('commits/{commit}', [ApiCommitController::class, 'destroy'])->name('api-commit.destroy'); + // Commit Preview: a draft as the commit it would become, which keeps nothing... + Route::post('commits/preview', [CommitPreviewController::class, 'store'])->name('api-commit-preview.store'); + // Commit Reply... Route::get('commits/{commit}/replies', [CommitReplyController::class, 'index']) ->withTrashed() diff --git a/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php b/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php new file mode 100644 index 000000000..79d38ec4e --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitPreviewControllerTest.php @@ -0,0 +1,88 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('previews a draft the way the commit it becomes is shown', function (string $body): void { + $preview = signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => $body]); + + $commit = signedApiRequest($this->keyPair, 'POST', route('api-commit.store'), ['body' => $body])->json('data'); + + $preview->assertOk() + ->assertExactJson(['data' => [ + 'body' => $commit['body'], + 'body_segments' => $commit['body_segments'], + 'preview_segments' => $commit['preview_segments'], + 'mentions' => $commit['mentions'], + ]]); +})->with([ + 'code, mentions and emoji' => " shipped :rocket: with @nunomaduro\n```php\n\$name = 'devnull';\n```\nrun `composer test` :fire: ", + 'runs of blank lines' => "Shipped it.\n\n\n\nFinally.", + 'too long to show whole in a list' => implode("\n", array_fill(0, 12, 'One more line of the changelog.')), +]); + +it('previews publishable text longer than 4000 raw characters when it contains links', function (): void { + Queue::fake([UnfurlLink::class]); + $body = implode(' ', array_fill(0, 3, mb_str_pad('https://example.com/', 1400, 'a'))); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => $body]) + ->assertOk() + ->assertJsonPath('data.body', $body); + + expect(Commit::query()->exists())->toBeFalse(); + Queue::assertNothingPushed(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit.store'), ['body' => $body]) + ->assertCreated() + ->assertJsonPath('data.body', $body); +}); + +it('folds a long draft as a list would, and leaves a short one whole', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => str_repeat('word ', 200)]) + ->assertOk() + ->assertJsonPath('data.preview_segments.0.type', 'text'); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => 'It was DNS.']) + ->assertOk() + ->assertJsonPath('data.body_segments', [['type' => 'text', 'text' => 'It was DNS.']]) + ->assertJsonPath('data.preview_segments', null) + ->assertJsonPath('data.mentions', []); +}); + +it('previews drafts without committing them or spending the commit budget', function (): void { + foreach (range(1, 11) as $draft) { + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), ['body' => "Draft {$draft}"])->assertOk(); + } + + expect(Commit::query()->exists())->toBeFalse(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit.store'), ['body' => 'Shipped it.'])->assertCreated(); +}); + +it('requires a body the composer could commit', function (array $data, string $message): void { + signedApiRequest($this->keyPair, 'POST', route('api-commit-preview.store'), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors(['body' => $message]); +})->with([ + 'missing' => [[], 'The body field is required.'], + 'blank' => [['body' => " \n "], 'The body field is required.'], + 'too long' => [['body' => str_repeat('a', Commit::MAX_BODY_LENGTH + 1)], 'The body field must not be greater than '.Commit::MAX_BODY_LENGTH.' characters.'], + 'oversized link' => [['body' => mb_str_pad('https://example.com/', Urls::MAX_LENGTH + 1, 'a')], 'Each link must not be greater than '.Urls::MAX_LENGTH.' characters.'], + 'raw body too long' => [['body' => implode(' ', array_fill(0, 20, mb_str_pad('https://example.com/', Urls::MAX_LENGTH, 'a')))], 'The body field must not be greater than '.CommitBodyLength::MAX_RAW_LENGTH.' characters.'], +]); + +it('requires a signed request', function (): void { + $this->postJson(route('api-commit-preview.store'), ['body' => 'It was DNS.']) + ->assertUnauthorized(); +});