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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ String 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)` |
Expand Down Expand Up @@ -307,6 +307,14 @@ lettermint.team().duplicateRole(role.id(), DuplicateTeamRoleData.builder().name(
lettermint.team().deleteRole(role.id(), new DeleteTeamRoleQuery(replacementRoleId));
```

`team().members().remove(userId)` removes a member from the team; the Owner can't be removed. `ListTeamMembersQuery.filterProjectId(...)` limits the member list to members who can access that project: members with access to all projects and members selected for it.

```java
for (TeamMemberData member : lettermint.team().members().iterate(ListTeamMembersQuery.builder().filterProjectId(projectId).build())) {
System.out.println(member.email());
}
```

Request and response types are immutable records in `co.lettermint.types`. Build requests with their builder (`UpdateRouteData.builder().name("Main").build()`) or, for small ones, the constructor (`new StoreDomainData("acme.com")`). In update requests, a field that is optional and nullable is an `OptionalNullable`: leave it unset to keep the current value, or pass `null` to the builder to clear it:

```java
Expand Down
25 changes: 24 additions & 1 deletion src/main/java/co/lettermint/TeamMembers.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import co.lettermint.types.CursorPage;
import co.lettermint.types.ListTeamMembersQuery;
import co.lettermint.types.MessageResponse;
import co.lettermint.types.Operations;
import co.lettermint.types.TeamMemberData;
import co.lettermint.types.UpdateTeamMemberAssignmentData;
Expand All @@ -24,7 +25,8 @@ public CursorPage<TeamMemberData> list() {
}

/**
* Lists team members, one page at a time.
* Lists team members, one page at a time. {@code filterProjectId} limits the list to members who
* can access that project.
*
* @param query the query parameters, or null
* @return the response
Expand Down Expand Up @@ -118,6 +120,27 @@ public TeamMemberData updateAssignment(String userId, UpdateTeamMemberAssignment
return transport.call(Operations.UPDATE_TEAM_MEMBER_ASSIGNMENT, Transport.call("team.members.updateAssignment").path(userId).body(body).options(options));
}

/**
* Removes a member from the team. The Owner can't be removed.
*
* @param userId the user id
* @return the response
*/
public MessageResponse remove(String userId) {
return remove(userId, null);
}

/**
* Removes a member from the team. The Owner can't be removed.
*
* @param userId the user id
* @param options per-call options, or null
* @return the response
*/
public MessageResponse remove(String userId, RequestOptions options) {
return transport.call(Operations.DELETE_TEAM_MEMBER, Transport.call("team.members.remove").path(userId).options(options));
}

@Override
public String toString() {
return "TeamMembers{}";
Expand Down
60 changes: 49 additions & 11 deletions src/test/java/co/lettermint/TeamApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import co.lettermint.types.GetStatsQuery;
import co.lettermint.types.ListDomainsQuery;
import co.lettermint.types.ListTeamInvitesQuery;
import co.lettermint.types.ListTeamMembersQuery;
import co.lettermint.types.ListWebhookDeliveriesQuery;
import co.lettermint.types.MessageData;
import co.lettermint.types.MessageResponse;
Expand Down Expand Up @@ -72,13 +73,6 @@
import org.junit.jupiter.api.Test;

class TeamApiTest {
/**
* Operations that have no method yet: they are added by the next PRs in this stack. Each of those
* PRs removes its operations from this list, and the last one removes the list.
*/
private static final Set<String> PENDING = Set.of(
"DELETE /team/members/{userId}");

private FakeServer server;
private Lettermint client;

Expand Down Expand Up @@ -203,6 +197,7 @@ void everyOperationIsReachable() {
() -> client.team().members().list(),
() -> client.team().members().iterate().forEach(item -> { }),
() -> client.team().members().retrieve("u1"),
() -> client.team().members().remove("u1"),
() -> client.team().members().updateAssignment("u1", new UpdateTeamMemberAssignmentData("role1",
new UpdateTeamMemberAssignmentDataProjectAccess(ProjectAccessScope.of("all"), null))),
() -> client.team().invites().list(),
Expand Down Expand Up @@ -232,10 +227,7 @@ void everyOperationIsReachable() {
for (FakeServer.Request request : server.requests()) {
reached.add(operationKey(request));
}
assertTrue(Operations.ALL.keySet().containsAll(PENDING), "every pending operation is generated");
Set<String> expected = new TreeSet<>(Operations.ALL.keySet());
expected.removeAll(PENDING);
assertEquals(expected, reached);
assertEquals(new TreeSet<>(Operations.ALL.keySet()), reached);
}

@Test
Expand Down Expand Up @@ -568,6 +560,52 @@ void teamRoleErrorsMapToApiExceptions() {
assertEquals(List.of("Choose a replacement role."), invalid.getErrors().get("replacement_role_id"));
}

@Test
void removesATeamMember() {
server.enqueue(FakeServer.Response.json(200, "{\"message\":\"Member removed.\"}"));
MessageResponse removed = client.team().members().remove("u 1");
assertTeamRequest("DELETE", "/v1/team/members/u%201");
assertEquals("", server.last().body());
assertEquals("Member removed.", removed.message());

server.enqueue(FakeServer.Response.json(200, "{\"message\":\"Member removed.\"}"));
client.team().members().remove("u1", RequestOptions.timeout(Duration.ofSeconds(5)));
assertTeamRequest("DELETE", "/v1/team/members/u1");

server.enqueue(FakeServer.Response.json(409, "{\"error\":{\"code\":\"owner_protected\",\"message\":\"The Owner cannot be removed.\"}}"));
ConflictException conflict = assertThrows(ConflictException.class, () -> client.team().members().remove("owner"));
assertEquals(409, conflict.getStatus());
assertEquals("owner_protected", conflict.getCode());
assertEquals("The Owner cannot be removed.", conflict.getMessage());

server.enqueue(FakeServer.Response.json(404, "{\"message\":\"Not found\"}"));
assertThrows(NotFoundException.class, () -> client.team().members().remove("u2"));

int before = server.requests().size();
assertThrows(LettermintConfigException.class, () -> client.team().members().remove(""));
assertThrows(LettermintConfigException.class, () -> client.team().members().remove(".."));
Lettermint sending = Lettermint.builder().sendingToken(ClientTest.SENDING).baseUrl(server.baseUrl()).build();
assertThrows(LettermintConfigException.class, () -> sending.team().members().remove("u1"));
assertEquals(before, server.requests().size());
}

@Test
void filtersTeamMembersByProject() {
server.enqueue(FakeServer.Response.json(200, page(null, "u1")));
client.team().members().list(ListTeamMembersQuery.builder().filterProjectId("p1").build());
assertTeamRequest("GET", "/v1/team/members");
assertEquals("filter%5Bproject_id%5D=p1", server.last().rawQuery());

server.enqueue(FakeServer.Response.json(200, page("c2", "u1")));
server.enqueue(FakeServer.Response.json(200, page(null, "u2")));
client.team().members().iterate(ListTeamMembersQuery.builder().pageSize(1).filterProjectId("p1").build()).forEach(item -> { });
assertEquals("page%5Bsize%5D=1&page%5Bcursor%5D=c2&filter%5Bproject_id%5D=p1", server.last().rawQuery());

server.enqueue(FakeServer.Response.json(200, page(null)));
assertTrue(client.team().members().list().data().isEmpty());
assertNull(server.last().rawQuery());
}

@Test
void updatesSendExplicitNulls() {
client.routes().update("r1", UpdateRouteData.builder().inboundDomain(null).name("Main").build());
Expand Down
Loading