From d5ab50b1586993800ac3c0cf338a0da255666cdb Mon Sep 17 00:00:00 2001 From: "Dustin H." Date: Sat, 4 Apr 2026 14:03:44 +0200 Subject: [PATCH 1/3] test: add basic chat connection tests including chat messages, gifts. follows, join, like, sub --- README.md | 19 ++ pom.xml | 19 ++ .../live/PirateTokClientWssSmokeTest.java | 191 ++++++++++++++++++ .../live/http/ApiIntegrationTest.java | 59 ++++++ 4 files changed, 288 insertions(+) create mode 100644 src/test/java/com/piratetok/live/PirateTokClientWssSmokeTest.java create mode 100644 src/test/java/com/piratetok/live/http/ApiIntegrationTest.java diff --git a/README.md b/README.md index 8d6a9d6..10fc209 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,25 @@ java -cp target/classes GiftTracker # track gifts with diamond to With **Make** instead of Maven, use `make build` and `-cp out`. +## Integration tests (real TikTok API) + +Tests live under `src/test/java` and call TikTok over the network. They are **skipped unless environment variables are set**, so `mvn test` succeeds in CI without secrets. + +| Variable | Required | Purpose | +|:---------|:---------|:--------| +| `PIRATETOK_LIVE_TEST_USER` | for HTTP + WSS tests | Username that is **live** for the whole run | +| `PIRATETOK_LIVE_TEST_OFFLINE_USER` | optional | Username that must **not** be live (`HostNotOnlineException`) | +| `PIRATETOK_LIVE_TEST_COOKIES` | optional | Cookie header for `fetchRoomInfo` on age-restricted rooms | + +Examples: + +```bash +set PIRATETOK_LIVE_TEST_USER=some_live_creator +mvn test +``` + +Expect occasional failures from rate limits, blocks, or the stream going offline. + ## Known gaps - Proxy transport support is not wired yet. diff --git a/pom.xml b/pom.xml index f25d2e2..4176cdb 100644 --- a/pom.xml +++ b/pom.xml @@ -15,8 +15,18 @@ UTF-8 25 + 5.11.4 + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + @@ -58,6 +68,15 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + false + + diff --git a/src/test/java/com/piratetok/live/PirateTokClientWssSmokeTest.java b/src/test/java/com/piratetok/live/PirateTokClientWssSmokeTest.java new file mode 100644 index 0000000..68a61ce --- /dev/null +++ b/src/test/java/com/piratetok/live/PirateTokClientWssSmokeTest.java @@ -0,0 +1,191 @@ +package com.piratetok.live; + +import com.piratetok.live.events.EventType; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Short WebSocket smoke tests against a real live room. Flaky under rate limits or quiet streams. + * + *

Requires {@code PIRATETOK_LIVE_TEST_USER} (live during the run). Uses EU CDN, modest retries.

