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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,12 +277,14 @@ $html = $lettermint->messages->html('message-id');
| `stats` | `retrieve` |
| `suppressions` | `list`, `iterate`, `create`, `delete` |
| `team` | `retrieve`, `update`, `usage`, `roles`, `retrieveRole`, `createRole`, `updateRole`, `deleteRole`, `duplicateRole` |
| `team->members` | `list`, `iterate`, `retrieve`, `updateAssignment` |
| `team->members` | `list`, `iterate`, `retrieve`, `updateAssignment`, `remove` |
| `team->invites` | `list`, `iterate`, `retrieve`, `create`, `update`, `revoke` |
| `webhooks` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerateSecret` |
| `webhooks->deliveries` | `list($webhookId)`, `iterate($webhookId)`, `retrieve($webhookId, $deliveryId)`, `retry($webhookId, $deliveryId)` |
| (root) | `ping`, `analytics`, `analyticsPages`, `blockedFileTypes` |

`team->members->list()` and `iterate()` take `['filter' => ['project_id' => …]]` to return only the members who can access that project: members with access to all projects and members selected for it. `team->members->remove()` removes a member from the team; the Owner cannot be removed.

`team->invites->create()` sends the invitation email; the team needs a free seat, and the Owner role cannot be assigned. `team->invites->update()` replaces the email, role and project access of a pending invite and sends a new email only when the address changes:

```php
Expand Down
14 changes: 12 additions & 2 deletions src/Resources/TeamMembers.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Generator;
use Lettermint\Types\ListTeamMembersResponse;
use Lettermint\Types\MessageResponse;
use Lettermint\Types\TeamMemberData;

/**
Expand All @@ -17,9 +18,10 @@
final class TeamMembers extends Resource
{
/**
* Lists team members, one page at a time.
* Lists team members, one page at a time. `filter[project_id]` returns only
* the members who can access that project.
*
* @param ListTeamMembersQuery $query
* @param ListTeamMembersQuery $query For example `['filter' => ['project_id' => 'project-id']]`.
*/
public function list(array $query = []): ListTeamMembersResponse
{
Expand Down Expand Up @@ -51,4 +53,12 @@ public function updateAssignment(string $userId, array $body): TeamMemberData
{
return $this->transport->object(TeamMemberData::class, 'PUT /team/members/{userId}/assignment', 'team.members.updateAssignment', ['userId' => $userId], json: $body);
}

/**
* Removes a member from the team. The Owner cannot be removed.
*/
public function remove(string $userId): MessageResponse
{
return $this->transport->object(MessageResponse::class, 'DELETE /team/members/{userId}', 'team.members.remove', ['userId' => $userId]);
}
}
80 changes: 60 additions & 20 deletions tests/TeamApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Lettermint\Exceptions\ApiException;
use Lettermint\Exceptions\ConflictException;
use Lettermint\Exceptions\LettermintConfigException;
use Lettermint\Exceptions\NotFoundException;
use Lettermint\Exceptions\UnexpectedResponseException;
use Lettermint\Exceptions\ValidationException;
use Lettermint\Lettermint;
Expand Down Expand Up @@ -65,6 +66,7 @@ function operationCalls(): array
'DELETE /routes/{routeId}' => fn (Lettermint $l) => $l->routes->delete($ids['routeId']),
'DELETE /suppressions/{suppressionId}' => fn (Lettermint $l) => $l->suppressions->delete($ids['suppressionId']),
'DELETE /team/invites/{inviteId}' => fn (Lettermint $l) => $l->team->invites->revoke($ids['inviteId']),
'DELETE /team/members/{userId}' => fn (Lettermint $l) => $l->team->members->remove($ids['userId']),
'DELETE /team/roles/{roleId}' => fn (Lettermint $l) => $l->team->deleteRole($ids['roleId']),
'DELETE /webhooks/{webhookId}' => fn (Lettermint $l) => $l->webhooks->delete($ids['webhookId']),
'GET /blocked-file-types' => fn (Lettermint $l) => $l->blockedFileTypes(),
Expand Down Expand Up @@ -132,22 +134,6 @@ function operationCalls(): array
];
}

/**
* Operations in the generated table whose SDK methods are added by the next
* PR in this stack (member removal), which removes this list.
*/
const PENDING_OPERATIONS = [
'DELETE /team/members/{userId}',
];

/**
* @return list<string>
*/
function coveredOperations(): array
{
return array_values(array_diff(array_keys(Operations::ALL), PENDING_OPERATIONS));
}

