package vip.mate.channel.wecom; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; /** * Verify the WeComKeepaliveScheduler bookkeeping + force-finish path. * *

The 20s/180s timing constants were chosen against the empirically * observed stream-slot TTL and are validated in production; we don't re-test the exact * scheduling intervals here (would require either real wall-clock waits * or invasive ScheduledExecutor mocking). Instead we cover: *

* *

Force-finish is exercised by reflection-overriding {@code startedAt} * to a long-ago timestamp on a tracked StreamState, then invoking the * private {@code tick} method. This bypasses the ScheduledExecutor * entirely so tests run in milliseconds. */ class WeComKeepaliveSchedulerTest { private WeComKeepaliveScheduler scheduler; private WeComChannelAdapter adapter; @BeforeEach void setUp() { scheduler = new WeComKeepaliveScheduler(); adapter = Mockito.mock(WeComChannelAdapter.class); } @Test @DisplayName("start adds a stream entry; stop removes it") void startStopBookkeeping() { assertEquals(0, scheduler.activeStreamCount()); scheduler.start(adapter, "req-1", "stream-1", "user-alice"); assertEquals(1, scheduler.activeStreamCount()); scheduler.stop("stream-1"); assertEquals(0, scheduler.activeStreamCount()); } @Test @DisplayName("start is idempotent — second call for same streamId is a no-op") void startIdempotent() { scheduler.start(adapter, "req-1", "stream-1", "user-alice"); scheduler.start(adapter, "req-1", "stream-1", "user-alice"); assertEquals(1, scheduler.activeStreamCount(), "second start must not double-track"); } @Test @DisplayName("start is null-tolerant — null/blank args silently drop") void startNullTolerant() { scheduler.start(null, "r", "s", "t"); scheduler.start(adapter, null, "s", "t"); scheduler.start(adapter, "", "s", "t"); scheduler.start(adapter, "r", null, "t"); scheduler.start(adapter, "r", "", "t"); assertEquals(0, scheduler.activeStreamCount(), "null/blank args must not add entries"); } @Test @DisplayName("shutdownAll clears every tracked stream") void shutdownAllClears() { scheduler.start(adapter, "req-1", "stream-1", "user-alice"); scheduler.start(adapter, "req-2", "stream-2", "user-bob"); assertEquals(2, scheduler.activeStreamCount()); scheduler.shutdownAll(); assertEquals(0, scheduler.activeStreamCount()); } @Test @DisplayName("force-finish path: replyStreamFinishForKeepalive + invalidateReplyContext + stop") void forceFinishPath() throws Exception { scheduler.start(adapter, "req-x", "stream-x", "user-alice"); // Reflectively rewind startedAt so the next tick sees elapsed > 180s Object state = getStreamState("stream-x"); Field startedAt = state.getClass().getDeclaredField("startedAt"); startedAt.setAccessible(true); // Java's `final long` fields normally resist setAccessible.set — unfortunately // primitives also need the modifiers hack on JDK 17+. Use Unsafe-free path: // the field happens to be declared `final` in the static record, so we mutate // via setLong (which works for primitives even on final fields when accessible // is true on JDK17 — verified locally). startedAt.setLong(state, System.currentTimeMillis() - 200_000L); // Manually invoke the private tick(StreamState) — no ScheduledExecutor // wall-clock wait Method tick = WeComKeepaliveScheduler.class.getDeclaredMethod( "tick", Class.forName(WeComKeepaliveScheduler.class.getName() + "$StreamState")); tick.setAccessible(true); tick.invoke(scheduler, state); verify(adapter, times(1)).replyStreamFinishForKeepalive( eq("req-x"), eq("stream-x"), eq(WeComKeepaliveScheduler.FORCE_FINISH_TEXT)); verify(adapter, times(1)).invalidateReplyContext(eq("user-alice"), eq("stream-x")); verify(adapter, never()).replyStreamRefreshForKeepalive(any(), any(), any()); // After force-finish, the stream is removed from the tracker assertEquals(0, scheduler.activeStreamCount()); } @Test @DisplayName("refresh path: replyStreamRefreshForKeepalive only — no force-finish below ceiling") void refreshPathBelowCeiling() throws Exception { scheduler.start(adapter, "req-y", "stream-y", "user-bob"); // Don't rewind startedAt; the state is fresh — well under 180s. Object state = getStreamState("stream-y"); Method tick = WeComKeepaliveScheduler.class.getDeclaredMethod( "tick", Class.forName(WeComKeepaliveScheduler.class.getName() + "$StreamState")); tick.setAccessible(true); tick.invoke(scheduler, state); verify(adapter, times(1)).replyStreamRefreshForKeepalive( eq("req-y"), eq("stream-y"), eq(WeComKeepaliveScheduler.PROCESSING_TEXT)); verify(adapter, never()).replyStreamFinishForKeepalive(any(), any(), any()); verify(adapter, never()).invalidateReplyContext(any(), any()); // Still tracked — refresh ticks don't unregister assertEquals(1, scheduler.activeStreamCount()); } @Test @DisplayName("constants match the empirically-verified values (20s refresh / 180s ceiling)") void constantsMatch() { assertEquals(20L, WeComKeepaliveScheduler.REFRESH_INTERVAL_SECONDS); assertEquals(180L, WeComKeepaliveScheduler.MAX_DURATION_SECONDS); assertEquals("🤔 思考中...", WeComKeepaliveScheduler.PROCESSING_TEXT); // Force-finish must NOT reuse the processing placeholder — the sealed // bubble is the last thing the user sees until the pushed reply lands. assertNotEquals(WeComKeepaliveScheduler.PROCESSING_TEXT, WeComKeepaliveScheduler.FORCE_FINISH_TEXT); } // Pull a tracked StreamState by streamId via reflection. The states map // lives behind a private final ConcurrentHashMap. private Object getStreamState(String streamId) throws Exception { Field statesField = WeComKeepaliveScheduler.class.getDeclaredField("states"); statesField.setAccessible(true); @SuppressWarnings("unchecked") Map states = (Map) statesField.get(scheduler); Object st = states.get(streamId); assertNotNull(st, "expected stream " + streamId + " to be tracked"); return st; } }