+ */ +@Tag("integration") +class PirateTokClientWssSmokeTest { + + private static final Duration AWAIT_TRAFFIC = Duration.ofSeconds(90); + private static final Duration AWAIT_CHAT = Duration.ofSeconds(120); + private static final Duration AWAIT_GIFT = Duration.ofSeconds(180); + private static final Duration AWAIT_LIKE = Duration.ofSeconds(120); + private static final Duration AWAIT_JOIN = Duration.ofSeconds(150); + private static final Duration AWAIT_FOLLOW = Duration.ofSeconds(180); + private static final Duration AWAIT_SUBSCRIPTION = Duration.ofSeconds(240); + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") + void connect_receivesTrafficBeforeTimeout() throws Exception { + String user = System.getenv("PIRATETOK_LIVE_TEST_USER").strip(); + awaitWssEvent(user, AWAIT_TRAFFIC, (client, hit) -> { + client.on(EventType.ROOM_USER_SEQ, e -> hit.run()); + client.on(EventType.MEMBER, e -> hit.run()); + client.on(EventType.CHAT, e -> hit.run()); + client.on(EventType.LIKE, e -> hit.run()); + client.on(EventType.CONTROL, e -> hit.run()); + }, "no room traffic within %ds (quiet stream or block)".formatted(AWAIT_TRAFFIC.toSeconds())); + } + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") + void connect_receivesChatBeforeTimeout() throws Exception { + String user = System.getenv("PIRATETOK_LIVE_TEST_USER").strip(); + awaitWssEvent(user, AWAIT_CHAT, (client, hit) -> client.on(EventType.CHAT, e -> { + @SuppressWarnings("unchecked") + var chatter = (Map) e.data().getOrDefault("user", Map.of()); + System.out.println("[integration test chat] " + + chatter.getOrDefault("uniqueId", "?") + + ": " + + e.data().get("content")); + hit.run(); + }), "no chat message within %ds (quiet stream or block)".formatted(AWAIT_CHAT.toSeconds())); + } + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") + void connect_receivesGiftBeforeTimeout() throws Exception { + String user = System.getenv("PIRATETOK_LIVE_TEST_USER").strip(); + awaitWssEvent(user, AWAIT_GIFT, (client, hit) -> client.on(EventType.GIFT, e -> { + @SuppressWarnings("unchecked") + var gifter = (Map) e.data().getOrDefault("user", Map.of()); + @SuppressWarnings("unchecked") + var gift = (Map) e.data().getOrDefault("gift", Map.of()); + long diamonds = ((Number) gift.getOrDefault("diamondCount", 0)).longValue(); + long repeat = ((Number) e.data().getOrDefault("repeatCount", 1)).longValue(); + System.out.println("[integration test gift] " + + gifter.getOrDefault("uniqueId", "?") + + " -> " + + gift.getOrDefault("name", "?") + + " x" + + repeat + + " (" + + diamonds + + " diamonds each)"); + hit.run(); + }), "no gift within %ds (quiet stream or no gifts — try a busier stream)".formatted(AWAIT_GIFT.toSeconds())); + } + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") + void connect_receivesLikeBeforeTimeout() throws Exception { + String user = System.getenv("PIRATETOK_LIVE_TEST_USER").strip(); + awaitWssEvent(user, AWAIT_LIKE, (client, hit) -> client.on(EventType.LIKE, e -> { + @SuppressWarnings("unchecked") + var liker = (Map) e.data().getOrDefault("user", Map.of()); + System.out.println("[integration test like] " + + liker.getOrDefault("uniqueId", "?") + + " count=" + + e.data().get("count") + + " total=" + + e.data().get("total")); + hit.run(); + }), "no like within %ds (quiet stream or block)".formatted(AWAIT_LIKE.toSeconds())); + } + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") + void connect_receivesJoinBeforeTimeout() throws Exception { + String user = System.getenv("PIRATETOK_LIVE_TEST_USER").strip(); + awaitWssEvent(user, AWAIT_JOIN, (client, hit) -> client.on(EventType.JOIN, e -> { + @SuppressWarnings("unchecked") + var member = (Map) e.data().getOrDefault("user", Map.of()); + System.out.println("[integration test join] " + member.getOrDefault("uniqueId", "?")); + hit.run(); + }), "no join within %ds (try a busier stream)".formatted(AWAIT_JOIN.toSeconds())); + } + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") + void connect_receivesFollowBeforeTimeout() throws Exception { + String user = System.getenv("PIRATETOK_LIVE_TEST_USER").strip(); + awaitWssEvent(user, AWAIT_FOLLOW, (client, hit) -> client.on(EventType.FOLLOW, e -> { + @SuppressWarnings("unchecked") + var follower = (Map) e.data().getOrDefault("user", Map.of()); + System.out.println("[integration test follow] " + follower.getOrDefault("uniqueId", "?")); + hit.run(); + }), "no follow within %ds (follows are infrequent — try a growing stream)".formatted(AWAIT_FOLLOW.toSeconds())); + } + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") + @Disabled + void connect_receivesSubscriptionSignalBeforeTimeout() throws Exception { + String user = System.getenv("PIRATETOK_LIVE_TEST_USER").strip(); + awaitWssEvent(user, AWAIT_SUBSCRIPTION, (client, hit) -> { + client.on(EventType.SUB_NOTIFY, e -> { + System.out.println("[integration test subscription] subNotify"); + hit.run(); + }); + client.on(EventType.SUBSCRIPTION_NOTIFY, e -> { + System.out.println("[integration test subscription] subscriptionNotify"); + hit.run(); + }); + client.on(EventType.SUB_CAPSULE, e -> { + System.out.println("[integration test subscription] subCapsule"); + hit.run(); + }); + client.on(EventType.SUB_PIN_EVENT, e -> { + System.out.println("[integration test subscription] subPinEvent"); + hit.run(); + }); + }, "no subscription-related event within %ds (need subs/gifts on a sub-enabled stream)".formatted(AWAIT_SUBSCRIPTION.toSeconds())); + } + + private static void awaitWssEvent( + String user, + Duration await, + BiConsumer registerListeners, + String failureMessage + ) throws Exception { + var latch = new CountDownLatch(1); + var workerError = new AtomicReference(); + Runnable onHit = latch::countDown; + + var client = new PirateTokClient(user) + .cdnEU() + .timeout(Duration.ofSeconds(15)) + .maxRetries(5) + .staleTimeout(Duration.ofSeconds(45)); + + registerListeners.accept(client, onHit); + + Thread worker = new Thread(() -> { + try { + client.connect(); + } catch (Throwable t) { + workerError.set(t); + } + }, "wss-smoke-" + user); + worker.start(); + + try { + boolean got = latch.await(await.toSeconds(), TimeUnit.SECONDS); + assertNull(workerError.get(), () -> "connect thread failed: " + workerError.get()); + assertTrue(got, failureMessage); + } finally { + client.disconnect(); + worker.join(Duration.ofSeconds(30).toMillis()); + assertFalse(worker.isAlive(), "worker should exit after disconnect"); + } + } +} diff --git a/src/test/java/com/piratetok/live/http/ApiIntegrationTest.java b/src/test/java/com/piratetok/live/http/ApiIntegrationTest.java new file mode 100644 index 0000000..51d1112 --- /dev/null +++ b/src/test/java/com/piratetok/live/http/ApiIntegrationTest.java @@ -0,0 +1,59 @@ +package com.piratetok.live.http; + +import com.piratetok.live.Errors.HostNotOnlineException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Hits real TikTok HTTP endpoints. Disabled unless env vars are set — default {@code mvn test} stays green. + * + *
    + *
  • {@code PIRATETOK_LIVE_TEST_USER} — TikTok username that is live during the run
  • + *
  • {@code PIRATETOK_LIVE_TEST_OFFLINE_USER} — optional; must not be live
  • + *
  • {@code PIRATETOK_LIVE_TEST_COOKIES} — optional; browser cookie header for 18+ room info
  • + *
