Fixed bugs in member endpoints; Added merge-into member endpoint

This commit is contained in:
Constantin Graf
2025-03-05 16:01:37 -05:00
committed by Constantin Graf
parent f93c5370bf
commit ab263e725f
13 changed files with 487 additions and 3 deletions

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Correction;
use App\Enums\Role;
use App\Models\Member;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class CorrectionPlaceholderMembersCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'correction:placeholder-members '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sets all members who belong to a placeholder user to role placeholder';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Sets all members who belong to a placeholder user to role placeholder...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$members = Member::query()
->where('role', '!=', Role::Placeholder->value)
->whereHas('user', function (Builder $builder): void {
/** @var Builder<User> $builder */
$builder->where('is_placeholder', '=', true);
})
->get();
foreach ($members as $member) {
/** @var Member $member */
$member->role = Role::Placeholder->value;
if (! $dryRun) {
$member->save();
}
$this->line('Set role of member (id='.$member->getKey().') to placeholder');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
{
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
{
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
}

View File

@@ -7,12 +7,17 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest;
use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberResource;
@@ -21,9 +26,12 @@ use App\Models\Organization;
use App\Service\BillableRateService;
use App\Service\InvitationService;
use App\Service\MemberService;
use App\Service\UserService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class MemberController extends Controller
{
@@ -63,6 +71,7 @@ class MemberController extends Controller
* @throws OrganizationNeedsAtLeastOneOwner
* @throws OnlyOwnerCanChangeOwnership
* @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws ChangingRoleOfPlaceholderIsNotAllowed
*
* @operationId updateMember
*/
@@ -105,7 +114,7 @@ class MemberController extends Controller
/**
* Make a member a placeholder member
*
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed
*/
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
@@ -114,6 +123,9 @@ class MemberController extends Controller
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
if ($member->role === Role::Placeholder->value) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
$memberService->makeMemberToPlaceholder($member);
@@ -122,10 +134,37 @@ class MemberController extends Controller
return response()->json(null, 204);
}
/**
* @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws \Throwable
*/
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request): JsonResponse
{
$this->checkPermission($organization, 'members:merge-into', $member);
$user = $member->user;
if ($member->role !== Role::Placeholder->value || ! $user->is_placeholder) {
throw new OnlyPlaceholdersCanBeMergedIntoAnotherMember;
}
$memberTo = Member::findOrFail($request->getMemberId());
DB::transaction(function () use ($organization, $member, $user, $memberTo): void {
app(UserService::class)->assignOrganizationEntitiesToDifferentMember($organization, $user, $memberTo->user, $memberTo);
$member->delete();
$user->delete();
});
return response()->json(null, 204);
}
/**
* Invite a placeholder member to become a real member of the organization
*
* @throws AuthorizationException|UserNotPlaceholderApiException
* @throws AuthorizationException
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
*
* @operationId invitePlaceholder
*/
@@ -138,6 +177,10 @@ class MemberController extends Controller
throw new UserNotPlaceholderApiException;
}
if (Str::endsWith($user->email, '@solidtime-import.test')) {
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
}
$invitationService->inviteUser($organization, $user->email, Role::Employee);
return response()->json(null, 204);

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class MemberMergeIntoRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
// ID of the member to which the data should be transferred (destination)
'member_id' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
];
}
public function getMemberId(): string
{
return (string) $this->input('member_id');
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -24,6 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read User $user
* @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<TimeEntry> $timeEntries
*
* @method static MemberFactory factory()
*/
@@ -59,6 +62,14 @@ class Member extends JetstreamMembership implements AuditableContract
return $this->belongsTo(Organization::class, 'organization_id');
}
/**
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{
return $this->hasMany(TimeEntry::class, 'member_id');
}
/**
* @return HasMany<ProjectMember>
*/

View File

@@ -7,6 +7,7 @@ namespace App\Service;
use App\Enums\Role;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
@@ -75,6 +76,7 @@ class MemberService
* @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws OnlyOwnerCanChangeOwnership
* @throws OrganizationNeedsAtLeastOneOwner
* @throws ChangingRoleOfPlaceholderIsNotAllowed
*/
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
{
@@ -82,6 +84,9 @@ class MemberService
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($oldRole === Role::Placeholder) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}

View File

