From 90b0674d59c9004c6b68b457b18b307658fd98a3 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 31 Oct 2026 16:27:39 +0100 Subject: [PATCH 1/2] test: cover API parity endpoints and update response assertions --- .../Controllers/Api/CallControllerTest.php | 31 ++++++ .../Controllers/Api/CommitControllerTest.php | 29 ++++++ .../Api/ConversationCallControllerTest.php | 96 +++++++++++++++++++ ...onversationCallIceServerControllerTest.php | 20 ++++ .../Api/LiveFollowingControllerTest.php | 18 ++++ .../Controllers/Api/OrgCallControllerTest.php | 65 +++++++++++++ .../Api/OrgCallIceServerControllerTest.php | 20 ++++ .../Api/OrganizationAdminControllerTest.php | 49 ++++++++++ .../Api/OrganizationControllerTest.php | 28 ++++-- .../Controllers/Api/UserControllerTest.php | 3 +- .../Api/UserLiveControllerTest.php | 26 +++++ 11 files changed, 374 insertions(+), 11 deletions(-) create mode 100644 tests/Feature/Controllers/Api/CallControllerTest.php create mode 100644 tests/Feature/Controllers/Api/ConversationCallControllerTest.php create mode 100644 tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php create mode 100644 tests/Feature/Controllers/Api/LiveFollowingControllerTest.php create mode 100644 tests/Feature/Controllers/Api/OrgCallControllerTest.php create mode 100644 tests/Feature/Controllers/Api/OrgCallIceServerControllerTest.php create mode 100644 tests/Feature/Controllers/Api/OrganizationAdminControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserLiveControllerTest.php diff --git a/tests/Feature/Controllers/Api/CallControllerTest.php b/tests/Feature/Controllers/Api/CallControllerTest.php new file mode 100644 index 000000000..07ac6c770 --- /dev/null +++ b/tests/Feature/Controllers/Api/CallControllerTest.php @@ -0,0 +1,31 @@ + true, 'remotes.everyone' => true]); + $user = User::factory()->withRemotes()->create(); + $caller = User::factory()->withRemotes()->create(); + $key = apiKeyPairFor($user); + + signedApiRequest($key, 'GET', route('api-call.show'))->assertOk()->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['call' => null, 'org_calls' => []]); + sendChatMessage($caller, $user); + resolve(CreateConversationCall::class)->handle($caller, $user, 'v=0 incoming'); + [$member] = followersOf($user, 1); + $org = Conversation::query()->findOrFail(createOrg($user, [$member])); + resolve(CreateOrgCall::class)->handle($member, $org); + + signedApiRequest($key, 'GET', route('api-call.show'))->assertOk() + ->assertJsonPath('call.partner.id', $caller->id) + ->assertJsonPath('call.sdp', 'v=0 incoming') + ->assertJsonPath('org_calls.0.members.0.user.id', $member->id) + ->assertJsonPath('org_calls.0.members.0.joined_at', now()->getTimestampMs()) + ->assertJsonMissingPath('call.partner.email') + ->assertJsonMissingPath('org_calls.0.members.0.user.email'); +}); diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index aefd13237..f811fcac9 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -5,6 +5,7 @@ use App\Enums\CommitStatus; use App\Enums\PollDuration; use App\Http\Requests\CreateApiCommitRequest; +use App\Http\Requests\CreateCommitReplyRequest; use App\Jobs\ModerateCommit; use App\Models\Commit; use App\Models\CommitImage; @@ -18,6 +19,33 @@ use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; +it('bounds combined inherited and explicit reply recipients', function (int $inheritedCount, bool $accepted): void { + Queue::fake(); + $author = User::factory()->create(); + $users = User::factory()->count($inheritedCount)->create(); + $mentions = $users->mapWithKeys(fn (User $user): array => [$user->handle => $user->id])->all(); + $participant = User::factory()->create(['handle' => 'new-participant']); + $parent = Commit::factory()->create(['mentions' => $mentions]); + + $response = signedApiRequest(apiKeyPairFor($author), 'POST', route('api-commit.store'), [ + 'parent_id' => $parent->id, + 'body' => 'Hello @new-participant @'.$users->firstOrFail()->handle, + 'reply_recipients' => array_values($mentions), + ]); + + if ($accepted) { + $response->assertCreated(); + expect($parent->replies()->sole()->mentions)->toHaveCount(CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS); + } else { + $response->assertUnprocessable()->assertJsonValidationErrors('reply_recipients'); + expect($parent->replies()->count())->toBe(0) + ->and($participant->notifications()->count())->toBe(0); + } +})->with([ + 'at limit with overlapping explicit mention' => [CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS - 1, true], + 'combined recipients over limit' => [CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS, false], +]); + it('shows an approved commit to anyone', function (): void { $user = User::factory()->create([ 'handle' => 'octocat', @@ -290,6 +318,7 @@ 'quoted_commit' => null, 'poll' => null, ]], + 'reply_participants' => [], ]); }); diff --git a/tests/Feature/Controllers/Api/ConversationCallControllerTest.php b/tests/Feature/Controllers/Api/ConversationCallControllerTest.php new file mode 100644 index 000000000..0086eb201 --- /dev/null +++ b/tests/Feature/Controllers/Api/ConversationCallControllerTest.php @@ -0,0 +1,96 @@ + true, 'remotes.everyone' => true]); +}); + +it('rings, answers and ends a call through signed requests', function (): void { + $caller = User::factory()->withRemotes()->create(); + $partner = User::factory()->withRemotes()->create(); + $callerKey = apiKeyPairFor($caller); + $partnerKey = apiKeyPairFor($partner); + sendChatMessage($caller, $partner); + + signedApiRequest($callerKey, 'POST', route('api-conversation-call.store', $partner), ['sdp' => "v=0\r\no=caller\r\n"])->assertNoContent(); + + expect(resolve(CallQuery::class)->get($partner)->offer)->toBe("v=0\r\no=caller\r\n"); + signedApiRequest($callerKey, 'GET', route('api-conversation-call.show', $partner)) + ->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertExactJson(['call' => ['answer' => null]]); + signedApiRequest($partnerKey, 'PUT', route('api-conversation-call.update', $caller), ['sdp' => 'v=0 answer'])->assertNoContent(); + signedApiRequest($callerKey, 'GET', route('api-conversation-call.show', $partner))->assertExactJson(['call' => ['answer' => 'v=0 answer']]); + signedApiRequest($callerKey, 'DELETE', route('api-conversation-call.destroy', $partner))->assertNoContent(); + + expect(resolve(CallQuery::class)->get($partner))->toBeNull() + ->and(Message::query()->whereNotNull('event')->orderBy('id')->pluck('event')->all()) + ->toBe([OrgEvent::CallStarted, OrgEvent::CallAnswered]); +}); + +it('records a declined or failed incoming call', function (bool $failed, OrgEvent $event): void { + $caller = User::factory()->withRemotes()->create(); + $partner = User::factory()->withRemotes()->create(); + sendChatMessage($caller, $partner); + resolve(CreateConversationCall::class)->handle($caller, $partner, 'v=0'); + + signedApiRequest(apiKeyPairFor($partner), 'DELETE', route('api-conversation-call.destroy', $caller), ['failed' => $failed])->assertNoContent(); + + expect(resolve(CallQuery::class)->get($caller))->toBeNull(); + $this->assertDatabaseHas('messages', ['event' => $event->value, 'user_id' => $partner->id]); +})->with([[false, OrgEvent::CallDeclined], [true, OrgEvent::CallFailed]]); + +it('hides every call operation across a block', function (): void { + $caller = User::factory()->withRemotes()->create(); + $partner = User::factory()->withRemotes()->create(); + $partner->blocking()->attach($caller); + $key = apiKeyPairFor($caller); + + signedApiRequest($key, 'POST', route('api-conversation-call.store', $partner), ['sdp' => 'v=0'])->assertNotFound(); + signedApiRequest($key, 'PUT', route('api-conversation-call.update', $partner), ['sdp' => 'v=0'])->assertNotFound(); + signedApiRequest($key, 'GET', route('api-conversation-call.show', $partner))->assertNotFound(); + signedApiRequest($key, 'DELETE', route('api-conversation-call.destroy', $partner))->assertNotFound(); +}); + +it('refuses a call before the conversation starts and an answer after it ends', function (): void { + $caller = User::factory()->withRemotes()->create(); + $partner = User::factory()->withRemotes()->create(); + $key = apiKeyPairFor($caller); + + signedApiRequest($key, 'POST', route('api-conversation-call.store', $partner), ['sdp' => 'v=0']) + ->assertForbidden()->assertJsonPath('message', "Say hi to {$partner->mention()} before calling them."); + signedApiRequest($key, 'PUT', route('api-conversation-call.update', $partner), ['sdp' => 'v=0']) + ->assertForbidden()->assertJsonPath('message', 'This call has ended.'); + expect(resolve(CallQuery::class)->get($partner))->toBeNull(); +}); + +it('refuses calling when either person is in an org call', function (bool $callerIsInOne): void { + $caller = User::factory()->withRemotes()->create(); + $partner = User::factory()->withRemotes()->create(); + sendChatMessage($caller, $partner); + Cache::put(CreateOrgCall::memberKey(($callerIsInOne ? $caller : $partner)->id), 'org', CreateOrgCall::SECONDS); + + signedApiRequest(apiKeyPairFor($caller), 'POST', route('api-conversation-call.store', $partner), ['sdp' => 'v=0']) + ->assertForbidden()->assertJsonPath('message', $callerIsInOne ? "You're already on a call." : "You can't call {$partner->mention()} right now."); +})->with([true, false]); + +it('rejects an invalid offer and answer without changing the call', function (): void { + $caller = User::factory()->withRemotes()->create(); + $partner = User::factory()->withRemotes()->create(); + sendChatMessage($caller, $partner); + + signedApiRequest(apiKeyPairFor($caller), 'POST', route('api-conversation-call.store', $partner), ['sdp' => 'invalid']) + ->assertUnprocessable()->assertJsonValidationErrors('sdp'); + expect(resolve(CallQuery::class)->get($partner))->toBeNull(); + resolve(CreateConversationCall::class)->handle($caller, $partner, 'v=0'); + signedApiRequest(apiKeyPairFor($partner), 'PUT', route('api-conversation-call.update', $caller), ['sdp' => 'invalid']) + ->assertUnprocessable()->assertJsonValidationErrors('sdp'); + expect(resolve(CallQuery::class)->get($partner)->answer)->toBeNull(); +}); diff --git a/tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php b/tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php new file mode 100644 index 000000000..5bb708dae --- /dev/null +++ b/tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php @@ -0,0 +1,20 @@ + true, 'remotes.everyone' => true]); + $caller = User::factory()->withRemotes()->create(); + $partner = User::factory()->withRemotes()->create(); + $key = apiKeyPairFor($caller); + $url = route('api-conversation-call-ice-server.index', $partner); + + signedApiRequest($key, 'GET', $url)->assertNotFound(); + sendChatMessage($caller, $partner); + signedApiRequest($key, 'GET', $url)->assertOk()->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['ice_servers' => [], 'is_relay_only' => false]); + $partner->blocking()->attach($caller); + signedApiRequest($key, 'GET', $url)->assertNotFound(); +}); diff --git a/tests/Feature/Controllers/Api/LiveFollowingControllerTest.php b/tests/Feature/Controllers/Api/LiveFollowingControllerTest.php new file mode 100644 index 000000000..ddd81a5a3 --- /dev/null +++ b/tests/Feature/Controllers/Api/LiveFollowingControllerTest.php @@ -0,0 +1,18 @@ +create(); + $live = User::factory()->create(['live_platform' => LivePlatform::Twitch, 'live_url' => 'https://www.twitch.tv/octocat', 'live_ends_at' => now()->addHour()]); + $expired = User::factory()->create(['live_platform' => LivePlatform::Twitch, 'live_url' => 'https://www.twitch.tv/expired', 'live_ends_at' => now()->subMinute()]); + $user->following()->attach([$live->id, $expired->id]); + + signedApiRequest(apiKeyPairFor($user), 'GET', route('api-live-following.index'))->assertOk() + ->assertJsonPath('data.*.id', [$live->id]) + ->assertJsonPath('data.0.live.url', 'https://www.twitch.tv/octocat') + ->assertJsonMissingPath('data.0.email'); +}); diff --git a/tests/Feature/Controllers/Api/OrgCallControllerTest.php b/tests/Feature/Controllers/Api/OrgCallControllerTest.php new file mode 100644 index 000000000..87e277aca --- /dev/null +++ b/tests/Feature/Controllers/Api/OrgCallControllerTest.php @@ -0,0 +1,65 @@ +withRemotes()->create(); + [$member] = followersOf($user, 1); + $org = Conversation::query()->findOrFail(createOrg($user, [$member])); + $key = apiKeyPairFor($user); + $memberKey = apiKeyPairFor($member); + $signal = ['id' => 'offer-1', 'type' => 'offer', 'sdp' => "v=0\r\no=caller\r\n"]; + + signedApiRequest($key, 'POST', route('api-org-call.store', $org))->assertCreated() + ->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('call.members.0.user.id', $user->id); + signedApiRequest($memberKey, 'POST', route('api-org-call.store', $org))->assertCreated()->assertJsonCount(2, 'call.members'); + signedApiRequest($key, 'PUT', route('api-org-call.update', $org), ['signals' => [$member->id => $signal]]) + ->assertOk()->assertJsonPath('call.signals', []); + signedApiRequest($memberKey, 'PUT', route('api-org-call.update', $org), ['signals' => []]) + ->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('call.signals', [$user->id => $signal]); + signedApiRequest($key, 'DELETE', route('api-org-call.destroy', $org))->assertNoContent(); + expect(resolve(OrgCallQuery::class)->isInOne($user))->toBeFalse(); +}); + +it('hides call operations from nonmembers and members with Remotes off', function (bool $isMember): void { + $owner = User::factory()->withRemotes()->create(); + [$member] = followersOf($owner, 1); + $org = Conversation::query()->findOrFail(createOrg($owner, [$member])); + $user = $isMember ? $member : User::factory()->withRemotes()->create(); + $user->update(['remotes_enabled' => ! $isMember]); + + $key = apiKeyPairFor($user); + + signedApiRequest($key, 'POST', route('api-org-call.store', $org))->assertNotFound(); + signedApiRequest($key, 'PUT', route('api-org-call.update', $org), ['signals' => []])->assertNotFound(); + signedApiRequest($key, 'DELETE', route('api-org-call.destroy', $org))->assertNotFound(); +})->with([true, false]); + +it('refuses joining another org call and checking in after leaving', function (): void { + $user = User::factory()->withRemotes()->create(); + [$member] = followersOf($user, 1); + $first = Conversation::query()->findOrFail(createOrg($user, [$member], 'first')); + $second = Conversation::query()->findOrFail(createOrg($user, [$member], 'second')); + $key = apiKeyPairFor($user); + resolve(CreateOrgCall::class)->handle($user, $first); + + signedApiRequest($key, 'POST', route('api-org-call.store', $second))->assertForbidden() + ->assertJsonPath('message', "You're already on a call."); + signedApiRequest($key, 'PUT', route('api-org-call.update', $second), ['signals' => []])->assertForbidden() + ->assertJsonPath('message', "You're no longer in this call."); +}); + +it('rejects invalid signals without delivering them', function (): void { + $user = User::factory()->withRemotes()->create(); + [$member] = followersOf($user, 1); + $org = Conversation::query()->findOrFail(createOrg($user, [$member])); + resolve(CreateOrgCall::class)->handle($user, $org); + + signedApiRequest(apiKeyPairFor($user), 'PUT', route('api-org-call.update', $org), ['signals' => [$member->id => ['id' => 'bad', 'type' => 'offer', 'sdp' => 'invalid']]]) + ->assertUnprocessable()->assertJsonValidationErrors('signals.'.$member->id.'.sdp'); +}); diff --git a/tests/Feature/Controllers/Api/OrgCallIceServerControllerTest.php b/tests/Feature/Controllers/Api/OrgCallIceServerControllerTest.php new file mode 100644 index 000000000..4aca0d14c --- /dev/null +++ b/tests/Feature/Controllers/Api/OrgCallIceServerControllerTest.php @@ -0,0 +1,20 @@ +withRemotes()->create(); + [$member] = followersOf($user, 1); + $org = Conversation::query()->findOrFail(createOrg($user, [$member])); + $url = route('api-org-call-ice-server.index', $org); + $key = apiKeyPairFor($user); + + signedApiRequest($key, 'GET', $url)->assertOk()->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['ice_servers' => [], 'is_relay_only' => false]); + signedApiRequest(apiKeyPairFor(User::factory()->withRemotes()->create()), 'GET', $url)->assertNotFound(); + $user->update(['remotes_enabled' => false]); + signedApiRequest($key, 'GET', $url)->assertNotFound(); +}); diff --git a/tests/Feature/Controllers/Api/OrganizationAdminControllerTest.php b/tests/Feature/Controllers/Api/OrganizationAdminControllerTest.php new file mode 100644 index 000000000..fe07b8bfa --- /dev/null +++ b/tests/Feature/Controllers/Api/OrganizationAdminControllerTest.php @@ -0,0 +1,49 @@ +create(); + $organization = Organization::factory()->create(); + $this->mock(GitHubProvider::class)->shouldReceive('isPublicMember')->once() + ->with($organization->profile->github_id, $user->github_id)->andReturnTrue(); + + signedApiRequest(apiKeyPairFor($user), 'POST', route('api-organization-admin.store', ['handle' => $organization->handle]))->assertNoContent(); + + expect($organization->admins()->pluck('users.id')->all())->toBe([$user->id]); +}); + +it('refuses unknown, already claimed and nonpublic organization memberships', function (): void { + $user = User::factory()->create(); + $key = apiKeyPairFor($user); + $organization = Organization::factory()->create(); + $this->mock(GitHubProvider::class)->shouldReceive('isPublicMember')->once()->andReturnFalse(); + + signedApiRequest($key, 'POST', route('api-organization-admin.store', ['handle' => 'missing']))->assertNotFound(); + signedApiRequest($key, 'POST', route('api-organization-admin.store', ['handle' => $organization->handle]))->assertForbidden() + ->assertJsonPath('message', "GitHub does not list you as a public member of @{$organization->handle}. On GitHub, make your membership of @{$organization->handle} public, then try again."); + $admin = User::factory()->create(); + $organization->admins()->attach($admin); + signedApiRequest($key, 'POST', route('api-organization-admin.store', ['handle' => $organization->handle]))->assertForbidden() + ->assertJsonPath('message', "@{$organization->handle} was already claimed by @{$admin->handle}."); + expect($organization->admins()->pluck('users.id')->all())->toBe([$admin->id]); +}); + +it('returns a conflict if someone else claims the organization during the GitHub check', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(); + $organization = Organization::factory()->create(); + $this->mock(GitHubProvider::class)->shouldReceive('isPublicMember')->once()->andReturnUsing(function () use ($organization, $other): bool { + $organization->admins()->attach($other); + + return true; + }); + + signedApiRequest(apiKeyPairFor($user), 'POST', route('api-organization-admin.store', ['handle' => $organization->handle])) + ->assertConflict()->assertExactJson(['message' => 'Unable to claim this organization. Please try again.']); + expect($organization->admins()->pluck('users.id')->all())->toBe([$other->id]); +}); diff --git a/tests/Feature/Controllers/Api/OrganizationControllerTest.php b/tests/Feature/Controllers/Api/OrganizationControllerTest.php index a2016d28c..69a96244b 100644 --- a/tests/Feature/Controllers/Api/OrganizationControllerTest.php +++ b/tests/Feature/Controllers/Api/OrganizationControllerTest.php @@ -17,16 +17,24 @@ $this->getJson(route('api-organization.show', ['handle' => 'laravel'])) ->assertOk() - ->assertExactJson(['data' => [ - 'handle' => 'laravel', - 'name' => 'Laravel', - 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072', - 'bio' => 'The PHP framework for web artisans.', - 'location' => 'United States of America', - 'website' => 'https://laravel.com', - 'github_url' => 'https://github.com/laravel', - 'html_url' => route('user.show', ['user' => 'laravel']), - ]]); + ->assertExactJson([ + 'data' => [ + 'handle' => 'laravel', + 'name' => 'Laravel', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/958072', + 'bio' => 'The PHP framework for web artisans.', + 'location' => 'United States of America', + 'website' => 'https://laravel.com', + 'github_url' => 'https://github.com/laravel', + 'html_url' => route('user.show', ['user' => 'laravel']), + 'lane' => null, + 'followers_count' => 0, + 'admins' => [], + ], + 'is_following' => false, + 'can_claim' => false, + 'settings_url' => null, + ]); }); it('returns not found for handles without an organization page', function (): void { diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index 597df343d..ae25275e1 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -31,7 +31,7 @@ $response = $this->getJson(route('api-user.show', $user)); $response->assertOk() - ->assertExactJson(['data' => [ + ->assertExactJson(['bio_profiles' => [], 'data' => [ 'id' => $user->id, 'handle' => 'octocat', 'name' => 'The Octocat', @@ -165,6 +165,7 @@ 'streaks' => ['longest' => 1, 'current' => 1], 'achievements' => resolve(UserAchievementsQuery::class)->get($user, $viewer), 'pinned_commit' => null, + 'bio_profiles' => [], ]) ->assertJsonPath('achievements.rank', 'daemon') ->assertJsonPath('achievements.level', 2) diff --git a/tests/Feature/Controllers/Api/UserLiveControllerTest.php b/tests/Feature/Controllers/Api/UserLiveControllerTest.php new file mode 100644 index 000000000..5a89f4bb2 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserLiveControllerTest.php @@ -0,0 +1,26 @@ +create(); + $key = apiKeyPairFor($user); + + signedApiRequest($key, 'PUT', route('api-user-live.update'), ['url' => ' https://twitch.tv/octocat ', 'hours' => 3]) + ->assertOk()->assertJsonPath('data.live.url', 'https://www.twitch.tv/octocat')->assertJsonPath('unread_notifications_count', 0); + expect($user->refresh()->live_platform)->toBe(LivePlatform::Twitch) + ->and($user->live_ends_at?->toIso8601String())->toBe(now()->addHours(3)->toIso8601String()); + signedApiRequest($key, 'DELETE', route('api-user-live.destroy'))->assertOk()->assertJsonPath('data.live', null); + expect($user->refresh()->live_url)->toBeNull()->and($user->live_ends_at)->toBeNull(); +}); + +it('rejects invalid stream data without going live', function (): void { + $user = User::factory()->create(); + + signedApiRequest(apiKeyPairFor($user), 'PUT', route('api-user-live.update'), ['url' => 'https://example.com', 'hours' => 0]) + ->assertUnprocessable()->assertJsonValidationErrors(['url', 'hours']); + expect($user->refresh()->live_url)->toBeNull(); +}); From fab5526e022ebef7951907fd2eb143137055b3b1 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 31 Oct 2026 16:43:38 +0100 Subject: [PATCH 2/2] test: strengthen API call cleanup and JSON shape assertions --- .../Controllers/Api/ConversationCallControllerTest.php | 2 +- tests/Feature/Controllers/Api/OrgCallControllerTest.php | 7 +++++-- tests/Feature/Controllers/Api/UserControllerTest.php | 4 ++++ tests/Feature/Controllers/Api/UserLiveControllerTest.php | 3 ++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/Feature/Controllers/Api/ConversationCallControllerTest.php b/tests/Feature/Controllers/Api/ConversationCallControllerTest.php index 0086eb201..ebf059771 100644 --- a/tests/Feature/Controllers/Api/ConversationCallControllerTest.php +++ b/tests/Feature/Controllers/Api/ConversationCallControllerTest.php @@ -43,7 +43,7 @@ signedApiRequest(apiKeyPairFor($partner), 'DELETE', route('api-conversation-call.destroy', $caller), ['failed' => $failed])->assertNoContent(); - expect(resolve(CallQuery::class)->get($caller))->toBeNull(); + expect(resolve(CallQuery::class)->get($partner))->toBeNull(); $this->assertDatabaseHas('messages', ['event' => $event->value, 'user_id' => $partner->id]); })->with([[false, OrgEvent::CallDeclined], [true, OrgEvent::CallFailed]]); diff --git a/tests/Feature/Controllers/Api/OrgCallControllerTest.php b/tests/Feature/Controllers/Api/OrgCallControllerTest.php index 87e277aca..5728e63d6 100644 --- a/tests/Feature/Controllers/Api/OrgCallControllerTest.php +++ b/tests/Feature/Controllers/Api/OrgCallControllerTest.php @@ -18,8 +18,11 @@ signedApiRequest($key, 'POST', route('api-org-call.store', $org))->assertCreated() ->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('call.members.0.user.id', $user->id); signedApiRequest($memberKey, 'POST', route('api-org-call.store', $org))->assertCreated()->assertJsonCount(2, 'call.members'); - signedApiRequest($key, 'PUT', route('api-org-call.update', $org), ['signals' => [$member->id => $signal]]) - ->assertOk()->assertJsonPath('call.signals', []); + + $response = signedApiRequest($key, 'PUT', route('api-org-call.update', $org), ['signals' => [$member->id => $signal]]); + + $response->assertOk()->assertJsonPath('call.signals', []); + expect(json_decode($response->getContent(), flags: JSON_THROW_ON_ERROR)->call->signals)->toBeInstanceOf(stdClass::class); signedApiRequest($memberKey, 'PUT', route('api-org-call.update', $org), ['signals' => []]) ->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('call.signals', [$user->id => $signal]); signedApiRequest($key, 'DELETE', route('api-org-call.destroy', $org))->assertNoContent(); diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index ae25275e1..f15b3390a 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -49,6 +49,8 @@ 'followers_count' => 3, 'following_count' => 1, ]]); + + expect(json_decode($response->getContent(), flags: JSON_THROW_ON_ERROR)->bio_profiles)->toBeInstanceOf(stdClass::class); }); it('never exposes hidden fields', function (): void { @@ -174,6 +176,8 @@ ->assertJsonCount(7, 'activity.buckets') ->assertJsonPath('activity.buckets.3', ['date' => '2026-10-12', 'count' => 1]) ->assertJsonPath('activity.buckets.6', ['date' => '2026-10-15', 'count' => 2]); + + expect(json_decode($response->getContent(), flags: JSON_THROW_ON_ERROR)->bio_profiles)->toBeInstanceOf(stdClass::class); }); it('counts the pending commits of a signed-in user on their own profile', function (): void { diff --git a/tests/Feature/Controllers/Api/UserLiveControllerTest.php b/tests/Feature/Controllers/Api/UserLiveControllerTest.php index 5a89f4bb2..b25f0a775 100644 --- a/tests/Feature/Controllers/Api/UserLiveControllerTest.php +++ b/tests/Feature/Controllers/Api/UserLiveControllerTest.php @@ -13,7 +13,8 @@ ->assertOk()->assertJsonPath('data.live.url', 'https://www.twitch.tv/octocat')->assertJsonPath('unread_notifications_count', 0); expect($user->refresh()->live_platform)->toBe(LivePlatform::Twitch) ->and($user->live_ends_at?->toIso8601String())->toBe(now()->addHours(3)->toIso8601String()); - signedApiRequest($key, 'DELETE', route('api-user-live.destroy'))->assertOk()->assertJsonPath('data.live', null); + signedApiRequest($key, 'DELETE', route('api-user-live.destroy'))->assertOk() + ->assertJsonStructure(['data' => ['live']])->assertJsonPath('data.live', null); expect($user->refresh()->live_url)->toBeNull()->and($user->live_ends_at)->toBeNull(); });