function responseFor(string $key): Response
{
$response = Operations::ALL[$key]['response'];
Expand All @@ -167,12 +153,11 @@ function responseFor(string $key): Response
describe('operation coverage', function () {
it('has an SDK method for every operation in the generated table', function () {
$calls = array_keys(operationCalls());
$operations = coveredOperations();
$operations = array_keys(Operations::ALL);
sort($calls);
sort($operations);
expect($calls)->toBe($operations)
->and(Operations::ALL)->toHaveCount(71)
->and(array_diff(PENDING_OPERATIONS, array_keys(Operations::ALL)))->toBe([]);
->and($operations)->toHaveCount(71);
});

it('calls the operation with the right method, path, token and result type', function (string $key) {
Expand Down Expand Up @@ -206,7 +191,7 @@ function responseFor(string $key): Response
default => expect($result)->toBeInstanceOf(ApiObject::class)
->and(get_class($result))->toBe('Lettermint\\Types\\'.$type),
};
})->with(coveredOperations());
})->with(array_keys(Operations::ALL));
});

describe('request bodies and options', function () {
Expand Down Expand Up @@ -566,6 +551,61 @@ function responseFor(string $key): Response
})->with(['', '.', '..']);
});

describe('team members', function () {
it('removes a member', function () {
[$lettermint, $api] = client(fn () => json(200, ['message' => 'The member was removed.']));
$result = $lettermint->team->members->remove('user/id');
expect($result)->toBeInstanceOf(MessageResponse::class)
->and($result->message)->toBe('The member was removed.')
->and($api->last()->method)->toBe('DELETE')
->and($api->last()->url)->toBe(BASE_URL.'/team/members/user%2Fid')
->and($api->last()->headers['authorization'])->toBe('Bearer '.TEAM_TOKEN)
->and($api->last()->headers)->not->toHaveKey('x-lettermint-token')
->and($api->last()->headers)->not->toHaveKey('content-type')
->and($api->last()->rawBody)->toBe('');
});

it('throws a ConflictException when the Owner is removed', function () {
[$lettermint] = client(fn () => json(409, ['error' => ['code' => 'owner_protected', 'message' => 'The Owner cannot be removed.']]));
$error = thrown(fn () => $lettermint->team->members->remove('owner'));
expect($error::class)->toBe(ConflictException::class)
->and($error->status)->toBe(409)
->and($error->errorCode)->toBe(RbacConflictCode::OWNER_PROTECTED)
->and($error->getMessage())->toBe('The Owner cannot be removed.');
});

it('throws a NotFoundException for an unknown member', function () {
[$lettermint] = client(fn () => json(404, ['message' => 'Not found.']));
expect(thrown(fn () => $lettermint->team->members->remove('nobody')))->toBeInstanceOf(NotFoundException::class);
});

it('rejects an empty user ID before any request', function (string $id) {
[$lettermint, $api] = client();
expect(fn () => $lettermint->team->members->remove($id))
->toThrow(LettermintConfigException::class, 'team.members.remove: userId must be a non-empty string other than "." and "..".')
->and($api->requests)->toBeEmpty();
})->with(['', '.', '..']);

it('lists only the members who can access a project', function () {
$pages = [
'first' => ['data' => [['id' => 'u1']], 'next_cursor' => 'c2'],
'c2' => ['data' => [['id' => 'u2']], 'next_cursor' => null],
];
[$lettermint, $api] = client(fn (RecordedRequest $request) => json(200, $pages[$request->query['page[cursor]'] ?? 'first']));
$page = $lettermint->team->members->list(['filter' => ['project_id' => 'project/1'], 'page' => ['size' => 1]]);
expect($page->data[0])->toBeInstanceOf(TeamMemberData::class)
->and($api->last()->url)->toBe(BASE_URL.'/team/members?filter%5Bproject_id%5D=project%2F1&page%5Bsize%5D=1')
->and($api->last()->query)->toBe(['filter[project_id]' => 'project/1', 'page[size]' => '1']);

$members = iterator_to_array($lettermint->team->members->iterate(['filter' => ['project_id' => 'project/1']]), false);
expect(array_map(fn (TeamMemberData $member): string => $member->id, $members))->toBe(['u1', 'u2'])
->and(array_map(fn (RecordedRequest $r): array => $r->query, array_slice($api->requests, 1)))->toBe([
['filter[project_id]' => 'project/1'],
['filter[project_id]' => 'project/1', 'page[cursor]' => 'c2'],
]);
});
});

describe('query parameters', function () {
it('serializes nested arrays to the bracket syntax', function () {
[$lettermint, $api] = client(fn () => json(200, ['data' => [], 'next_cursor' => null]));
Expand Down
2 changes: 2 additions & 0 deletions tests/phpstan/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,7 @@ function lettermintTypeAssertions(Lettermint $lettermint): void
assertType('string|null', $role->description);
assertType(TeamRoleData::class, $lettermint->team->duplicateRole('r', ['name' => 'Copy']));
assertType('string', $lettermint->team->deleteRole('r', ['replacement_role_id' => 'r2'])->message);
assertType('string', $lettermint->team->members->remove('u')->message);
assertType('list<Lettermint\Types\TeamMemberData>', $lettermint->team->members->list(['filter' => ['project_id' => 'p']])->data);
assertType('string', (new Webhook('whsec_x'))->verify('{}', [])->event);
}
Loading