Standardize guild API endpoints and add music access checks

Unified paths in guild-related endpoints to include consistent `{guildId}` structure, improving endpoint clarity and alignment with REST routing conventions. Added centralized `MusicAccess` utility to streamline music feature permission checks, replacing scattered logic in `MusicCommand`. Updated OpenAPI documentation and added tests for new and updated behaviors.
This commit is contained in:
anthorob committed 2026-10-14 08:02:31 -04:00
1 parent bedc8b9f02
commit 91b6de708d
18 files changed
+431 -66

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ a path prefix.
| Handler | Base path | Endpoints |
|---|---|---|
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). |
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/{guildId}/channels` — text channels (JSON); `/guilds/{guildId}/roles` — roles (JSON). |
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown. |
| `SettingsHandler` | `/settings` | `/settings/catalog` — every known setting with its default value and description; `/settings/current/{guildId}` — current guild settings; `/settings/reload/{guildId}` — invalidate & reload the settings cache. |
@@ -9,10 +9,9 @@ import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.utils.UserFunction;
import me.bobert.netneutrality.utils.MusicAccess;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
@@ -77,26 +76,7 @@ public abstract class MusicCommand extends AbstractCommand {
return true;
}
Guild guild = member.getGuild();
String roleDb = guildService(guild.getId()).getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE);
if (roleDb != null && !roleDb.isEmpty()) {
if (UserFunction.isAdmin(member)) {
return true;
}
Role neededRole = guild.getRoleById(roleDb);
if (neededRole == null) {
// Role doesn't exist on guild anymore, remove from db
guildService(guild.getId()).getGuildSettingService().set(GSettings.MUSIC_ROLE, null);
return true;
}
return member.getRoles().contains(neededRole);
}
return true;
return MusicAccess.canUse(member, guildService(member.getGuild()));
}
@Override
@@ -27,11 +27,13 @@ import me.bobert.netneutrality.http.server.dto.GuildConfigResponse;
import me.bobert.netneutrality.http.server.dto.GuildLogChannelUpdateRequest;
import me.bobert.netneutrality.http.server.dto.GuildOwnerEntryResponse;
import me.bobert.netneutrality.http.server.dto.GuildSummaryResponse;
import me.bobert.netneutrality.http.server.dto.MemberAccessResponse;
import me.bobert.netneutrality.http.server.dto.MemberRolesResponse;
import me.bobert.netneutrality.http.server.dto.MemberSummaryResponse;
import me.bobert.netneutrality.http.server.dto.OwnerResponse;
import me.bobert.netneutrality.http.server.dto.RoleSummaryResponse;
import me.bobert.netneutrality.http.server.dto.VoiceChannelSummaryResponse;
import me.bobert.netneutrality.utils.MusicAccess;
import me.bobert.netneutrality.utils.UserFunction;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
@@ -42,6 +44,9 @@ import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import java.util.function.Supplier;
/**
@@ -54,30 +59,41 @@ public final class GuildRoutes {
}
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
routes.get("/guilds", ctx -> guilds(jdaSupplier, ctx));
routes.get("/guilds", ctx -> guilds(jdaSupplier, guildServices, ctx));
routes.get("/guilds/owner", ctx -> owner(jdaSupplier, ctx));
routes.get("/guilds/owner/{guildId}", ctx -> ownerOf(jdaSupplier, ctx));
routes.get("/guilds/channels/{guildId}", ctx -> channels(jdaSupplier, ctx));
routes.get("/guilds/roles/{guildId}", ctx -> roles(jdaSupplier, ctx));
routes.get("/guilds/members/{guildId}/{userId}", ctx -> member(jdaSupplier, ctx));
routes.get("/guilds/members/{guildId}/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx));
routes.get("/guilds/members/{guildId}/{userId}/admin", ctx -> memberAdmin(jdaSupplier, ctx));
routes.get("/guilds/voice-channels/{guildId}", ctx -> voiceChannels(jdaSupplier, ctx));
routes.get("/guilds/voice-channels/{guildId}/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx));
routes.get("/guilds/config/{guildId}", ctx -> config(jdaSupplier, guildServices, ctx));
routes.put("/guilds/debug/{guildId}", ctx -> updateDebug(jdaSupplier, guildServices, ctx));
routes.put("/guilds/logging/{guildId}", ctx -> updateLogging(jdaSupplier, guildServices, ctx));
routes.put("/guilds/log-channel/{guildId}", ctx -> updateLogChannel(jdaSupplier, guildServices, ctx));
routes.get("/guilds/{guildId}/owner", ctx -> ownerOf(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/channels", ctx -> channels(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/roles", ctx -> roles(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/members/{userId}/access", ctx -> memberAccess(jdaSupplier, guildServices, ctx));
routes.get("/guilds/{guildId}/members/{userId}", ctx -> member(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/members/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/members/{userId}/admin", ctx -> memberAdmin(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/voice-channels", ctx -> voiceChannels(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/voice-channels/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx));
routes.get("/guilds/{guildId}/config", ctx -> config(jdaSupplier, guildServices, ctx));
routes.put("/guilds/{guildId}/debug", ctx -> updateDebug(jdaSupplier, guildServices, ctx));
routes.put("/guilds/{guildId}/logging", ctx -> updateLogging(jdaSupplier, guildServices, ctx));
routes.put("/guilds/{guildId}/log-channel", ctx -> updateLogChannel(jdaSupplier, guildServices, ctx));
}
@OpenApi(
path = "/guilds",
methods = HttpMethod.GET,
summary = "List every guild the bot is currently in",
summary = "List every guild the bot is in, or only the guilds shared with a user when user_id is given",
tags = {"Guilds"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildSummaryResponse[].class))
queryParams = @OpenApiParam(name = "user_id", description = "Optional Discord user id; restricts the list to mutual guilds and switches to the user-guild shape"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildSummaryResponse[].class)),
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void guilds(Supplier<JDA> jdaSupplier, Context ctx) {
private static void guilds(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
String userId = ctx.queryParam("user_id");
if (userId != null) {
userGuilds(jda(jdaSupplier), guildServices, userId, ctx);
return;
}
ctx.json(
jda(jdaSupplier).getGuilds().stream()
.map(GuildRoutes::guildSummary)
@@ -85,6 +101,49 @@ public final class GuildRoutes {
);
}
/**
* Lists the guilds shared by the bot and the given user, with the user's access flags. Members
* missing from the cache are resolved through REST; a failed lookup means "not a member".
*/
private static void userGuilds(JDA jda, GuildServiceProvider guildServices, String userId, Context ctx) {
if (!userId.matches("\\d{1,20}")) {
throw new BadRequestResponse("user_id must be a numeric Discord id");
}
List<CompletableFuture<Optional<Map<String, Object>>>> lookups = jda.getGuilds().stream()
.map(guild -> resolveMember(guild, userId)
.thenApply(member -> Optional.ofNullable(member)
.map(m -> userGuildEntry(guild, m, guildServices.get(guild.getId()))))
.exceptionally(error -> Optional.<Map<String, Object>>empty()))
.toList();
ctx.future(() -> CompletableFuture.allOf(lookups.toArray(new CompletableFuture[0])).thenApply(ignored -> {
ctx.json(lookups.stream().map(CompletableFuture::join).flatMap(Optional::stream).toList());
return null;
}));
}
private static CompletableFuture<Member> resolveMember(Guild guild, String userId) {
Member cached = guild.getMemberById(userId);
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return guild.retrieveMemberById(userId).submit();
}
private static Map<String, Object> userGuildEntry(Guild guild, Member member, GuildService guildService) {
boolean owner = guild.getOwnerIdLong() == member.getIdLong();
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("id", guild.getId());
entry.put("name", guild.getName());
entry.put("icon", guild.getIconUrl());
entry.put("is_owner", owner);
entry.put("is_admin", owner || UserFunction.isAdmin(member));
entry.put("can_use_music", MusicAccess.canUse(member, guildService));
return entry;
}
/**
* Builds the {@code /guilds} entry for a guild, falling back to a REST fetch for the owner id
* when it is not present in the cache.
@@ -126,7 +185,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/owner/{guildId}",
path = "/guilds/{guildId}/owner",
methods = HttpMethod.GET,
summary = "Get a guild's owner, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"},
@@ -148,7 +207,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/channels/{guildId}",
path = "/guilds/{guildId}/channels",
methods = HttpMethod.GET,
summary = "List the text channels of a guild",
tags = {"Guilds"},
@@ -167,7 +226,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/roles/{guildId}",
path = "/guilds/{guildId}/roles",
methods = HttpMethod.GET,
summary = "List the roles of a guild",
tags = {"Guilds"},
@@ -187,7 +246,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/members/{guildId}/{userId}",
path = "/guilds/{guildId}/members/{userId}",
methods = HttpMethod.GET,
summary = "Get a guild member, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"},
@@ -213,7 +272,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/members/{guildId}/{userId}/roles",
path = "/guilds/{guildId}/members/{userId}/roles",
methods = HttpMethod.GET,
summary = "List the roles of a guild member, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"},
@@ -239,7 +298,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/members/{guildId}/{userId}/admin",
path = "/guilds/{guildId}/members/{userId}/admin",
methods = HttpMethod.GET,
summary = "Check whether a guild member is an admin of the guild, falling back to a REST fetch on a member cache miss",
tags = {"Guilds"},
@@ -270,8 +329,54 @@ public final class GuildRoutes {
);
}
@OpenApi(
path = "/guilds/{guildId}/members/{userId}/access",
methods = HttpMethod.GET,
summary = "Get a member's roles, admin/owner flags, music access and presence in one call",
tags = {"Guilds"},
pathParams = {
@io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
@io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id")
},
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = MemberAccessResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void memberAccess(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
Guild guild = guild(jdaSupplier, ctx);
String userId = ctx.pathParam("userId");
Function<Member, Map<String, Object>> toBody = member -> {
boolean owner = guild.getOwnerIdLong() == member.getIdLong();
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("guild_id", guild.getId());
summary.put("user_id", member.getId());
summary.put("role_ids", member.getRoles().stream().map(Role::getId).toList());
summary.put("is_owner", owner);
summary.put("is_admin", owner || UserFunction.isAdmin(member));
summary.put("can_use_music", MusicAccess.canUse(member, guildServices.get(guild.getId())));
return summary;
};
Member cached = guild.getMemberById(userId);
if (cached != null) {
ctx.json(toBody.apply(cached));
return;
}
ctx.future(() -> guild.retrieveMemberById(userId).submit().handle((member, error) -> {
if (error != null || member == null) {
ctx.status(404).json(Map.of("status", "error", "message", "Member not found in guild"));
} else {
ctx.json(toBody.apply(member));
}
return null;
}));
}
/**
* Builds the {@code /guilds/members/{guildId}/{userId}} response for a resolved member.
* Builds the {@code /guilds/{guildId}/members/{userId}} response for a resolved member.
*/
private static Map<String, Object> memberSummary(Member member) {
Map<String, Object> summary = new LinkedHashMap<>();
@@ -283,7 +388,7 @@ public final class GuildRoutes {
}
/**
* Builds the {@code /guilds/members/{guildId}/{userId}/roles} response for a resolved member.
* Builds the {@code /guilds/{guildId}/members/{userId}/roles} response for a resolved member.
*/
private static Map<String, Object> memberRolesSummary(Guild guild, Member member) {
Map<String, Object> summary = new LinkedHashMap<>();
@@ -298,7 +403,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/voice-channels/{guildId}",
path = "/guilds/{guildId}/voice-channels",
methods = HttpMethod.GET,
summary = "List the voice channels of a guild, including the members currently connected",
tags = {"Guilds"},
@@ -317,7 +422,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/voice-channels/{guildId}/{channelId}",
path = "/guilds/{guildId}/voice-channels/{channelId}",
methods = HttpMethod.GET,
summary = "Get a voice channel, including the members currently connected",
tags = {"Guilds"},
@@ -382,7 +487,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/config/{guildId}",
path = "/guilds/{guildId}/config",
methods = HttpMethod.GET,
summary = "Get a guild's debug/logging configuration",
tags = {"Guilds"},
@@ -398,7 +503,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/debug/{guildId}",
path = "/guilds/{guildId}/debug",
methods = HttpMethod.PUT,
summary = "Enable or disable debug/error logging for a guild",
tags = {"Guilds"},
@@ -421,7 +526,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/logging/{guildId}",
path = "/guilds/{guildId}/logging",
methods = HttpMethod.PUT,
summary = "Enable or disable logging for a guild",
tags = {"Guilds"},
@@ -444,7 +549,7 @@ public final class GuildRoutes {
}
@OpenApi(
path = "/guilds/log-channel/{guildId}",
path = "/guilds/{guildId}/log-channel",
methods = HttpMethod.PUT,
summary = "Set (or clear) the log channel for a guild",
tags = {"Guilds"},
@@ -482,7 +587,7 @@ public final class GuildRoutes {
}
/**
* Builds the {@code /guilds/config/{guildId}} response for a guild's current configuration.
* Builds the {@code /guilds/{guildId}/config} response for a guild's current configuration.
*/
private static GuildConfigResponse guildConfig(Guild guild, GuildServiceProvider guildServices) {
GuildService guildService = guildServices.get(guild.getId());
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}/admin} response.
* OpenAPI schema for the {@code /guilds/{guildId}/members/{userId}/admin} response.
*/
public record AdminStatusResponse(
String guildId,
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for a {@code /guilds/channels/{guildId}} and {@code /guilds/roles/{guildId}}
* OpenAPI schema for a {@code /guilds/{guildId}/channels} and {@code /guilds/{guildId}/roles}
* entry.
*/
public record ChannelSummaryResponse(
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for the {@code /guilds/debug/{guildId}} and {@code /guilds/logging/{guildId}}
* OpenAPI schema for the {@code /guilds/{guildId}/debug} and {@code /guilds/{guildId}/logging}
* request bodies.
*/
public record GuildBooleanUpdateRequest(
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for the {@code /guilds/config/{guildId}} response, as well as the
* OpenAPI schema for the {@code /guilds/{guildId}/config} response, as well as the
* debug/logging/log-channel update responses.
*/
public record GuildConfigResponse(
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for the {@code /guilds/log-channel/{guildId}} request body.
* OpenAPI schema for the {@code /guilds/{guildId}/log-channel} request body.
*/
public record GuildLogChannelUpdateRequest(
String value
@@ -0,0 +1,22 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.List;
/**
* OpenAPI schema for {@code /guilds/{guildId}/members/{userId}/access}.
*/
public record MemberAccessResponse(
String guild_id,
String user_id,
List<String> role_ids,
boolean is_owner,
boolean is_admin,
boolean can_use_music
) {
}
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}/roles} response.
* OpenAPI schema for the {@code /guilds/{guildId}/members/{userId}/roles} response.
*/
public record MemberRolesResponse(
String guild_id,
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}} response.
* OpenAPI schema for the {@code /guilds/{guildId}/members/{userId}} response.
*/
public record MemberSummaryResponse(
String user_id,
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for the {@code /guilds/owner/{guildId}} response.
* OpenAPI schema for the {@code /guilds/{guildId}/owner} response.
*/
public record OwnerResponse(
String guild_id,
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for a role entry of the {@code /guilds/members/{guildId}/{userId}/roles}
* OpenAPI schema for a role entry of the {@code /guilds/{guildId}/members/{userId}/roles}
* response.
*/
public record RoleSummaryResponse(
@@ -0,0 +1,20 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for a {@code /guilds?user_id=} entry.
*/
public record UserGuildResponse(
String id,
String name,
String icon,
boolean is_owner,
boolean is_admin,
boolean can_use_music
) {
}
@@ -7,8 +7,8 @@ package me.bobert.netneutrality.http.server.dto;
*/
/**
* OpenAPI schema for the {@code /guilds/voice-channels/{guildId}} and
* {@code /guilds/voice-channels/{guildId}/{channelId}} responses.
* OpenAPI schema for the {@code /guilds/{guildId}/voice-channels} and
* {@code /guilds/{guildId}/voice-channels/{channelId}} responses.
*/
public record VoiceChannelSummaryResponse(
String channel_id,
@@ -0,0 +1,55 @@
package me.bobert.netneutrality.utils;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
/**
* Single source of truth for the music-access rule.
*/
public final class MusicAccess {
private MusicAccess() {
}
/**
* Whether the member may use music features in their guild.
*
* @param member the member, {@code null} is allowed
* @param guildService the guild service of the member's guild
* @return true when access is allowed
*/
public static boolean canUse(Member member, GuildService guildService) {
if (member == null) {
return true;
}
Guild guild = member.getGuild();
String roleDb = guildService.getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE);
if (roleDb != null && !roleDb.isEmpty()) {
if (UserFunction.isAdmin(member)) {
return true;
}
Role neededRole = guild.getRoleById(roleDb);
if (neededRole == null) {
// Role doesn't exist on guild anymore, remove from db
guildService.getGuildSettingService().set(GSettings.MUSIC_ROLE, null);
return true;
}
return member.getRoles().contains(neededRole);
}
return true;
}
}
@@ -13,7 +13,12 @@ import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.requests.restaction.CacheRestAction;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -24,8 +29,10 @@ import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -56,6 +63,22 @@ class WebApiTest {
when(guild.getMemberCount()).thenReturn(7);
when(guild.getChannels()).thenReturn(List.of());
when(guild.getRoles()).thenReturn(List.of());
when(guild.getOwnerIdLong()).thenReturn(42L);
Member plain = mockMember(guild, 100L, List.of());
Role manageServer = mock(Role.class);
when(manageServer.getPermissions()).thenReturn(EnumSet.of(Permission.MANAGE_SERVER));
Member admin = mockMember(guild, 200L, List.of(manageServer));
Member owner = mockMember(guild, 42L, List.of());
when(guild.getMemberById(any(String.class))).thenAnswer(inv -> switch ((String) inv.getArgument(0)) {
case "100" -> plain;
case "200" -> admin;
case "42" -> owner;
default -> null;
});
CacheRestAction<Member> failing = mock(CacheRestAction.class);
when(failing.submit()).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("unknown member")));
when(guild.retrieveMemberById(any(String.class))).thenReturn(failing);
JDA jda = mock(JDA.class);
when(jda.getGuilds()).thenReturn(List.of(guild));
@@ -157,6 +180,72 @@ class WebApiTest {
assertTrue(response.body().contains("\"owner_id\":\"42\""));
}
private static Member mockMember(Guild guild, long id, List<Role> roles) {
Member member = mock(Member.class);
when(member.getId()).thenReturn(String.valueOf(id));
when(member.getIdLong()).thenReturn(id);
when(member.getGuild()).thenReturn(guild);
when(member.getRoles()).thenReturn(roles);
when(member.getOnlineStatus()).thenReturn(OnlineStatus.ONLINE);
return member;
}
@Test
void userGuildsReturnsAccessFlagsForPlainMember() throws Exception {
HttpResponse<String> response = get("/guilds?user_id=100");
assertEquals(200, response.statusCode());
assertEquals("[{\"id\":\"" + GUILD_ID + "\",\"name\":\"My Guild\",\"icon\":\"https://example.com/icon.png\","
+ "\"is_owner\":false,\"is_admin\":false,\"can_use_music\":true}]", response.body());
}
@Test
void userGuildsFlagsManageServerAsAdmin() throws Exception {
HttpResponse<String> response = get("/guilds?user_id=200");
assertTrue(response.body().contains("\"is_owner\":false,\"is_admin\":true"));
}
@Test
void userGuildsFlagsOwner() throws Exception {
HttpResponse<String> response = get("/guilds?user_id=42");
assertTrue(response.body().contains("\"is_owner\":true,\"is_admin\":true"));
}
@Test
void userGuildsForUnknownUserIsEmpty() throws Exception {
HttpResponse<String> response = get("/guilds?user_id=999");
assertEquals(200, response.statusCode());
assertEquals("[]", response.body());
}
@Test
void userGuildsWithMalformedUserIdIsRejected() throws Exception {
assertEquals(400, get("/guilds?user_id=abc").statusCode());
}
@Test
void memberAccessReturnsRolesFlagsAndPresence() throws Exception {
HttpResponse<String> response = get("/guilds/" + GUILD_ID + "/members/200/access");
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"user_id\":\"200\""));
assertTrue(response.body().contains("\"is_owner\":false,\"is_admin\":true,\"can_use_music\":true"));
assertTrue(response.body().contains("\"presence\":\"ONLINE\""));
}
@Test
void memberAccessForUserNotInGuildIsNotFound() throws Exception {
assertEquals(404, get("/guilds/" + GUILD_ID + "/members/999/access").statusCode());
}
@Test
void userGuildsRequiresApiKey() throws Exception {
assertEquals(401, getWithoutKey("/guilds?user_id=100").statusCode());
}
@Test
void guildsRequiresApiKey() throws Exception {
assertEquals(401, getWithoutKey("/guilds").statusCode());
@@ -206,7 +295,7 @@ class WebApiTest {
@Test
void unknownGuildReturnsNotFound() throws Exception {
HttpResponse<String> response = get("/guilds/channels/999");
HttpResponse<String> response = get("/guilds/999/channels");
assertEquals(404, response.statusCode());
assertTrue(response.body().contains("\"status\":\"error\""));
@@ -0,0 +1,94 @@
package me.bobert.netneutrality.utils;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildSettingService;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.EnumSet;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MusicAccessTest {
private GuildSettingService settings;
private GuildService service;
private Guild guild;
private Member member;
@BeforeEach
void setUp() {
UserFunction.bind(null);
settings = mock(GuildSettingService.class);
service = mock(GuildService.class);
when(service.getGuildSettingService()).thenReturn(settings);
guild = mock(Guild.class);
when(guild.getId()).thenReturn("g1");
member = mock(Member.class);
when(member.getGuild()).thenReturn(guild);
when(member.getRoles()).thenReturn(List.of());
}
@Test
void nullMemberAndUnsetRoleAllowEveryone() {
assertTrue(MusicAccess.canUse(null, service));
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("");
assertTrue(MusicAccess.canUse(member, service));
}
@Test
void memberWithoutRoleIsDenied() {
Role needed = mock(Role.class);
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("r1");
when(guild.getRoleById("r1")).thenReturn(needed);
assertFalse(MusicAccess.canUse(member, service));
}
@Test
void memberWithRoleIsAllowed() {
Role needed = mock(Role.class);
when(needed.getPermissions()).thenReturn(EnumSet.noneOf(Permission.class));
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("r1");
when(guild.getRoleById("r1")).thenReturn(needed);
when(member.getRoles()).thenReturn(List.of(needed));
assertTrue(MusicAccess.canUse(member, service));
}
@Test
void adminIsAllowed() {
Role adminRole = mock(Role.class);
when(adminRole.getPermissions()).thenReturn(EnumSet.of(Permission.MANAGE_SERVER));
when(member.getRoles()).thenReturn(List.of(adminRole));
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("r1");
when(guild.getRoleById("r1")).thenReturn(mock(Role.class));
assertTrue(MusicAccess.canUse(member, service));
}
@Test
void missingRoleAllowsAndClearsSetting() {
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("gone");
when(guild.getRoleById("gone")).thenReturn(null);
assertTrue(MusicAccess.canUse(member, service));
verify(settings).set(GSettings.MUSIC_ROLE, null);
}
}