From 5d953f9db998a38f113385359b401ee733dcece7 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 30 Oct 2026 21:04:11 +0100 Subject: [PATCH 1/2] feat(party): let players ask to join and open parties to everyone A player outside a party can ask the party of an online player to let them in. A private party keeps the request for its leader, who accepts or rejects it; a request expires after its TTL, and a denial or an expiry starts a cooldown. A leader can open the party to everyone. While it is open a player joins directly, up to the public size limit. A kicked player cannot join or request the party until the leader invites them again. Bans, open requests and cooldowns end with the party. A change of leader closes an open party, and a closed party keeps its members. party.leader-changed carries wasPublic when the change closed the party. --- .../quelea/backend/api/JoinAnswerRequest.java | 13 + .../quelea/backend/api/PartyController.java | 68 +++++ .../quelea/backend/api/PlayerController.java | 53 ++++ .../backend/api/PublicPartyRequest.java | 12 + .../quelea/backend/api/TargetNameRequest.java | 10 + .../quelea/backend/event/PartyEventType.java | 4 +- .../quelea/backend/party/JoinOutcome.java | 13 + .../quelea/backend/party/JoinRequest.java | 11 + .../quelea/backend/party/Party.java | 26 +- .../quelea/backend/party/PartyService.java | 205 ++++++++++++- .../quelea/backend/party/PartySettings.java | 35 ++- .../quelea/backend/party/port/PartyStore.java | 29 ++ .../backend/problem/PartyException.java | 19 +- .../problem/PartyExceptionHandler.java | 6 +- .../quelea/backend/problem/PartyProblem.java | 6 + .../quelea/backend/store/redis/RedisKeys.java | 20 ++ .../backend/store/redis/RedisPartyStore.java | 67 ++++ backend/src/main/resources/application.yml | 6 + .../api/JoinRequestsControllerTest.java | 145 +++++++++ .../quelea/backend/party/JoinRequestTest.java | 244 +++++++++++++++ .../quelea/backend/party/KickBanTest.java | 82 +++++ .../backend/party/PartyPublicJsonTest.java | 40 +++ .../quelea/backend/party/PublicPartyTest.java | 288 ++++++++++++++++++ .../redis/RedisJoinRequestStoreTest.java | 124 ++++++++ .../quelea/backend/support/ApiClient.java | 5 +- .../backend/support/InMemoryPartyStore.java | 83 +++++ docs/problems.md | 42 +++ 27 files changed, 1639 insertions(+), 17 deletions(-) create mode 100644 backend/src/main/java/net/onelitefeather/quelea/backend/api/JoinAnswerRequest.java create mode 100644 backend/src/main/java/net/onelitefeather/quelea/backend/api/PublicPartyRequest.java create mode 100644 backend/src/main/java/net/onelitefeather/quelea/backend/api/TargetNameRequest.java create mode 100644 backend/src/main/java/net/onelitefeather/quelea/backend/party/JoinOutcome.java create mode 100644 backend/src/main/java/net/onelitefeather/quelea/backend/party/JoinRequest.java create mode 100644 backend/src/test/java/net/onelitefeather/quelea/backend/api/JoinRequestsControllerTest.java create mode 100644 backend/src/test/java/net/onelitefeather/quelea/backend/party/JoinRequestTest.java create mode 100644 backend/src/test/java/net/onelitefeather/quelea/backend/party/KickBanTest.java create mode 100644 backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyPublicJsonTest.java create mode 100644 backend/src/test/java/net/onelitefeather/quelea/backend/party/PublicPartyTest.java create mode 100644 backend/src/test/java/net/onelitefeather/quelea/backend/store/redis/RedisJoinRequestStoreTest.java diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/api/JoinAnswerRequest.java b/backend/src/main/java/net/onelitefeather/quelea/backend/api/JoinAnswerRequest.java new file mode 100644 index 0000000..a1d2742 --- /dev/null +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/api/JoinAnswerRequest.java @@ -0,0 +1,13 @@ +package net.onelitefeather.quelea.backend.api; + +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.UUID; + +/** The leader's answer to a join request, naming the requester as the backend resolves names. */ +@Serdeable +public record JoinAnswerRequest(@NotNull UUID actorId, @NotBlank @Size(max = 16) String requesterName) { +} diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/api/PartyController.java b/backend/src/main/java/net/onelitefeather/quelea/backend/api/PartyController.java index 0ddf6a0..c927907 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/api/PartyController.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/api/PartyController.java @@ -197,6 +197,74 @@ public void warp(@PathVariable UUID partyId, @Body @Valid WarpRequest request, new Precondition(ifMatch))); } + @Post("/{partyId}/public") + @Operation(operationId = "setPublic", summary = "Open or close the party to everyone (leader only). Opening needs the proxy permission; closing does not") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "The party after the change", + headers = @io.swagger.v3.oas.annotations.headers.Header(name = HttpHeaders.ETAG, description = ETAG_DESCRIPTION, + schema = @Schema(type = "string")), + content = @Content(schema = @Schema(implementation = Party.class))), + @ApiResponse(responseCode = "400", description = "Invalid body", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "403", description = "Not the leader", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "404", description = "The party does not exist", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "412", description = "If-Match names another version; the party is unchanged", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))) + }) + public HttpResponse setPublic(@PathVariable UUID partyId, @Body @Valid PublicPartyRequest request, + @Parameter(in = ParameterIn.HEADER, name = HttpHeaders.IF_MATCH, description = IF_MATCH_DESCRIPTION) + @Nullable @Header(HttpHeaders.IF_MATCH) String ifMatch) { + return metrics.record("public", () -> PartyResponses.ok( + parties.setPublic(partyId, request.actorId(), request.open(), new Precondition(ifMatch)))); + } + + @Post("/{partyId}/join-requests/accept") + @Operation(operationId = "acceptJoinRequest", summary = "Accept a player's join request (leader only)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "The party after the join", + headers = @io.swagger.v3.oas.annotations.headers.Header(name = HttpHeaders.ETAG, description = ETAG_DESCRIPTION, + schema = @Schema(type = "string")), + content = @Content(schema = @Schema(implementation = Party.class))), + @ApiResponse(responseCode = "400", description = "Invalid body", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "403", description = "Not the leader, or the player is banned", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "404", description = "The party, the player or the open request does not exist", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "409", description = "The player is in a party or the party is full", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "412", description = "If-Match names another version; the party is unchanged", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))) + }) + public HttpResponse acceptJoinRequest(@PathVariable UUID partyId, @Body @Valid JoinAnswerRequest request, + @Parameter(in = ParameterIn.HEADER, name = HttpHeaders.IF_MATCH, description = IF_MATCH_DESCRIPTION) + @Nullable @Header(HttpHeaders.IF_MATCH) String ifMatch) { + return metrics.record("accept", () -> PartyResponses.ok( + parties.acceptRequest(partyId, request.actorId(), request.requesterName(), new Precondition(ifMatch)))); + } + + @Post("/{partyId}/join-requests/reject") + @Status(HttpStatus.NO_CONTENT) + @Operation(operationId = "rejectJoinRequest", summary = "Reject a player's join request (leader only); starts the player's cooldown") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "The request was rejected"), + @ApiResponse(responseCode = "400", description = "Invalid body", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "403", description = "Not the leader", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "404", description = "The party, the player or the open request does not exist", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "412", description = "If-Match names another version; the request is unchanged", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))) + }) + public void rejectJoinRequest(@PathVariable UUID partyId, @Body @Valid JoinAnswerRequest request, + @Parameter(in = ParameterIn.HEADER, name = HttpHeaders.IF_MATCH, description = IF_MATCH_DESCRIPTION) + @Nullable @Header(HttpHeaders.IF_MATCH) String ifMatch) { + metrics.run("reject", () -> parties.rejectRequest(partyId, request.actorId(), request.requesterName(), new Precondition(ifMatch))); + } + @Post("/{partyId}/chat") @Status(HttpStatus.ACCEPTED) @Operation(operationId = "chat", summary = "Send a chat message to the party (members only)") diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/api/PlayerController.java b/backend/src/main/java/net/onelitefeather/quelea/backend/api/PlayerController.java index 3bfe37b..e17fd55 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/api/PlayerController.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/api/PlayerController.java @@ -6,6 +6,7 @@ import io.micronaut.http.HttpHeaders; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Body; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; import io.micronaut.http.annotation.Header; @@ -14,6 +15,7 @@ import io.micronaut.http.annotation.Status; import io.micronaut.security.annotation.Secured; import io.micronaut.validation.Validated; +import jakarta.validation.Valid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -24,6 +26,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import net.onelitefeather.quelea.backend.party.Invite; +import net.onelitefeather.quelea.backend.party.JoinOutcome; +import net.onelitefeather.quelea.backend.party.JoinRequest; import net.onelitefeather.quelea.backend.party.Party; import net.onelitefeather.quelea.backend.party.PartyService; import net.onelitefeather.quelea.backend.problem.Problem; @@ -108,6 +112,55 @@ public void deny(@PathVariable UUID playerId, @PathVariable UUID partyId) { metrics.run("deny", () -> parties.deny(playerId, partyId)); } + @Post("/{playerId}/party/join") + @Operation(operationId = "joinParty", summary = "Join the public party of an online player by name") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "The party after the join", + headers = @io.swagger.v3.oas.annotations.headers.Header(name = HttpHeaders.ETAG, + description = "Strong entity tag of the party: its version, for example \"4\"", + schema = @Schema(type = "string")), + content = @Content(schema = @Schema(implementation = Party.class))), + @ApiResponse(responseCode = "403", description = "The player was removed from the party", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "404", description = "The player is offline, the target is not in a party, or the player is not online", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "409", description = "The player is in a party, the party is not public, or it is full", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))) + }) + public HttpResponse join(@PathVariable UUID playerId, @Body @Valid TargetNameRequest request) { + return metrics.record("join", () -> PartyResponses.ok(parties.join(playerId, request.targetName()))); + } + + @Post("/{playerId}/party/requests") + @Operation(operationId = "requestJoin", summary = "Ask the party of an online player to let you in; a public party admits you at once") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "The party is public: the player joined it directly", + headers = @io.swagger.v3.oas.annotations.headers.Header(name = HttpHeaders.ETAG, + description = "Strong entity tag of the party: its version, for example \"4\"", + schema = @Schema(type = "string")), + content = @Content(schema = @Schema(implementation = Party.class))), + @ApiResponse(responseCode = "201", description = "The party is private: the request is open until its expiry", + content = @Content(schema = @Schema(implementation = JoinRequest.class))), + @ApiResponse(responseCode = "403", description = "The player was removed from the party", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "404", description = "The player is offline, or the target is not in a party", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "409", description = "The player is in a party, a request is already open, or the party is full", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))), + @ApiResponse(responseCode = "429", description = "The player asked too recently; Retry-After gives the seconds left", + content = @Content(mediaType = PROBLEM, schema = @Schema(implementation = Problem.class))) + }) + public HttpResponse requestJoin(@PathVariable UUID playerId, @Body @Valid TargetNameRequest request) { + return metrics.record("request", () -> joinOutcome(parties.requestJoin(playerId, request.targetName()))); + } + + private static HttpResponse joinOutcome(JoinOutcome outcome) { + return switch (outcome) { + case JoinOutcome.Joined joined -> PartyResponses.ok(joined.party()); + case JoinOutcome.Requested requested -> HttpResponse.created(requested.request()); + }; + } + @Post("/{playerId}/party/leave") @Status(HttpStatus.NO_CONTENT) @Operation(operationId = "leaveParty", summary = "Leave the party the player is in") diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/api/PublicPartyRequest.java b/backend/src/main/java/net/onelitefeather/quelea/backend/api/PublicPartyRequest.java new file mode 100644 index 0000000..7ec4843 --- /dev/null +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/api/PublicPartyRequest.java @@ -0,0 +1,12 @@ +package net.onelitefeather.quelea.backend.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +/** The leader's open or close command; the JSON key of the flag is {@code public}. */ +@Serdeable +public record PublicPartyRequest(@NotNull UUID actorId, @JsonProperty("public") boolean open) { +} diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/api/TargetNameRequest.java b/backend/src/main/java/net/onelitefeather/quelea/backend/api/TargetNameRequest.java new file mode 100644 index 0000000..870587f --- /dev/null +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/api/TargetNameRequest.java @@ -0,0 +1,10 @@ +package net.onelitefeather.quelea.backend.api; + +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** A player's join or request, naming a player whose party is the target. */ +@Serdeable +public record TargetNameRequest(@NotBlank @Size(max = 16) String targetName) { +} diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/event/PartyEventType.java b/backend/src/main/java/net/onelitefeather/quelea/backend/event/PartyEventType.java index 5823350..33aed3e 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/event/PartyEventType.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/event/PartyEventType.java @@ -11,7 +11,9 @@ public enum PartyEventType { DISBANDED("party.disbanded"), EXPIRING("party.expiring"), WARP("party.warp"), - CHAT("party.chat"); + CHAT("party.chat"), + JOIN_REQUESTED("party.join-requested"), + JOIN_REQUEST_ANSWERED("party.join-request-answered"); private final String wireName; diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/party/JoinOutcome.java b/backend/src/main/java/net/onelitefeather/quelea/backend/party/JoinOutcome.java new file mode 100644 index 0000000..516dedf --- /dev/null +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/party/JoinOutcome.java @@ -0,0 +1,13 @@ +package net.onelitefeather.quelea.backend.party; + +/** What a player's request to join a party led to: a direct join into a public party, or an open request to a private one. */ +public sealed interface JoinOutcome permits JoinOutcome.Joined, JoinOutcome.Requested { + + /** The player joined directly, because the party is public. */ + record Joined(Party party) implements JoinOutcome { + } + + /** The request is open until its expiry and waits for the leader's answer. */ + record Requested(JoinRequest request) implements JoinOutcome { + } +} diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/party/JoinRequest.java b/backend/src/main/java/net/onelitefeather/quelea/backend/party/JoinRequest.java new file mode 100644 index 0000000..a09957b --- /dev/null +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/party/JoinRequest.java @@ -0,0 +1,11 @@ +package net.onelitefeather.quelea.backend.party; + +import io.micronaut.serde.annotation.Serdeable; + +import java.time.Instant; +import java.util.UUID; + +/** A player's open request to join a private party. It lives in the store until it is answered or expires. */ +@Serdeable +public record JoinRequest(UUID partyId, UUID requesterId, String requesterName, Instant expiresAt) { +} diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/party/Party.java b/backend/src/main/java/net/onelitefeather/quelea/backend/party/Party.java index 70ecff1..43925e4 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/party/Party.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/party/Party.java @@ -1,5 +1,6 @@ package net.onelitefeather.quelea.backend.party; +import com.fasterxml.jackson.annotation.JsonProperty; import io.micronaut.serde.annotation.Serdeable; import java.time.Instant; @@ -12,17 +13,25 @@ * created and grows by one with every change of the party's state; it is the basis of the HTTP entity tag. * {@code aloneSince} is the start of the current alone period (one member), or {@code null} when the party has two or * more members or was stored before the field existed; {@link #aloneStart()} resolves the null case to the creation. + * {@code publicParty} is the open flag, serialised as {@code public}: a public party admits online players without a + * request. It is cleared whenever the leader changes, and reads as false in parties stored before it existed. */ @Serdeable -public record Party(UUID id, UUID leaderId, List members, Instant createdAt, long version, Instant aloneSince) { +public record Party(UUID id, UUID leaderId, List members, Instant createdAt, long version, Instant aloneSince, + @JsonProperty("public") boolean publicParty) { public Party { members = List.copyOf(members); } - /** A party without an alone-since time, as before the field existed. */ + /** A private party without an alone-since time, as before the fields existed. */ public Party(UUID id, UUID leaderId, List members, Instant createdAt, long version) { - this(id, leaderId, members, createdAt, version, null); + this(id, leaderId, members, createdAt, version, null, false); + } + + /** A private party with the given alone-since time. */ + public Party(UUID id, UUID leaderId, List members, Instant createdAt, long version, Instant aloneSince) { + this(id, leaderId, members, createdAt, version, aloneSince, false); } /** When the current alone period began: the stored alone-since time, or the creation for a legacy party. */ @@ -44,11 +53,16 @@ public boolean isLeader(UUID playerId) { /** The party after a join: the next version, with no alone period (a join ends the one before). */ public Party withMembers(List newMembers) { - return new Party(id, leaderId, newMembers, createdAt, version + 1, null); + return new Party(id, leaderId, newMembers, createdAt, version + 1, null, publicParty); } - /** The party after a change of its leader: the next version. */ + /** The party after a change of its leader: the next version. A change of leader closes the party. */ public Party withLeader(UUID newLeaderId) { - return new Party(id, newLeaderId, members, createdAt, version + 1, aloneSince); + return new Party(id, newLeaderId, members, createdAt, version + 1, aloneSince, false); + } + + /** The party after the leader opened or closed it: the next version. */ + public Party withPublic(boolean open) { + return new Party(id, leaderId, members, createdAt, version + 1, aloneSince, open); } } diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartyService.java b/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartyService.java index b244b7c..e23a4b1 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartyService.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartyService.java @@ -107,6 +107,8 @@ public Invite invite(UUID actorId, String targetName) { throw new PartyException(PartyProblem.ALREADY_IN_PARTY, target.name() + " is already in a party."); } ensureRoom(party); + // The leader's explicit invite lifts a kick ban, at the moment the invite is created. + parties.unban(partyId, target.id()); Duration ttl = settings.getInviteTtl(); Invite invite = new Invite(partyId, actorId, actor.name(), target.id(), target.name(), clock.instant().plus(ttl)); parties.saveInvite(invite, ttl); @@ -158,6 +160,135 @@ public void deny(UUID playerId, UUID partyId) { Map.of("playerId", playerId, "playerName", invite.targetName())); } + /** + * A player asks to join the party of {@code targetName}. A public party admits the player at once; a private one + * records a request that expires after its TTL, and the leader answers it. A denial or an expiry starts a cooldown. + */ + public JoinOutcome requestJoin(UUID requesterId, String targetName) { + PlayerPresence requester = onlinePlayer(requesterId); + requireNotInParty(requesterId); + UUID partyId = targetParty(requesterId, targetName); + return locked(partyId, () -> { + Party party = require(partyId); + requireNotInParty(requesterId); + if (parties.isBanned(partyId, requesterId)) { + throw new PartyException(PartyProblem.PARTY_BANNED, "The player was removed from this party."); + } + if (party.publicParty()) { + return new JoinOutcome.Joined(admit(party, requesterId, requester.name())); + } + // The pending check comes first: the cooldown key also exists while the request is open. + if (parties.findJoinRequest(partyId, requesterId).isPresent()) { + throw new PartyException(PartyProblem.JOIN_REQUEST_PENDING, "A join request to this party is already open."); + } + Duration cooldown = parties.cooldownLeft(partyId, requesterId); + if (!cooldown.isZero()) { + throw new PartyException(PartyProblem.JOIN_REQUEST_COOLDOWN, + "The player may ask this party again after the cooldown.", wholeSeconds(cooldown)); + } + ensureRoom(party); + Duration ttl = settings.getJoinRequestTtl(); + Instant expiresAt = clock.instant().plus(ttl); + JoinRequest request = new JoinRequest(partyId, requesterId, requester.name(), expiresAt); + parties.saveJoinRequest(request, ttl); + // The cooldown outlives the request: while it is pending it is the duplicate check, after an expiry it holds + // the player back until exactly the cooldown has passed since the expiry. + parties.setCooldown(partyId, requesterId, ttl.plus(settings.getJoinRequestCooldown())); + publish(PartyEventType.JOIN_REQUESTED, party, Map.of( + "requesterId", requesterId, + "requesterName", requester.name(), + "expiresAt", expiresAt)); + return new JoinOutcome.Requested(request); + }); + } + + /** Joins {@code targetName}'s party directly. Only a public party admits a player without a request. */ + public Party join(UUID playerId, String targetName) { + PlayerPresence joiner = onlinePlayer(playerId); + requireNotInParty(playerId); + UUID partyId = targetParty(playerId, targetName); + return locked(partyId, () -> { + Party party = require(partyId); + if (!party.publicParty()) { + throw new PartyException(PartyProblem.PARTY_NOT_PUBLIC, "The party is not public; ask to join with /party request."); + } + requireNotInParty(playerId); + if (parties.isBanned(partyId, playerId)) { + throw new PartyException(PartyProblem.PARTY_BANNED, "The player was removed from this party."); + } + return admit(party, playerId, joiner.name()); + }); + } + + /** Leader only. Accepts the open request of {@code requesterName}, which joins them like an invite does. */ + public Party acceptRequest(UUID partyId, UUID actorId, String requesterName) { + return acceptRequest(partyId, actorId, requesterName, Precondition.NONE); + } + + public Party acceptRequest(UUID partyId, UUID actorId, String requesterName, Precondition precondition) { + return locked(partyId, () -> { + Party party = require(partyId); + precondition.check(party); + requireLeader(party, actorId); + UUID requesterId = presenceOfName(requesterName).id(); + JoinRequest request = parties.findJoinRequest(partyId, requesterId) + .orElseThrow(() -> new PartyException(PartyProblem.JOIN_REQUEST_NOT_FOUND, "There is no open request from that player.")); + requireNotInParty(requesterId); + if (parties.isBanned(partyId, requesterId)) { + throw new PartyException(PartyProblem.PARTY_BANNED, "The player was removed from this party."); + } + Party joined = admit(party, requesterId, request.requesterName()); + parties.deleteJoinRequest(partyId, requesterId); + parties.clearCooldown(partyId, requesterId); + publish(PartyEventType.JOIN_REQUEST_ANSWERED, joined, Map.of( + "requesterId", requesterId, + "requesterName", request.requesterName(), + "outcome", "accepted")); + return joined; + }); + } + + /** Leader only. Denies the open request of {@code requesterName} and starts the requester's cooldown. */ + public void rejectRequest(UUID partyId, UUID actorId, String requesterName) { + rejectRequest(partyId, actorId, requesterName, Precondition.NONE); + } + + public void rejectRequest(UUID partyId, UUID actorId, String requesterName, Precondition precondition) { + locked(partyId, () -> { + Party party = require(partyId); + precondition.check(party); + requireLeader(party, actorId); + UUID requesterId = presenceOfName(requesterName).id(); + JoinRequest request = parties.findJoinRequest(partyId, requesterId) + .orElseThrow(() -> new PartyException(PartyProblem.JOIN_REQUEST_NOT_FOUND, "There is no open request from that player.")); + parties.deleteJoinRequest(partyId, requesterId); + parties.setCooldown(partyId, requesterId, settings.getJoinRequestCooldown()); + publish(PartyEventType.JOIN_REQUEST_ANSWERED, party, Map.of( + "requesterId", requesterId, + "requesterName", request.requesterName(), + "outcome", "denied")); + return null; + }); + } + + /** + * Leader only. Opens or closes the party. Closing needs no permission; the proxy checks it for opening only. A change + * that does not alter the flag changes nothing and keeps the version. + */ + public Party setPublic(UUID partyId, UUID actorId, boolean open, Precondition precondition) { + return locked(partyId, () -> { + Party party = require(partyId); + precondition.check(party); + requireLeader(party, actorId); + if (party.publicParty() == open) { + return party; + } + Party changed = party.withPublic(open); + parties.save(changed); + return changed; + }); + } + public void leave(UUID playerId, String reason) { UUID partyId = parties.partyOf(playerId) .orElseThrow(() -> new PartyException(PartyProblem.NOT_IN_PARTY, "The player is not in a party.")); @@ -189,8 +320,11 @@ public Party kick(UUID partyId, UUID actorId, UUID targetId, Precondition precon } Member target = party.member(targetId) .orElseThrow(() -> new PartyException(PartyProblem.NOT_MEMBER, "The player is not a member of this party.")); - return removeMember(party, target, PartyEventType.KICKED, + Party after = removeMember(party, target, PartyEventType.KICKED, Map.of("playerId", target.id(), "playerName", target.name())); + // Ban the kicked player in the same locked command; the ban ends with the party. + parties.ban(partyId, target.id()); + return after; }); } @@ -211,7 +345,8 @@ public Party promote(UUID partyId, UUID actorId, UUID targetId, Precondition pre } Party promoted = party.withLeader(targetId); parties.save(promoted); - publish(PartyEventType.LEADER_CHANGED, promoted, Map.of("previousLeaderId", actorId, "leaderId", targetId)); + publish(PartyEventType.LEADER_CHANGED, promoted, + leaderChangedPayload(actorId, targetId, party.publicParty())); return promoted; }); } @@ -316,7 +451,9 @@ private Party removeMember(Party party, Member gone, PartyEventType type, Map stillBound, l parties.releasePlayer(member.id(), party.id()); } parties.clearAlone(party.id()); + // Open requests, cooldowns and kick bans belong to the party and go with it. + parties.deleteRequestsOf(party.id()); + parties.deleteBans(party.id()); parties.delete(party.id()); statistics.disbanded(reason, Duration.between(party.createdAt(), clock.instant())); List> formerMembers = new ArrayList<>(); @@ -363,6 +503,59 @@ private void markAlone(Party party) { parties.markAlone(party.id(), expireAt, warnAt); } + /** Adds a player to a party under its lock: the room check, the player's binding, the new version and the event. */ + private Party admit(Party party, UUID playerId, String name) { + ensureRoom(party); + if (!parties.claimPlayer(playerId, party.id())) { + throw new PartyException(PartyProblem.ALREADY_IN_PARTY, "The player is already in a party."); + } + List members = new ArrayList<>(party.members()); + members.add(new Member(playerId, name, clock.instant())); + Party joined = party.withMembers(members); + parties.save(joined); + parties.clearAlone(party.id()); + statistics.joined(joined.members().size()); + publish(PartyEventType.JOINED, joined, Map.of("playerId", playerId, "playerName", name)); + return joined; + } + + /** Resolves the target by name and returns the party the target is in. The requester must not be the target. */ + private UUID targetParty(UUID requesterId, String targetName) { + PlayerPresence target = presenceOfName(targetName); + if (target.id().equals(requesterId)) { + throw new PartyException(PartyProblem.CANNOT_TARGET_SELF); + } + return parties.partyOf(target.id()) + .orElseThrow(() -> new PartyException(PartyProblem.TARGET_NOT_IN_PARTY, target.name() + " is not in a party.")); + } + + private PlayerPresence presenceOfName(String name) { + return presence.findByName(name) + .orElseThrow(() -> new PartyException(PartyProblem.PLAYER_NOT_ONLINE, "No online player is named " + name + ".")); + } + + private void requireNotInParty(UUID playerId) { + if (parties.partyOf(playerId).isPresent()) { + throw new PartyException(PartyProblem.ALREADY_IN_PARTY, "The player is already in a party."); + } + } + + /** + * The payload of party.leader-changed. {@code wasPublic} is present only when the change cleared the open flag, so + * the new leader can be told the party is private again. + */ + private static Map leaderChangedPayload(UUID previousLeaderId, UUID leaderId, boolean wasPublic) { + if (!wasPublic) { + return Map.of("previousLeaderId", previousLeaderId, "leaderId", leaderId); + } + return Map.of("previousLeaderId", previousLeaderId, "leaderId", leaderId, "wasPublic", true); + } + + /** Rounds up to whole seconds, the unit of Retry-After. */ + private static Duration wholeSeconds(Duration wait) { + return Duration.ofSeconds((wait.toMillis() + 999) / 1000); + } + private T locked(UUID partyId, Supplier action) { return locks.withLock("party:" + partyId, action); } @@ -383,8 +576,10 @@ private void requireLeader(Party party, UUID actorId) { } } + /** The private limit for a private party, the public limit for an open one. A closed party above the private limit stays full. */ private void ensureRoom(Party party) { - if (party.members().size() >= settings.getMaxSize()) { + int limit = party.publicParty() ? settings.getPublicMaxSize() : settings.getMaxSize(); + if (party.members().size() >= limit) { throw new PartyException(PartyProblem.PARTY_FULL, "The party is full."); } } diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartySettings.java b/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartySettings.java index ddf4231..c9711fd 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartySettings.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartySettings.java @@ -15,8 +15,11 @@ public class PartySettings { private Duration aloneTimeout = Duration.ofMinutes(30); private Duration aloneWarning = Duration.ofMinutes(5); private Duration expiryInterval = Duration.ofSeconds(15); + private int publicMaxSize = 20; + private Duration joinRequestTtl = Duration.ofSeconds(60); + private Duration joinRequestCooldown = Duration.ofSeconds(60); - /** Maximum number of members per party, counting the leader. */ + /** Maximum number of members per private party, counting the leader. */ public int getMaxSize() { return maxSize; } @@ -25,6 +28,36 @@ public void setMaxSize(@Min(2) int maxSize) { this.maxSize = maxSize; } + /** + * Maximum number of members per public party, counting the leader. A value below the private maximum is raised to + * it, so a public party never holds fewer members than a private one. + */ + public int getPublicMaxSize() { + return Math.max(publicMaxSize, maxSize); + } + + public void setPublicMaxSize(@Min(2) int publicMaxSize) { + this.publicMaxSize = publicMaxSize; + } + + /** How long a join request stays open before it expires. */ + public Duration getJoinRequestTtl() { + return joinRequestTtl; + } + + public void setJoinRequestTtl(Duration joinRequestTtl) { + this.joinRequestTtl = joinRequestTtl; + } + + /** How long a player must wait before asking the same party again after a denial or an expiry. */ + public Duration getJoinRequestCooldown() { + return joinRequestCooldown; + } + + public void setJoinRequestCooldown(Duration joinRequestCooldown) { + this.joinRequestCooldown = joinRequestCooldown; + } + /** How long an invite stays valid. */ public Duration getInviteTtl() { return inviteTtl; diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/party/port/PartyStore.java b/backend/src/main/java/net/onelitefeather/quelea/backend/party/port/PartyStore.java index 5ad09c9..616c5ed 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/party/port/PartyStore.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/party/port/PartyStore.java @@ -1,6 +1,7 @@ package net.onelitefeather.quelea.backend.party.port; import net.onelitefeather.quelea.backend.party.Invite; +import net.onelitefeather.quelea.backend.party.JoinRequest; import net.onelitefeather.quelea.backend.party.Party; import java.time.Duration; @@ -54,4 +55,32 @@ public interface PartyStore { List dueExpiries(Instant now); void clearWarning(UUID partyId); + + /** Stores an open join request; it expires with {@code ttl}. */ + void saveJoinRequest(JoinRequest request, Duration ttl); + + /** Must not return expired requests. */ + Optional findJoinRequest(UUID partyId, UUID requesterId); + + void deleteJoinRequest(UUID partyId, UUID requesterId); + + /** Starts a cooldown of {@code ttl} for the player's requests to the party. */ + void setCooldown(UUID partyId, UUID requesterId, Duration ttl); + + /** The time left in the player's cooldown for the party; zero when there is none. */ + Duration cooldownLeft(UUID partyId, UUID requesterId); + + void clearCooldown(UUID partyId, UUID requesterId); + + /** Removes every open request and cooldown of the party; called when the party is disbanded. */ + void deleteRequestsOf(UUID partyId); + + void ban(UUID partyId, UUID playerId); + + void unban(UUID partyId, UUID playerId); + + boolean isBanned(UUID partyId, UUID playerId); + + /** Removes the ban set of the party; called when the party is disbanded. */ + void deleteBans(UUID partyId); } diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyException.java b/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyException.java index 531b8c1..4e33532 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyException.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyException.java @@ -1,13 +1,25 @@ package net.onelitefeather.quelea.backend.problem; -/** A domain rule was violated. The handler turns it into the Problem Details response of {@link #problem()}. */ +import java.time.Duration; +import java.util.Optional; + +/** + * A domain rule was violated. The handler turns it into the Problem Details response of {@link #problem()}. A rule + * that asks the client to wait carries the wait as {@link #retryAfter()}, sent as the {@code Retry-After} header. + */ public class PartyException extends RuntimeException { private final PartyProblem problem; + private final Duration retryAfter; public PartyException(PartyProblem problem, String detail) { + this(problem, detail, null); + } + + public PartyException(PartyProblem problem, String detail, Duration retryAfter) { super(detail); this.problem = problem; + this.retryAfter = retryAfter; } public PartyException(PartyProblem problem) { @@ -17,4 +29,9 @@ public PartyException(PartyProblem problem) { public PartyProblem problem() { return problem; } + + /** The whole seconds the client must wait, when the rule is a cooldown. */ + public Optional retryAfter() { + return Optional.ofNullable(retryAfter); + } } diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyExceptionHandler.java b/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyExceptionHandler.java index 6b4f1ac..8502c3c 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyExceptionHandler.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyExceptionHandler.java @@ -1,7 +1,9 @@ package net.onelitefeather.quelea.backend.problem; +import io.micronaut.http.HttpHeaders; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; +import io.micronaut.http.MutableHttpResponse; import io.micronaut.http.annotation.Produces; import io.micronaut.http.server.exceptions.ExceptionHandler; import jakarta.inject.Singleton; @@ -18,6 +20,8 @@ public PartyExceptionHandler(ProblemFactory factory) { @Override public HttpResponse handle(HttpRequest request, PartyException exception) { - return factory.response(factory.create(exception.problem(), exception.getMessage(), request)); + MutableHttpResponse response = factory.response(factory.create(exception.problem(), exception.getMessage(), request)); + exception.retryAfter().ifPresent(wait -> response.header(HttpHeaders.RETRY_AFTER, String.valueOf(wait.toSeconds()))); + return response; } } diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyProblem.java b/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyProblem.java index 301d3cb..d11a565 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyProblem.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/problem/PartyProblem.java @@ -17,6 +17,12 @@ public enum PartyProblem { NOT_LEADER("not-leader", HttpStatus.FORBIDDEN, "Only the party leader may do this"), NOT_MEMBER("not-member", HttpStatus.FORBIDDEN, "Only party members may do this"), PARTY_FULL("party-full", HttpStatus.CONFLICT, "Party is full"), + PARTY_NOT_PUBLIC("party-not-public", HttpStatus.CONFLICT, "Party is not public"), + PARTY_BANNED("party-banned", HttpStatus.FORBIDDEN, "Player is banned from the party"), + TARGET_NOT_IN_PARTY("target-not-in-party", HttpStatus.NOT_FOUND, "Target is not in a party"), + JOIN_REQUEST_PENDING("join-request-pending", HttpStatus.CONFLICT, "Join request already pending"), + JOIN_REQUEST_COOLDOWN("join-request-cooldown", HttpStatus.TOO_MANY_REQUESTS, "Join request cooldown"), + JOIN_REQUEST_NOT_FOUND("join-request-not-found", HttpStatus.NOT_FOUND, "Join request not found"), PLAYER_NOT_ONLINE("player-not-online", HttpStatus.NOT_FOUND, "Player is not online"), INVITE_NOT_FOUND("invite-not-found", HttpStatus.NOT_FOUND, "Invite not found"), CANNOT_TARGET_SELF("cannot-target-self", HttpStatus.BAD_REQUEST, "The action cannot target yourself"), diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisKeys.java b/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisKeys.java index ebdbec5..642d0cf 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisKeys.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisKeys.java @@ -84,6 +84,26 @@ public String idempotency(String client, String keyHash) { return prefix + "idem:" + client + ":" + keyHash; } + /** An open join request as JSON, expiring with the request TTL. */ + public String joinRequest(UUID partyId, UUID playerId) { + return prefix + "join-request:" + partyId + ":" + playerId; + } + + /** A player's cooldown for one party; present while the player may not ask the party again. */ + public String joinCooldown(UUID partyId, UUID playerId) { + return prefix + "join-cooldown:" + partyId + ":" + playerId; + } + + /** Players with a request or a cooldown for the party; entries may be stale and go away with the party. */ + public String requesters(UUID partyId) { + return prefix + "party:" + partyId + ":requesters"; + } + + /** Players the party has kicked and not invited since; a set of player ids, removed with the party. */ + public String bans(UUID partyId) { + return prefix + "party:" + partyId + ":bans"; + } + /** A distributed lock; {@code name} is the lock name as passed to {@code LockService}. */ public String lock(String name) { return prefix + "lock:" + name; diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStore.java b/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStore.java index 4eca18d..1029b10 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStore.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStore.java @@ -8,6 +8,7 @@ import io.micronaut.serde.ObjectMapper; import jakarta.inject.Singleton; import net.onelitefeather.quelea.backend.party.Invite; +import net.onelitefeather.quelea.backend.party.JoinRequest; import net.onelitefeather.quelea.backend.party.Party; import net.onelitefeather.quelea.backend.party.port.PartyStore; @@ -151,6 +152,72 @@ public void clearWarning(UUID partyId) { commands.zrem(keys.aloneWarn(), partyId.toString()); } + @Override + public void saveJoinRequest(JoinRequest request, Duration ttl) { + commands.set(keys.joinRequest(request.partyId(), request.requesterId()), write(request), + SetArgs.Builder.px(ttl.toMillis())); + commands.sadd(keys.requesters(request.partyId()), request.requesterId().toString()); + } + + @Override + public Optional findJoinRequest(UUID partyId, UUID requesterId) { + return Optional.ofNullable(commands.get(keys.joinRequest(partyId, requesterId))) + .map(value -> read(value, JoinRequest.class)); + } + + @Override + public void deleteJoinRequest(UUID partyId, UUID requesterId) { + commands.del(keys.joinRequest(partyId, requesterId)); + } + + @Override + public void setCooldown(UUID partyId, UUID requesterId, Duration ttl) { + commands.set(keys.joinCooldown(partyId, requesterId), "1", SetArgs.Builder.px(ttl.toMillis())); + commands.sadd(keys.requesters(partyId), requesterId.toString()); + } + + @Override + public Duration cooldownLeft(UUID partyId, UUID requesterId) { + // PTTL is -2 when the key is gone; the server's clock decides, so the cooldown ends exactly at its TTL. + Long millis = commands.pttl(keys.joinCooldown(partyId, requesterId)); + return millis == null || millis <= 0 ? Duration.ZERO : Duration.ofMillis(millis); + } + + @Override + public void clearCooldown(UUID partyId, UUID requesterId) { + commands.del(keys.joinCooldown(partyId, requesterId)); + } + + @Override + public void deleteRequestsOf(UUID partyId) { + String index = keys.requesters(partyId); + for (String requesterId : commands.smembers(index)) { + UUID requester = UUID.fromString(requesterId); + commands.del(keys.joinRequest(partyId, requester), keys.joinCooldown(partyId, requester)); + } + commands.del(index); + } + + @Override + public void ban(UUID partyId, UUID playerId) { + commands.sadd(keys.bans(partyId), playerId.toString()); + } + + @Override + public void unban(UUID partyId, UUID playerId) { + commands.srem(keys.bans(partyId), playerId.toString()); + } + + @Override + public boolean isBanned(UUID partyId, UUID playerId) { + return Boolean.TRUE.equals(commands.sismember(keys.bans(partyId), playerId.toString())); + } + + @Override + public void deleteBans(UUID partyId) { + commands.del(keys.bans(partyId)); + } + private List due(String sortedSet, Instant now) { List partyIds = new ArrayList<>(); for (String partyId : commands.zrangebyscore(sortedSet, Range.create(0L, now.toEpochMilli()))) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 0ac9917..fcd7a4b 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -94,6 +94,12 @@ quelea: alone-warning: ${QUELEA_PARTY_ALONE_WARNING:5m} # How often the expiry job runs (one replica per run, under a lock). expiry-interval: ${QUELEA_PARTY_EXPIRY_INTERVAL:15s} + # Maximum number of members of a public party, counting the leader; raised to max-size when lower. + public-max-size: ${QUELEA_PARTY_PUBLIC_MAX_SIZE:20} + # How long a join request stays open before it expires. + join-request-ttl: ${QUELEA_JOIN_REQUEST_TTL:60s} + # How long a player must wait to ask the same party again after a denial or an expiry. + join-request-cooldown: ${QUELEA_JOIN_REQUEST_COOLDOWN:60s} presence: # A proxy that has not sent a heartbeat for this long is treated as dead and its players are disconnected. proxy-timeout: ${QUELEA_PROXY_TIMEOUT:45s} diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/api/JoinRequestsControllerTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/api/JoinRequestsControllerTest.java new file mode 100644 index 0000000..f1a7b91 --- /dev/null +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/api/JoinRequestsControllerTest.java @@ -0,0 +1,145 @@ +package net.onelitefeather.quelea.backend.api; + +import io.micronaut.http.HttpRequest; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import net.onelitefeather.quelea.backend.security.TestJwtIssuer; +import net.onelitefeather.quelea.backend.support.ApiClient; +import net.onelitefeather.quelea.backend.support.ApiClient.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest(rebuildContext = true) +class JoinRequestsControllerTest { + + static final String PROXY = "proxy-requests"; + + @Inject + @Client("/") + HttpClient client; + + @Inject + TestJwtIssuer jwt; + + private ApiClient api; + + @BeforeEach + void setUp() { + api = new ApiClient(client, jwt); + } + + @Test + void requestToPrivatePartyAnswers201WithTheOpenRequest() { + String leaderName = name("lead"); + UUID leader = api.login(PROXY, leaderName); + String partyId = createParty(leader); + UUID requester = api.login(PROXY, name("req")); + + Response response = request(requester, leaderName); + + assertEquals(201, response.status()); + assertEquals(partyId, response.json().get("partyId").asText()); + assertEquals(requester.toString(), response.json().get("requesterId").asText()); + assertTrue(response.json().hasNonNull("expiresAt"), "the request must tell when it expires"); + } + + @Test + void requestToPublicPartyAnswers200WithThePartyAndTheRequesterAsMember() { + String leaderName = name("lead"); + UUID leader = api.login(PROXY, leaderName); + String partyId = createParty(leader); + setPublic(partyId, leader, true); + UUID requester = api.login(PROXY, name("req")); + + Response response = request(requester, leaderName); + + assertEquals(200, response.status()); + assertEquals(partyId, response.json().get("id").asText()); + assertEquals(2, response.json().get("members").size(), "the requester must be a member of the public party"); + } + + @Test + void joinOnPrivatePartyAnswers409PartyNotPublic() { + String leaderName = name("lead"); + UUID leader = api.login(PROXY, leaderName); + createParty(leader); + UUID joiner = api.login(PROXY, name("join")); + + Response response = api.send(HttpRequest.POST("/v1/players/" + joiner + "/party/join", + "{\"targetName\":\"" + leaderName + "\"}")); + + assertEquals(409, response.status()); + assertEquals("party-not-public", response.problemCode()); + } + + @Test + void secondRequestWhileOpenAnswers409JoinRequestPending() { + String leaderName = name("lead"); + UUID leader = api.login(PROXY, leaderName); + createParty(leader); + UUID requester = api.login(PROXY, name("req")); + request(requester, leaderName); + + Response response = request(requester, leaderName); + + assertEquals(409, response.status()); + assertEquals("join-request-pending", response.problemCode()); + } + + @Test + void requestAfterADenialAnswers429WithRetryAfter() { + String leaderName = name("lead"); + UUID leader = api.login(PROXY, leaderName); + String partyId = createParty(leader); + String deniedName = name("who"); + UUID denied = api.login(PROXY, deniedName); + request(denied, leaderName); + Response rejection = api.postJson("/v1/parties/" + partyId + "/join-requests/reject", + "{\"actorId\":\"" + leader + "\",\"requesterName\":\"" + deniedName + "\"}"); + assertEquals(204, rejection.status()); + + Response retry = request(denied, leaderName); + + assertEquals(429, retry.status()); + assertEquals("join-request-cooldown", retry.problemCode()); + assertFalse(retry.retryAfter().isEmpty(), "a cooldown answer must carry Retry-After"); + } + + @Test + void requestToAPlayerInNoPartyAnswers404TargetNotInParty() { + String targetName = name("lonely"); + api.login(PROXY, targetName); + UUID requester = api.login(PROXY, name("req")); + + Response response = request(requester, targetName); + + assertEquals(404, response.status()); + assertEquals("target-not-in-party", response.problemCode()); + } + + private Response request(UUID requester, String targetName) { + return api.postJson("/v1/players/" + requester + "/party/requests", "{\"targetName\":\"" + targetName + "\"}"); + } + + private void setPublic(String partyId, UUID leader, boolean open) { + Response response = api.postJson("/v1/parties/" + partyId + "/public", + "{\"actorId\":\"" + leader + "\",\"public\":" + open + "}"); + assertEquals(200, response.status(), "the leader must be able to change the flag"); + } + + private String createParty(UUID leader) { + return api.postJson("/v1/parties", "{\"leaderId\":\"" + leader + "\"}").json().get("id").asText(); + } + + static String name(String prefix) { + return prefix + UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/party/JoinRequestTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/party/JoinRequestTest.java new file mode 100644 index 0000000..8fd42b1 --- /dev/null +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/party/JoinRequestTest.java @@ -0,0 +1,244 @@ +package net.onelitefeather.quelea.backend.party; + +import net.onelitefeather.quelea.backend.event.PartyEvent; +import net.onelitefeather.quelea.backend.problem.PartyException; +import net.onelitefeather.quelea.backend.problem.PartyProblem; +import net.onelitefeather.quelea.backend.support.PartyFixture; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Join requests to a private party: the leader answers, a request expires, and the cooldown after a denial or expiry. */ +class JoinRequestTest { + + @Test + void acceptedRequestAddsTheRequesterToThePrivateParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + + Party after = f.partyService.acceptRequest(party.id(), leader, "Bravo"); + + assertTrue(after.isMember(requester), "the accepted requester must be a member"); + assertProblem(PartyProblem.JOIN_REQUEST_NOT_FOUND, () -> f.partyService.acceptRequest(party.id(), leader, "Bravo")); + } + + @Test + void deniedRequestLeavesTheRequesterInNoParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + + f.partyService.rejectRequest(party.id(), leader, "Bravo"); + + assertProblem(PartyProblem.NOT_IN_PARTY, () -> f.partyService.partyOf(requester)); + } + + @Test + void acceptingAnExpiredRequestIsJoinRequestNotFoundAndLeavesTheParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + + f.clock.advance(Duration.ofSeconds(60)); + + assertProblem(PartyProblem.JOIN_REQUEST_NOT_FOUND, () -> f.partyService.acceptRequest(party.id(), leader, "Bravo")); + assertEquals(1, f.partyService.get(party.id()).members().size(), "an expired request must not change the party"); + } + + @Test + void secondRequestWhilePendingIsJoinRequestPending() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + + assertProblem(PartyProblem.JOIN_REQUEST_PENDING, () -> f.partyService.requestJoin(requester, "Alpha")); + } + + @Test + void requesterAlreadyInAPartyIsAlreadyInParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + f.partyService.create(leader); + f.partyService.create(requester); + + assertProblem(PartyProblem.ALREADY_IN_PARTY, () -> f.partyService.requestJoin(requester, "Alpha")); + } + + @Test + void targetWithoutPartyIsTargetNotInParty() { + PartyFixture f = new PartyFixture(); + UUID requester = f.online("Bravo"); + f.online("Alpha"); + + assertProblem(PartyProblem.TARGET_NOT_IN_PARTY, () -> f.partyService.requestJoin(requester, "Alpha")); + } + + @Test + void offlineTargetIsPlayerNotOnline() { + PartyFixture f = new PartyFixture(); + UUID requester = f.online("Bravo"); + + assertProblem(PartyProblem.PLAYER_NOT_ONLINE, () -> f.partyService.requestJoin(requester, "Nobody")); + } + + @Test + void answerByAMemberIsNotLeader() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID member = f.online("Charlie"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Charlie"); + f.partyService.accept(member, party.id()); + f.partyService.requestJoin(requester, "Alpha"); + + assertProblem(PartyProblem.NOT_LEADER, () -> f.partyService.acceptRequest(party.id(), member, "Bravo")); + } + + @Test + void requestPublishesJoinRequestedWithRequesterAndExpiry() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + f.partyService.create(leader); + + f.partyService.requestJoin(requester, "Alpha"); + + PartyEvent event = lastEvent(f, "party.join-requested"); + assertEquals(requester, event.payload().get("requesterId")); + assertEquals("Bravo", event.payload().get("requesterName")); + assertEquals(f.clock.instant().plus(Duration.ofSeconds(60)), event.payload().get("expiresAt")); + } + + @Test + void acceptPublishesAnsweredAcceptedAndJoined() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + + f.partyService.acceptRequest(party.id(), leader, "Bravo"); + + assertEquals("accepted", lastEvent(f, "party.join-request-answered").payload().get("outcome")); + assertEquals(1, count(f, "party.joined"), "an accepted request must publish party.joined"); + } + + @Test + void rejectPublishesAnsweredDenied() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + + f.partyService.rejectRequest(party.id(), leader, "Bravo"); + + assertEquals("denied", lastEvent(f, "party.join-request-answered").payload().get("outcome")); + } + + // --- Cooldown ------------------------------------------------------------------------------- + + @Test + void cooldownAfterDenialStillRefusesOneSecondBeforeItEnds() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + f.partyService.rejectRequest(party.id(), leader, "Bravo"); + + f.clock.advance(Duration.ofSeconds(59)); + + PartyException thrown = assertThrows(PartyException.class, () -> f.partyService.requestJoin(requester, "Alpha")); + assertEquals(PartyProblem.JOIN_REQUEST_COOLDOWN, thrown.problem()); + assertEquals(Duration.ofSeconds(1), thrown.retryAfter().orElseThrow(), "Retry-After must be the whole seconds left"); + } + + @Test + void cooldownAfterDenialAllowsANewRequestExactlyWhenItEnds() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + f.partyService.rejectRequest(party.id(), leader, "Bravo"); + + f.clock.advance(Duration.ofSeconds(60)); + + assertInstanceOf(JoinOutcome.Requested.class, f.partyService.requestJoin(requester, "Alpha")); + } + + @Test + void cooldownAfterExpiryStillRefusesOneSecondBeforeItEnds() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + + f.clock.advance(Duration.ofSeconds(119)); + + PartyException thrown = assertThrows(PartyException.class, () -> f.partyService.requestJoin(requester, "Alpha")); + assertEquals(PartyProblem.JOIN_REQUEST_COOLDOWN, thrown.problem()); + assertEquals(Duration.ofSeconds(1), thrown.retryAfter().orElseThrow()); + } + + @Test + void cooldownAfterExpiryAllowsANewRequestTwoMinutesAfterTheRequest() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + + f.clock.advance(Duration.ofSeconds(120)); + + assertInstanceOf(JoinOutcome.Requested.class, f.partyService.requestJoin(requester, "Alpha")); + } + + @Test + void acceptedRequestLeavesNoCooldownForALaterRequest() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.requestJoin(requester, "Alpha"); + f.partyService.acceptRequest(party.id(), leader, "Bravo"); + f.partyService.leave(requester, "leave"); + + assertInstanceOf(JoinOutcome.Requested.class, f.partyService.requestJoin(requester, "Alpha")); + } + + private static PartyEvent lastEvent(PartyFixture f, String type) { + List matching = f.events.events().stream().filter(e -> e.type().equals(type)).toList(); + assertTrue(!matching.isEmpty(), "no event of type " + type + " was published: " + f.events.types()); + return matching.get(matching.size() - 1); + } + + private static long count(PartyFixture f, String type) { + return f.events.types().stream().filter(type::equals).count(); + } + + private static void assertProblem(PartyProblem expected, org.junit.jupiter.api.function.Executable action) { + PartyException thrown = assertThrows(PartyException.class, action, "expected " + expected.code()); + assertEquals(expected, thrown.problem(), "wrong problem for the rule that was broken"); + } +} diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/party/KickBanTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/party/KickBanTest.java new file mode 100644 index 0000000..f22f33b --- /dev/null +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/party/KickBanTest.java @@ -0,0 +1,82 @@ +package net.onelitefeather.quelea.backend.party; + +import net.onelitefeather.quelea.backend.problem.PartyException; +import net.onelitefeather.quelea.backend.problem.PartyProblem; +import net.onelitefeather.quelea.backend.support.PartyFixture; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** A kicked player is banned from the party until the leader invites them, and the ban ends with the party. */ +class KickBanTest { + + @Test + void kickedPlayerIsBlockedFromJoiningAPublicParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID kicked = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(kicked, party.id()); + f.partyService.kick(party.id(), leader, kicked); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + assertProblem(PartyProblem.PARTY_BANNED, () -> f.partyService.join(kicked, "Alpha")); + } + + @Test + void kickedPlayerIsBlockedFromRequestingAPrivateParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID kicked = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(kicked, party.id()); + f.partyService.kick(party.id(), leader, kicked); + + assertProblem(PartyProblem.PARTY_BANNED, () -> f.partyService.requestJoin(kicked, "Alpha")); + } + + @Test + void inviteLiftsTheBanSoTheInvitedPlayerCanJoin() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID kicked = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(kicked, party.id()); + f.partyService.kick(party.id(), leader, kicked); + + f.partyService.invite(leader, "Bravo"); + Party after = f.partyService.accept(kicked, party.id()); + + assertTrue(after.isMember(kicked), "an explicit invite must lift the ban for the invited player"); + } + + @Test + void banEndsWithTheParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID kicked = f.online("Bravo"); + Party old = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(kicked, old.id()); + f.partyService.kick(old.id(), leader, kicked); + f.partyService.disband(old.id(), leader); + + f.partyService.create(leader); + JoinOutcome outcome = f.partyService.requestJoin(kicked, "Alpha"); + + assertInstanceOf(JoinOutcome.Requested.class, outcome, "a ban must not outlive the party it belongs to"); + } + + private static void assertProblem(PartyProblem expected, org.junit.jupiter.api.function.Executable action) { + PartyException thrown = assertThrows(PartyException.class, action, "expected " + expected.code()); + assertEquals(expected, thrown.problem(), "wrong problem for the rule that was broken"); + } +} diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyPublicJsonTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyPublicJsonTest.java new file mode 100644 index 0000000..da2499b --- /dev/null +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyPublicJsonTest.java @@ -0,0 +1,40 @@ +package net.onelitefeather.quelea.backend.party; + +import io.micronaut.serde.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PartyPublicJsonTest { + + private static final Instant CREATED = Instant.parse("2026-01-01T12:00:00Z"); + private static final UUID PARTY = UUID.fromString("00000000-0000-0000-0000-0000000000aa"); + private static final UUID LEADER = UUID.fromString("00000000-0000-0000-0000-0000000000bb"); + + private final ObjectMapper json = ObjectMapper.getDefault(); + + @Test + void publicFlagIsWrittenUnderThePublicKey() throws Exception { + Party open = new Party(PARTY, LEADER, List.of(new Member(LEADER, "Alpha", CREATED)), CREATED, 2, null, true); + + String written = json.writeValueAsString(open); + + assertTrue(written.contains("\"public\":true"), "the open flag must be written as \"public\": " + written); + } + + @Test + void partyStoredBeforeThePublicFlagReadsAsPrivate() throws Exception { + String legacy = """ + {"id":"%s","leaderId":"%s","members":[{"id":"%s","name":"Alpha","joinedAt":"%s"}],\ + "createdAt":"%s","version":3}""".formatted(PARTY, LEADER, LEADER, CREATED, CREATED); + + Party party = json.readValue(legacy, Party.class); + + assertFalse(party.publicParty(), "a party stored without the flag must read as private"); + } +} diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/party/PublicPartyTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PublicPartyTest.java new file mode 100644 index 0000000..b1c7066 --- /dev/null +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PublicPartyTest.java @@ -0,0 +1,288 @@ +package net.onelitefeather.quelea.backend.party; + +import net.onelitefeather.quelea.backend.event.PartyEvent; +import net.onelitefeather.quelea.backend.problem.PartyException; +import net.onelitefeather.quelea.backend.problem.PartyProblem; +import net.onelitefeather.quelea.backend.support.PartyFixture; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Public parties: direct joins, their own size limit, and the leader's control of the flag. */ +class PublicPartyTest { + + @Test + void publicJoinAddsTheMemberAndPublishesJoined() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID joiner = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + Party after = f.partyService.join(joiner, "Alpha"); + + assertTrue(after.isMember(joiner), "a public join must make the player a member"); + assertEquals(1, count(f, "party.joined"), "a public join must publish party.joined"); + } + + @Test + void requestOnAPublicPartyJoinsDirectlyWithoutHoldingARequest() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID requester = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + JoinOutcome outcome = f.partyService.requestJoin(requester, "Alpha"); + + JoinOutcome.Joined joined = assertInstanceOf(JoinOutcome.Joined.class, outcome); + assertTrue(joined.party().isMember(requester), "the requester must be a member after a request on a public party"); + assertEquals(0, count(f, "party.join-requested"), "no join request may be announced for a public party"); + } + + @Test + void joinOnAPrivatePartyIsPartyNotPublic() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID joiner = f.online("Bravo"); + f.partyService.create(leader); + + assertProblem(PartyProblem.PARTY_NOT_PUBLIC, () -> f.partyService.join(joiner, "Alpha")); + assertProblem(PartyProblem.NOT_IN_PARTY, () -> f.partyService.partyOf(joiner)); + } + + @Test + void publicPartyRefusesTheTwentyFirstMember() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + Party party = f.partyService.create(leader); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + for (int i = 1; i < 20; i++) { + f.partyService.join(f.online("Player" + i), "Alpha"); + } + UUID overflow = f.online("Overflow"); + + assertProblem(PartyProblem.PARTY_FULL, () -> f.partyService.join(overflow, "Alpha")); + assertEquals(20, f.partyService.get(party.id()).members().size(), "a full public party must keep its 20 members"); + } + + @Test + void privateLimitStaysAtTheConfiguredMaximum() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID bravo = f.online("Bravo"); + UUID charlie = f.online("Charlie"); + f.online("Delta"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(bravo, party.id()); + f.partyService.invite(leader, "Charlie"); + f.partyService.accept(charlie, party.id()); + + // The fixture's private limit is 3 members; a private party is full at that size. + assertProblem(PartyProblem.PARTY_FULL, () -> f.partyService.invite(leader, "Delta")); + assertEquals(3, f.partyService.get(party.id()).members().size()); + assertFalse(f.partyService.get(party.id()).publicParty()); + } + + @Test + void closedPartyAbovePrivateLimitKeepsItsMembersAndRejectsNewOnes() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + Party party = f.partyService.create(leader); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + for (int i = 1; i < 12; i++) { + f.partyService.join(f.online("Player" + i), "Alpha"); + } + f.partyService.setPublic(party.id(), leader, false, Precondition.NONE); + UUID invited = f.online("Invited"); + + assertProblem(PartyProblem.PARTY_FULL, () -> f.partyService.invite(leader, "Invited")); + assertEquals(12, f.partyService.get(party.id()).members().size(), "closing must keep all 12 members"); + } + + @Test + void onlyTheLeaderMayOpenTheParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID member = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(member, party.id()); + + assertProblem(PartyProblem.NOT_LEADER, () -> f.partyService.setPublic(party.id(), member, true, Precondition.NONE)); + } + + @Test + void leaderClosesThePartyWithoutAnyPermissionCheck() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + Party party = f.partyService.create(leader); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + Party closed = f.partyService.setPublic(party.id(), leader, false, Precondition.NONE); + + assertFalse(closed.publicParty(), "the leader must be able to close the party"); + } + + @Test + void setPublicBumpsTheVersion() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + Party party = f.partyService.create(leader); + long before = party.version(); + + Party opened = f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + assertEquals(before + 1, opened.version(), "opening must bump the version"); + } + + @Test + void promotionClosesThePublicParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID member = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(member, party.id()); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + Party promoted = f.partyService.promote(party.id(), leader, member); + + assertEquals(member, promoted.leaderId()); + assertFalse(promoted.publicParty(), "a change of leader must close the party"); + } + + @Test + void leaderLeavingClosesThePublicParty() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID first = f.online("Bravo"); + UUID second = f.online("Charlie"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(first, party.id()); + f.partyService.invite(leader, "Charlie"); + f.partyService.accept(second, party.id()); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + f.partyService.leave(leader, "leave"); + + Party after = f.partyService.get(party.id()); + assertEquals(first, after.leaderId(), "the longest-standing member must succeed the leader"); + assertFalse(after.publicParty(), "automatic succession must close the party"); + } + + @Test + void publicPartyStaysPublicWhenItDropsToOneMemberThroughANonLeaderLeave() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID member = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(member, party.id()); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + f.partyService.leave(member, "leave"); + + Party after = f.partyService.get(party.id()); + assertEquals(1, after.members().size()); + assertTrue(after.publicParty(), "a non-leader leave must keep the party public"); + } + + @Test + void memberJoiningKeepsThePartyPublic() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID joiner = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + f.partyService.join(joiner, "Alpha"); + + assertTrue(f.partyService.get(party.id()).publicParty(), "a member change must keep the flag"); + } + + @Test + void promotionOfAPublicPartyCarriesWasPublicOnLeaderChanged() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID member = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(member, party.id()); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + f.partyService.promote(party.id(), leader, member); + + assertEquals(true, lastEvent(f, "party.leader-changed").payload().get("wasPublic"), + "a promotion that closed the party must say so"); + } + + @Test + void promotionOfAPrivatePartyOmitsWasPublic() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID member = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(member, party.id()); + + f.partyService.promote(party.id(), leader, member); + + assertFalse(lastEvent(f, "party.leader-changed").payload().containsKey("wasPublic"), + "wasPublic must be omitted when the flag was not cleared"); + } + + @Test + void successionOfAPublicPartyCarriesWasPublicOnLeaderChanged() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID member = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(member, party.id()); + f.partyService.setPublic(party.id(), leader, true, Precondition.NONE); + + f.partyService.leave(leader, "leave"); + + assertEquals(true, lastEvent(f, "party.leader-changed").payload().get("wasPublic"), + "an automatic succession that closed the party must say so"); + } + + @Test + void successionOfAPrivatePartyOmitsWasPublic() { + PartyFixture f = new PartyFixture(); + UUID leader = f.online("Alpha"); + UUID member = f.online("Bravo"); + Party party = f.partyService.create(leader); + f.partyService.invite(leader, "Bravo"); + f.partyService.accept(member, party.id()); + + f.partyService.leave(leader, "leave"); + + assertFalse(lastEvent(f, "party.leader-changed").payload().containsKey("wasPublic")); + } + + private static PartyEvent lastEvent(PartyFixture f, String type) { + List matching = f.events.events().stream().filter(e -> e.type().equals(type)).toList(); + assertTrue(!matching.isEmpty(), "no event of type " + type); + return matching.get(matching.size() - 1); + } + + private static long count(PartyFixture f, String type) { + return f.events.types().stream().filter(type::equals).count(); + } + + private static void assertProblem(PartyProblem expected, org.junit.jupiter.api.function.Executable action) { + PartyException thrown = assertThrows(PartyException.class, action, "expected " + expected.code()); + assertEquals(expected, thrown.problem(), "wrong problem for the rule that was broken"); + } +} diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/store/redis/RedisJoinRequestStoreTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/store/redis/RedisJoinRequestStoreTest.java new file mode 100644 index 0000000..96dec5d --- /dev/null +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/store/redis/RedisJoinRequestStoreTest.java @@ -0,0 +1,124 @@ +package net.onelitefeather.quelea.backend.store.redis; + +import io.micronaut.serde.ObjectMapper; +import net.onelitefeather.quelea.backend.party.JoinRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The join-request, cooldown and ban keys in Dragonfly, including their cleanup when a party is disbanded. */ +@Tag("integration") +class RedisJoinRequestStoreTest { + + private static final Instant NOW = Instant.parse("2026-01-01T12:00:00Z"); + + private static DragonflyFixture dragonfly; + + private RedisPartyStore store; + + @BeforeAll + static void startDragonfly() { + dragonfly = DragonflyFixture.start(); + } + + @AfterAll + static void stopDragonfly() { + if (dragonfly != null) { + dragonfly.close(); + } + } + + @BeforeEach + void freshStore() { + dragonfly.flushAll(); + store = new RedisPartyStore(dragonfly.connection(), new RedisKeys("quelea:"), ObjectMapper.getDefault()); + } + + @Test + void savedJoinRequestCarriesAnExpiringKeyUnderTheRequestKey() { + UUID party = UUID.randomUUID(); + UUID requester = UUID.randomUUID(); + JoinRequest request = new JoinRequest(party, requester, "Bravo", NOW.plus(Duration.ofSeconds(60))); + + store.saveJoinRequest(request, Duration.ofSeconds(60)); + + assertEquals(request, store.findJoinRequest(party, requester).orElseThrow()); + Long pttl = dragonfly.connection().sync().pttl("quelea:join-request:" + party + ":" + requester); + assertTrue(pttl != null && pttl > 0 && pttl <= 60_000L, "the request key must expire with the request TTL, was " + pttl); + } + + @Test + void rejectionOverwritesTheCooldownWithTheShorterWindow() { + UUID party = UUID.randomUUID(); + UUID requester = UUID.randomUUID(); + store.setCooldown(party, requester, Duration.ofSeconds(120)); + + store.setCooldown(party, requester, Duration.ofSeconds(60)); + + assertTrue(store.cooldownLeft(party, requester).compareTo(Duration.ofSeconds(60)) <= 0, + "a rejection must start a fresh cooldown of the cooldown length, not keep the longer one"); + } + + @Test + void acceptedRequestClearsItsCooldown() { + UUID party = UUID.randomUUID(); + UUID requester = UUID.randomUUID(); + store.setCooldown(party, requester, Duration.ofSeconds(60)); + + store.clearCooldown(party, requester); + + assertTrue(store.cooldownLeft(party, requester).isZero(), "an accepted request must leave no cooldown"); + } + + @Test + void deleteRequestsOfRemovesOpenRequestsAndCooldownsOfTheParty() { + UUID party = UUID.randomUUID(); + UUID requester = UUID.randomUUID(); + store.saveJoinRequest(new JoinRequest(party, requester, "Bravo", NOW.plus(Duration.ofSeconds(60))), Duration.ofSeconds(60)); + store.setCooldown(party, requester, Duration.ofSeconds(120)); + + store.deleteRequestsOf(party); + + assertTrue(store.findJoinRequest(party, requester).isEmpty(), "disband must remove the open request"); + assertTrue(store.cooldownLeft(party, requester).isZero(), "disband must remove the cooldown"); + assertFalse(dragonfly.connection().sync().exists("quelea:party:" + party + ":requesters") > 0, + "disband must remove the requester index"); + } + + @Test + void deleteBansRemovesTheBanSetOfTheParty() { + UUID party = UUID.randomUUID(); + UUID kicked = UUID.randomUUID(); + store.ban(party, kicked); + assertTrue(store.isBanned(party, kicked)); + + store.deleteBans(party); + + assertFalse(store.isBanned(party, kicked), "disband must remove the ban set"); + assertFalse(dragonfly.connection().sync().exists("quelea:party:" + party + ":bans") > 0); + } + + @Test + void unbanLiftsOnlyTheInvitedPlayer() { + UUID party = UUID.randomUUID(); + UUID invited = UUID.randomUUID(); + UUID other = UUID.randomUUID(); + store.ban(party, invited); + store.ban(party, other); + + store.unban(party, invited); + + assertFalse(store.isBanned(party, invited)); + assertTrue(store.isBanned(party, other)); + } +} diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/support/ApiClient.java b/backend/src/test/java/net/onelitefeather/quelea/backend/support/ApiClient.java index 29b2c9b..0f8b173 100644 --- a/backend/src/test/java/net/onelitefeather/quelea/backend/support/ApiClient.java +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/support/ApiClient.java @@ -70,7 +70,7 @@ public UUID login(String proxyId, String name) { * The status, content type, body, {@code WWW-Authenticate} challenge and {@code ETag} (each empty when absent) of a * reply. */ - public record Response(int status, String contentType, String body, String challenge, String etag) { + public record Response(int status, String contentType, String body, String challenge, String etag, String retryAfter) { static Response of(HttpResponse response) { return new Response( @@ -78,7 +78,8 @@ static Response of(HttpResponse response) { response.getContentType().map(MediaType::toString).orElse(""), response.getBody(String.class).orElse(""), java.util.Optional.ofNullable(response.getHeaders().get(HttpHeaders.WWW_AUTHENTICATE)).orElse(""), - java.util.Optional.ofNullable(response.getHeaders().get(HttpHeaders.ETAG)).orElse("")); + java.util.Optional.ofNullable(response.getHeaders().get(HttpHeaders.ETAG)).orElse(""), + java.util.Optional.ofNullable(response.getHeaders().get(HttpHeaders.RETRY_AFTER)).orElse("")); } public JsonNode json() { diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/support/InMemoryPartyStore.java b/backend/src/test/java/net/onelitefeather/quelea/backend/support/InMemoryPartyStore.java index 5df7889..8582d7d 100644 --- a/backend/src/test/java/net/onelitefeather/quelea/backend/support/InMemoryPartyStore.java +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/support/InMemoryPartyStore.java @@ -1,6 +1,7 @@ package net.onelitefeather.quelea.backend.support; import net.onelitefeather.quelea.backend.party.Invite; +import net.onelitefeather.quelea.backend.party.JoinRequest; import net.onelitefeather.quelea.backend.party.Party; import net.onelitefeather.quelea.backend.party.port.PartyStore; @@ -10,6 +11,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -21,12 +23,22 @@ private record StoredInvite(Invite invite, Instant expiresAt) { private record InviteKey(UUID partyId, UUID targetId) { } + /** A party and a player: the key of a request and of a cooldown. */ + private record RequestKey(UUID partyId, UUID playerId) { + } + + private record Expiring(T value, Instant expiresAt) { + } + private final Clock clock; private final Map parties = new ConcurrentHashMap<>(); private final Map bindings = new ConcurrentHashMap<>(); private final Map invites = new ConcurrentHashMap<>(); private final Map warnings = new ConcurrentHashMap<>(); private final Map expiries = new ConcurrentHashMap<>(); + private final Map> joinRequests = new ConcurrentHashMap<>(); + private final Map cooldowns = new ConcurrentHashMap<>(); + private final Map> bans = new ConcurrentHashMap<>(); public InMemoryPartyStore(Clock clock) { this.clock = clock; @@ -131,6 +143,77 @@ public void clearWarning(UUID partyId) { warnings.remove(partyId); } + @Override + public void saveJoinRequest(JoinRequest request, Duration ttl) { + joinRequests.put(new RequestKey(request.partyId(), request.requesterId()), + new Expiring<>(request, clock.instant().plus(ttl))); + } + + @Override + public Optional findJoinRequest(UUID partyId, UUID requesterId) { + return Optional.ofNullable(joinRequests.get(new RequestKey(partyId, requesterId))) + .filter(stored -> isLive(stored.expiresAt())) + .map(Expiring::value); + } + + @Override + public void deleteJoinRequest(UUID partyId, UUID requesterId) { + joinRequests.remove(new RequestKey(partyId, requesterId)); + } + + @Override + public void setCooldown(UUID partyId, UUID requesterId, Duration ttl) { + cooldowns.put(new RequestKey(partyId, requesterId), clock.instant().plus(ttl)); + } + + @Override + public Duration cooldownLeft(UUID partyId, UUID requesterId) { + Instant until = cooldowns.get(new RequestKey(partyId, requesterId)); + if (until == null || !clock.instant().isBefore(until)) { + return Duration.ZERO; + } + return Duration.between(clock.instant(), until); + } + + @Override + public void clearCooldown(UUID partyId, UUID requesterId) { + cooldowns.remove(new RequestKey(partyId, requesterId)); + } + + @Override + public void deleteRequestsOf(UUID partyId) { + joinRequests.keySet().removeIf(key -> key.partyId().equals(partyId)); + cooldowns.keySet().removeIf(key -> key.partyId().equals(partyId)); + } + + @Override + public void ban(UUID partyId, UUID playerId) { + bans.computeIfAbsent(partyId, id -> ConcurrentHashMap.newKeySet()).add(playerId); + } + + @Override + public void unban(UUID partyId, UUID playerId) { + Set set = bans.get(partyId); + if (set != null) { + set.remove(playerId); + } + } + + @Override + public boolean isBanned(UUID partyId, UUID playerId) { + Set set = bans.get(partyId); + return set != null && set.contains(playerId); + } + + @Override + public void deleteBans(UUID partyId) { + bans.remove(partyId); + } + + private boolean isLive(Instant expiresAt) { + return clock.instant().isBefore(expiresAt); + } + private static List due(Map entries, Instant now) { return entries.entrySet().stream() .filter(entry -> !entry.getValue().isAfter(now)) diff --git a/docs/problems.md b/docs/problems.md index 7f4b5c1..dade6a4 100644 --- a/docs/problems.md +++ b/docs/problems.md @@ -136,6 +136,48 @@ Common fields: `type`, `title`, `status`, `detail`, `instance` (the request path not run. The state was not changed. - **What to do:** retry after a short delay. The request is safe to repeat. +## party-not-public + +- **Status:** 409 +- **When:** a player joins a party directly, but the party is private. Only a public party admits a + player without a request. +- **What to do:** ask to join with a join request instead; the leader then answers it. + +## party-banned + +- **Status:** 403 +- **When:** a player who was kicked from the party joins it or asks to join it. The ban lasts while the + party exists; an invite from the leader lifts it. +- **What to do:** the player must wait for an invite from the party's leader. + +## target-not-in-party + +- **Status:** 404 +- **When:** a player asks to join, or to request, the party of a target who is online but in no party. +- **What to do:** ask the target to create a party first, or invite the player to the target. + +## join-request-pending + +- **Status:** 409 +- **When:** the player already has an open join request to that party, which is still waiting for the + leader's answer or for its expiry (`QUELEA_JOIN_REQUEST_TTL`, default 60 seconds). +- **What to do:** wait for the leader's answer. Do not repeat the request. + +## join-request-cooldown + +- **Status:** 429, with a `Retry-After` header of the whole seconds left +- **When:** the player's last request to that party was denied, or expired unanswered, less than + `QUELEA_JOIN_REQUEST_COOLDOWN` ago (default 60 seconds). The cooldown starts at the denial, or at the + expiry of the request. +- **What to do:** wait for `Retry-After` seconds, then ask again. Clients must not report this as a fault. + +## join-request-not-found + +- **Status:** 404 +- **When:** the leader accepts or rejects a request that is not open: the player never asked, or the + request has expired. +- **What to do:** the leader must not act on it again. The player may ask again once the cooldown is over. + ## internal-error - **Status:** 500 From 6586387aa751e1009a42d8efef32a85c6f5e166c Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 30 Oct 2026 21:04:11 +0100 Subject: [PATCH 2/2] feat(party): add public and join-request commands to the proxy plugin The proxy gains /party public on|off, request, join, approve and reject. Opening a party is refused before any service call unless the player holds quelea.party.public; closing needs no permission. A request to a public party reads as a join, a request to a private one as sent, with its expiry. The leader gets a clickable prompt for a join request. The requester is told the answer once. A leadership change that closed a public party tells the new leader, with the reopen hint only when they hold the permission. Messages for the six new problem codes are in English and German. A cooldown names its wait only when the service sent Retry-After. None of the six codes is reported to Sentry. --- README.md | 15 ++ .../quelea/velocity/QueleaVelocity.java | 2 +- .../quelea/velocity/command/HelpText.java | 5 + .../quelea/velocity/command/PartyActions.java | 77 +++++++- .../velocity/command/PartyCommands.java | 23 ++- .../quelea/velocity/event/EventHandler.java | 69 +++++++ .../velocity/http/HttpQueleaGateway.java | 66 ++++++- .../quelea/velocity/http/JoinResult.java | 18 ++ .../quelea/velocity/http/QueleaGateway.java | 20 ++ .../velocity/http/QueleaProblemException.java | 18 ++ .../quelea/velocity/i18n/Messages.java | 6 + .../quelea/velocity/player/LocalPlayer.java | 8 + .../velocity/player/VelocityLocalPlayers.java | 5 + .../resources/lang/messages_de.properties | 34 ++++ .../resources/lang/messages_en.properties | 34 ++++ .../velocity/command/FakeQueleaGateway.java | 54 ++++++ .../velocity/command/PartyActionsTest.java | 2 +- .../command/PublicPartyActionsTest.java | 137 +++++++++++++ .../error/JoinRequestFailureReportsTest.java | 39 ++++ .../velocity/event/JoinRequestEventsTest.java | 180 ++++++++++++++++++ .../velocity/http/JoinRequestGatewayTest.java | 115 +++++++++++ .../i18n/JoinRequestMessagesTest.java | 71 +++++++ .../quelea/velocity/i18n/MessagesTest.java | 3 +- .../listener/PresenceListenerTest.java | 25 +++ .../velocity/player/FakeLocalPlayer.java | 13 ++ 25 files changed, 1033 insertions(+), 6 deletions(-) create mode 100644 velocity/src/main/java/net/onelitefeather/quelea/velocity/http/JoinResult.java create mode 100644 velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PublicPartyActionsTest.java create mode 100644 velocity/src/test/java/net/onelitefeather/quelea/velocity/error/JoinRequestFailureReportsTest.java create mode 100644 velocity/src/test/java/net/onelitefeather/quelea/velocity/event/JoinRequestEventsTest.java create mode 100644 velocity/src/test/java/net/onelitefeather/quelea/velocity/http/JoinRequestGatewayTest.java create mode 100644 velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/JoinRequestMessagesTest.java diff --git a/README.md b/README.md index 7dad173..248bb60 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Events are delivered at most once. A proxy that reconnects does not receive even **Lonely parties.** A party that has only one member after `QUELEA_PARTY_ALONE_TIMEOUT` is disbanded. The timeout counts from the creation or from the leave or kick that left that one member, and a join ends it. A leave or kick that leaves one member keeps the party open. `QUELEA_PARTY_ALONE_WARNING` before that, the service publishes `party.expiring` with the expiry time, and the proxy tells the leader how many minutes are left. Expired parties are disbanded with the reason `expired-alone`. Every `party.disbanded` event carries a `reason`: `leader` (the leader disbanded), `last-member-left` (no member remained) or `expired-alone`. Clients must treat an unknown reason like `leader`. +**Join requests and public parties.** A player outside a party may ask the party of an online player to let them in (`POST /v1/players/{playerId}/party/requests`). A private party records the request for its leader, who answers it with `POST /v1/parties/{partyId}/join-requests/accept` or `.../reject`; the service publishes `party.join-requested` and `party.join-request-answered`. A request expires after `QUELEA_JOIN_REQUEST_TTL`, unanswered and without an event. A denial or an expiry starts a cooldown (`QUELEA_JOIN_REQUEST_COOLDOWN`) during which the player gets `429 join-request-cooldown` with `Retry-After`. A leader opens a party with `POST /v1/parties/{partyId}/public` (`public: true`); while it is open, a join (`POST /v1/players/{playerId}/party/join`) or a request admits the player at once, up to `QUELEA_PARTY_PUBLIC_MAX_SIZE`. Closing needs no permission and keeps the members. A change of leader closes the party. A player kicked from a party cannot join or request it until the leader invites them. Bans, open requests and cooldowns end with the party. + **Party versions and conditional requests.** Every party has a `version`: 1 when it is created, +1 with every change. It is in the party JSON and in every event. `GET /v1/parties/{id}` and `GET /v1/players/{id}/party` send it as a strong `ETag` (for example `"4"`) and answer `304 Not Modified` without a body when `If-None-Match` names the current tag. `kick`, `promote`, `disband` and `warp` accept `If-Match` with one or more strong tags or `*`. If none matches the current version, the command changes nothing and answers `412 precondition-failed`. A weak tag (`W/`) never matches. Without the header the command runs unconditionally. **Retries.** A `POST` may carry an `Idempotency-Key` header (1 to 255 visible ASCII characters). The first request with a key runs; a 2xx response is stored for `QUELEA_IDEMPOTENCY_TTL`, and a repeat with the same key and the same request is answered with the stored response without running again. The key is scoped to the authenticated client. The same key with another request answers `422 idempotency-key-reused`; a repeat while the first request is still running answers `409 request-in-progress` with `Retry-After: 1`. Failed responses are not stored, so a retry after an error runs again. The key is kept for `QUELEA_IDEMPOTENCY_LOCK` while a request runs, so a crashed request does not block its key for longer than that. @@ -48,6 +50,12 @@ Events are delivered at most once. A proxy that reconnects does not receive even | `/party invite ` | leader | Invites an online player. Creates a party if you are not in one. The invite expires after 60 s. | | `/party accept [inviter]` | invited player | Joins the party. Without a name, works when exactly one invite is pending; otherwise lists the inviters. | | `/party deny [inviter]` | invited player | Declines an invite. Same name rule as `accept`. | +| `/party public on` | leader with `quelea.party.public` | Opens the party to everyone: anyone can join it by name with `/party join`. Without the permission the proxy refuses and nothing is sent to the service. | +| `/party public off` | leader | Closes the party again. Needs no permission, so a leader who lost it can still close. Members stay. | +| `/party request ` | player not in a party | Asks the party of an online player to let you in. A public party lets you in at once; a private one tells its leader, who answers within 60 s. After a denial or an expiry you wait 60 s before asking again. | +| `/party join ` | player not in a party | Joins the party of an online player directly. Only works for a public party; a private one names `/party request`. | +| `/party approve ` | leader | Accepts the join request of a player. The leader's prompt offers the same as clickable `[Accept]`. | +| `/party reject ` | leader | Declines the join request of a player. The leader's prompt offers the same as clickable `[Deny]`. | | `/party leave` | member | Leaves the party. If the leader leaves, the longest-standing member becomes leader. | | `/party kick ` | leader | Removes a member. | | `/party promote ` | leader | Makes a member the leader. | @@ -58,6 +66,10 @@ Events are delivered at most once. A proxy that reconnects does not receive even Messages are shown in English or German, following the player's client language. +**Public parties and the permission.** Opening a party (`/party public on`) needs the Velocity permission `quelea.party.public`; the proxy checks it before it calls the service. Closing needs no permission. A change of leader (promotion, or a leader who leaves) closes an open party: the new leader is told, and is told how to reopen it only if they hold the permission. A public party admits up to `QUELEA_PARTY_PUBLIC_MAX_SIZE` members, and a kicked player cannot join or request the party until the leader invites them again. + +**Every proxy needs the new plugin.** A request reaches the leader only through the proxy the leader is on. Install the plugin version with `/party request` on every proxy before players use it; a proxy with an older plugin drops the prompt silently, and the request then expires. + ### Languages and message overrides The plugin ships English and German. A player sees the bundle for their client locale: `de_DE`, then `de`, then `en`. A key that is missing in the chosen bundle is shown in English, so a partial translation never shows a raw key. @@ -90,6 +102,9 @@ Set these on the service. Defaults are in `backend/src/main/resources/applicatio | `QUELEA_PARTY_ALONE_TIMEOUT` | `30m` | A party that has only one member for this long is disbanded with reason `expired-alone`. | | `QUELEA_PARTY_ALONE_WARNING` | `5m` | `party.expiring` is published this long before the expiry. `0s` disables the warning. | | `QUELEA_PARTY_EXPIRY_INTERVAL` | `15s` | How often the expiry job runs; one replica per run, under a lock. The expiry can be this late. | +| `QUELEA_PARTY_PUBLIC_MAX_SIZE` | `20` | Maximum members of a public party, counting the leader. Raised to `QUELEA_PARTY_MAX_SIZE` when lower. | +| `QUELEA_JOIN_REQUEST_TTL` | `60s` | How long a join request to a private party stays open before it expires. | +| `QUELEA_JOIN_REQUEST_COOLDOWN` | `60s` | How long a player waits before asking the same party again after a denial or an expiry. | | `QUELEA_PROXY_TIMEOUT` | `45s` | A proxy without a heartbeat for this long is treated as dead; its players are disconnected from their parties. | | `QUELEA_SSE_KEEPALIVE` | `15s` | Interval of the keep-alive comment on idle event streams. | | `QUELEA_SSE_BUFFER` | `256` | Events one stream may hold unread. A stream that overflows is closed. | diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java index 9b94c21..d2a2fa4 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java @@ -108,7 +108,7 @@ public void onProxyInitialize(ProxyInitializeEvent event) { proxy.getEventManager().register(this, new PresenceListener(gateway, executor, config.proxyId(), failures, logger)); - PartyActions actions = new PartyActions(gateway, executor, messages, failures, logger); + PartyActions actions = new PartyActions(gateway, executor, messages, failures, logger, Clock.systemUTC()); PartyCommands commands = new PartyCommands(actions, localPlayers, localPlayers::wrap, messages); registerCommand(commands.party(), "p"); registerCommand(commands.chat()); diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/HelpText.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/HelpText.java index 15834fa..80f7802 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/HelpText.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/HelpText.java @@ -23,6 +23,11 @@ record Entry(String name, String suggestion) { new Entry("invite", "/party invite "), new Entry("accept", "/party accept "), new Entry("deny", "/party deny "), + new Entry("public", "/party public "), + new Entry("request", "/party request "), + new Entry("join", "/party join "), + new Entry("approve", "/party approve "), + new Entry("reject", "/party reject "), new Entry("leave", "/party leave"), new Entry("list", "/party list"), new Entry("kick", "/party kick "), diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java index ed94c25..499563a 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java @@ -3,6 +3,7 @@ import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.onelitefeather.quelea.velocity.error.FailureReports; import net.onelitefeather.quelea.velocity.http.Invite; +import net.onelitefeather.quelea.velocity.http.JoinResult; import net.onelitefeather.quelea.velocity.http.Member; import net.onelitefeather.quelea.velocity.http.Party; import net.onelitefeather.quelea.velocity.http.QueleaGateway; @@ -12,6 +13,9 @@ import net.onelitefeather.quelea.velocity.player.LocalPlayer; import org.slf4j.Logger; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -29,14 +33,16 @@ public final class PartyActions { private final Messages messages; private final FailureReports failures; private final Logger logger; + private final Clock clock; public PartyActions(QueleaGateway gateway, Executor executor, Messages messages, FailureReports failures, - Logger logger) { + Logger logger, Clock clock) { this.gateway = gateway; this.executor = executor; this.messages = messages; this.failures = failures; this.logger = logger; + this.clock = clock; } public void create(LocalPlayer player) { @@ -143,6 +149,75 @@ public void chat(LocalPlayer player, String message) { gateway.chat(party.id(), player.id().toString(), message)); } + /** + * Opens the leader's party. Without the Velocity permission the answer is given here and the service is not called. + */ + public void publicOn(LocalPlayer player) { + if (!player.hasPermission(LocalPlayer.PUBLIC_PARTY_PERMISSION)) { + run(player, locale -> player.send(messages.message(locale, "command.public.no-permission"))); + return; + } + withParty(player, (party, locale) -> { + gateway.setPublic(party.id(), player.id().toString(), true); + player.send(messages.message(locale, "command.public.opened")); + }); + } + + /** Closes the party. Any leader may close it, with or without the permission. */ + public void publicOff(LocalPlayer player) { + withParty(player, (party, locale) -> { + gateway.setPublic(party.id(), player.id().toString(), false); + player.send(messages.message(locale, "command.public.closed")); + }); + } + + /** + * Asks the party of {@code name} to let the player in. A public party answers that the player joined; a private one + * answers that the request was sent and for how long it stays open. + */ + public void request(LocalPlayer player, String name) { + run(player, locale -> { + JoinResult result = gateway.request(player.id().toString(), name); + switch (result) { + case JoinResult.Joined joined -> player.send(messages.message(locale, "command.request.joined", + Placeholder.unparsed("name", name))); + case JoinResult.Sent sent -> player.send(messages.message(locale, "command.request.sent", + Placeholder.unparsed("name", name), + Placeholder.unparsed("seconds", String.valueOf(secondsLeft(sent.expiresAt()))))); + } + }); + } + + /** Joins the party of {@code name} directly; a private party answers with the party-not-public text. */ + public void join(LocalPlayer player, String name) { + run(player, locale -> { + gateway.join(player.id().toString(), name); + player.send(messages.message(locale, "command.join.done", Placeholder.unparsed("name", name))); + }); + } + + /** The leader accepts the open request of {@code name}. */ + public void approve(LocalPlayer player, String name) { + withParty(player, (party, locale) -> { + gateway.acceptRequest(party.id(), player.id().toString(), name); + player.send(messages.message(locale, "command.approve.done", Placeholder.unparsed("name", name))); + }); + } + + /** The leader declines the open request of {@code name}. */ + public void reject(LocalPlayer player, String name) { + withParty(player, (party, locale) -> { + gateway.rejectRequest(party.id(), player.id().toString(), name); + player.send(messages.message(locale, "command.reject.done", Placeholder.unparsed("name", name))); + }); + } + + /** Whole seconds from now until {@code expiresAt}, rounded up, at least one. */ + private long secondsLeft(Instant expiresAt) { + long millis = Duration.between(clock.instant(), expiresAt).toMillis(); + return Math.max(1, (millis + 999) / 1000); + } + /** * The invite a command refers to. Sends the answer and returns empty when there is none to pick. An accept with no * matching invite from a player who is already in a party answers that they are already in it: that is what a diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyCommands.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyCommands.java index 9564a01..a40cc0f 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyCommands.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyCommands.java @@ -79,7 +79,28 @@ public BrigadierCommand party() { .then(BrigadierCommand.literalArgumentBuilder("list") .executes(context -> playersOnly(context.getSource(), actions::list))) .then(BrigadierCommand.literalArgumentBuilder("warp") - .executes(context -> playersOnly(context.getSource(), actions::warp))); + .executes(context -> playersOnly(context.getSource(), actions::warp))) + .then(BrigadierCommand.literalArgumentBuilder("public") + .then(BrigadierCommand.literalArgumentBuilder("on") + .executes(context -> playersOnly(context.getSource(), actions::publicOn))) + .then(BrigadierCommand.literalArgumentBuilder("off") + .executes(context -> playersOnly(context.getSource(), actions::publicOff)))) + .then(BrigadierCommand.literalArgumentBuilder("request") + .then(playerArgument() + .executes(context -> playersOnly(context.getSource(), + player -> actions.request(player, playerName(context)))))) + .then(BrigadierCommand.literalArgumentBuilder("join") + .then(playerArgument() + .executes(context -> playersOnly(context.getSource(), + player -> actions.join(player, playerName(context)))))) + .then(BrigadierCommand.literalArgumentBuilder("approve") + .then(playerArgument() + .executes(context -> playersOnly(context.getSource(), + player -> actions.approve(player, playerName(context)))))) + .then(BrigadierCommand.literalArgumentBuilder("reject") + .then(playerArgument() + .executes(context -> playersOnly(context.getSource(), + player -> actions.reject(player, playerName(context)))))); return new BrigadierCommand(root); } diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java index 5073a4c..14c6807 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java @@ -1,5 +1,6 @@ package net.onelitefeather.quelea.velocity.event; +import com.google.gson.JsonObject; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; @@ -58,6 +59,8 @@ public void handle(PartyEvent event) { case "party.expiring" -> expiring(event); case "party.warp" -> warp(event); case "party.chat" -> chat(event); + case "party.join-requested" -> joinRequested(event); + case "party.join-request-answered" -> joinRequestAnswered(event); default -> { // Unknown types are ignored on purpose: the service may add events before the plugin knows them. } @@ -150,6 +153,72 @@ private void leaderChanged(PartyEvent event) { sendTo(member.id(), "party.leader-changed", Placeholder.unparsed("player", leaderName(event))); } } + closedNotice(event); + } + + /** + * A change of leader that cleared the open flag ({@code wasPublic}) tells the new leader that the party is private + * again. The reopen hint is shown only to a leader who holds the permission to open it. + */ + private void closedNotice(PartyEvent event) { + JsonObject payload = event.payload(); + if (!payload.has("wasPublic") || !payload.get("wasPublic").getAsBoolean()) { + return; + } + UUID leader = uuid(event.leaderId()); + if (leader == null) { + return; + } + players.find(leader).ifPresent(player -> { + String key = player.hasPermission(LocalPlayer.PUBLIC_PARTY_PERMISSION) + ? "party.public-closed.reopen" : "party.public-closed"; + player.send(messages.message(player.locale(), key)); + }); + } + + /** + * The leader's prompt for a join request. The two buttons run the answer commands with the requester's name, which + * the service resolves. + */ + private void joinRequested(PartyEvent event) { + String requesterName = event.field("requesterName"); + UUID leader = uuid(event.leaderId()); + if (leader == null || requesterName == null) { + return; + } + players.find(leader).ifPresent(player -> { + TagResolver requester = Placeholder.unparsed("requester", requesterName); + Component accept = messages.line(player.locale(), "party.join-request.accept") + .clickEvent(ClickEvent.runCommand("/party approve " + requesterName)) + .hoverEvent(HoverEvent.showText( + messages.line(player.locale(), "party.join-request.accept.hover", requester))); + Component deny = messages.line(player.locale(), "party.join-request.deny") + .clickEvent(ClickEvent.runCommand("/party reject " + requesterName)) + .hoverEvent(HoverEvent.showText( + messages.line(player.locale(), "party.join-request.deny.hover", requester))); + player.send(messages.message(player.locale(), "party.join-request", requester) + .append(Component.space()) + .append(accept) + .append(Component.space()) + .append(deny)); + }); + } + + /** + * Tells the requester, if local, how the leader answered. The requester is the joining player of an accepted + * request, so the party.joined of that join skips them: this line is the only one they get. + */ + private void joinRequestAnswered(PartyEvent event) { + String outcome = event.field("outcome"); + String key; + if ("accepted".equals(outcome)) { + key = "party.join-request.accepted"; + } else if ("denied".equals(outcome)) { + key = "party.join-request.denied"; + } else { + return; + } + sendTo(event.field("requesterId"), key); } private void disbanded(PartyEvent event) { diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGateway.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGateway.java index a942ceb..2ea72e6 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGateway.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGateway.java @@ -14,6 +14,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -150,6 +151,42 @@ public void chat(String partyId, String actorId, String message) { call("POST", partyRoute(partyId) + "/chat", body); } + @Override + public Party setPublic(String partyId, String actorId, boolean open) { + JsonObject body = new JsonObject(); + body.addProperty("actorId", actorId); + body.addProperty("public", open); + return decode(call("POST", partyRoute(partyId) + "/public", body), QueleaJson::party); + } + + @Override + public Party join(String playerId, String targetName) { + return decode(call("POST", "/v1/players/" + encode(playerId) + "/party/join", targetBody(targetName)), + QueleaJson::party); + } + + /** 201 is an open request to a private party; 200 is a direct join into a public one. */ + @Override + public JoinResult request(String playerId, String targetName) { + HttpResponse response = call("POST", "/v1/players/" + encode(playerId) + "/party/requests", + targetBody(targetName)); + if (response.statusCode() == 201) { + return new JoinResult.Sent(decode(response, json -> Instant.parse(QueleaJson.optionalString(json, "expiresAt")))); + } + return new JoinResult.Joined(decode(response, QueleaJson::party)); + } + + @Override + public Party acceptRequest(String partyId, String actorId, String requesterName) { + return decode(call("POST", partyRoute(partyId) + "/join-requests/accept", answerBody(actorId, requesterName)), + QueleaJson::party); + } + + @Override + public void rejectRequest(String partyId, String actorId, String requesterName) { + call("POST", partyRoute(partyId) + "/join-requests/reject", answerBody(actorId, requesterName)); + } + @Override public void snapshot(String proxyId, List players) { JsonArray array = new JsonArray(); @@ -248,7 +285,8 @@ private static RuntimeException failure(HttpResponse response) { return new QueleaUnavailableException("party service is still processing this request"); } if (code != null) { - return new QueleaProblemException(code, status, QueleaJson.optionalString(problem, "detail")); + return new QueleaProblemException(code, status, QueleaJson.optionalString(problem, "detail"), + retryAfterSeconds(response)); } } catch (RuntimeException ignored) { // Not a problem document: handled below as unavailable. @@ -273,6 +311,32 @@ private static T decodeArray(HttpResponse response, Function response) { + return response.headers().firstValue("Retry-After") + .map(value -> { + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return null; + } + }) + .orElse(null); + } + + private static JsonObject targetBody(String targetName) { + JsonObject body = new JsonObject(); + body.addProperty("targetName", targetName); + return body; + } + + private static JsonObject answerBody(String actorId, String requesterName) { + JsonObject body = new JsonObject(); + body.addProperty("actorId", actorId); + body.addProperty("requesterName", requesterName); + return body; + } + private static JsonObject actorTarget(String actorId, String targetId) { JsonObject body = new JsonObject(); body.addProperty("actorId", actorId); diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/JoinResult.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/JoinResult.java new file mode 100644 index 0000000..6389744 --- /dev/null +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/JoinResult.java @@ -0,0 +1,18 @@ +package net.onelitefeather.quelea.velocity.http; + +import java.time.Instant; + +/** + * What {@code /party request} got back: the party the player joined directly, or an open request to a private party + * that is valid until {@code expiresAt}. + */ +public sealed interface JoinResult permits JoinResult.Joined, JoinResult.Sent { + + /** The party is public: the player is a member now (HTTP 200). */ + record Joined(Party party) implements JoinResult { + } + + /** The leader has been asked and can answer until {@code expiresAt} (HTTP 201). */ + record Sent(Instant expiresAt) implements JoinResult { + } +} diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaGateway.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaGateway.java index 2a74366..f6675c0 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaGateway.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaGateway.java @@ -40,6 +40,26 @@ public interface QueleaGateway { void chat(String partyId, String actorId, String message); + /** + * Opens ({@code open = true}) or closes the leader's party to everyone. + */ + Party setPublic(String partyId, String actorId, boolean open); + + /** + * Joins the party of {@code targetName} directly; the service refuses it unless the party is public. + */ + Party join(String playerId, String targetName); + + /** + * Asks the party of {@code targetName} to let the player in: a public party admits them at once, a private one + * holds the request for its leader. + */ + JoinResult request(String playerId, String targetName); + + Party acceptRequest(String partyId, String actorId, String requesterName); + + void rejectRequest(String partyId, String actorId, String requesterName); + void snapshot(String proxyId, List players); void login(String proxyId, String playerId, String name); diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaProblemException.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaProblemException.java index d03e425..f0f6876 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaProblemException.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaProblemException.java @@ -1,5 +1,7 @@ package net.onelitefeather.quelea.velocity.http; +import java.util.Optional; + /** * The service answered with a problem document. Callers switch on {@link #code()}. */ @@ -8,12 +10,21 @@ public final class QueleaProblemException extends RuntimeException { private final String code; private final int status; private final String detail; + private final Long retryAfterSeconds; public QueleaProblemException(String code, int status, String detail) { + this(code, status, detail, null); + } + + /** + * @param retryAfterSeconds the whole seconds of the {@code Retry-After} header, or {@code null} when the answer had none + */ + public QueleaProblemException(String code, int status, String detail, Long retryAfterSeconds) { super(code + " (HTTP " + status + ")" + (detail == null ? "" : ": " + detail)); this.code = code; this.status = status; this.detail = detail; + this.retryAfterSeconds = retryAfterSeconds; } /** @@ -36,4 +47,11 @@ public int status() { public String detail() { return detail; } + + /** + * @return the seconds the service asked the client to wait, when the answer carried a {@code Retry-After} header + */ + public Optional retryAfterSeconds() { + return Optional.ofNullable(retryAfterSeconds); + } } diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/i18n/Messages.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/i18n/Messages.java index ca04656..ac14251 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/i18n/Messages.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/i18n/Messages.java @@ -34,6 +34,7 @@ public final class Messages { private static final String PROBLEM_PREFIX = "problem."; private static final String UNKNOWN_PROBLEM = "problem.unknown"; + private static final String COOLDOWN_CODE = "join-request-cooldown"; private static final String ENGLISH = "en"; private static final List BUILT_IN = List.of(ENGLISH, "de"); private static final String FILE_PREFIX = "messages_"; @@ -100,6 +101,11 @@ public Component line(Locale locale, String key, TagResolver... resolvers) { */ public Component problem(Locale locale, QueleaProblemException problem) { String key = PROBLEM_PREFIX + problem.code(); + // The cooldown names the wait only when the service said how long it is; otherwise the plain text is shown. + if (COOLDOWN_CODE.equals(problem.code()) && problem.retryAfterSeconds().isPresent()) { + return message(locale, key + ".wait", + Placeholder.unparsed("seconds", String.valueOf(problem.retryAfterSeconds().get()))); + } if (hasKey(locale, key)) { return message(locale, key); } diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/player/LocalPlayer.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/player/LocalPlayer.java index d463813..cef0062 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/player/LocalPlayer.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/player/LocalPlayer.java @@ -12,8 +12,16 @@ */ public interface LocalPlayer { + /** The Velocity permission a leader needs to open a party to everyone. Closing needs none. */ + String PUBLIC_PARTY_PERMISSION = "quelea.party.public"; + UUID id(); + /** + * @return whether the player holds the Velocity permission {@code permission} + */ + boolean hasPermission(String permission); + String name(); Locale locale(); diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/player/VelocityLocalPlayers.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/player/VelocityLocalPlayers.java index f57cdf9..f90129d 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/player/VelocityLocalPlayers.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/player/VelocityLocalPlayers.java @@ -52,6 +52,11 @@ public String name() { return player.getUsername(); } + @Override + public boolean hasPermission(String permission) { + return player.hasPermission(permission); + } + @Override public Locale locale() { return player.getEffectiveLocale(); diff --git a/velocity/src/main/resources/lang/messages_de.properties b/velocity/src/main/resources/lang/messages_de.properties index 88ac5c0..369ffe1 100644 --- a/velocity/src/main/resources/lang/messages_de.properties +++ b/velocity/src/main/resources/lang/messages_de.properties @@ -35,6 +35,13 @@ problem.lock-timeout=<#fca5a5>Die Party ist gerade beschäftigt. Bitte versuche problem.internal-error=<#fca5a5>Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut. problem.unknown=<#fca5a5>Etwas ist schiefgelaufen (). Bitte versuche es erneut. problem.unavailable=<#fca5a5>Der Party-Dienst ist gerade nicht erreichbar, bitte versuche es später erneut. +problem.party-not-public=<#fca5a5>Diese Party ist nicht für alle offen. Frag mit <#c4b5fd>/party request <#a78bfa>[Spieler] <#fca5a5>nach, ob du beitreten darfst. +problem.party-banned=<#fca5a5>Der Leiter hat dich aus dieser Party entfernt. Bitte um eine Einladung, um wieder beizutreten. +problem.target-not-in-party=<#fca5a5>Dieser Spieler ist in keiner Party. +problem.join-request-pending=<#fde68a>Du hast diese Party bereits gefragt. Warte auf die Antwort des Leiters. +problem.join-request-cooldown=<#fde68a>Du hast diese Party vor Kurzem gefragt. Bitte warte kurz, bevor du erneut fragst. +problem.join-request-cooldown.wait=<#fde68a>Du hast diese Party vor Kurzem gefragt. Bitte warte noch Sekunden, bevor du erneut fragst. +problem.join-request-not-found=<#fca5a5>Es gibt keine offene Anfrage von diesem Spieler. Sie ist vielleicht abgelaufen. command.players-only=<#fca5a5>Nur Spieler können diesen Befehl benutzen. command.help.title=Party-Befehle @@ -50,6 +57,11 @@ command.help.promote=<#c4b5fd>/party promote <#a78bfa>[Spieler]/party disband » die Party auflösen (Leiter) command.help.warp=<#c4b5fd>/party warp » die Party zu deinem Server holen (Leiter) command.help.chat=<#c4b5fd>/pc <#a78bfa>[Nachricht] » mit deiner Party chatten +command.help.public=<#c4b5fd>/party public <#a78bfa>on|off » die Party für alle öffnen oder schließen +command.help.request=<#c4b5fd>/party request <#a78bfa>[Spieler] » bei der Party eines Spielers anfragen +command.help.join=<#c4b5fd>/party join <#a78bfa>[Spieler] » der offenen Party eines Spielers beitreten +command.help.approve=<#c4b5fd>/party approve <#a78bfa>[Spieler] » eine Anfrage annehmen (Leiter) +command.help.reject=<#c4b5fd>/party reject <#a78bfa>[Spieler] » eine Beitrittsanfrage ablehnen (Leiter) command.help.create.hover=Gründe eine Party, in der du der Leiter bist. command.help.invite.hover=Lade einen online Spieler ein. Er bekommt die Knöpfe [Annehmen] und [Ablehnen]. command.help.accept.hover=Tritt der Party bei, die dich eingeladen hat. @@ -61,6 +73,11 @@ command.help.promote.hover=Macht ein Mitglied zum neuen Leiter. Nur für L command.help.disband.hover=Beendet die Party für alle. Nur für Leiter. command.help.warp.hover=Holt alle Mitglieder zu deinem Server. Nur für Leiter. command.help.chat.hover=Sendet eine Nachricht, die nur deine Party liest. +command.help.public.hover=Öffnet deine Party, damit jeder mit /party join beitreten kann. Das Öffnen braucht eine Berechtigung, das Schließen nicht. Nur für Leiter. +command.help.request.hover=Frag bei der Party eines online Spielers an, ob du beitreten darfst. Eine offene Party lässt dich sofort herein. +command.help.join.hover=Tritt der Party eines online Spielers direkt bei. Funktioniert nur bei einer offenen Party. +command.help.approve.hover=Lass den Spieler, dessen Anfrage eingegangen ist, in deine Party. Nur für Leiter. +command.help.reject.hover=Lehne eine Beitrittsanfrage ab. Der Spieler muss vor einer neuen Anfrage warten. Nur für Leiter. command.created=<#86efac>Party erstellt. Lade Spieler mit <#c4b5fd>/party invite <#a78bfa>[Spieler] ein. command.invite.sent=<#86efac>Einladung an gesendet. command.invites.none=<#fca5a5>Du hast keine offenen Party-Einladungen. @@ -80,6 +97,14 @@ command.list.leader= ★ command.list.member= • command.warp.done=<#86efac>Die Party wird zu geholt. command.warp.no-server=<#fca5a5>Du bist auf keinem Server. +command.public.opened=<#86efac>Deine Party ist jetzt für alle offen. +command.public.closed=Deine Party ist wieder privat. +command.public.no-permission=<#fca5a5>Du darfst keine Party für alle öffnen. +command.request.joined=<#86efac>Du bist der Party von beigetreten. +command.request.sent=<#86efac>Deine Anfrage an die Party von wurde gesendet. Sie bleibt Sekunden offen. +command.join.done=<#86efac>Du bist der Party von beigetreten. +command.approve.done=<#86efac>Du hast in die Party gelassen. +command.reject.done=Du hast die Anfrage von abgelehnt. party.created= <#86efac>hat die Party erstellt. party.joined= <#86efac>ist der Party beigetreten. @@ -101,3 +126,12 @@ party.invite.deny.hover=Die Einladung von ablehnen. party.warp.unknown-server=<#fca5a5>Die Party kann nicht zu folgen: Der Proxy kennt diesen Server nicht. party.warp.refused=<#fca5a5>Die Party konnte nicht zu folgen. Du bleibst, wo du bist. party.chat=Party │ <#e9d5ff> » +party.join-request= <#fde68a>möchte deiner Party beitreten. +party.join-request.accept=<#86efac>[Annehmen] +party.join-request.accept.hover=Lass in deine Party. +party.join-request.deny=<#fca5a5>[Ablehnen] +party.join-request.deny.hover=Die Anfrage von ablehnen. +party.join-request.accepted=<#86efac>Die Party hat dich hereingelassen. +party.join-request.denied=<#fca5a5>Die Party hat deine Anfrage abgelehnt. +party.public-closed=Die Party ist wieder privat. +party.public-closed.reopen=Die Party ist wieder privat. Öffne sie erneut mit <#c4b5fd>/party public on. diff --git a/velocity/src/main/resources/lang/messages_en.properties b/velocity/src/main/resources/lang/messages_en.properties index 4c4d707..d614175 100644 --- a/velocity/src/main/resources/lang/messages_en.properties +++ b/velocity/src/main/resources/lang/messages_en.properties @@ -35,6 +35,13 @@ problem.lock-timeout=<#fca5a5>The party is busy right now. Please try again.An unexpected error occurred. Please try again. problem.unknown=<#fca5a5>Something went wrong (). Please try again. problem.unavailable=<#fca5a5>The party service is unavailable, please try again later. +problem.party-not-public=<#fca5a5>That party is not open to everyone. Ask to join with <#c4b5fd>/party request <#a78bfa>[player]<#fca5a5>. +problem.party-banned=<#fca5a5>The leader removed you from that party. Ask for an invite to join again. +problem.target-not-in-party=<#fca5a5>That player is not in a party. +problem.join-request-pending=<#fde68a>You have already asked that party. Wait for the leader's answer. +problem.join-request-cooldown=<#fde68a>You asked that party recently. Please wait a little before you ask again. +problem.join-request-cooldown.wait=<#fde68a>You asked that party recently. Please wait seconds before you ask again. +problem.join-request-not-found=<#fca5a5>There is no open request from that player. It may have expired. command.players-only=<#fca5a5>Only players can use this command. command.help.title=Party commands @@ -50,6 +57,11 @@ command.help.promote=<#c4b5fd>/party promote <#a78bfa>[player]/party disband » end the party (leader) command.help.warp=<#c4b5fd>/party warp » bring the party to your server (leader) command.help.chat=<#c4b5fd>/pc <#a78bfa>[message] » chat with your party +command.help.public=<#c4b5fd>/party public <#a78bfa>on|off » open the party to everyone, or close it +command.help.request=<#c4b5fd>/party request <#a78bfa>[player] » ask a player's party to let you in +command.help.join=<#c4b5fd>/party join <#a78bfa>[player] » join a player's open party +command.help.approve=<#c4b5fd>/party approve <#a78bfa>[player] » let a requester in (leader) +command.help.reject=<#c4b5fd>/party reject <#a78bfa>[player] » decline a join request (leader) command.help.create.hover=Start a party with you as its leader. command.help.invite.hover=Invite an online player. They get [Accept] and [Deny] buttons. command.help.accept.hover=Join the party that invited you. @@ -61,6 +73,11 @@ command.help.promote.hover=Make a member the new leader. Leaders only.End the party for everyone. Leaders only. command.help.warp.hover=Bring every member to your server. Leaders only. command.help.chat.hover=Send a message that only your party reads. +command.help.public.hover=Open your party so anyone can join it with /party join. Opening needs a permission; closing does not. Leaders only. +command.help.request.hover=Ask the party of an online player to let you in. A public party lets you in at once. +command.help.join.hover=Join the party of an online player directly. Only works for an open party. +command.help.approve.hover=Let the player whose request you received into your party. Leaders only. +command.help.reject.hover=Decline a join request. The player must wait before asking again. Leaders only. command.created=<#86efac>Party created. Invite players with <#c4b5fd>/party invite <#a78bfa>[player] command.invite.sent=<#86efac>Invite sent to . command.invites.none=<#fca5a5>You have no pending party invites. @@ -80,6 +97,14 @@ command.list.leader= ★ command.list.member= • command.warp.done=<#86efac>Bringing the party to . command.warp.no-server=<#fca5a5>You are not on a server. +command.public.opened=<#86efac>Your party is open to everyone now. +command.public.closed=Your party is private again. +command.public.no-permission=<#fca5a5>You are not allowed to open a party to everyone. +command.request.joined=<#86efac>You joined the party of . +command.request.sent=<#86efac>Your request to the party of was sent. It stays open for seconds. +command.join.done=<#86efac>You joined the party of . +command.approve.done=<#86efac>You let into the party. +command.reject.done=You declined the request from . party.created= <#86efac>created the party. party.joined= <#86efac>joined the party. @@ -101,3 +126,12 @@ party.invite.deny.hover=Decline the invite from . party.warp.unknown-server=<#fca5a5>The party cannot follow to : the proxy does not know that server. party.warp.refused=<#fca5a5>Could not follow the party to . You stay where you are. party.chat=Party │ <#e9d5ff> » +party.join-request= <#fde68a>asks to join your party. +party.join-request.accept=<#86efac>[Accept] +party.join-request.accept.hover=Let into your party. +party.join-request.deny=<#fca5a5>[Deny] +party.join-request.deny.hover=Decline the request from . +party.join-request.accepted=<#86efac>The party let you in. +party.join-request.denied=<#fca5a5>The party declined your request. +party.public-closed=The party is private again. +party.public-closed.reopen=The party is private again. Reopen it with <#c4b5fd>/party public on. diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java index 5f18091..54a6ece 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java @@ -1,6 +1,7 @@ package net.onelitefeather.quelea.velocity.command; import net.onelitefeather.quelea.velocity.http.Invite; +import net.onelitefeather.quelea.velocity.http.JoinResult; import net.onelitefeather.quelea.velocity.http.Party; import net.onelitefeather.quelea.velocity.http.PlayerRef; import net.onelitefeather.quelea.velocity.http.QueleaGateway; @@ -104,6 +105,59 @@ public void chat(String partyId, String actorId, String message) { throw new UnsupportedOperationException(); } + /** The service calls that changed the public flag: {@code "on"} or {@code "off"}, in order. */ + final List publicCalls = new ArrayList<>(); + /** Names passed to {@code join}, in order. */ + final List joins = new ArrayList<>(); + /** Names passed to {@code request}, in order. */ + final List requests = new ArrayList<>(); + /** {@code accept:} and {@code reject:} for each answer, in order. */ + final List answers = new ArrayList<>(); + private JoinResult requestResult; + + FakeQueleaGateway withRequestResult(JoinResult result) { + this.requestResult = result; + return this; + } + + @Override + public Party setPublic(String partyId, String actorId, boolean open) { + if (failure != null) { + throw failure; + } + publicCalls.add(open ? "on" : "off"); + return parties.stream().filter(party -> party.id().equals(partyId)).findFirst().orElseThrow(); + } + + @Override + public Party join(String playerId, String targetName) { + if (failure != null) { + throw failure; + } + joins.add(targetName); + return parties.get(0); + } + + @Override + public JoinResult request(String playerId, String targetName) { + if (failure != null) { + throw failure; + } + requests.add(targetName); + return requestResult; + } + + @Override + public Party acceptRequest(String partyId, String actorId, String requesterName) { + answers.add("accept:" + requesterName); + return parties.stream().filter(party -> party.id().equals(partyId)).findFirst().orElseThrow(); + } + + @Override + public void rejectRequest(String partyId, String actorId, String requesterName) { + answers.add("reject:" + requesterName); + } + @Override public void snapshot(String proxyId, List players) { throw new UnsupportedOperationException(); diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PartyActionsTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PartyActionsTest.java index 6d85055..6db7d6b 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PartyActionsTest.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PartyActionsTest.java @@ -45,7 +45,7 @@ private PartyActions actions(FakeQueleaGateway gateway) { FailureReports failures = new FailureReports(reporter, messages, PROXY_ID); // Runs the work on the calling thread, so the answer is there when the call returns. return new PartyActions(gateway, Runnable::run, messages, failures, - LoggerFactory.getLogger(PartyActionsTest.class)); + LoggerFactory.getLogger(PartyActionsTest.class), java.time.Clock.fixed(NOW, java.time.ZoneOffset.UTC)); } private static Party party() { diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PublicPartyActionsTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PublicPartyActionsTest.java new file mode 100644 index 0000000..8dfcc66 --- /dev/null +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/PublicPartyActionsTest.java @@ -0,0 +1,137 @@ +package net.onelitefeather.quelea.velocity.command; + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.onelitefeather.quelea.velocity.error.FailureReports; +import net.onelitefeather.quelea.velocity.error.FakeErrorReporter; +import net.onelitefeather.quelea.velocity.http.JoinResult; +import net.onelitefeather.quelea.velocity.http.Member; +import net.onelitefeather.quelea.velocity.http.Party; +import net.onelitefeather.quelea.velocity.http.QueleaProblemException; +import net.onelitefeather.quelea.velocity.i18n.Messages; +import net.onelitefeather.quelea.velocity.player.FakeLocalPlayer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The public-party and join-request commands: the permission check, the direct join, and the leader's answers. */ +class PublicPartyActionsTest { + + private static final UUID ALICE = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + private static final UUID BOB = UUID.fromString("00000000-0000-0000-0000-00000000000b"); + private static final Instant NOW = Instant.parse("2026-10-28T10:00:00Z"); + private static final String PARTY_ID = "p-1"; + + @TempDir + Path overrides; + + private final FakeErrorReporter reporter = new FakeErrorReporter(); + + private PartyActions actions(FakeQueleaGateway gateway) { + Messages messages = Messages.load(overrides, LoggerFactory.getLogger(PublicPartyActionsTest.class)); + FailureReports failures = new FailureReports(reporter, messages, "proxy-1"); + return new PartyActions(gateway, Runnable::run, messages, failures, + LoggerFactory.getLogger(PublicPartyActionsTest.class), Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private static Party party() { + return new Party(PARTY_ID, ALICE.toString(), + List.of(new Member(ALICE.toString(), "Alice", NOW), new Member(BOB.toString(), "Bob", NOW)), NOW); + } + + private static FakeLocalPlayer leader() { + return new FakeLocalPlayer(ALICE, "Alice", Locale.ENGLISH, "Lobby-1"); + } + + private static String lastLine(FakeLocalPlayer player) { + assertFalse(player.sent().isEmpty(), "the player must be answered"); + return PlainTextComponentSerializer.plainText().serialize(player.sent().get(player.sent().size() - 1)); + } + + @Test + void openingWithoutThePermissionAnswersAndCallsNoService() { + FakeQueleaGateway gateway = new FakeQueleaGateway().withParty(party()); + FakeLocalPlayer player = leader(); + + actions(gateway).publicOn(player); + + assertTrue(gateway.publicCalls.isEmpty(), "the service must not be asked to open the party"); + assertTrue(lastLine(player).contains("not allowed"), lastLine(player)); + } + + @Test + void closingWithoutThePermissionStillReachesTheService() { + FakeQueleaGateway gateway = new FakeQueleaGateway().withParty(party()); + + actions(gateway).publicOff(leader()); + + assertEquals(List.of("off"), gateway.publicCalls, "closing needs no permission"); + } + + @Test + void openingWithThePermissionAsksTheServiceAndReadsItIsOpen() { + FakeQueleaGateway gateway = new FakeQueleaGateway().withParty(party()); + FakeLocalPlayer player = leader().withPermission("quelea.party.public"); + + actions(gateway).publicOn(player); + + assertEquals(List.of("on"), gateway.publicCalls); + assertTrue(lastLine(player).contains("open to everyone"), lastLine(player)); + } + + @Test + void requestAnsweredJoinedShowsTheJoinNotARequest() { + FakeQueleaGateway gateway = new FakeQueleaGateway().withRequestResult(new JoinResult.Joined(party())); + FakeLocalPlayer player = new FakeLocalPlayer(BOB, "Bob", Locale.ENGLISH, "Lobby-1"); + + actions(gateway).request(player, "Alice"); + + assertTrue(lastLine(player).contains("joined"), lastLine(player)); + assertFalse(lastLine(player).contains("sent"), "a direct join must not read as a request that was sent"); + } + + @Test + void requestAnsweredSentShowsHowLongItStaysOpen() { + FakeQueleaGateway gateway = new FakeQueleaGateway() + .withRequestResult(new JoinResult.Sent(NOW.plusSeconds(60))); + FakeLocalPlayer player = new FakeLocalPlayer(BOB, "Bob", Locale.ENGLISH, "Lobby-1"); + + actions(gateway).request(player, "Alice"); + + assertTrue(lastLine(player).contains("sent"), lastLine(player)); + assertTrue(lastLine(player).contains("60 seconds"), lastLine(player)); + } + + @Test + void joinOnAPrivatePartyNamesTheRequestCommand() { + FakeQueleaGateway gateway = new FakeQueleaGateway() + .failingWith(new QueleaProblemException("party-not-public", 409, null)); + FakeLocalPlayer player = new FakeLocalPlayer(BOB, "Bob", Locale.ENGLISH, "Lobby-1"); + + actions(gateway).join(player, "Alice"); + + assertTrue(lastLine(player).contains("/party request"), lastLine(player)); + } + + @Test + void approveAndRejectAnswerTheRequesterByName() { + FakeQueleaGateway gateway = new FakeQueleaGateway().withParty(party()); + PartyActions actions = actions(gateway); + + actions.approve(leader(), "Bob"); + actions.reject(leader(), "Bob"); + + assertEquals(List.of("accept:Bob", "reject:Bob"), gateway.answers); + } +} diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/error/JoinRequestFailureReportsTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/error/JoinRequestFailureReportsTest.java new file mode 100644 index 0000000..330345b --- /dev/null +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/error/JoinRequestFailureReportsTest.java @@ -0,0 +1,39 @@ +package net.onelitefeather.quelea.velocity.error; + +import net.onelitefeather.quelea.velocity.http.QueleaProblemException; +import net.onelitefeather.quelea.velocity.i18n.Messages; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** The six join-request codes are documented domain answers and must never reach Sentry. */ +class JoinRequestFailureReportsTest { + + @TempDir + Path overrides; + + @Test + void noJoinRequestProblemIsReported() { + FakeErrorReporter reporter = new FakeErrorReporter(); + FailureReports failures = new FailureReports(reporter, + Messages.load(overrides, LoggerFactory.getLogger(JoinRequestFailureReportsTest.class)), "proxy-1"); + + List answers = List.of( + new QueleaProblemException("party-not-public", 409, null), + new QueleaProblemException("party-banned", 403, null), + new QueleaProblemException("target-not-in-party", 404, null), + new QueleaProblemException("join-request-pending", 409, null), + new QueleaProblemException("join-request-cooldown", 429, null, 60L), + new QueleaProblemException("join-request-not-found", 404, null)); + for (QueleaProblemException answer : answers) { + failures.failure(answer, null); + } + + assertEquals(0, reporter.reports().size(), "documented join-request answers must not be reported to Sentry"); + } +} diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/JoinRequestEventsTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/JoinRequestEventsTest.java new file mode 100644 index 0000000..7c755ca --- /dev/null +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/JoinRequestEventsTest.java @@ -0,0 +1,180 @@ +package net.onelitefeather.quelea.velocity.event; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.onelitefeather.quelea.velocity.follow.FollowMode; +import net.onelitefeather.quelea.velocity.follow.FollowPolicy; +import net.onelitefeather.quelea.velocity.i18n.Messages; +import net.onelitefeather.quelea.velocity.player.FakeLocalPlayer; +import net.onelitefeather.quelea.velocity.player.FakeLocalPlayers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The join-request prompt and answers, and the reopen notice of a leadership change that closed a public party. */ +class JoinRequestEventsTest { + + private static final UUID ALICE = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + private static final UUID BOB = UUID.fromString("00000000-0000-0000-0000-00000000000b"); + private static final Instant NOW = Instant.parse("2026-10-28T10:00:00Z"); + + @TempDir + Path overrides; + + private Messages messages; + private FakeLocalPlayer alice; + private FakeLocalPlayer bob; + + @BeforeEach + void setUp() { + messages = Messages.load(overrides, LoggerFactory.getLogger(JoinRequestEventsTest.class)); + alice = new FakeLocalPlayer(ALICE, "Alice", Locale.ENGLISH, "Lobby-1"); + bob = new FakeLocalPlayer(BOB, "Bob", Locale.ENGLISH, "Lobby-1"); + } + + private EventHandler handler(FakeLocalPlayers players) { + return new EventHandler(players, messages, + FollowPolicy.of("(?i)^lobby.*", FollowMode.LOBBY), Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private FakeLocalPlayers both() { + return new FakeLocalPlayers().with(alice, bob); + } + + private static String member(UUID id, String name) { + return "{\"id\":\"" + id + "\",\"name\":\"" + name + "\",\"joinedAt\":\"2026-10-28T10:00:00Z\"}"; + } + + private static String members() { + return member(ALICE, "Alice") + "," + member(BOB, "Bob"); + } + + private static PartyEvent event(String type, String leaderId, String members, String payload) { + return PartyEvent.parse(""" + {"id":"e-1","type":"%s","partyId":"p-1","occurredAt":"2026-10-28T10:00:00Z", + "leaderId":%s,"members":[%s],"payload":%s,"traceparent":null} + """.formatted(type, leaderId == null ? "null" : "\"" + leaderId + "\"", members, payload)); + } + + private static String plain(Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } + + private static List plainLines(FakeLocalPlayer player) { + return player.sent().stream().map(JoinRequestEventsTest::plain).toList(); + } + + /** The commands run by the click actions anywhere in the component tree. */ + private static List runCommands(Component component) { + List found = new ArrayList<>(); + ClickEvent click = component.clickEvent(); + if (click != null && click.payload() instanceof ClickEvent.Payload.Text text) { + found.add(text.value()); + } + for (Component child : component.children()) { + found.addAll(runCommands(child)); + } + return found; + } + + @Test + void theLeaderPromptOffersClickableAcceptAndDenyForTheRequester() { + PartyEvent requested = event("party.join-requested", ALICE.toString(), members(), """ + {"requesterId":"%s","requesterName":"Bob","expiresAt":"2026-10-28T10:01:00Z"}""".formatted(BOB)); + + handler(both()).handle(requested); + + assertEquals(1, alice.sent().size(), "the local leader gets one prompt"); + List commands = runCommands(alice.sent().get(0)); + assertTrue(commands.contains("/party approve Bob"), "accept runs the approve command: " + commands); + assertTrue(commands.contains("/party reject Bob"), "deny runs the reject command: " + commands); + } + + @Test + void thePromptIsNotSentWhenTheLeaderIsOnAnotherProxy() { + PartyEvent requested = event("party.join-requested", ALICE.toString(), members(), """ + {"requesterId":"%s","requesterName":"Bob","expiresAt":"2026-10-28T10:01:00Z"}""".formatted(BOB)); + + handler(new FakeLocalPlayers().with(bob)).handle(requested); + + assertTrue(bob.sent().isEmpty(), "a prompt is only for a local leader"); + } + + @Test + void anAcceptedRequestTellsTheRequesterExactlyOnce() { + PartyEvent joined = event("party.joined", ALICE.toString(), members(), + "{\"playerId\":\"%s\",\"playerName\":\"Bob\"}".formatted(BOB)); + PartyEvent answered = event("party.join-request-answered", ALICE.toString(), members(), + "{\"requesterId\":\"%s\",\"requesterName\":\"Bob\",\"outcome\":\"accepted\"}".formatted(BOB)); + + EventHandler handler = handler(both()); + handler.handle(joined); + handler.handle(answered); + + assertEquals(1, bob.sent().size(), "the requester gets one line, not the join line and the answer"); + assertTrue(plain(bob.sent().get(0)).contains("let you in"), plain(bob.sent().get(0))); + } + + @Test + void aDeniedRequestTellsTheRequester() { + PartyEvent answered = event("party.join-request-answered", ALICE.toString(), members(), + "{\"requesterId\":\"%s\",\"requesterName\":\"Bob\",\"outcome\":\"denied\"}".formatted(BOB)); + + handler(both()).handle(answered); + + assertEquals(1, bob.sent().size()); + assertTrue(plain(bob.sent().get(0)).contains("declined your request"), plain(bob.sent().get(0))); + } + + @Test + void theNewLeaderWithThePermissionIsToldHowToReopenThePartyAfterItWasClosed() { + FakeLocalPlayer newLeader = new FakeLocalPlayer(BOB, "Bob", Locale.ENGLISH, "Lobby-1") + .withPermission("quelea.party.public"); + PartyEvent changed = event("party.leader-changed", BOB.toString(), members(), + "{\"previousLeaderId\":\"%s\",\"leaderId\":\"%s\",\"wasPublic\":true}".formatted(ALICE, BOB)); + + handler(new FakeLocalPlayers().with(newLeader)).handle(changed); + + assertTrue(plainLines(newLeader).stream().anyMatch(line -> line.contains("/party public on")), + "the new leader must be told how to reopen: " + plainLines(newLeader)); + } + + @Test + void theNewLeaderWithoutThePermissionIsOnlyToldThePartyIsPrivate() { + PartyEvent changed = event("party.leader-changed", BOB.toString(), members(), + "{\"previousLeaderId\":\"%s\",\"leaderId\":\"%s\",\"wasPublic\":true}".formatted(ALICE, BOB)); + + handler(new FakeLocalPlayers().with(bob)).handle(changed); + + List lines = plainLines(bob); + assertTrue(lines.stream().anyMatch(line -> line.contains("private again")), lines.toString()); + assertFalse(lines.stream().anyMatch(line -> line.contains("/party public on")), + "without the permission there is no reopen hint: " + lines); + } + + @Test + void aLeadershipChangeThatDidNotCloseAPublicPartySendsNoNotice() { + PartyEvent changed = event("party.leader-changed", BOB.toString(), members(), + "{\"previousLeaderId\":\"%s\",\"leaderId\":\"%s\"}".formatted(ALICE, BOB)); + + handler(new FakeLocalPlayers().with(bob)).handle(changed); + + assertFalse(plainLines(bob).stream().anyMatch(line -> line.contains("private again")), + "the closed notice is only for a change that cleared the flag"); + } +} diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/http/JoinRequestGatewayTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/http/JoinRequestGatewayTest.java new file mode 100644 index 0000000..d43aed7 --- /dev/null +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/http/JoinRequestGatewayTest.java @@ -0,0 +1,115 @@ +package net.onelitefeather.quelea.velocity.http; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The gateway's answers for the join routes: 201 is a sent request, 200 a direct join, 429 carries Retry-After. */ +class JoinRequestGatewayTest { + + private static final String PARTY_JSON = """ + {"id":"p-1","leaderId":"leader-1", + "members":[{"id":"leader-1","name":"Alice","joinedAt":"2026-10-28T10:00:00Z"}], + "createdAt":"2026-10-28T10:00:00Z"} + """; + + private HttpServer server; + private volatile int status = 200; + private volatile String body = PARTY_JSON; + private volatile String retryAfter; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/", exchange -> { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + if (retryAfter != null) { + exchange.getResponseHeaders().add("Retry-After", retryAfter); + } + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + exchange.close(); + }); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private HttpQueleaGateway gateway() { + return new HttpQueleaGateway("http://" + server.getAddress().getAddress().getHostAddress() + ":" + + server.getAddress().getPort(), HttpClient.newHttpClient(), Optional.empty()); + } + + @Test + void aCreated_answerIsASentRequestWithItsExpiry() { + status = 201; + body = """ + {"partyId":"p-1","requesterId":"req-1","requesterName":"Bob","expiresAt":"2026-10-28T10:01:00Z"} + """; + + JoinResult result = gateway().request("req-1", "Alice"); + + JoinResult.Sent sent = assertInstanceOf(JoinResult.Sent.class, result); + assertEquals(Instant.parse("2026-10-28T10:01:00Z"), sent.expiresAt()); + } + + @Test + void anOk_answerIsADirectJoinWithTheParty() { + status = 200; + + JoinResult result = gateway().request("req-1", "Alice"); + + JoinResult.Joined joined = assertInstanceOf(JoinResult.Joined.class, result); + assertEquals("p-1", joined.party().id()); + } + + @Test + void aCooldownAnswerCarriesTheRetryAfterSeconds() { + status = 429; + retryAfter = "42"; + body = """ + {"type":"https://github.com/OneLiteFeatherNET/Quelea/blob/main/docs/problems.md#join-request-cooldown", + "title":"Join request cooldown","status":429,"detail":"wait","code":"join-request-cooldown"} + """; + + QueleaProblemException problem = org.junit.jupiter.api.Assertions.assertThrows(QueleaProblemException.class, + () -> gateway().request("req-1", "Alice")); + + assertEquals("join-request-cooldown", problem.code()); + assertEquals(42L, problem.retryAfterSeconds().orElseThrow()); + } + + @Test + void aProblemWithoutRetryAfterHasNoWait() { + status = 409; + body = """ + {"title":"Party is not public","status":409,"detail":"x","code":"party-not-public"} + """; + + QueleaProblemException problem = org.junit.jupiter.api.Assertions.assertThrows(QueleaProblemException.class, + () -> gateway().join("req-1", "Alice")); + + assertTrue(problem.retryAfterSeconds().isEmpty(), "no Retry-After header means no wait"); + } +} diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/JoinRequestMessagesTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/JoinRequestMessagesTest.java new file mode 100644 index 0000000..cd962cb --- /dev/null +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/JoinRequestMessagesTest.java @@ -0,0 +1,71 @@ +package net.onelitefeather.quelea.velocity.i18n; + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.onelitefeather.quelea.velocity.http.QueleaProblemException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The join-request problem keys and the cooldown wording. */ +class JoinRequestMessagesTest { + + private static final List JOIN_CODES = List.of( + "party-not-public", "party-banned", "target-not-in-party", "join-request-pending", + "join-request-cooldown", "join-request-not-found"); + + @TempDir + Path overrides; + + private final Messages messages = Messages.load(overrides, LoggerFactory.getLogger(JoinRequestMessagesTest.class)); + + private static Properties builtIn(String language) throws IOException { + Properties properties = new Properties(); + try (InputStream in = JoinRequestMessagesTest.class.getResourceAsStream("/lang/messages_" + language + ".properties")) { + properties.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + return properties; + } + + private static String plain(net.kyori.adventure.text.Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } + + @Test + void everyJoinProblemHasAKeyInEnglishAndGerman() throws IOException { + Properties english = builtIn("en"); + Properties german = builtIn("de"); + + for (String code : JOIN_CODES) { + assertTrue(english.containsKey("problem." + code), "English is missing problem." + code); + assertTrue(german.containsKey("problem." + code), "German is missing problem." + code); + } + } + + @Test + void theCooldownNamesTheWaitWhenTheServiceSentRetryAfter() { + String text = plain(messages.problem(Locale.ENGLISH, + new QueleaProblemException("join-request-cooldown", 429, null, 42L))); + + assertTrue(text.contains("42 seconds"), text); + } + + @Test + void theCooldownNamesNoNumberWithoutRetryAfter() { + String text = plain(messages.problem(Locale.ENGLISH, + new QueleaProblemException("join-request-cooldown", 429, null))); + + assertFalse(text.matches(".*\\d.*"), "without Retry-After the text must not invent a number: " + text); + } +} diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java index c0a8caa..135492d 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java @@ -32,7 +32,8 @@ class MessagesTest { "validation-failed", "not-found", "method-not-allowed", "unsupported-media-type", "unauthorized", "forbidden", "precondition-failed", "idempotency-key-reused", "request-in-progress", "party-not-found", "not-in-party", "already-in-party", "not-leader", "not-member", "party-full", "player-not-online", "invite-not-found", - "cannot-target-self", "lock-timeout", "internal-error"); + "cannot-target-self", "lock-timeout", "internal-error", "party-not-public", "party-banned", + "target-not-in-party", "join-request-pending", "join-request-cooldown", "join-request-not-found"); private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(MessagesTest.class); diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/listener/PresenceListenerTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/listener/PresenceListenerTest.java index ebb4588..fcfbacb 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/listener/PresenceListenerTest.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/listener/PresenceListenerTest.java @@ -166,6 +166,31 @@ public void chat(String partyId, String actorId, String message) { throw new UnsupportedOperationException(); } + @Override + public Party setPublic(String partyId, String actorId, boolean open) { + throw new UnsupportedOperationException(); + } + + @Override + public Party join(String playerId, String targetName) { + throw new UnsupportedOperationException(); + } + + @Override + public net.onelitefeather.quelea.velocity.http.JoinResult request(String playerId, String targetName) { + throw new UnsupportedOperationException(); + } + + @Override + public Party acceptRequest(String partyId, String actorId, String requesterName) { + throw new UnsupportedOperationException(); + } + + @Override + public void rejectRequest(String partyId, String actorId, String requesterName) { + throw new UnsupportedOperationException(); + } + @Override public void snapshot(String proxyId, List players) { throw new UnsupportedOperationException(); diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/player/FakeLocalPlayer.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/player/FakeLocalPlayer.java index a1cc951..04987bf 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/player/FakeLocalPlayer.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/player/FakeLocalPlayer.java @@ -33,11 +33,24 @@ public FakeLocalPlayer(UUID id, String name, Locale locale, String currentServer this.currentServer = currentServer; } + private final java.util.Set permissions = new java.util.HashSet<>(); + public FakeLocalPlayer refusingConnections() { this.outcome = ConnectOutcome.REFUSED; return this; } + /** Grants the Velocity permission {@code permission} to this player. */ + public FakeLocalPlayer withPermission(String permission) { + permissions.add(permission); + return this; + } + + @Override + public boolean hasPermission(String permission) { + return permissions.contains(permission); + } + public List sent() { return List.copyOf(sent); }