Files
teamspeak-music-bot/scripts/test_corner_cases.py
T
saopig1andClaude Opus 4.6 4643f70f4a fix: harden bot lifecycle, validate HTTP inputs, make YouTube truly optional
Major bug fixes and corner-case hardening across the backend, plus a
comprehensive feature test suite. All 94 unit tests + 51 integration
tests pass against a local TS3 server.

Lifecycle & state consistency
-----------------------------
- Bug A: startBot() now wraps connect() in a 15s deadline. A hung TS
  handshake no longer blocks the /start HTTP call forever; the failing
  instance is torn down and the caller gets a clean 500.
- Bug B: executeCommand rejects audio-dispatching commands (play, add,
  next, skip, prev, playlist, album, fm) when the bot is disconnected.
  Config-only commands (vol, mode, clear, stop, queue, now, lyrics)
  still work so the UI stays usable while offline.
- Bug C: the tsClient 'disconnected' handler always clears player state
  now, even when connect() never completed. A separate disconnectEmitted
  flag guards duplicate external event emission. Previously an orphaned
  connect attempt that idle-timed-out would leave playing=true forever.
- resolveAndPlay re-checks this.connected AFTER the URL-resolve await so
  a stop() during the network call can't spawn ffmpeg on a disconnected
  bot.
- connect() throws if disconnect() fired during the handshake await,
  preventing a concurrent stop from being overwritten by a late connected
  flag flip.
- startBot always disconnects the outgoing BotInstance before creating
  a replacement, covering the mid-handshake case where isConnected()
  still returned false but the library client was live.
- startBot now reuses the stored identity so server groups granted to
  the bot survive restarts (was regenerating a fresh UID each time).

WebSocket reliability
---------------------
- BotManager extends EventEmitter and emits 'botInstance' whenever a
  new instance is created. websocket.ts listens and re-attaches its
  stateChange / connected / disconnected listeners immediately, fixing
  the bug where player-bar UI never updated until manual refresh.
- attachedBots map now stores the BotInstance reference and detaches
  stale listeners when the instance is replaced. Safety-net interval
  (5s) also reconciles to catch anything missed.
- removeBot emits 'botInstanceRemoved' -> WS broadcasts a new
  {type:"botRemoved", botId} message. Client drops the bot from its
  local store instead of showing it as permanently offline.

HTTP input validation
---------------------
- /volume rejects non-number, NaN, Infinity, and out-of-range values
  with a proper 400 instead of a 200 OK wrapping a usage-text string.
- /mode rejects anything not in {seq, loop, random, rloop} with 400.
- /seek rejects NaN / Infinity / negative (previously NaN slipped
  through typeof==="number" and poisoned seekOffset).
- /play-at validates index < queue.size() BEFORE stopping current
  playback (was silently killing the current song on invalid input).
- /play, /add, /playlist, /play-by-id, /add-by-id, /play-playlist
  all honour platform=youtube now (previously fell through to netease
  and silently played the wrong platform).

YouTube made truly optional
---------------------------
- Lazy checkYtDlpAvailable() runs `yt-dlp --version` once, caches only
  positive results so users can install yt-dlp mid-run and have it
  picked up without a restart.
- getAuthStatus() returns loggedIn=false with nickname
  "YouTube (yt-dlp not installed)" when the binary is missing. UI can
  grey out YouTube instead of silently returning empty searches.
- findYtDlp() picks .exe on win32 and bare binary elsewhere.
- /auth/status?platform=youtube now routes to the YouTube provider
  instead of falling through to NetEase and leaking the NetEase
  user's nickname + avatar.
- /auth/cookie rejects platform=youtube with 400 instead of clobbering
  the NetEase cookie entry.
- README documents yt-dlp install paths (bin/ local vs PATH) and adds
  a dedicated "Optional: YouTube source" section.

Bot Selector UI
---------------
- New power button in each row of the dropdown with play-state-aware
  styling: disabled + wait-cursor during API call, green highlight when
  connected, greys out when the bot is offline.
- Dropdown always visible when >=1 bot exists, bigger font + padding.

Queue correctness
-----------------
- PlayQueue.remove(current) now decrements currentIndex so next() in
  sequential mode advances to the shifted song. Previously removing
  the currently-playing track silently skipped the next track because
  current() falsely reported it as active and next() then incremented
  past it.

Vote-skip hardening
-------------------
- cmdVote: needed threshold is Math.max(1, ceil(users/2)) so a single
  voter in an empty channel can't unanimously pass a vote with
  needed=0.
- resolveAndPlay clears voteSkipUsers on every new track load so votes
  can't leak across songs via cmdPlay/cmdPlaylist/cmdAlbum/cmdFm paths.

cmdAdd parity
-------------
- cmdAdd auto-plays the newly-added song if the player was idle,
  matching /api/player/:id/add-by-id behaviour. Previously add'ing to
  an empty queue on a connected+idle bot silently enqueued without
  starting playback.

Test suite
----------
- scripts/test_full_feature.py — 51 tests across 10 groups exercising
  every HTTP endpoint, WebSocket broadcasts, all music providers, bot
  lifecycle, disconnected-state corners, seek validation, input
  validation, and the main race conditions. Captures and restores the
  target bot's initial state. Resilient to TS3 anti-flood via retry
  with exponential backoff. Runs against a real local TS3 server.