+ */ +@Tag("integration") +class ApiIntegrationTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(25); + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") + void checkOnline_liveUser_returnsRoomId() throws Exception { + String user = System.getenv("PIRATETOK_LIVE_TEST_USER").strip(); + var result = Api.checkOnline(user, TIMEOUT); + assertNotNull(result.roomId()); + assertFalse(result.roomId().isEmpty()); + assertFalse("0".equals(result.roomId())); + } + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") + void fetchRoomInfo_liveRoom_returnsRoomInfo() throws Exception { + String user = System.getenv("PIRATETOK_LIVE_TEST_USER").strip(); + var room = Api.checkOnline(user, TIMEOUT); + String cookies = System.getenv("PIRATETOK_LIVE_TEST_COOKIES"); + if (cookies == null) { + cookies = ""; + } + var info = Api.fetchRoomInfo(room.roomId(), TIMEOUT, cookies); + assertNotNull(info); + assertTrue(info.viewers() >= 0); + } + + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_OFFLINE_USER", matches = ".+") + void checkOnline_offlineUser_throwsHostNotOnline() { + String user = System.getenv("PIRATETOK_LIVE_TEST_OFFLINE_USER").strip(); + assertThrows(HostNotOnlineException.class, () -> Api.checkOnline(user, TIMEOUT)); + } +} From bd27f688d9a71ae49655d2179a0ec8db0a7989ae Mon Sep 17 00:00:00 2001 From: "Dustin H." Date: Sat, 4 Apr 2026 14:08:16 +0200 Subject: [PATCH 2/3] test: integration probe for UserNotFound via PIRATETOK_LIVE_TEST_HTTP --- README.md | 1 + .../piratetok/live/http/ApiIntegrationTest.java | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/README.md b/README.md index 10fc209..320bc95 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ Tests live under `src/test/java` and call TikTok over the network. They are **sk | `PIRATETOK_LIVE_TEST_USER` | for HTTP + WSS tests | Username that is **live** for the whole run | | `PIRATETOK_LIVE_TEST_OFFLINE_USER` | optional | Username that must **not** be live (`HostNotOnlineException`) | | `PIRATETOK_LIVE_TEST_COOKIES` | optional | Cookie header for `fetchRoomInfo` on age-restricted rooms | +| `PIRATETOK_LIVE_TEST_HTTP` | optional (`1`, `true`, or `yes`) | Enables HTTP probe for `UserNotFoundException` (fixed synthetic username; no live stream) | Examples: diff --git a/src/test/java/com/piratetok/live/http/ApiIntegrationTest.java b/src/test/java/com/piratetok/live/http/ApiIntegrationTest.java index 51d1112..621f2be 100644 --- a/src/test/java/com/piratetok/live/http/ApiIntegrationTest.java +++ b/src/test/java/com/piratetok/live/http/ApiIntegrationTest.java @@ -1,12 +1,14 @@ package com.piratetok.live.http; import com.piratetok.live.Errors.HostNotOnlineException; +import com.piratetok.live.Errors.UserNotFoundException; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import java.time.Duration; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -19,13 +21,26 @@ *
  • {@code PIRATETOK_LIVE_TEST_USER} — TikTok username that is live during the run
  • *
  • {@code PIRATETOK_LIVE_TEST_OFFLINE_USER} — optional; must not be live
  • *
  • {@code PIRATETOK_LIVE_TEST_COOKIES} — optional; browser cookie header for 18+ room info
  • + *
  • {@code PIRATETOK_LIVE_TEST_HTTP=1} — enables {@link #checkOnline_nonexistentUser_throwsUserNotFound()} (fixed synthetic username)
  • * */ @Tag("integration") class ApiIntegrationTest { + /** Unlikely to be registered; TikTok must return user-not-found for this probe. */ + private static final String SYNTHETIC_NONEXISTENT_USER = "piratetok_java_nf_7a3c9e2f1b8d4a6c0e5f3a2b1d9c8e7"; + private static final Duration TIMEOUT = Duration.ofSeconds(25); + @Test + @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_HTTP", matches = "1|true|yes", disabledReason = "set PIRATETOK_LIVE_TEST_HTTP=1 to call TikTok user/room for not-found probe") + void checkOnline_nonexistentUser_throwsUserNotFound() { + var ex = assertThrows( + UserNotFoundException.class, + () -> Api.checkOnline(SYNTHETIC_NONEXISTENT_USER, TIMEOUT)); + assertEquals(SYNTHETIC_NONEXISTENT_USER, ex.username); + } + @Test @EnabledIfEnvironmentVariable(named = "PIRATETOK_LIVE_TEST_USER", matches = ".+") void checkOnline_liveUser_returnsRoomId() throws Exception { From 6580261e0192ea5301816d6f9baaca6c5637967a Mon Sep 17 00:00:00 2001 From: "Dustin H." Date: Sat, 4 Apr 2026 14:08:20 +0200 Subject: [PATCH 3/3] test: unit tests for Json, WssUrl, and Proto --- .../piratetok/live/connection/WssUrlTest.java | 51 ++++++++++++++ .../com/piratetok/live/http/JsonTest.java | 57 ++++++++++++++++ .../com/piratetok/live/proto/ProtoTest.java | 68 +++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 src/test/java/com/piratetok/live/connection/WssUrlTest.java create mode 100644 src/test/java/com/piratetok/live/http/JsonTest.java create mode 100644 src/test/java/com/piratetok/live/proto/ProtoTest.java diff --git a/src/test/java/com/piratetok/live/connection/WssUrlTest.java b/src/test/java/com/piratetok/live/connection/WssUrlTest.java new file mode 100644 index 0000000..ff19e2e --- /dev/null +++ b/src/test/java/com/piratetok/live/connection/WssUrlTest.java @@ -0,0 +1,51 @@ +package com.piratetok.live.connection; + +import org.junit.jupiter.api.Test; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WssUrlTest { + + @Test + void build_schemeHostPathAndRoomId() { + String cdn = "webcast-ws.eu.tiktok.com"; + String roomId = "7123456789012345678"; + String url = WssUrl.build(cdn, roomId); + + assertTrue(url.startsWith("wss://" + cdn + "/webcast/im/ws_proxy/ws_reuse_supplement/?")); + + Map q = parseQuery(url.substring(url.indexOf('?') + 1)); + assertEquals(roomId, q.get("room_id")); + assertEquals("protobuf", q.get("resp_content_type")); + assertEquals("1988", q.get("aid")); + assertEquals("audience", q.get("identity")); + } + + @Test + void build_encodesSpecialCharactersInRoomId() { + String roomId = "id+with spaces"; + String url = WssUrl.build("example.test", roomId); + Map q = parseQuery(url.substring(url.indexOf('?') + 1)); + assertEquals(roomId, q.get("room_id")); + } + + private static Map parseQuery(String raw) { + var out = new LinkedHashMap(); + for (String part : raw.split("&")) { + int eq = part.indexOf('='); + if (eq <= 0) { + continue; + } + String k = URLDecoder.decode(part.substring(0, eq), StandardCharsets.UTF_8); + String v = URLDecoder.decode(part.substring(eq + 1), StandardCharsets.UTF_8); + out.put(k, v); + } + return out; + } +} diff --git a/src/test/java/com/piratetok/live/http/JsonTest.java b/src/test/java/com/piratetok/live/http/JsonTest.java new file mode 100644 index 0000000..445231b --- /dev/null +++ b/src/test/java/com/piratetok/live/http/JsonTest.java @@ -0,0 +1,57 @@ +package com.piratetok.live.http; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JsonTest { + + @Test + void parseObject_nestedAndPrimitives() { + Map m = Json.parseObject(""" + {"statusCode":0,"data":{"user":{"roomId":"7123456789"},"n":42}}"""); + assertEquals(0, ((Number) m.get("statusCode")).intValue()); + @SuppressWarnings("unchecked") + var data = (Map) m.get("data"); + @SuppressWarnings("unchecked") + var user = (Map) data.get("user"); + assertEquals("7123456789", user.get("roomId")); + assertEquals(42, ((Number) data.get("n")).intValue()); + } + + @Test + void parseObject_stringEscapes() { + Map m = Json.parseObject("{\"t\":\"line1\\nline2\\t\\\"\"}"); + assertEquals("line1\nline2\t\"", m.get("t")); + } + + @Test + void parse_arrayAndNullAndBool() { + Object v = Json.parse("[true,false,null,3.5]"); + assertInstanceOf(List.class, v); + @SuppressWarnings("unchecked") + var list = (List) v; + assertEquals(Boolean.TRUE, list.get(0)); + assertEquals(Boolean.FALSE, list.get(1)); + assertNull(list.get(2)); + assertEquals(3.5, ((Number) list.get(3)).doubleValue()); + } + + @Test + void parseObject_rejectsNonObject() { + assertThrows(IllegalArgumentException.class, () -> Json.parseObject("[1]")); + } + + @Test + void parseObject_emptyObject() { + Map m = Json.parseObject("{}"); + assertTrue(m.isEmpty()); + } +} diff --git a/src/test/java/com/piratetok/live/proto/ProtoTest.java b/src/test/java/com/piratetok/live/proto/ProtoTest.java new file mode 100644 index 0000000..ce528fb --- /dev/null +++ b/src/test/java/com/piratetok/live/proto/ProtoTest.java @@ -0,0 +1,68 @@ +package com.piratetok.live.proto; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProtoTest { + + @Test + void encodeDecode_stringAndVarint() { + byte[] encoded = Proto.encode(w -> { + w.writeString(1, "hello"); + w.writeInt64(2, 42L); + }); + Proto.ProtoMap m = Proto.decode(encoded); + assertEquals("hello", m.getString(1)); + assertEquals(42L, m.getVarint(2)); + } + + @Test + void encodeDecode_nestedMessage() { + byte[] inner = Proto.encode(w -> w.writeString(1, "inner")); + byte[] outer = Proto.encode(w -> w.writeMessage(3, inner)); + Proto.ProtoMap root = Proto.decode(outer); + Proto.ProtoMap nested = root.getMessage(3); + assertEquals("inner", nested.getString(1)); + } + + @Test + void encodeDecode_stringMap() { + byte[] encoded = Proto.encode(w -> w.writeMap(5, Map.of("k1", "v1", "k2", "ab"))); + Proto.ProtoMap m = Proto.decode(encoded); + Map map = m.getStringMap(5); + assertEquals("v1", map.get("k1")); + assertEquals("ab", map.get("k2")); + } + + @Test + void encodeDecode_boolAndBytes() { + byte[] raw = {0x01, 0x02, 0x03}; + byte[] encoded = Proto.encode(w -> { + w.writeBool(1, true); + w.writeBytes(2, raw); + }); + Proto.ProtoMap m = Proto.decode(encoded); + assertTrue(m.getBool(1)); + assertArrayEquals(raw, m.getRawBytes(2)); + } + + @Test + void decode_emptyMessage() { + Proto.ProtoMap m = Proto.decode(new byte[0]); + assertTrue(m.fields().isEmpty()); + } + + @Test + void getString_utf8RoundTrip() { + String u = "测试 🎁"; + byte[] encoded = Proto.encode(w -> w.writeString(1, u)); + assertEquals(u, Proto.decode(encoded).getString(1)); + assertEquals(u, new String(Proto.decode(encoded).getRawBytes(1), StandardCharsets.UTF_8)); + } +}