package best.lolicon.felis.link; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.UUID; /** * ModLinkTest runs the mods' shared {@code /link} ({@link ModLink}) the way a mod * does, with a fake command source, a hand-cranked I/O thread and server thread, and * the real LinkClient against a stub felis-api. It checks what the caller gets: the * console and an offline-mode server are refused before anything is minted, a player * hears "requesting" at once while the code is fetched off the server thread, the * code and where to redeem it (the minted panel URL, else the console) arrive back on * the server thread, and any failure is one generic line with the details in the log. * *

Run: {@code javac -d shared/src/main/java/best/lolicon/felis/link/*.java * shared/test/best/lolicon/felis/link/ModLinkTest.java && java -cp * best.lolicon.felis.link.ModLinkTest} (plugins/test.sh does). */ public final class ModLinkTest { private static final String CODE = "K7Q2-9XPM"; private static int checks; // The stub's knobs and what it saw. private static volatile int status = 201; private static volatile String panelUrl; private static final List bodies = new ArrayList<>(); public static void main(String[] args) throws Exception { HttpServer stub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); stub.createContext("/", ModLinkTest::handle); stub.start(); String base = "http://127.0.0.1:" + stub.getAddress().getPort(); try { LinkClient client = new LinkClient(new LinkConfig(base, "mod-token")); consoleIsRefused(client); offlineModeIsRefused(client); playerGetsTheCodeAndThePanelUrl(client); codeWithoutPanelUrlPointsAtTheConsole(client); refusalIsOneGenericLine(client); } finally { stub.stop(0); } felisApiDownIsOneGenericLine(new LinkClient(new LinkConfig(base, "mod-token"))); System.out.println("ModLinkTest OK (" + checks + " checks)"); } private static void consoleIsRefused(LinkClient client) { Rig rig = new Rig(client); Caller console = new Caller(null, true); assertEq("the console's /link is refused", 0, rig.link.run(console)); assertEq("... as players-only", List.of(ModLink.PLAYERS_ONLY), console.failures); assertEq("... and nothing is fetched", 0, rig.io.size()); } private static void offlineModeIsRefused(LinkClient client) { Rig rig = new Rig(client); Caller player = new Caller(UUID.randomUUID(), false); assertEq("/link on an offline-mode server is refused", 0, rig.link.run(player)); assertEq("... with the reason", List.of(ModLink.OFFLINE_REPLY), player.failures); assertEq("... logged", List.of(ModLink.OFFLINE_LOG), rig.warnings); assertEq("... and no code minted for a UUID nobody checked", 0, rig.io.size()); assertEq("... nor a line told", List.of(), player.told); } private static void playerGetsTheCodeAndThePanelUrl(LinkClient client) { Rig rig = new Rig(client); panelUrl = "https://panel.example.test/link"; UUID id = UUID.randomUUID(); Caller player = new Caller(id, true); int before = bodies.size(); assertEq("/link runs", 1, rig.link.run(player)); assertEq("the player hears at once that the code is coming", List.of(ModLink.REQUESTING), player.told); assertEq("... and felis-api is not called on the server thread", before, bodies.size()); rig.runIo(player); assertEq("the I/O thread mints one code", before + 1, bodies.size()); assertTrue("... for this player's UUID", bodies.get(before).contains(id.toString())); assertEq("... and tells nothing itself", List.of(ModLink.REQUESTING), player.told); player.drainServerThread(); assertEq("back on the server thread: the code, then the panel URL", List.of(ModLink.REQUESTING, ModLink.codeLine(new LinkCode(CODE, null, panelUrl)), "在此完成绑定 / Finish linking at: " + panelUrl), player.told); assertTrue("the code line shows the code", player.told.get(1).contains(CODE)); assertEq("nothing logged", List.of(), rig.warnings); panelUrl = null; } private static void codeWithoutPanelUrlPointsAtTheConsole(LinkClient client) { Rig rig = new Rig(client); Caller player = new Caller(UUID.randomUUID(), true); rig.link.run(player); rig.runIo(player); player.drainServerThread(); assertEq("an older felis-api's code points at the console", ModLink.CONSOLE_HINT, last(player.told)); } private static void refusalIsOneGenericLine(LinkClient client) { Rig rig = new Rig(client); status = 429; UUID id = UUID.randomUUID(); Caller player = new Caller(id, true); rig.link.run(player); rig.runIo(player); player.drainServerThread(); assertEq("a refusal: requesting, then one generic line", List.of(ModLink.REQUESTING, ModLink.FAILED), player.told); assertEq("... logged once", 1, rig.warnings.size()); String log = rig.warnings.get(0); assertTrue("... naming the player, the status and felis-api's code: " + log, log.contains(id.toString()) && log.contains("status=429") && log.contains("code=rate_limited")); assertTrue("... while the player never sees felis-api's words", !String.join(" ", player.told).contains("slow down")); status = 201; } private static void felisApiDownIsOneGenericLine(LinkClient client) { Rig rig = new Rig(client); Caller player = new Caller(UUID.randomUUID(), true); rig.link.run(player); rig.runIo(player); player.drainServerThread(); assertEq("felis-api unreachable: the same generic line", ModLink.FAILED, last(player.told)); assertTrue("... logged as a transport failure", rig.warnings.get(0).contains("status=0")); } // ---- rig ---- private static final class Rig { final List io = new ArrayList<>(); final List warnings = new ArrayList<>(); final ModLink link; Rig(LinkClient client) { link = new ModLink(client, io::add, warnings::add); } /** runIo runs the queued felis-api calls, off the caller's server thread. */ void runIo(Caller caller) { caller.onServerThread = false; List queued = new ArrayList<>(io); io.clear(); queued.forEach(Runnable::run); caller.onServerThread = true; } } /** Caller is a command source: a player (or the console), and what it was told. */ private static final class Caller implements ModLink.Source { final UUID id; final boolean online; final List failures = new ArrayList<>(); final List told = new ArrayList<>(); final List serverThread = new ArrayList<>(); boolean onServerThread = true; Caller(UUID id, boolean online) { this.id = id; this.online = online; } void drainServerThread() { List queued = new ArrayList<>(serverThread); serverThread.clear(); queued.forEach(Runnable::run); } @Override public UUID player() { return id; } @Override public boolean onlineMode() { return online; } @Override public void fail(String line) { failures.add(line); } @Override public void tell(String line) { if (!onServerThread) { throw new AssertionError("told the player off the server thread: " + line); } told.add(line); } @Override public void onServerThread(Runnable task) { serverThread.add(task); } } private static void handle(HttpExchange ex) throws IOException { String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); if (!"Bearer mod-token".equals(ex.getRequestHeaders().getFirst("Authorization")) || !ex.getRequestURI().getPath().endsWith("/account/link/code")) { reply(ex, 404, "{\"error\":{\"code\":\"not_found\",\"message\":\"not_found\"}}"); return; } synchronized (bodies) { bodies.add(body); } if (status != 201) { reply(ex, status, "{\"error\":{\"code\":\"rate_limited\",\"message\":\"slow down\"}}"); return; } String url = panelUrl; reply(ex, 201, "{\"code\":\"" + CODE + "\",\"expires_at\":\"2026-09-27T12:10:00Z\"" + (url != null ? ",\"panel_url\":\"" + url + "\"" : "") + "}"); } private static void reply(HttpExchange ex, int code, String body) throws IOException { byte[] bytes = body.getBytes(StandardCharsets.UTF_8); ex.getResponseHeaders().set("Content-Type", "application/json"); ex.sendResponseHeaders(code, bytes.length); try (OutputStream os = ex.getResponseBody()) { os.write(bytes); } } private static T last(List list) { return list.isEmpty() ? null : list.get(list.size() - 1); } private static void assertTrue(String what, boolean ok) { if (!ok) { throw new AssertionError(what); } checks++; } private static void assertEq(String what, Object want, Object got) { if (want == null ? got != null : !want.equals(got)) { throw new AssertionError(what + ": got " + got + ", want " + want); } checks++; } }