- scripts/test_rapid_cycle.py — Bugs A/B/C regressions
- scripts/test_corner_cases.py — disconnect-during-connect race, config
  commands while disconnected, etc.
- scripts/test_more_corners.py — resolveAndPlay race, seek NaN
- scripts/test_power_button.py — E2E for the new power button
- scripts/test_bot_remove.py — E2E for WS botRemoved broadcast
- scripts/test_playbar.py — player bar auto-show regression (updated
  to restore bot state on exit)
- scripts/test_multibot.py — two-bot concurrent playback monitor
- src/audio/queue.test.ts — 4 new vitest cases for remove() edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:35:14 +08:00

172 lines
5.6 KiB
Python

"""Corner case regressions that go beyond Bugs A/B/C.
A. Race — disconnect() called during connect()'s awaited handshake must
NOT leave the bot reporting connected=true afterwards.
B. Config-only commands (vol, mode, clear) must work even when the bot is
disconnected (UI should stay usable while the bot is offline).
C. After a disconnect mid-playback, the player must not be able to
auto-advance to the next queued song (trackEnd → resolveAndPlay).
"""
import time
import threading
import requests
BASE = "http://localhost:3000"
def api(path, method="GET", **kw):
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
def get_bot(bot_id):
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_connected(bot_id, want, timeout=15):
end = time.time() + timeout
while time.time() < end:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.1)
return False
def test_config_commands_when_disconnected(bot_id):
"""B. vol/mode/clear should succeed while bot is disconnected."""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
# volume is the simplest config-only command
r = api(
f"/api/player/{bot_id}/volume",
method="POST",
json={"volume": 60},
)
assert r.status_code == 200, f"volume failed while disconnected: {r.status_code} {r.text[:100]}"
r = api(
f"/api/player/{bot_id}/mode",
method="POST",
json={"mode": "seq"},
)
assert r.status_code == 200, f"mode failed while disconnected: {r.status_code} {r.text[:100]}"
r = api(f"/api/player/{bot_id}/clear", method="POST")
assert r.status_code == 200, f"clear failed while disconnected: {r.status_code} {r.text[:100]}"
print("[PASS] config commands (vol/mode/clear) work when disconnected")
def test_play_rejected_when_disconnected(bot_id):
"""B (negative). play/add/next/prev should still be rejected."""
r = api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "test", "platform": "netease"},
)
assert r.status_code >= 400, f"play should fail while disconnected"
r = api(
f"/api/player/{bot_id}/add",
method="POST",
json={"query": "test", "platform": "netease"},
)
assert r.status_code >= 400, f"add should fail while disconnected"
r = api(f"/api/player/{bot_id}/next", method="POST")
assert r.status_code >= 400, f"next should fail while disconnected"
print("[PASS] audio commands (play/add/next) rejected when disconnected")
def test_disconnect_during_connect_race(bot_id):
"""A. disconnect() called while connect() is awaiting must win the race.
Fires a stop 200ms into a start call; after things settle the bot's
connected state must be stable (either cleanly disconnected, or cleanly
connected if the stop happened after connect completed). It must NOT
end up in a weird state where connected=true but a subsequent query
shows inconsistent data.
"""
# Ensure disconnected first
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
time.sleep(1) # give TS server a moment to forget us
def delayed_stop():
time.sleep(0.2)
try:
api(f"/api/bot/{bot_id}/stop", method="POST")
except Exception:
pass
threading.Thread(target=delayed_stop, daemon=True).start()
try:
r = api(f"/api/bot/{bot_id}/start", method="POST")
except Exception as e:
r = None
print(f"[info] start threw: {e}")
# Wait for all state transitions to settle
time.sleep(2)
b = get_bot(bot_id)
# The key invariant: if connected is false, playing must also be false;
# if connected is true, the transport is actually up (we can issue
# another command without error).
assert not (b["connected"] is False and b["playing"] is True), (
f"inconsistent state: connected={b['connected']} playing={b['playing']}"
)
print(
f"[PASS] disconnect-during-connect race — final state consistent "
f"(connected={b['connected']} playing={b['playing']})"
)
def test_resolve_guard(bot_id):
"""C. resolveAndPlay on a disconnected bot is a no-op.
We can't directly invoke resolveAndPlay from the API, but we can
verify by checking that after a stop, the bot stays idle even if we
wait for a trackEnd-like event to fire.
"""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
time.sleep(1.5) # more than a frame cycle
b = get_bot(bot_id)
assert not b["playing"], (
f"player should stay stopped after disconnect: {b}"
)
print("[PASS] player stays idle after disconnect (no ghost autoplay)")
def main():
bots = api("/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots")
return
bot_id = bots[0]["id"]
initial = bots[0]["connected"]
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
try:
test_config_commands_when_disconnected(bot_id)
test_play_rejected_when_disconnected(bot_id)
test_resolve_guard(bot_id)
test_disconnect_during_connect_race(bot_id)
print("ALL GREEN")
finally:
if initial:
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
else:
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
print(f"[restore] connected={get_bot(bot_id)['connected']}")
if __name__ == "__main__":
main()