test(velocity): 测试桩按真实 wake 的顺序从服的状态推导回复,运行中的服对谁都 202 就绪,403 和 start_failed 不再由用例手配,停着的服一律报 fallback

This commit is contained in:
Lemon-miaow committed 2026-09-27 00:36:41 +08:00
1 parent 2d82e8cca7
commit d0ab983f0f
6 files changed
+183 -37

No files matched your search

+3 -1
View File
@@ -43,7 +43,9 @@
# fake proxy and a stub felis-api — a refresh registers, moves and drops # fake proxy and a stub felis-api — a refresh registers, moves and drops
# backends and lets go of a renamed subdomain, the login gate and host routing # backends and lets go of a renamed subdomain, the login gate and host routing
# admit only linked players, each wake refusal reaches the player as its own # admit only linked players, each wake refusal reaches the player as its own
# message, felis:control acts only for the connection's player and holds its # message (the stub answers a wake from the server's state in felis-api's own
# order, so it cannot hand the router an answer the real API never gives),
# felis:control acts only for the connection's player and holds its
# frame budget. They ride the module's verified dependency set, which is why # frame budget. They ride the module's verified dependency set, which is why
# they live in Gradle rather than in the javac mains above. # they live in Gradle rather than in the javac mains above.
# #
@@ -216,20 +216,32 @@ public final class ControlChannelTest {
} }
private static void wakeAndTransfer() { private static void wakeAndTransfer() {
// A WakeRequest parks the player; when the backend is ready the lobby hears // A WakeRequest for a stopped server parks the player; when the backend is ready
// TransferReady just before the proxy moves them. // the lobby hears TransferReady just before the proxy moves them.
Fakes.FakePlayer p = player(true); Fakes.FakePlayer p = player(true);
p.current = lobby; p.current = lobby;
message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest("forged-name", "beta"))); message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest("forged-name", "gamma")));
Fakes.await("beta woken", () -> api.count("POST " + SERVERS + "beta/wake") == 1); Fakes.await("gamma woken", () -> api.count("POST " + SERVERS + "gamma/wake") == 1);
Fakes.await("waker queued", () -> router.waitingCount() == 2); Fakes.await("waker queued", () -> router.waitingCount() == 2);
api.ready.put("beta", true); api.ready.put("gamma", true);
router.tick(); router.tick();
List<ControlFrame> r = frames(p); List<ControlFrame> r = frames(p);
assertEq("one frame to the lobby", 1, r.size()); assertEq("one frame to the lobby", 1, r.size());
assertEq("transfer ready", ControlFrame.TRANSFER_READY + " " + p.name + " beta", assertEq("transfer ready", ControlFrame.TRANSFER_READY + " " + p.name + " gamma",
r.get(0).type() + " " + r.get(0).player() + " " + r.get(0).server()); r.get(0).type() + " " + r.get(0).player() + " " + r.get(0).server());
assertEq("then moved", List.of("beta"), List.copyOf(p.connects)); assertEq("then moved", List.of("gamma"), List.copyOf(p.connects));
// For a running one it is a join: TransferReady and the move at once, no wake.
Fakes.FakePlayer j = player(true);
j.current = lobby;
message(j.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.wakeRequest(j.name, "beta")));
Fakes.await("running server joined", () -> j.connects.size() == 1);
List<ControlFrame> jr = frames(j);
assertEq("running server: one frame to the lobby", 1, jr.size());
assertEq("running server: transfer ready", ControlFrame.TRANSFER_READY + " " + j.name + " beta",
jr.get(0).type() + " " + jr.get(0).player() + " " + jr.get(0).server());
assertEq("running server: moved", List.of("beta"), List.copyOf(j.connects));
assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake"));
} }
private static void budget() { private static void budget() {
@@ -295,8 +307,12 @@ public final class ControlChannelTest {
// Shaped like the operator's status: up is direct at addr; down is the fallback, // Shaped like the operator's status: up is direct at addr; down is the fallback,
// whose name ("login") sits in the address field. // whose name ("login") sits in the address field.
// view is a server as GET /servers lists it, and the stub API's status, menu and
// wake answer for the same state: up at addr, or down on the "login" fallback.
private static ServerView view(String name, boolean ready, String addr) { private static ServerView view(String name, boolean ready, String addr) {
return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "ownerOnly", api.ready.put(name, ready);
api.address.put(name, addr);
return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "public",
"Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20); "Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20);
} }
@@ -33,6 +33,8 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
import java.util.function.Function; import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** /**
* Fakes are the test doubles the routing tests run the real proxy classes against: * Fakes are the test doubles the routing tests run the real proxy classes against:
@@ -432,13 +434,18 @@ final class Fakes {
/** /**
* Api is a stub felis-api internal face: link status from {@link #linked}, server * Api is a stub felis-api internal face: link status from {@link #linked}, server
* status from {@link #ready}, wake answering 202 unless {@link #wakeError} holds an * status, menu and wake from one server state ({@link #ready}, {@link #phase},
* answer for the server, and join-events recorded. {@link #linkDown} makes the * {@link #desired}, {@link #gaveUp}, {@link #policy}, {@link #owner}), and
* link-status route answer 500. * join-events recorded. {@link #linkDown} makes the link-status route answer 500.
* *
* <p>Status replies carry the endpoint the way the operator writes it: a server that * <p>Status replies carry the endpoint the way the operator writes it: a server that
* is up reports {@code direct} and its {@link #address}; one that is not reports * is up reports {@code direct} and its {@link #address}; one that is not reports
* {@code fallback} with the fallback server's NAME, "login", in the address field. * {@code fallback} with the fallback server's NAME, "login", in the address field.
*
* <p>The wake follows handleInternalWake step for step, so a test cannot give the
* router an answer the real API never gives: an up server is 202 ready for anyone,
* 403 and 409 start_failed come from the state, and {@link #wakeError} only holds
* what the state cannot say (a cooldown, the running cap, maintenance, a fault).
*/ */
static final class Api implements AutoCloseable { static final class Api implements AutoCloseable {
final Set<UUID> linked = ConcurrentHashMap.newKeySet(); final Set<UUID> linked = ConcurrentHashMap.newKeySet();
@@ -455,9 +462,19 @@ final class Fakes {
final Map<String, String> desired = new ConcurrentHashMap<>(); final Map<String, String> desired = new ConcurrentHashMap<>();
final Map<String, Integer> restarts = new ConcurrentHashMap<>(); final Map<String, Integer> restarts = new ConcurrentHashMap<>();
final Set<String> gaveUp = ConcurrentHashMap.newKeySet(); final Set<String> gaveUp = ConcurrentHashMap.newKeySet();
/**
* policy is a server's autostartPolicy, public unless set, and owner the player
* who owns it. Only public or the owner passes the wake's gate: ownerOnly, and an
* allowlist the stub keeps empty, refuse everyone else.
*/
final Map<String, String> policy = new ConcurrentHashMap<>();
final Map<String, UUID> owner = new ConcurrentHashMap<>();
/** statusDown makes the status route answer 500. */ /** statusDown makes the status route answer 500. */
volatile boolean statusDown; volatile boolean statusDown;
/** wakeError maps a server to "status code" (e.g. "403 forbidden"). */ /**
* wakeError maps a server to "status code" (e.g. "429 cooldown"), answered where
* the real wake reaches its cooldown: after the gate and the start_failed check.
*/
final Map<String, String> wakeError = new ConcurrentHashMap<>(); final Map<String, String> wakeError = new ConcurrentHashMap<>();
volatile int joinStatus = 204; volatile int joinStatus = 204;
/** claimable names the servers the menu route reports as claimable. */ /** claimable names the servers the menu route reports as claimable. */
@@ -511,16 +528,13 @@ final class Fakes {
reply(ex, 200, status(name, ready.getOrDefault(name, false))); reply(ex, 200, status(name, ready.getOrDefault(name, false)));
return; return;
case "POST wake": case "POST wake":
if (!error(ex, wakeError.get(name))) { wake(ex, name, bodies.get(method + " " + path));
// The real wake reply is this subset of the view: no endpoint.
reply(ex, 202, "{\"name\":\"" + name + "\",\"desiredState\":\"Running\","
+ "\"phase\":\"Stopped\",\"ready\":false}");
}
return; return;
case "GET menu": case "GET menu":
if (!error(ex, menuError.get(name))) { if (!error(ex, menuError.get(name))) {
reply(ex, 200, "{\"name\":\"" + name + "\",\"phase\":\"Stopped\",\"ready\":false," reply(ex, 200, "{\"name\":\"" + name + "\",\"phase\":\"" + phaseOf(name)
+ "\"playersOnline\":3,\"playersMax\":20,\"claimable\":" + claimable.contains(name) + "}"); + "\",\"ready\":" + ready.getOrDefault(name, false)
+ ",\"playersOnline\":3,\"playersMax\":20,\"claimable\":" + claimable.contains(name) + "}");
} }
return; return;
case "POST claim": case "POST claim":
@@ -548,6 +562,52 @@ final class Fakes {
return true; return true;
} }
// wake answers the way handleInternalWake does, in its order: a server that is up
// and meant to stay up is 202 ready with no gate, nothing flipped; then the
// autostartPolicy gate on the body's mc_uuid; then 409 start_failed for a start
// whose retries are spent; then the cooldown and the rest of wakeError; then the
// flip to Running and a 202 with the phase as it stands.
private void wake(HttpExchange ex, String name, String body) throws IOException {
boolean up = ready.getOrDefault(name, false);
String want = desired.getOrDefault(name, "Running");
if (up && "Running".equals(want)) {
reply(ex, 202, wakeReply(name, want, true));
return;
}
UUID waker = mcUuid(body);
if (!"public".equals(policy.getOrDefault(name, "public"))
&& (waker == null || !waker.equals(owner.get(name)))) {
error(ex, "403 forbidden");
return;
}
if (gaveUp.contains(name) && "Running".equals(want)) {
error(ex, "409 start_failed");
return;
}
if (error(ex, wakeError.get(name))) {
return;
}
desired.put(name, "Running");
reply(ex, 202, wakeReply(name, "Running", up));
}
// The real wake reply is this subset of the view: no endpoint.
private String wakeReply(String name, String desiredState, boolean up) {
return "{\"name\":\"" + name + "\",\"desiredState\":\"" + desiredState + "\",\"phase\":\""
+ phaseOf(name) + "\",\"ready\":" + up + "}";
}
private static final Pattern MC_UUID = Pattern.compile("\"mc_uuid\"\\s*:\\s*\"([^\"]+)\"");
private static UUID mcUuid(String body) {
Matcher m = MC_UUID.matcher(body == null ? "" : body);
return m.find() ? UUID.fromString(m.group(1)) : null;
}
private String phaseOf(String name) {
return ready.getOrDefault(name, false) ? "Running" : phase.getOrDefault(name, "Stopped");
}
private String status(String name, boolean up) { private String status(String name, boolean up) {
String addr = address.get(name); String addr = address.get(name);
String endpoint = up String endpoint = up
@@ -556,7 +616,8 @@ final class Fakes {
// Like the real ServerInfo, autoRestarts and startGaveUp are left out at 0/false. // Like the real ServerInfo, autoRestarts and startGaveUp are left out at 0/false.
int restarts = this.restarts.getOrDefault(name, 0); int restarts = this.restarts.getOrDefault(name, 0);
return "{\"name\":\"" + name + "\",\"subdomain\":\"" + name + "\",\"phase\":\"" return "{\"name\":\"" + name + "\",\"subdomain\":\"" + name + "\",\"phase\":\""
+ (up ? "Running" : phase.getOrDefault(name, "Stopped")) + "\",\"ready\":" + up + phaseOf(name) + "\",\"ready\":" + up
+ ",\"autostartPolicy\":\"" + policy.getOrDefault(name, "public") + "\""
+ ",\"desiredState\":\"" + desired.getOrDefault(name, "Running") + "\"" + ",\"desiredState\":\"" + desired.getOrDefault(name, "Running") + "\""
+ (restarts == 0 ? "" : ",\"autoRestarts\":" + restarts) + (restarts == 0 ? "" : ",\"autoRestarts\":" + restarts)
+ (gaveUp.contains(name) ? ",\"startGaveUp\":true" : "") + (gaveUp.contains(name) ? ",\"startGaveUp\":true" : "")
@@ -162,7 +162,7 @@ public final class LegacyForwardingTest {
} }
private static ServerView view(String name, boolean legacy) { private static ServerView view(String name, boolean legacy) {
return new ServerView(name, name, "Running", true, null, "Running", "ClusterIP", "10.43.0.9:25565", 0, 20, legacy); return new ServerView(name, name, "Running", true, null, "Running", "direct", "10.43.0.9:25565", 0, 20, legacy);
} }
private static void assertEq(String what, Object want, Object got) { private static void assertEq(String what, Object want, Object got) {
@@ -31,9 +31,9 @@ public final class ServerListSourceTest {
private static volatile List<ServerView> answer; private static volatile List<ServerView> answer;
private static final ServerView LOGIN = new ServerView("login", null, "Running", true, private static final ServerView LOGIN = new ServerView("login", null, "Running", true,
null, "Running", "ClusterIP", "10.43.0.17:25565", 0, 0); null, "Running", "direct", "10.43.0.17:25565", 0, 0);
private static final ServerView SURVIVAL = new ServerView("survival", "Survival", "Stopped", false, private static final ServerView SURVIVAL = new ServerView("survival", "Survival", "Stopped", false,
"Anyone", "Stopped", "ClusterIP", "10.43.9.2:25565", 3, 20); "public", "Stopped", "fallback", "login", 3, 20);
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("felis-server-list"); Path dir = Files.createTempDirectory("felis-server-list");
@@ -84,10 +84,10 @@ public final class ServerListSourceTest {
assertEq("survival subdomain", "Survival", s.subdomain()); assertEq("survival subdomain", "Survival", s.subdomain());
assertEq("survival phase", "Stopped", s.phase()); assertEq("survival phase", "Stopped", s.phase());
assertEq("survival ready", false, s.ready()); assertEq("survival ready", false, s.ready());
assertEq("survival policy", "Anyone", s.autostartPolicy()); assertEq("survival policy", "public", s.autostartPolicy());
assertEq("survival desired", "Stopped", s.desiredState()); assertEq("survival desired", "Stopped", s.desiredState());
assertEq("survival mode", "ClusterIP", s.endpointMode()); assertEq("survival mode", "fallback", s.endpointMode());
assertEq("survival address", "10.43.9.2:25565", s.endpointAddress()); assertEq("survival address", "login", s.endpointAddress());
assertEq("survival online", 3, s.playersOnline()); assertEq("survival online", 3, s.playersOnline());
assertEq("survival max", 20, s.playersMax()); assertEq("survival max", 20, s.playersMax());
} }
@@ -154,7 +154,7 @@ public final class ServerListSourceTest {
private static void theLegacyForwardingMarkIsSaved(Path dir) throws Exception { private static void theLegacyForwardingMarkIsSaved(Path dir) throws Exception {
Path file = fresh(dir); Path file = fresh(dir);
ServerView legacy = new ServerView("legacy18", "old", "Running", true, ServerView legacy = new ServerView("legacy18", "old", "Running", true,
null, "Running", "ClusterIP", "10.43.7.1:25565", 0, 20, true); null, "Running", "direct", "10.43.7.1:25565", 0, 20, true);
answer = List.of(legacy, SURVIVAL); answer = List.of(legacy, SURVIVAL);
new ServerListSource(ServerListSourceTest::fetch, file).next(); new ServerListSource(ServerListSourceTest::fetch, file).next();
answer = null; answer = null;
@@ -167,7 +167,7 @@ public final class ServerListSourceTest {
// The exact shape GET /api/v1/servers answers with, extra fields included. // The exact shape GET /api/v1/servers answers with, extra fields included.
private static void theApiEnvelopeParses() { private static void theApiEnvelopeParses() {
List<ServerView> v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\"," List<ServerView> v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\","
+ "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"ClusterIP\"," + "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"direct\","
+ "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100," + "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100,"
+ "\"owner\":\"x\"},\"junk\",{\"name\":\"legacy18\",\"legacyForwarding\":true}]}"); + "\"owner\":\"x\"},\"junk\",{\"name\":\"legacy18\",\"legacyForwarding\":true}]}");
assertEq("non-object entries skipped", 2, v.size()); assertEq("non-object entries skipped", 2, v.size());
@@ -71,6 +71,7 @@ public final class WaitingRouterTest {
loginGate(); loginGate();
wakeRefusals(); wakeRefusals();
queue(); queue();
wakeAnswers();
longStart(); longStart();
menuAndCommands(); menuAndCommands();
joins(); joins();
@@ -97,6 +98,9 @@ public final class WaitingRouterTest {
view("theta", false, "10.43.0.22:25565"), view("theta", false, "10.43.0.22:25565"),
view("iota", false, "10.43.0.23:25565"), view("iota", false, "10.43.0.23:25565"),
view("kappa", false, "10.43.0.24:25565"), view("kappa", false, "10.43.0.24:25565"),
view("lambda", false, "10.43.0.25:25565"),
view("omicron", false, "10.43.0.26:25565"),
view("sigma", false, "10.43.0.27:25565"),
view("fresh", false, null))); view("fresh", false, null)));
if (withGone) { if (withGone) {
list.add(view("gone", false, "10.43.0.9:25565")); list.add(view("gone", false, "10.43.0.9:25565"));
@@ -216,17 +220,27 @@ public final class WaitingRouterTest {
} }
private static void wakeRefusals() { private static void wakeRefusals() {
// The first two come from the server's state, as on the real API: a stranger's
// wake of an ownerOnly server, and a start whose automatic retries are spent.
String[][] cases = { String[][] cases = {
{"403 forbidden", "You're not allowed to start « gamma »", null}, {"ownerOnly, not the owner", "You're not allowed to start « gamma »", null},
{"retries spent", "« gamma » failed to start and its automatic retries are spent", null},
{"409 maintenance_in_progress", "« gamma » is under maintenance", null}, {"409 maintenance_in_progress", "« gamma » is under maintenance", null},
{"409 start_failed", "« gamma » failed to start and its automatic retries are spent", null},
{"409 conflict", "Couldn't start « gamma » right now", "wake gamma failed (status=409)"}, {"409 conflict", "Couldn't start « gamma » right now", "wake gamma failed (status=409)"},
{"503 at_capacity", "The cluster is at capacity right now", null}, {"503 at_capacity", "The cluster is at capacity right now", null},
{"503 unavailable", "Couldn't start « gamma » right now", "wake gamma failed (status=503)"}, {"503 unavailable", "Couldn't start « gamma » right now", "wake gamma failed (status=503)"},
{"500 internal", "Couldn't start « gamma » right now", "wake gamma failed (status=500)"}, {"500 internal", "Couldn't start « gamma » right now", "wake gamma failed (status=500)"},
}; };
for (String[] c : cases) { for (String[] c : cases) {
api.wakeError.put("gamma", c[0]); switch (c[0]) {
case "ownerOnly, not the owner" -> api.policy.put("gamma", "ownerOnly");
case "retries spent" -> {
api.phase.put("gamma", "Failed");
api.restarts.put("gamma", 3);
api.gaveUp.add("gamma");
}
default -> api.wakeError.put("gamma", c[0]);
}
int before = router.waitingCount(); int before = router.waitingCount();
int warned = c[2] == null ? 0 : log.count("WARN", c[2]); int warned = c[2] == null ? 0 : log.count("WARN", c[2]);
Fakes.FakePlayer p = player("gamma.mc.test", true); Fakes.FakePlayer p = player("gamma.mc.test", true);
@@ -238,6 +252,11 @@ public final class WaitingRouterTest {
if (c[2] != null) { if (c[2] != null) {
assertEq(c[0] + ": logged", warned + 1, log.count("WARN", c[2])); assertEq(c[0] + ": logged", warned + 1, log.count("WARN", c[2]));
} }
api.policy.remove("gamma");
api.phase.remove("gamma");
api.restarts.remove("gamma");
api.gaveUp.remove("gamma");
api.wakeError.remove("gamma");
} }
// 429: a wake is already in flight, so join the wait. // 429: a wake is already in flight, so join the wait.
api.wakeError.put("gamma", "429 cooldown"); api.wakeError.put("gamma", "429 cooldown");
@@ -331,6 +350,53 @@ public final class WaitingRouterTest {
assertEq("registered: queue empty", 0, router.waitingCount()); assertEq("registered: queue empty", 0, router.waitingCount());
} }
// The wakes that are not refused, each answered from the server's state the way the
// real API answers it.
private static void wakeAnswers() {
assertEq("wake answers: queue empty to begin with", 0, router.waitingCount());
// The owner of an ownerOnly server may wake it, and the wake names the player
// who asked: the gate reads nothing else.
api.policy.put("lambda", "ownerOnly");
Fakes.FakePlayer owner = player("lambda.mc.test", true);
api.owner.put("lambda", owner.id);
choose(owner);
assertEq("owner: released to the lobby", "lobby", allowedTo(release(owner)));
assertEq("owner: told it is starting", true, owner.said("Starting « lambda »"));
assertEq("owner: queued", 1, router.waitingCount());
assertEq("the wake carried the joining player", true,
api.bodies.get("POST " + SERVERS + "lambda/wake").contains(owner.id.toString()));
// Up since the last refresh: the host path still wakes, and the API answers 202
// ready without the gate. Nobody is refused or told a start is under way, and
// the next drain moves them in.
api.policy.put("omicron", "ownerOnly");
api.ready.put("omicron", true);
Fakes.FakePlayer friend = player("omicron.mc.test", true);
choose(friend);
assertEq("up behind a stale list: released to the lobby", "lobby", allowedTo(release(friend)));
assertEq("up behind a stale list: not refused", false, friend.said("not allowed"));
assertEq("up behind a stale list: no promise of a start", false, friend.said("Starting « omicron »"));
assertEq("up behind a stale list: queued", 2, router.waitingCount());
router.tick();
assertEq("up behind a stale list: moved at the next drain", List.of("omicron"), List.copyOf(friend.connects));
assertEq("up behind a stale list: only the owner's wait is left", 1, router.waitingCount());
// Failed, but inside its restart backoff: the next attempt is coming, so the
// wake is a 202 and the player waits for it.
api.phase.put("sigma", "Failed");
api.restarts.put("sigma", 1);
Fakes.FakePlayer patient = player("sigma.mc.test", true);
choose(patient);
assertEq("in the backoff: released to the lobby", "lobby", allowedTo(release(patient)));
assertEq("in the backoff: told it is starting", true, patient.said("Starting « sigma »"));
assertEq("in the backoff: queued", 2, router.waitingCount());
net.players.clear();
router.tick();
assertEq("wake answers: queue empty again", 0, router.waitingCount());
}
// A modpack's cold start outlasts any fixed wait: the operator gives a start 300 s, // A modpack's cold start outlasts any fixed wait: the operator gives a start 300 s,
// then recreates the pod up to three times with a 1, 2, 4 min backoff. The waiter // then recreates the pod up to three times with a 1, 2, 4 min backoff. The waiter
// follows the server's own progress instead of a clock, and hears how it is going. // follows the server's own progress instead of a clock, and hears how it is going.
@@ -446,9 +512,10 @@ public final class WaitingRouterTest {
assertEq("only the menu entry tells the lobby", List.of(menu.name + "@epsilon"), List.copyOf(notified)); assertEq("only the menu entry tells the lobby", List.of(menu.name + "@epsilon"), List.copyOf(notified));
// A friend's running server: the menu and /felis go join it without waking it. // A friend's running server: the menu and /felis go join it without waking it.
// The API refuses a non-owner's wake of an ownerOnly server, and that refusal // The API used to refuse a non-owner's wake of a running ownerOnly server, and
// was all a friend got from the green tile while every entry woke first. // that refusal was all a friend got from the green tile while every entry woke
api.wakeError.put("beta", "403 forbidden"); // first.
api.policy.put("beta", "ownerOnly");
Fakes.FakePlayer friend = player(null, true); Fakes.FakePlayer friend = player(null, true);
friend.current = lobby; friend.current = lobby;
router.enqueueFromMenu(friend.player, "beta"); router.enqueueFromMenu(friend.player, "beta");
@@ -462,7 +529,7 @@ public final class WaitingRouterTest {
assertEq("running server via /felis go: joined", List.of("beta"), List.copyOf(friend2.connects)); assertEq("running server via /felis go: joined", List.of("beta"), List.copyOf(friend2.connects));
assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake")); assertEq("running server: never woken", 0, api.count("POST " + SERVERS + "beta/wake"));
assertEq("running server: nobody refused", false, friend.said("not allowed") || friend2.said("not allowed")); assertEq("running server: nobody refused", false, friend.said("not allowed") || friend2.said("not allowed"));
api.wakeError.remove("beta"); api.policy.remove("beta");
// Asking for the server you stand on is answered at once, case-insensitively. // Asking for the server you stand on is answered at once, case-insensitively.
Fakes.FakePlayer there = player(null, true); Fakes.FakePlayer there = player(null, true);
@@ -674,7 +741,7 @@ public final class WaitingRouterTest {
if (addr != null) { if (addr != null) {
api.address.put(name, addr); api.address.put(name, addr);
} }
return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "ownerOnly", return new ServerView(name, name, ready ? "Running" : "Stopped", ready, "public",
"Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20); "Running", ready ? "direct" : "fallback", ready ? addr : "login", 0, 20);
} }