From 7cf84452aed7c42db3c60c9a58c56f2aa026048b Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 28 Oct 2026 14:12:27 +0100 Subject: [PATCH] test: cover the API endpoints added without tests --- .../Api/CommitPinControllerTest.php | 128 ++++++++++ .../Api/CommitRestorationControllerTest.php | 93 ++++++++ .../Api/CommitSearchControllerTest.php | 90 +++++++ .../Api/CommitTranslationControllerTest.php | 125 ++++++++++ .../Controllers/Api/DeviceControllerTest.php | 70 ++++++ .../Api/IssueSuggestionControllerTest.php | 58 +++++ .../Api/MentionSuggestionControllerTest.php | 107 +++++++++ .../Api/ScheduledCommitControllerTest.php | 220 ++++++++++++++++++ ...cheduledCommitSubmissionControllerTest.php | 80 +++++++ ...UserAutomaticTranslationControllerTest.php | 46 ++++ .../Api/UserSearchControllerTest.php | 85 +++++++ .../Api/UserSettingsControllerTest.php | 60 +++++ .../Api/UserTimezoneControllerTest.php | 74 ++++++ .../UserTranslationLanguageControllerTest.php | 57 +++++ 14 files changed, 1293 insertions(+) create mode 100644 tests/Feature/Controllers/Api/CommitPinControllerTest.php create mode 100644 tests/Feature/Controllers/Api/CommitRestorationControllerTest.php create mode 100644 tests/Feature/Controllers/Api/CommitSearchControllerTest.php create mode 100644 tests/Feature/Controllers/Api/CommitTranslationControllerTest.php create mode 100644 tests/Feature/Controllers/Api/IssueSuggestionControllerTest.php create mode 100644 tests/Feature/Controllers/Api/MentionSuggestionControllerTest.php create mode 100644 tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php create mode 100644 tests/Feature/Controllers/Api/ScheduledCommitSubmissionControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserAutomaticTranslationControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserSearchControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserSettingsControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserTimezoneControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserTranslationLanguageControllerTest.php diff --git a/tests/Feature/Controllers/Api/CommitPinControllerTest.php b/tests/Feature/Controllers/Api/CommitPinControllerTest.php new file mode 100644 index 000000000..6e2add212 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitPinControllerTest.php @@ -0,0 +1,128 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('pins an own top-level commit and answers with it pinned', function (string $status): void { + $commit = Commit::factory()->for($this->user)->create(['status' => $status]); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $commit)); + + $response->assertOk() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.is_pinned', true); + + expect($this->user->fresh()->pinned_commit_id)->toBe($commit->id); +})->with(['approved', 'pending', 'rejected']); + +it('replaces the commit pinned before', function (): void { + $first = Commit::factory()->for($this->user)->create(); + $replacement = Commit::factory()->for($this->user)->create(); + + signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $first))->assertOk(); + signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $replacement)) + ->assertOk() + ->assertJsonPath('data.is_pinned', true); + + expect($this->user->fresh()->pinned_commit_id)->toBe($replacement->id); +}); + +it('unpins idempotently and answers with it unpinned', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + $this->user->update(['pinned_commit_id' => $commit->id]); + + signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $commit)) + ->assertOk() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.is_pinned', false); + signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $commit))->assertOk(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +}); + +it('does not clear a replacement on a stale unpin', function (): void { + $first = Commit::factory()->for($this->user)->create(); + $replacement = Commit::factory()->for($this->user)->create(); + $this->user->update(['pinned_commit_id' => $replacement->id]); + + signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $first)) + ->assertOk() + ->assertJsonPath('data.is_pinned', false); + + expect($this->user->fresh()->pinned_commit_id)->toBe($replacement->id); +}); + +it('forbids changing the pin of another author, even for an admin', function (string $method, string $route): void { + config(['app.admins' => ['admin']]); + $admin = User::factory()->create(['handle' => 'admin']); + $commit = Commit::factory()->create(); + $commit->user->update(['pinned_commit_id' => $commit->id]); + + signedApiRequest(apiKeyPairFor($admin), $method, route($route, $commit))->assertForbidden(); + + expect($commit->user->fresh()->pinned_commit_id)->toBe($commit->id) + ->and($admin->fresh()->pinned_commit_id)->toBeNull(); +})->with([ + 'pin' => ['PUT', 'api-commit-pin.update'], + 'unpin' => ['DELETE', 'api-commit-pin.destroy'], +]); + +it('forbids pinning or unpinning a reply', function (string $method, string $route): void { + $reply = Commit::factory()->for($this->user)->for(Commit::factory(), 'parent')->create(); + + signedApiRequest($this->keyPair, $method, route($route, $reply))->assertForbidden(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with([ + 'pin' => ['PUT', 'api-commit-pin.update'], + 'unpin' => ['DELETE', 'api-commit-pin.destroy'], +]); + +it('hides the commits the user may not see', function (string $method, string $route, Closure $commit): void { + $commit = $commit($this->user); + + signedApiRequest($this->keyPair, $method, route($route, $commit))->assertNotFound(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with([ + 'pin' => ['PUT', 'api-commit-pin.update'], + 'unpin' => ['DELETE', 'api-commit-pin.destroy'], +])->with([ + 'pending' => fn (User $user): Commit => Commit::factory()->pending()->create(), + 'across a block' => function (User $user): Commit { + $commit = Commit::factory()->create(); + $user->blocking()->attach($commit->user); + + return $commit; + }, +]); + +it('cannot change the pin of a deleted commit', function (string $method, string $route): void { + $commit = Commit::factory()->for($this->user)->create(); + $commit->delete(); + + signedApiRequest($this->keyPair, $method, route($route, $commit))->assertNotFound(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with([ + 'pin' => ['PUT', 'api-commit-pin.update'], + 'unpin' => ['DELETE', 'api-commit-pin.destroy'], +]); + +it('requires a signed request', function (string $method, string $route): void { + $commit = Commit::factory()->for($this->user)->create(); + + $this->json($method, route($route, $commit))->assertUnauthorized(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with([ + 'pin' => ['PUT', 'api-commit-pin.update'], + 'unpin' => ['DELETE', 'api-commit-pin.destroy'], +]); diff --git a/tests/Feature/Controllers/Api/CommitRestorationControllerTest.php b/tests/Feature/Controllers/Api/CommitRestorationControllerTest.php new file mode 100644 index 000000000..b4a82526d --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitRestorationControllerTest.php @@ -0,0 +1,93 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('restores a deleted commit within thirty seconds and answers with it', function (CommitStatus $status): void { + $commit = Commit::factory()->for($this->user)->create(['status' => $status]); + resolve(DeleteCommit::class)->handle($commit); + $this->travel(29)->seconds(); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit)); + + $response->assertOk() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.status', $status->value); + + $this->assertNotSoftDeleted($commit); +})->with(CommitStatus::cases()); + +it('answers with the restored commit as the commit endpoint shows it', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + Commit::factory()->for($commit, 'parent')->create(); + resolve(DeleteCommit::class)->handle($commit); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit)); + + $response->assertOk() + ->assertExactJson(['data' => signedApiRequest($this->keyPair, 'GET', route('api-commit.show', $commit))->json('data')]); +}); + +it('rejects undo at and after the deadline', function (int $seconds): void { + $this->travelTo(now()->startOfSecond()); + $commit = Commit::factory()->for($this->user)->create(); + resolve(DeleteCommit::class)->handle($commit); + $this->travel($seconds)->seconds(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['undo' => 'The 30-second undo window has expired.']); + + $this->assertSoftDeleted($commit); +})->with([30, 31, 3600]); + +it('rejects a commit that is not deleted, and a repeated undo', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['undo' => 'This commit is no longer deleted.']); + + resolve(DeleteCommit::class)->handle($commit); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit))->assertOk(); + signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['undo' => 'This commit is no longer deleted.']); + + $this->assertNotSoftDeleted($commit); +}); + +it('hides the deleted commits of others', function (): void { + $commit = Commit::factory()->create(); + resolve(DeleteCommit::class)->handle($commit); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit))->assertNotFound(); + + $this->assertSoftDeleted($commit); +}); + +it('cannot restore a force deleted commit', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + $commit->forceDelete(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-restoration.store', $commit))->assertNotFound(); +}); + +it('requires a signed request', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + resolve(DeleteCommit::class)->handle($commit); + + $this->postJson(route('api-commit-restoration.store', $commit))->assertUnauthorized(); + + $this->assertSoftDeleted($commit); +}); diff --git a/tests/Feature/Controllers/Api/CommitSearchControllerTest.php b/tests/Feature/Controllers/Api/CommitSearchControllerTest.php new file mode 100644 index 000000000..9ed1c7aa8 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitSearchControllerTest.php @@ -0,0 +1,90 @@ + 'sk-test']); + + $this->user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('returns the commits nearest the term, closest first, as the commit endpoint shows them', function (): void { + fakeSearch(); + + $closest = Commit::factory()->create(['body' => 'Shipped a new feature', 'embedding' => embeddingVector(0)]); + $closest->stars()->attach($this->user); + $other = Commit::factory()->create(['body' => 'Something else', 'embedding' => embeddingVector(1)]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', ['q' => 'feature'])); + + $response->assertOk() + ->assertJsonPath('data.*.id', [$closest->id, $other->id]) + ->assertJsonPath('data.0.starred', true) + ->assertJsonPath('data.0.author.handle', $closest->user->handle) + ->assertJsonMissingPath('data.0.embedding'); +}); + +it('drops the candidates the decision model calls irrelevant', function (): void { + fakeSearch([true, false]); + + $relevant = Commit::factory()->create(['embedding' => embeddingVector(0)]); + Commit::factory()->create(['embedding' => embeddingVector(1)]); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', ['q' => 'indexing'])) + ->assertOk() + ->assertJsonPath('data.*.id', [$relevant->id]); +}); + +it('returns no more than the limit asked for, and 20 by default', function (?int $limit, int $count): void { + fakeSearch(); + + Commit::factory()->count(21)->create(['embedding' => embeddingVector(0)]); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', array_filter(['q' => 'feature', 'limit' => $limit]))) + ->assertOk() + ->assertJsonCount($count, 'data'); +})->with([ + 'the default' => [null, 20], + 'a limit' => [2, 2], +]); + +it('leaves out the commits of people the user blocked', function (): void { + fakeSearch(); + + $visible = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $blocked = Commit::factory()->create(['embedding' => embeddingVector(0)]); + $this->user->blocking()->attach($blocked->user); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', ['q' => 'feature'])) + ->assertOk() + ->assertJsonPath('data.*.id', [$visible->id]); +}); + +it('rejects short or long terms and out-of-range limits without asking openai', function (array $query, string $field): void { + Http::fake(); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-search.index', $query)) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); + + Http::assertNothingSent(); +})->with([ + 'no term' => [[], 'q'], + 'a term under 3 characters' => [['q' => 'ab'], 'q'], + 'a term over 100 characters' => [['q' => str_repeat('a', 101)], 'q'], + 'a limit of zero' => [['q' => 'feature', 'limit' => 0], 'limit'], + 'a limit over 100' => [['q' => 'feature', 'limit' => 101], 'limit'], +]); + +it('requires a signed request', function (): void { + Http::fake(); + + $this->getJson(route('api-commit-search.index', ['q' => 'feature']))->assertUnauthorized(); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Controllers/Api/CommitTranslationControllerTest.php b/tests/Feature/Controllers/Api/CommitTranslationControllerTest.php new file mode 100644 index 000000000..4260c1258 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitTranslationControllerTest.php @@ -0,0 +1,125 @@ + 'test-key']); + + $this->user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('translates a commit into the language asked for', function (): void { + CommitTranslator::fake([ + ['source_language' => 'de', 'body' => 'Good morning'], + ])->preventStrayPrompts(); + $commit = Commit::factory()->create(['body' => 'Guten morgen']); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-translation.store', $commit), ['language' => 'en']) + ->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertJsonPath('translation.language', 'en') + ->assertJsonPath('translation.source_name', 'German') + ->assertJsonPath('translation.translated.body_segments.0.text', 'Good morning'); + + expect($commit->refresh()->body)->toBe('Guten morgen'); + CommitTranslator::assertPromptedTimes(1); +}); + +it('translates a pending commit for an admin', function (): void { + CommitTranslator::fake([ + ['source_language' => 'de', 'body' => 'Good morning'], + ]); + config(['app.admins' => ['admin']]); + $keyPair = apiKeyPairFor(User::factory()->create(['handle' => 'admin'])); + $commit = Commit::factory()->pending()->create(['body' => 'Guten morgen']); + + signedApiRequest($keyPair, 'POST', route('api-commit-translation.store', $commit), ['language' => 'en']) + ->assertOk() + ->assertJsonPath('translation.translated.body_segments.0.text', 'Good morning'); +}); + +it('hides the commits the web does not translate for the user', function (string $state): void { + CommitTranslator::fake()->preventStrayPrompts(); + $commit = Commit::factory()->create(['body' => 'Guten morgen']); + + match ($state) { + 'pending' => $commit->update(['status' => CommitStatus::Pending]), + 'rejected' => $commit->update(['status' => CommitStatus::Rejected]), + 'blocked' => $this->user->blocking()->attach($commit->user), + 'empty' => $commit->update(['body' => '']), + 'switched off' => resolve(Features::class)->update(Feature::Translations, false), + }; + + signedApiRequest($this->keyPair, 'POST', route('api-commit-translation.store', $commit), ['language' => 'en']) + ->assertNotFound(); + + CommitTranslator::assertNeverPrompted(); +})->with(['pending', 'rejected', 'blocked', 'empty', 'switched off']); + +it('rejects unsupported target languages before calling the provider', function (mixed $language): void { + CommitTranslator::fake()->preventStrayPrompts(); + $commit = Commit::factory()->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-translation.store', $commit), ['language' => $language]) + ->assertUnprocessable() + ->assertJsonValidationErrors('language'); + + CommitTranslator::assertNeverPrompted(); +})->with([null, 'invented', 'de-DE', [['en']]]); + +it('answers a failed translation with a 503 the app can retry', function (): void { + Exceptions::fake(); + $attempts = 0; + CommitTranslator::fake(function () use (&$attempts): array { + throw_if($attempts++ === 0, RuntimeException::class, 'Sensitive provider response', 503); + + return ['source_language' => 'de', 'body' => 'Good morning']; + }); + $commit = Commit::factory()->create(['body' => 'Guten morgen']); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-translation.store', $commit), ['language' => 'en']) + ->assertServiceUnavailable() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['message' => 'Could not translate this commit. Try again.']); + + expect(resolve(CommitTranslations::class)->cached($commit))->toBeNull(); + Exceptions::assertReported(fn (CommitProcessingFailed $exception): bool => $exception->getMessage() === 'Translation failed for commit '.$commit->id.' (RuntimeException, code 503). Reason: provider_request.'); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-translation.store', $commit), ['language' => 'en']) + ->assertOk() + ->assertJsonPath('translation.translated.body_segments.0.text', 'Good morning'); +}); + +it('says when the provider answers in an unsupported source language', function (): void { + Exceptions::fake(); + CommitTranslator::fake([ + ['source_language' => 'invented', 'body' => 'Sensitive translation'], + ]); + $commit = Commit::factory()->create(['body' => 'Guten morgen']); + + signedApiRequest($this->keyPair, 'POST', route('api-commit-translation.store', $commit), ['language' => 'en']) + ->assertServiceUnavailable() + ->assertExactJson(['message' => 'The translation service returned an unsupported source language. Try again.']); + + Exceptions::assertReported(fn (CommitProcessingFailed $exception): bool => $exception->reason === 'unsupported_source_language'); +}); + +it('requires a signed request', function (): void { + CommitTranslator::fake()->preventStrayPrompts(); + + $this->postJson(route('api-commit-translation.store', Commit::factory()->create()), ['language' => 'en']) + ->assertUnauthorized(); + + CommitTranslator::assertNeverPrompted(); +}); diff --git a/tests/Feature/Controllers/Api/DeviceControllerTest.php b/tests/Feature/Controllers/Api/DeviceControllerTest.php index 056f28747..0b27ae8c0 100644 --- a/tests/Feature/Controllers/Api/DeviceControllerTest.php +++ b/tests/Feature/Controllers/Api/DeviceControllerTest.php @@ -206,3 +206,73 @@ ->assertTooManyRequests() ->assertJsonPath('code', 'rate_limited'); }); + +it('lists the approved devices of the user, last approved first', function (): void { + $keyPair = apiKeyPairFor($user = User::factory()->create()); + $signing = Device::query()->sole(); + $signing->update(['approved_at' => now()->subMonth()]); + + $older = Device::factory()->for($user)->create([ + 'name' => 'devnull on iPad', + 'approved_at' => now()->subWeek(), + 'last_used_at' => now()->subDay(), + ]); + $newer = Device::factory()->for($user)->create(['name' => 'devnull on Mac']); + + Device::factory()->create(); + + $response = signedApiRequest($keyPair, 'GET', route('api-device.index')); + + $response->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0', [ + 'id' => $newer->id, + 'name' => 'devnull on Mac', + 'fingerprint' => $newer->fingerprint, + 'approved_at' => now()->toIso8601ZuluString(), + 'last_used_at' => null, + ]) + ->assertJsonPath('data.1.id', $older->id) + ->assertJsonPath('data.1.last_used_at', now()->subDay()->toIso8601ZuluString()) + ->assertJsonPath('data.2.id', $signing->id); +}); + +it('revokes a device', function (): void { + $keyPair = apiKeyPairFor($user = User::factory()->create()); + $device = Device::factory()->for($user)->create(); + + signedApiRequest($keyPair, 'DELETE', route('api-device.destroy', $device))->assertNoContent(); + + expect($device->fresh())->toBeNull(); + + signedApiRequest($keyPair, 'GET', route('api-current-user.show'))->assertOk(); +}); + +it('signs the app out when revoking the device that signed the request', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + + signedApiRequest($keyPair, 'DELETE', route('api-device.destroy', Device::query()->sole()))->assertNoContent(); + + signedApiRequest($keyPair, 'GET', route('api-current-user.show')) + ->assertUnauthorized() + ->assertJsonPath('code', 'device_not_approved'); +}); + +it("never revokes someone else's device", function (): void { + $device = Device::factory()->create(); + + signedApiRequest(apiKeyPairFor(User::factory()->create()), 'DELETE', route('api-device.destroy', $device))->assertNotFound(); + + expect($device->fresh())->not->toBeNull(); +}); + +it('requires a signed request to list or revoke devices', function (string $method, Closure $uri): void { + $device = Device::factory()->create(); + + $this->json($method, $uri($device))->assertUnauthorized(); + + expect($device->fresh())->not->toBeNull(); +})->with([ + 'the list' => ['GET', fn (Device $device): string => route('api-device.index')], + 'revoking' => ['DELETE', fn (Device $device): string => route('api-device.destroy', $device)], +]); diff --git a/tests/Feature/Controllers/Api/IssueSuggestionControllerTest.php b/tests/Feature/Controllers/Api/IssueSuggestionControllerTest.php new file mode 100644 index 000000000..620182d71 --- /dev/null +++ b/tests/Feature/Controllers/Api/IssueSuggestionControllerTest.php @@ -0,0 +1,58 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('finds the issue or pull request with that number, as the web composer does', function (): void { + Http::fake([ + 'api.github.com/repos/dev-null-sh/dev-null.sh/issues/722' => Http::response([ + 'number' => 722, + 'title' => 'Automatically link PRs and issues', + 'html_url' => 'https://github.com/dev-null-sh/dev-null.sh/pull/722', + 'body' => 'Not needed for suggestions', + ]), + ]); + + signedApiRequest($this->keyPair, 'GET', route('api-issue-suggestion.index', ['q' => '722'])) + ->assertOk() + ->assertExactJson(['issue' => [ + 'number' => 722, + 'title' => 'Automatically link PRs and issues', + 'url' => 'https://github.com/dev-null-sh/dev-null.sh/pull/722', + ]]); +}); + +it('answers null for an issue GitHub does not have', function (): void { + Http::fake([ + 'api.github.com/repos/dev-null-sh/dev-null.sh/issues/722' => Http::response([], 404), + ]); + + signedApiRequest($this->keyPair, 'GET', route('api-issue-suggestion.index', ['q' => '722'])) + ->assertOk() + ->assertExactJson(['issue' => null]); +}); + +it('rejects invalid issue numbers before contacting GitHub', function (string $number): void { + Http::fake(); + + signedApiRequest($this->keyPair, 'GET', route('api-issue-suggestion.index', ['q' => $number])) + ->assertUnprocessable() + ->assertJsonValidationErrors('q'); + + Http::assertNothingSent(); +})->with(['', '0', '-1', '0123', '123abc', '12345678901']); + +it('requires a signed request', function (): void { + Http::fake(); + + $this->getJson(route('api-issue-suggestion.index', ['q' => '722']))->assertUnauthorized(); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Controllers/Api/MentionSuggestionControllerTest.php b/tests/Feature/Controllers/Api/MentionSuggestionControllerTest.php new file mode 100644 index 000000000..2f923b3a1 --- /dev/null +++ b/tests/Feature/Controllers/Api/MentionSuggestionControllerTest.php @@ -0,0 +1,107 @@ +user = User::factory()->create(['handle' => 'viewer', 'name' => 'Viewer']); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('suggests the people the user follows without a term, most recently followed first', function (): void { + $first = User::factory()->create(); + $second = User::factory()->create(); + User::factory()->create(); + + $this->user->following()->attach($first, ['created_at' => now()->subMinute()]); + $this->user->following()->attach($second, ['created_at' => now()]); + + signedApiRequest($this->keyPair, 'GET', route('api-mention-suggestion.index')) + ->assertOk() + ->assertJsonPath('data.*.id', [$second->id, $first->id]) + ->assertJsonMissingPath('data.0.email'); +}); + +it('suggests matches, people the user follows first, leaving out muted people and people across a block', function (): void { + User::factory()->create(['handle' => 'taylor', 'name' => 'Taylor']); + $followed = User::factory()->create(['handle' => 'taylorotwell', 'name' => 'Taylor Otwell']); + $muted = User::factory()->create(['handle' => 'taylor_muted']); + $blocked = User::factory()->create(['handle' => 'taylor_blocked']); + $blocker = User::factory()->create(['handle' => 'taylor_blocker']); + + $this->user->following()->attach($followed); + $this->user->muting()->attach($muted); + $this->user->blocking()->attach($blocked); + $blocker->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, 'GET', route('api-mention-suggestion.index', ['q' => '@tay'])) + ->assertOk() + ->assertJsonPath('data.*.handle', ['taylorotwell', 'taylor']); +}); + +it("only suggests an org's members within it", function (): void { + [$taylor, $jess] = followersOf($this->user, 2); + $taylor->profile->update(['handle' => 'taylor']); + $jess->profile->update(['handle' => 'jess']); + User::factory()->create(['handle' => 'taylorotwell']); + $org = createOrg($this->user, [$taylor, $jess]); + + signedApiRequest($this->keyPair, 'GET', route('api-mention-suggestion.index', ['q' => 'tay', 'org' => $org])) + ->assertOk() + ->assertJsonPath('data.*.handle', ['taylor']); +}); + +it("does not suggest an org's members to people outside it", function (): void { + $mark = User::factory()->create(); + $org = createOrg($mark, followersOf($mark, 1)); + + signedApiRequest($this->keyPair, 'GET', route('api-mention-suggestion.index', ['q' => '', 'org' => $org])) + ->assertNotFound(); +}); + +it('ranks the people in the thread replied to before ordinary matches', function (): void { + $rootAuthor = User::factory()->create(['handle' => 'zroot', 'name' => 'Match Root']); + $recipient = User::factory()->create(['handle' => 'zrecipient', 'name' => 'Match Recipient']); + $outsider = User::factory()->create(['handle' => 'match', 'name' => 'Outsider']); + $this->user->following()->attach($outsider); + $root = Commit::factory()->for($rootAuthor)->create(); + $reply = Commit::factory()->for($recipient)->create(['parent_id' => $root->id]); + + signedApiRequest($this->keyPair, 'GET', route('api-mention-suggestion.index', ['q' => 'match', 'reply' => $reply->id])) + ->assertOk() + ->assertJsonPath('data.*.id', [$recipient->id, $rootAuthor->id, $outsider->id]); +}); + +it('ranks the quoted author before ordinary matches', function (): void { + $author = User::factory()->create(['handle' => 'zauthor', 'name' => 'Match Author']); + $followed = User::factory()->create(['handle' => 'match', 'name' => 'Followed']); + $this->user->following()->attach($followed); + $quoted = Commit::factory()->for($author)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-mention-suggestion.index', ['quote' => $quoted->id])) + ->assertOk() + ->assertJsonPath('data.*.id', [$author->id, $followed->id]); +}); + +it('rejects invalid suggestion contexts', function (Closure $query, string $field): void { + signedApiRequest($this->keyPair, 'GET', route('api-mention-suggestion.index', $query($this->user))) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'an overly long term' => [fn (): array => ['q' => str_repeat('a', 101)], 'q'], + 'a malformed reply' => [fn (): array => ['reply' => 'not-a-uuid'], 'reply'], + 'a hidden reply' => [fn (): array => ['reply' => Commit::factory()->pending()->create()->id], 'reply'], + 'a hidden quote' => [fn (): array => ['quote' => Commit::factory()->pending()->create()->id], 'quote'], + 'a reply and a quote' => [function (): array { + $commit = Commit::factory()->create(); + + return ['reply' => $commit->id, 'quote' => $commit->id]; + }, 'reply'], + 'an org and a reply' => [fn (User $user): array => ['org' => createOrg($user, followersOf($user, 1)), 'reply' => Commit::factory()->create()->id], 'org'], +]); + +it('requires a signed request', function (): void { + $this->getJson(route('api-mention-suggestion.index', ['q' => 'nuno']))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php b/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php new file mode 100644 index 000000000..0ab8afb0a --- /dev/null +++ b/tests/Feature/Controllers/Api/ScheduledCommitControllerTest.php @@ -0,0 +1,220 @@ +travelTo(now()->setDate(2026, 10, 15)->setTime(12, 0)); + + $this->user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it("lists the user's queue, what is still waiting first, soonest first", function (): void { + $later = ScheduledCommit::factory()->for($this->user)->create(['scheduled_at' => now()->addDays(2), 'body' => 'Later']); + $sooner = ScheduledCommit::factory()->for($this->user)->create(['scheduled_at' => now()->addHour(), 'body' => 'Sooner', 'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 1440]]); + $submitted = ScheduledCommit::factory()->for($this->user)->create(['scheduled_at' => now()]); + resolve(UpdateScheduledCommitSubmittedAt::class)->handle($submitted->id); + $canceled = ScheduledCommit::factory()->for($this->user)->create(); + resolve(DeleteScheduledCommit::class)->handle($this->user, $canceled); + ScheduledCommit::factory()->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-scheduled-commit.index')); + + $response->assertOk() + ->assertJsonPath('data.*.id', [$sooner->id, $later->id, $submitted->id]) + ->assertJsonPath('data.0.body', 'Sooner') + ->assertJsonPath('data.0.poll_options', ['Tabs', 'Spaces']) + ->assertJsonPath('data.0.image_count', 0) + ->assertJsonPath('data.0.scheduled_at', now()->addHour()->toIso8601String()) + ->assertJsonPath('data.0.submitted_at', null) + ->assertJsonPath('data.0.status', 'scheduled') + ->assertJsonPath('data.2.body', null) + ->assertJsonPath('data.2.body_segments', []) + ->assertJsonPath('data.2.status', 'pending') + ->assertJsonPath('meta.per_page', 20); +}); + +it('schedules a commit only the user sees until it is submitted', function (): void { + $response = signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), [ + 'body' => "Shipping tomorrow.\n\n\n\nStay tuned.", + 'scheduled_at' => now()->addHour()->setTimezone('Europe/Lisbon')->toIso8601String(), + ]); + + $scheduled = ScheduledCommit::query()->sole(); + + $response->assertCreated() + ->assertJsonPath('data.id', $scheduled->id) + ->assertJsonPath('data.body', "Shipping tomorrow.\n\nStay tuned.") + ->assertJsonPath('data.scheduled_at', now()->addHour()->toIso8601String()) + ->assertJsonPath('data.status', 'scheduled'); + + expect($scheduled->user_id)->toBe($this->user->id) + ->and($scheduled->scheduled_at->equalTo(now()->addHour()))->toBeTrue() + ->and(Commit::query()->count())->toBe(0); +}); + +it('schedules a poll that opens with its commit at the due time', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), [ + 'body' => 'Tabs or spaces?', + 'scheduled_at' => now()->addHour()->toIso8601String(), + 'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 1440], + ])->assertCreated() + ->assertJsonPath('data.poll_options', ['Tabs', 'Spaces']); + + $this->travel(1)->hour(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + $poll = Commit::query()->sole()->poll; + expect($poll->duration)->toBe(PollDuration::OneDay) + ->and($poll->options()->orderBy('position')->pluck('label')->all())->toBe(['Tabs', 'Spaces']); +}); + +it('schedules images from a signed request, with their descriptions, and attaches them at the due time', function (): void { + Storage::fake(); + + signedApiRequestWithImages($this->keyPair, 'POST', route('api-scheduled-commit.store'), [ + 'body' => 'Shipped the new dashboard.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + 'image_descriptions' => ['The dashboard', null], + ], [ + UploadedFile::fake()->image('wide.png', 300, 200), + UploadedFile::fake()->image('tall.jpg', 100, 400), + ])->assertCreated() + ->assertJsonPath('data.image_count', 2); + + $this->travel(1)->hour(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + expect(Commit::query()->sole()->images()->orderBy('position')->get()->map->only(['width', 'height', 'description'])->all())->toBe([ + ['width' => 300, 'height' => 200, 'description' => 'The dashboard'], + ['width' => 100, 'height' => 400, 'description' => null], + ]); +}); + +it('keeps the first link as the link card, unless the user chose another or none', function (array $choice, ?string $expected): void { + Queue::fake([UnfurlLink::class]); + + signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), [ + 'body' => 'Shipped https://example.com/first and https://example.com/second', + 'scheduled_at' => now()->addDay()->toIso8601String(), + ...$choice, + ])->assertCreated(); + + $this->travel(1)->day(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + expect(Commit::query()->sole()->linkPreview?->url)->toBe($expected); +})->with([ + 'the first link' => [[], 'https://example.com/first'], + 'another link' => [['link_preview_url' => 'https://example.com/second'], 'https://example.com/second'], + 'no card' => [['link_preview_url' => null], null], +]); + +it('rejects invalid commits as the web does', function (array $payload, string $field): void { + signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), [ + 'body' => 'Shipping tomorrow.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + ...$payload, + ])->assertUnprocessable() + ->assertJsonValidationErrors($field); + + expect(ScheduledCommit::query()->count())->toBe(0); +})->with([ + 'no body' => [['body' => ''], 'body'], + 'no time' => [['scheduled_at' => null], 'scheduled_at'], + 'a time without an offset' => [['scheduled_at' => '2026-10-15T13:00:00'], 'scheduled_at'], + 'a time in the past' => [['scheduled_at' => '2026-10-15T11:00:00+00:00'], 'scheduled_at'], + 'a time beyond 30 days' => [['scheduled_at' => '2026-11-15T12:00:00+00:00'], 'scheduled_at'], + 'a reply' => [['parent_id' => '01a1823b-8a82-70e9-abce-622eea7c82cf'], 'parent_id'], + 'a quote' => [['quoted_commit_id' => '01a1823b-8a82-70e9-abce-622eea7c82cf'], 'quoted_commit_id'], + 'a link card for a link not in the body' => [['link_preview_url' => 'https://example.com'], 'link_preview_url'], +]); + +it('limits how many commits the user may have waiting', function (): void { + ScheduledCommit::factory()->for($this->user)->count(10)->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), [ + 'body' => 'One too many.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + ])->assertUnprocessable() + ->assertJsonValidationErrors('scheduled_at'); +}); + +it('may not schedule a commit while the user is in timeout', function (): void { + UserTimeout::factory()->for($this->user)->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit.store'), [ + 'body' => 'Shipping tomorrow.', + 'scheduled_at' => now()->addHour()->toIso8601String(), + ])->assertForbidden() + ->assertJsonPath('code', 'timed_out'); + + expect(ScheduledCommit::query()->count())->toBe(0); +}); + +it('cancels a scheduled commit, which is never submitted', function (): void { + $scheduled = ScheduledCommit::factory()->for($this->user)->create(['scheduled_at' => now()]); + + signedApiRequest($this->keyPair, 'DELETE', route('api-scheduled-commit.destroy', $scheduled))->assertNoContent(); + + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + expect($scheduled->fresh()->canceled_at)->not->toBeNull() + ->and(Commit::query()->count())->toBe(0); +}); + +it('tells the app to refresh when canceling one already submitted or canceled', function (Closure $settle): void { + $scheduled = ScheduledCommit::factory()->for($this->user)->create(['scheduled_at' => now()]); + $settle($scheduled); + + signedApiRequest($this->keyPair, 'DELETE', route('api-scheduled-commit.destroy', $scheduled)) + ->assertConflict() + ->assertExactJson(['message' => 'This commit was already submitted or canceled. Refresh to see its status.']); +})->with([ + 'submitted' => fn (ScheduledCommit $scheduled): bool => resolve(UpdateScheduledCommitSubmittedAt::class)->handle($scheduled->id), + 'canceled' => fn (ScheduledCommit $scheduled): bool => resolve(DeleteScheduledCommit::class)->handle($scheduled->user, $scheduled), +]); + +it("may not cancel another user's scheduled commit", function (): void { + $scheduled = ScheduledCommit::factory()->create(); + + signedApiRequest($this->keyPair, 'DELETE', route('api-scheduled-commit.destroy', $scheduled))->assertNotFound(); + + expect($scheduled->fresh()->canceled_at)->toBeNull(); +}); + +it('requires a signed request', function (): void { + $scheduled = ScheduledCommit::factory()->for($this->user)->create(); + + $this->getJson(route('api-scheduled-commit.index'))->assertUnauthorized(); + $this->postJson(route('api-scheduled-commit.store'), [])->assertUnauthorized(); + $this->deleteJson(route('api-scheduled-commit.destroy', $scheduled))->assertUnauthorized(); +}); + +it('reflects what became of the submitted commit', function (Closure $outcome, string $status): void { + $scheduled = ScheduledCommit::factory()->for($this->user)->create(['scheduled_at' => now()]); + resolve(UpdateScheduledCommitSubmittedAt::class)->handle($scheduled->id); + $outcome(Commit::query()->sole()); + + signedApiRequest($this->keyPair, 'GET', route('api-scheduled-commit.index')) + ->assertOk() + ->assertJsonPath('data.0.status', $status) + ->assertJsonPath('data.0.body', null); +})->with([ + 'approved' => [fn (Commit $commit): bool => $commit->update(['status' => CommitStatus::Approved]), 'approved'], + 'rejected' => [fn (Commit $commit): bool => $commit->update(['status' => CommitStatus::Rejected]), 'rejected'], + 'deleted' => [fn (Commit $commit): ?bool => $commit->delete(), 'deleted'], +]); diff --git a/tests/Feature/Controllers/Api/ScheduledCommitSubmissionControllerTest.php b/tests/Feature/Controllers/Api/ScheduledCommitSubmissionControllerTest.php new file mode 100644 index 000000000..87a976e56 --- /dev/null +++ b/tests/Feature/Controllers/Api/ScheduledCommitSubmissionControllerTest.php @@ -0,0 +1,80 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('commits a scheduled commit now, before its time, through the usual review', function (): void { + $scheduled = ScheduledCommit::factory()->for($this->user)->create(['scheduled_at' => now()->addDay(), 'body' => 'Shipping early']); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit-submission.store', $scheduled)); + + $commit = Commit::query()->sole(); + + $response->assertOk() + ->assertJsonPath('data.id', $scheduled->id) + ->assertJsonPath('data.body', null) + ->assertJsonPath('data.submitted_at', now()->toIso8601String()) + ->assertJsonPath('data.status', 'pending'); + + expect($commit->body)->toBe('Shipping early') + ->and($commit->status)->toBe(CommitStatus::Pending) + ->and($scheduled->fresh()->commit_id)->toBe($commit->id); + + $this->travel(1)->day(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + + expect(Commit::query()->count())->toBe(1); +}); + +it('tells the app to refresh when committing now one already submitted or canceled', function (bool $canceled): void { + $scheduled = ScheduledCommit::factory()->for($this->user)->create(); + + if ($canceled) { + resolve(DeleteScheduledCommit::class)->handle($this->user, $scheduled); + } else { + signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit-submission.store', $scheduled))->assertOk(); + } + + signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit-submission.store', $scheduled)) + ->assertConflict() + ->assertExactJson(['message' => 'This commit was already submitted or canceled. Refresh to see its status.']); + + expect(Commit::query()->count())->toBe($canceled ? 0 : 1); +})->with(['canceled' => true, 'submitted' => false]); + +it("may not commit now another user's scheduled commit", function (): void { + $scheduled = ScheduledCommit::factory()->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit-submission.store', $scheduled))->assertNotFound(); + + expect(Commit::query()->count())->toBe(0) + ->and($scheduled->fresh()->submitted_at)->toBeNull(); +}); + +it('may not commit now while the user is in timeout', function (): void { + $scheduled = ScheduledCommit::factory()->for($this->user)->create(); + UserTimeout::factory()->for($this->user)->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-scheduled-commit-submission.store', $scheduled)) + ->assertForbidden() + ->assertJsonPath('code', 'timed_out'); + + expect(Commit::query()->count())->toBe(0); +}); + +it('requires a signed request', function (): void { + $scheduled = ScheduledCommit::factory()->for($this->user)->create(); + + $this->postJson(route('api-scheduled-commit-submission.store', $scheduled))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/UserAutomaticTranslationControllerTest.php b/tests/Feature/Controllers/Api/UserAutomaticTranslationControllerTest.php new file mode 100644 index 000000000..587035de5 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserAutomaticTranslationControllerTest.php @@ -0,0 +1,46 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('turns automatic translation on or off only for the user, and answers like me', function (bool $enabled): void { + $this->user->update(['automatic_translation' => ! $enabled]); + $other = User::factory()->create(['automatic_translation' => ! $enabled]); + Notification::factory()->for($this->user, 'recipient')->create(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-automatic-translation.update'), ['enabled' => $enabled, 'user_id' => $other->id]); + + $response->assertOk() + ->assertJsonPath('data.id', $this->user->id) + ->assertJsonMissingPath('data.automatic_translation') + ->assertJsonPath('unread_notifications_count', 1); + + expect($this->user->refresh()->automatic_translation)->toBe($enabled) + ->and($other->refresh()->automatic_translation)->toBe(! $enabled); + + signedApiRequest($this->keyPair, 'GET', route('api-user-settings.show')) + ->assertJsonPath('data.automatic_translation', $enabled); +})->with([true, false]); + +it('rejects missing or invalid preferences without changing the saved choice', function (array $data): void { + $this->user->update(['automatic_translation' => false]); + + signedApiRequest($this->keyPair, 'PUT', route('api-user-automatic-translation.update'), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors('enabled'); + + expect($this->user->refresh()->automatic_translation)->toBeFalse(); +})->with(['missing' => [[]], 'null' => [['enabled' => null]], 'text' => [['enabled' => 'invalid']], 'array' => [['enabled' => []]]]); + +it('requires a signed request', function (): void { + $this->putJson(route('api-user-automatic-translation.update'), ['enabled' => false])->assertUnauthorized(); + + expect($this->user->refresh()->automatic_translation)->toBeNull(); +}); diff --git a/tests/Feature/Controllers/Api/UserSearchControllerTest.php b/tests/Feature/Controllers/Api/UserSearchControllerTest.php new file mode 100644 index 000000000..5b1abe3ad --- /dev/null +++ b/tests/Feature/Controllers/Api/UserSearchControllerTest.php @@ -0,0 +1,85 @@ +user = User::factory()->create(['handle' => 'viewer', 'name' => 'Viewer']); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('finds people by handle or name, best matches first', function (): void { + User::factory()->create(['handle' => 'taylorotwell', 'name' => 'Taylor Otwell']); + User::factory()->create(['handle' => 'taylor', 'name' => 'Someone Else']); + User::factory()->create(['handle' => 'jess', 'name' => 'Jess Taylor']); + User::factory()->create(['handle' => 'nuno', 'name' => 'Nuno Maduro']); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-user-search.index', ['q' => 'Taylor'])); + + $response->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.handle', 'taylor') + ->assertJsonPath('data.1.handle', 'taylorotwell') + ->assertJsonPath('data.2.handle', 'jess') + ->assertJsonMissingPath('data.0.email') + ->assertJsonMissingPath('data.0.github_id'); +}); + +it('says whether the user follows each one and whether each one follows them', function (): void { + $followed = User::factory()->create(['handle' => 'taylor_followed']); + $follower = User::factory()->create(['handle' => 'taylor_follower']); + $mutual = User::factory()->create(['handle' => 'taylor_mutual']); + $stranger = User::factory()->create(['handle' => 'taylor_stranger']); + + $this->user->following()->attach([$followed->id, $mutual->id]); + $follower->following()->attach($this->user); + $mutual->following()->attach($this->user); + + $users = collect(signedApiRequest($this->keyPair, 'GET', route('api-user-search.index', ['q' => 'taylor_'])) + ->assertOk() + ->json('data'))->keyBy('handle'); + + expect($users)->toHaveCount(4) + ->and($users['taylor_followed'])->toMatchArray(['is_followed' => true, 'follows_you' => false]) + ->and($users['taylor_follower'])->toMatchArray(['is_followed' => false, 'follows_you' => true]) + ->and($users['taylor_mutual'])->toMatchArray(['is_followed' => true, 'follows_you' => true]) + ->and($users['taylor_stranger'])->toMatchArray(['is_followed' => false, 'follows_you' => false]); +}); + +it('leaves out muted people and people across a block', function (): void { + $muted = User::factory()->create(['handle' => 'taylor_muted']); + $blocked = User::factory()->create(['handle' => 'taylor_blocked']); + $blocker = User::factory()->create(['handle' => 'taylor_blocker']); + User::factory()->create(['handle' => 'taylor']); + + $this->user->muting()->attach($muted); + $this->user->blocking()->attach($blocked); + $blocker->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, 'GET', route('api-user-search.index', ['q' => 'taylor'])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.handle', 'taylor'); +}); + +it('finds no one without a query', function (array $query): void { + User::factory()->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-user-search.index', $query)) + ->assertOk() + ->assertExactJson(['data' => []]); +})->with([ + 'no query' => [[]], + 'a blank query' => [['q' => ' ']], +]); + +it('rejects overly long queries', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-user-search.index', ['q' => str_repeat('a', 101)])) + ->assertUnprocessable() + ->assertJsonValidationErrors('q'); +}); + +it('requires a signed request', function (): void { + $this->getJson(route('api-user-search.index', ['q' => 'taylor']))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/UserSettingsControllerTest.php b/tests/Feature/Controllers/Api/UserSettingsControllerTest.php new file mode 100644 index 000000000..d62ae883c --- /dev/null +++ b/tests/Feature/Controllers/Api/UserSettingsControllerTest.php @@ -0,0 +1,60 @@ +create([ + 'timezone' => 'Europe/Budapest', + 'show_local_time' => true, + 'translation_language' => 'ja', + 'automatic_translation' => false, + ]); + + $response = signedApiRequest(apiKeyPairFor($user), 'GET', route('api-user-settings.show')); + + $response->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertJsonPath('data.timezone', 'Europe/Budapest') + ->assertJsonPath('data.show_local_time', true) + ->assertJsonPath('data.translation_language', 'ja') + ->assertJsonPath('data.automatic_translation', false) + ->assertJsonPath('data.is_admin', false); +}); + +it('follows the device and translates automatically until the user chooses otherwise', function (): void { + $response = signedApiRequest(apiKeyPairFor(User::factory()->create()), 'GET', route('api-user-settings.show')); + + $response->assertOk() + ->assertJsonPath('data.timezone', null) + ->assertJsonPath('data.show_local_time', false) + ->assertJsonPath('data.translation_language', null) + ->assertJsonPath('data.automatic_translation', true); +}); + +it('lists the languages commits may be translated into, by their English name, each named in itself', function (): void { + $languages = signedApiRequest(apiKeyPairFor(User::factory()->create()), 'GET', route('api-user-settings.show')) + ->assertOk() + ->json('data.translation_languages'); + + $names = array_column($languages, 'name'); + $sorted = $names; + sort($sorted); + + expect($languages)->toContain(['code' => 'ja', 'name' => 'Japanese', 'native_name' => '日本語']) + ->toContain(['code' => 'fr', 'name' => 'French', 'native_name' => 'français']) + ->and($names)->toBe($sorted); +}); + +it('says whether the user is an admin', function (): void { + config(['app.admins' => ['Octocat']]); + + signedApiRequest(apiKeyPairFor(User::factory()->create(['handle' => 'octocat'])), 'GET', route('api-user-settings.show')) + ->assertOk() + ->assertJsonPath('data.is_admin', true); +}); + +it('requires a signed request', function (): void { + $this->getJson(route('api-user-settings.show'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/UserTimezoneControllerTest.php b/tests/Feature/Controllers/Api/UserTimezoneControllerTest.php new file mode 100644 index 000000000..fcd280c77 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserTimezoneControllerTest.php @@ -0,0 +1,74 @@ +user = User::factory()->create(['timezone' => 'Europe/Lisbon']); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may choose a timezone, and answers like me', function (): void { + Notification::factory()->for($this->user, 'recipient')->create(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-timezone.update'), ['timezone' => 'Europe/Budapest']); + + $response->assertOk() + ->assertJsonPath('data.id', $this->user->id) + ->assertJsonPath('data.handle', $this->user->handle) + ->assertJsonPath('unread_notifications_count', 1); + + expect($this->user->refresh()) + ->timezone->toBe('Europe/Budapest') + ->show_local_time->toBeFalse(); +}); + +it('may opt in to and out of showing the local time', function (bool $show): void { + $this->user->update(['show_local_time' => ! $show]); + + signedApiRequest($this->keyPair, 'PUT', route('api-user-timezone.update'), ['timezone' => 'Europe/Budapest', 'show_local_time' => $show]) + ->assertOk(); + + expect($this->user->refresh()->show_local_time)->toBe($show); +})->with([true, false]); + +it('never shows the local time without a timezone', function (): void { + $this->user->update(['show_local_time' => true]); + + signedApiRequest($this->keyPair, 'PUT', route('api-user-timezone.update'), ['timezone' => null, 'show_local_time' => true]) + ->assertOk(); + + expect($this->user->refresh()) + ->timezone->toBeNull() + ->show_local_time->toBeFalse(); +}); + +it('may clear the timezone to follow the device', function (mixed $timezone): void { + signedApiRequest($this->keyPair, 'PUT', route('api-user-timezone.update'), ['timezone' => $timezone])->assertOk(); + + expect($this->user->refresh()->timezone)->toBeNull(); +})->with([null, '']); + +it('rejects unknown timezones and a show_local_time that is not a boolean', function (array $data, string $field): void { + signedApiRequest($this->keyPair, 'PUT', route('api-user-timezone.update'), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); + + expect($this->user->refresh()->timezone)->toBe('Europe/Lisbon'); +})->with([ + 'an unknown timezone' => [['timezone' => 'Mars/Olympus_Mons'], 'timezone'], + 'an offset' => [['timezone' => 'GMT+2'], 'timezone'], + 'a lowercase timezone' => [['timezone' => 'europe/budapest'], 'timezone'], + 'a number' => [['timezone' => 42], 'timezone'], + 'a list' => [['timezone' => ['Europe/Budapest']], 'timezone'], + 'no timezone' => [[], 'timezone'], + 'a show_local_time that is not a boolean' => [['timezone' => 'Europe/Budapest', 'show_local_time' => 'maybe'], 'show_local_time'], +]); + +it('requires a signed request', function (): void { + $this->putJson(route('api-user-timezone.update'), ['timezone' => 'Europe/Budapest'])->assertUnauthorized(); + + expect($this->user->refresh()->timezone)->toBe('Europe/Lisbon'); +}); diff --git a/tests/Feature/Controllers/Api/UserTranslationLanguageControllerTest.php b/tests/Feature/Controllers/Api/UserTranslationLanguageControllerTest.php new file mode 100644 index 000000000..234ecd843 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserTranslationLanguageControllerTest.php @@ -0,0 +1,57 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('saves the translation language only for the user, and answers like me', function (): void { + $other = User::factory()->create(['translation_language' => 'de']); + Notification::factory()->for($this->user, 'recipient')->create(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-user-translation-language.update'), ['translation_language' => 'ja', 'user_id' => $other->id]); + + $response->assertOk() + ->assertJsonPath('data.id', $this->user->id) + ->assertJsonMissingPath('data.translation_language') + ->assertJsonPath('unread_notifications_count', 1); + + expect($this->user->refresh()->translation_language)->toBe('ja') + ->and($other->refresh()->translation_language)->toBe('de'); +}); + +it('clears the language to follow the device', function (mixed $language): void { + $this->user->update(['translation_language' => 'ja']); + + signedApiRequest($this->keyPair, 'PUT', route('api-user-translation-language.update'), ['translation_language' => $language])->assertOk(); + + expect($this->user->refresh()->translation_language)->toBeNull(); +})->with([null, '']); + +it('rejects invalid translation languages without changing the preference', function (array $data): void { + $this->user->update(['translation_language' => 'ja']); + + signedApiRequest($this->keyPair, 'PUT', route('api-user-translation-language.update'), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors('translation_language'); + + expect($this->user->refresh()->translation_language)->toBe('ja'); +})->with([ + 'an invented language' => [['translation_language' => 'invented']], + 'a locale' => [['translation_language' => 'ja-JP']], + 'an uppercase code' => [['translation_language' => 'JA']], + 'a number' => [['translation_language' => 42]], + 'a list' => [['translation_language' => ['ja']]], + 'no language' => [[]], +]); + +it('requires a signed request', function (): void { + $this->putJson(route('api-user-translation-language.update'), ['translation_language' => 'ja'])->assertUnauthorized(); + + expect($this->user->refresh()->translation_language)->toBeNull(); +});