Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,26 @@ java -cp target/classes GiftTracker <username> # 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 |
| `PIRATETOK_LIVE_TEST_HTTP` | optional (`1`, `true`, or `yes`) | Enables HTTP probe for `UserNotFoundException` (fixed synthetic username; no live stream) |

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.
Expand Down
19 changes: 19 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,18 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>25</maven.compiler.release>
<junit.version>5.11.4</junit.version>
</properties>

<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
Expand Down Expand Up @@ -58,6 +68,15 @@
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<!-- JUnit 5 -->
<useModulePath>false</useModulePath>
</configuration>
</plugin>
</plugins>
</build>
</project>
191 changes: 191 additions & 0 deletions src/test/java/com/piratetok/live/PirateTokClientWssSmokeTest.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Requires {@code PIRATETOK_LIVE_TEST_USER} (live during the run). Uses EU CDN, modest retries.</p>
*/
@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<String, Object>) 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<String, Object>) e.data().getOrDefault("user", Map.of());
@SuppressWarnings("unchecked")
var gift = (Map<String, Object>) 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<String, Object>) 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<String, Object>) 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<String, Object>) 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<PirateTokClient, Runnable> registerListeners,
String failureMessage
) throws Exception {
var latch = new CountDownLatch(1);
var workerError = new AtomicReference<Throwable>();
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");
}
}
}
51 changes: 51 additions & 0 deletions src/test/java/com/piratetok/live/connection/WssUrlTest.java
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<String, String> q = parseQuery(url.substring(url.indexOf('?') + 1));
assertEquals(roomId, q.get("room_id"));
}

private static Map<String, String> parseQuery(String raw) {
var out = new LinkedHashMap<String, String>();
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;
}
}
74 changes: 74 additions & 0 deletions src/test/java/com/piratetok/live/http/ApiIntegrationTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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;
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.
*
* <ul>
* <li>{@code PIRATETOK_LIVE_TEST_USER} — TikTok username that is <strong>live</strong> during the run</li>
* <li>{@code PIRATETOK_LIVE_TEST_OFFLINE_USER} — optional; must <strong>not</strong> be live</li>
* <li>{@code PIRATETOK_LIVE_TEST_COOKIES} — optional; browser cookie header for 18+ room info</li>
* <li>{@code PIRATETOK_LIVE_TEST_HTTP=1} — enables {@link #checkOnline_nonexistentUser_throwsUserNotFound()} (fixed synthetic username)</li>
* </ul>
*/
@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 {
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));
}
}
Loading