Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions app/Http/Controllers/Api/CommitPreviewController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Requests\CreateApiCommitPreviewRequest;
use App\Services\CommitBodies;
use App\Services\EmojiShortcodes;
use App\Services\Mentions;
use Illuminate\Http\JsonResponse;

final readonly class CommitPreviewController
{
/**
* Shows a draft as the commit it would become, as the web's composer previews it: the body as it would be kept, the
* segments the app draws it from, the excerpt a list would fold it to, and where it mentions someone. Nothing is
* kept, so previews don't count towards the commits a user may make a minute.
*/
public function store(CreateApiCommitPreviewRequest $request, EmojiShortcodes $emojiShortcodes, CommitBodies $commitBodies, Mentions $mentions): JsonResponse
{
$body = $emojiShortcodes->replace($request->string('body')->value());

return response()->json(['data' => [
'body' => $body,
...$commitBodies->segments($body),
'mentions' => $mentions->positions($body),
]]);
}
}
34 changes: 34 additions & 0 deletions app/Http/Requests/CreateApiCommitPreviewRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

final class CreateApiCommitPreviewRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* Blank lines are collapsed first, as they are when the draft is committed.
*/
public function prepareForValidation(): void
{
CreateCommitRequest::normalizeBody($this);
}

/**
* @return array<string, array<int, string|ValidationRule>>
*/
public function rules(): array
{
return [
'body' => CreateCommitRequest::bodyRules(),
];
}
}
4 changes: 4 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
88 changes: 88 additions & 0 deletions tests/Feature/Controllers/Api/CommitPreviewControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

use App\Jobs\UnfurlLink;
use App\Models\Commit;
use App\Models\User;
use App\Rules\CommitBodyLength;
use App\Services\Urls;
use Illuminate\Support\Facades\Queue;

beforeEach(function (): void {
$this->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();
});
Loading