@@ -4,15 +4,18 @@ declare(strict_types=1);
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -39,6 +42,9 @@ return [
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -13,7 +13,7 @@ return [
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
],
'generic_project' => [
'generic_projects' => [
'name' => 'Generic Projects',
'description' => 'If you want to import many projects yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
],

View File

@@ -51,6 +51,7 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
Route::post('/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
Route::post('/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
Route::post('member/{member}/merge-into', [MemberController::class, 'mergeInto'])->name('merge-into');
});
// User routes

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console\Commands\Correction;
use App\Console\Commands\Correction\CorrectionPlaceholderMembersCommand;
use App\Enums\Role;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(CorrectionPlaceholderMembersCommand::class)]
#[UsesClass(CorrectionPlaceholderMembersCommand::class)]
class CorrectionPlaceholderMembersCommandTest extends TestCaseWithDatabase
{
public function test_sets_member_role_to_placeholder_if_user_is_placeholder(): void
{
// Arrange
$organization = Organization::factory()->create();
$user1 = User::factory()->placeholder()->create();
$member1 = Member::factory()->forOrganization($organization)->forUser($user1)->role(Role::Admin)->create();
$user2 = User::factory()->create();
$member2 = Member::factory()->forOrganization($organization)->forUser($user2)->role(Role::Admin)->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:placeholder-members');
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$member1->refresh();
$this->assertSame(Role::Placeholder->value, $member1->role);
$member2->refresh();
$this->assertSame(Role::Admin->value, $member2->role);
$this->assertSame("Sets all members who belong to a placeholder user to role placeholder...\n".
'Set role of member (id='.$member1->getKey().") to placeholder\n", $output);
}
public function test_sets_member_role_to_placeholder_if_user_is_placeholder_dry_run(): void
{
// Arrange
$organization = Organization::factory()->create();
$user1 = User::factory()->placeholder()->create();
$member1 = Member::factory()->forOrganization($organization)->forUser($user1)->role(Role::Admin)->create();
$user2 = User::factory()->create();
$member2 = Member::factory()->forOrganization($organization)->forUser($user2)->role(Role::Admin)->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:placeholder-members --dry-run');
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$member1->refresh();
$this->assertSame(Role::Admin->value, $member1->role);
$member2->refresh();
$this->assertSame(Role::Admin->value, $member2->role);
$this->assertSame("Sets all members who belong to a placeholder user to role placeholder...\n".
"Running in dry-run mode. Nothing will be saved to the database.\n".
'Set role of member (id='.$member1->getKey().") to placeholder\n", $output);
}
}

View File

@@ -194,6 +194,164 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('message', 'Only owner can change ownership');
}
public function test_update_member_fails_if_user_tries_to_change_the_role_of_a_placeholder(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:update',
]);
$user = User::factory()->placeholder()->create();
$member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Placeholder)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
'role' => Role::Admin->value,
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'changing_role_of_placeholder_is_not_allowed',
'message' => 'Changing role of placeholder is not allowed',
]);
}
public function test_merge_into_fails_if_url_member_is_not_part_of_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->role(Role::Placeholder)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertForbidden();
}
public function test_merge_into_returns_validation_error_if_member_in_body_does_not_belong_to_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertStatus(422);
$response->assertExactJson([
'errors' => [
'member_id' => [
'The resource does not exist.',
],
],
'message' => 'The resource does not exist.',
]);
}
public function test_merge_into_fails_if_from_member_is_not_a_placeholder(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Admin)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'only_placeholders_can_be_merged_into_another_member',
'message' => 'Only placeholders can be merged into another member',
]);
}
public function test_merge_into_fails_if_user_has_no_permission_to_merge_members(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertForbidden();
}
public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
TimeEntry::factory()->forMember($memberSource)->createMany(3);
$project = Project::factory()->forOrganization($data->organization)->create();
ProjectMember::factory()->forMember($memberSource)->forProject($project)->create();
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertStatus(204);
$this->assertSame('', $response->getContent());
$this->assertDatabaseMissing(Member::class, [
'id' => $memberSource->getKey(),
]);
$this->assertDatabaseMissing(User::class, [
'id' => $userSource->getKey(),
]);
$memberDestination->refresh();
$this->assertCount(3, $memberDestination->timeEntries);
$this->assertCount(1, $memberDestination->projectMembers);
}
public function test_update_member_fails_if_user_tries_to_change_role_of_the_current_owner(): void
{
// Arrange
@@ -281,6 +439,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
public function test_invite_placeholder_succeeds_if_data_is_valid(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:invite-placeholder',
]);
@@ -301,6 +460,38 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(204);
}
public function test_invite_placeholder_fails_if_the_placeholder_has_a_invalid_email_from_an_import(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:invite-placeholder',
]);
$user = User::factory()->create([
'is_placeholder' => true,
'email' => 'some.user@solidtime-import.test',
]);
$member = Member::factory()
->forUser($user)
->forOrganization($data->organization)
->role(Role::Placeholder)
->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
'organization' => $data->organization->getKey(),
'member' => $member->getKey(),
]));
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception',
'message' => 'This placeholder can not be invited use the merge tool instead',
]);
}
public function test_destroy_member_fails_if_user_has_no_permission_to_delete_members(): void
{
// Arrange
@@ -455,6 +646,34 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
Event::assertNotDispatched(MemberMadeToPlaceholder::class);
}
public function test_make_placeholder_fails_if_user_is_already_a_placeholder(): void
{
// Arrange
Event::fake([
MemberMadeToPlaceholder::class,
]);
$data = $this->createUserWithPermission([
'members:make-placeholder',
]);
$user = User::factory()->placeholder()->create();
$member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.make-placeholder', [
'organization' => $data->organization->getKey(),
'member' => $member->getKey(),
]));
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'changing_role_of_placeholder_is_not_allowed',
'message' => 'Changing role of placeholder is not allowed',
]);
}
public function test_make_placeholder_fails_if_member_is_owner(): void
{
// Arrange