Files
SimpleFarm/tests/test_server.py
T
snowy-yang fcd4cb4649 style: 引入 ruff,474 项告警清零
配置见 pyproject.toml:
- line-length=100:贴着现有代码定。若用默认的 88 会重排 122 行,
  噪音淹没真正有价值的修复。
- 启用 E/W/F/I/UP/B/C4/SIM/RET/PERF/RUF;**不**启用 PTH。因为本项目的路径
  多是"字符串身份"——要塞进 subprocess 命令行、写进 JSON 存档、打印给用户,
  全换 pathlib 只是包一层再拆一层,换来 70 处纯风格改动。
- 忽略 RUF001/002/003:中文全角标点是正确写法,不是"歧义字符"。
- 忽略 PERF203:剩下两处都是"逐项容错"语义(逐个牧场存档要立刻上报失败、
  收获全部要跳过失败地块)。把 try 提到循环外会毁掉"一项失败不影响其它项"
  这个行为,是错的优化。

自动修复 358 项:Dict/List/Optional 换成 PEP 585/604 写法、导入排序、
失效的 noqa、空占位 f-string 等。

手工修复的(这些是真问题,不是风格偏好):
- B904 x9:except 中 raise 补异常链。刻意区分——网络/IO 失败用 from exc 保留
  根因;参数解析失败用 from None,因为底层 ValueError 对玩家只是噪音。
- RUF012 x2:StateView 的 plots/animals 原本是**类级共享可变列表**,
  改成在 __init__ 里初始化(apply({}) 会提前返回,所以必须显式摆好)。
- F841:TUI 里一个赋值后再没用过的死变量。
- SIM105 x11:try/except/pass 统一改成 contextlib.suppress。
- SIM115:日志句柄改用 with。Popen 已把 fd 复制给子进程,父进程可安全关闭。
- E501 x7:拆超长行;main.py 那处顺手提取变量,避免超长嵌套表达式。
- E741:把 l/r 改成 left_line/right_line。
- 其余:RUF046 / PERF401 / RET504 / RUF005 / UP031 / B007。

ruff format 重排 24 个文件。

验证:97 项测试全绿。
2026-09-18 21:18:22 +08:00

622 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""服务器层测试:动作表校验、REST 接口、多牧场隔离、鉴权、WebSocket 端到端。
分三层
------
1. :class:`TestActionTable` / :class:`TestFarmId` —— 纯函数,不需要服务器;
2. :class:`TestRestAPI` —— 用 FastAPI 的 TestClient 在进程内打接口;
3. :class:`TestLiveServer` —— 真的起一个 uvicorn,再用 :class:`farm.client.RemoteFarm`
通过 HTTP + WebSocket 连上去。这一层才是"客户端到底行不行"的答案。
如果机器上没装 FastAPI/uvicorn,第 2、3 层会跳过而不是失败——
单机引擎的测试在 test_engine.py,那部分永远可跑。
"""
from __future__ import annotations
import json
import os
import shutil
import socket
import tempfile
import threading
import time
import unittest
from farm import config
from farm.engine import FarmEngine, GameError
from farm.server.actions import ACTIONS, describe_actions, run_action
from farm.server.session import SessionManager, validate_farm_id
try:
import fastapi # noqa: F401
from fastapi.testclient import TestClient
HAS_FASTAPI = True
except ImportError: # pragma: no cover - 取决于环境
HAS_FASTAPI = False
try:
import uvicorn # noqa: F401
HAS_UVICORN = True
except ImportError: # pragma: no cover
HAS_UVICORN = False
def free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
# ======================================================================= 纯逻辑
class TestActionTable(unittest.TestCase):
"""动作表是所有入口的唯一执行通道,参数校验必须严格。"""
def setUp(self) -> None:
self.engine = FarmEngine(tick_seconds=60)
def test_all_actions_documented(self) -> None:
described = describe_actions()
self.assertEqual(len(described), len(ACTIONS))
for item in described:
self.assertTrue(item["summary"])
self.assertIn(item["action"], ACTIONS)
def test_plant_and_harvest_via_action_table(self) -> None:
result = run_action(self.engine, "plant", {"plot": 1, "crop": "wheat"})
self.assertTrue(result["ok"])
self.assertEqual(result["action"], "plant")
state = self.engine.snapshot()
self.assertEqual(state["plots"][0]["crop"], "wheat")
self.engine.advance_day()
self.engine.advance_day()
harvested = run_action(self.engine, "harvest", {"plot": 1})
self.assertGreater(harvested["earned"], 0)
def test_string_number_is_coerced(self) -> None:
"""命令行/表单常把数字传成字符串,这里必须能识别。"""
result = run_action(self.engine, "plant", {"plot": "1", "crop": "wheat"})
self.assertTrue(result["ok"])
def test_unknown_action_rejected(self) -> None:
with self.assertRaises(GameError) as ctx:
run_action(self.engine, "fly_to_moon", {})
self.assertEqual(ctx.exception.code, "unknown_action")
self.assertEqual(ctx.exception.status, 400)
def test_missing_required_param(self) -> None:
with self.assertRaises(GameError) as ctx:
run_action(self.engine, "plant", {"plot": 1})
self.assertEqual(ctx.exception.code, "missing_param")
def test_unknown_param_rejected(self) -> None:
with self.assertRaises(GameError) as ctx:
run_action(self.engine, "plant", {"plot": 1, "crop": "wheat", "magic": 1})
self.assertEqual(ctx.exception.code, "bad_param")
self.assertIn("magic", ctx.exception.message)
def test_bad_type_rejected(self) -> None:
with self.assertRaises(GameError) as ctx:
run_action(self.engine, "plant", {"plot": "第一块地", "crop": "wheat"})
self.assertEqual(ctx.exception.code, "bad_param")
def test_bool_param_forms(self) -> None:
result = run_action(self.engine, "pause", {"paused": "true"})
self.assertTrue(self.engine.paused)
result = run_action(self.engine, "pause", {"paused": False})
self.assertFalse(self.engine.paused)
self.assertEqual(result["action"], "pause")
with self.assertRaises(GameError):
run_action(self.engine, "pause", {"paused": "也许"})
def test_harvest_all_with_nothing_ripe(self) -> None:
with self.assertRaises(GameError) as ctx:
run_action(self.engine, "harvest_all", {})
self.assertEqual(ctx.exception.code, "nothing_to_harvest")
def test_business_error_keeps_code(self) -> None:
"""业务错误要保留机器可读的 code,AI 靠它分支。"""
with self.assertRaises(GameError) as ctx:
run_action(self.engine, "plant", {"plot": 99, "crop": "wheat"})
self.assertEqual(ctx.exception.code, "no_such_plot")
with self.assertRaises(GameError) as ctx:
run_action(self.engine, "buy_animal", {"kind": "dragon"})
self.assertEqual(ctx.exception.code, "unknown_animal")
class TestFarmId(unittest.TestCase):
"""牧场 ID 会变成文件名,必须挡住路径穿越之类的输入。"""
def test_normal(self) -> None:
for name in ("default", "alice", "ai-1", "玩家甲", "farm_01"):
self.assertEqual(validate_farm_id(name), name)
def test_default_when_empty(self) -> None:
self.assertEqual(validate_farm_id(None), "default")
self.assertEqual(validate_farm_id(""), "default")
def test_rejects_path_traversal(self) -> None:
for bad in ("../etc/passwd", "a/b", "a\\b", "..", "a b", "x" * 40, "农场:1"):
with self.assertRaises(GameError):
validate_farm_id(bad)
class TestSessionManager(unittest.TestCase):
"""多牧场隔离与回收策略。"""
def setUp(self) -> None:
self.tmp = tempfile.mkdtemp(prefix="farm-session-")
self.manager = SessionManager(save_dir=self.tmp, tick_seconds=60)
def tearDown(self) -> None:
self.manager.shutdown()
shutil.rmtree(self.tmp, ignore_errors=True)
def test_farms_are_isolated(self) -> None:
alice = self.manager.get("alice")
bob = self.manager.get("bob")
alice.engine.plant(1, "wheat")
alice.engine.state.gold = 999
self.assertIsNone(bob.engine.state.plots[0].crop)
self.assertNotEqual(bob.engine.state.gold, 999)
self.assertEqual(self.manager.count(), 2)
self.assertIs(self.manager.get("alice"), alice)
def test_save_and_reload_roundtrip(self) -> None:
alice = self.manager.get("alice")
alice.engine.plant(1, "carrot")
alice.engine.state.gold = 777
alice.save()
fresh = SessionManager(save_dir=self.tmp, tick_seconds=60)
try:
again = fresh.get("alice")
self.assertEqual(again.engine.state.gold, 777)
self.assertEqual(again.engine.state.plots[0].crop, "carrot")
finally:
fresh.shutdown()
def test_leaderboard_sorted_by_net_worth(self) -> None:
poor = self.manager.get("poor")
rich = self.manager.get("rich")
poor.engine.state.gold = 10
rich.engine.state.gold = 5000
rows = self.manager.leaderboard()
self.assertEqual(rows[0]["farm"], "rich")
self.assertEqual(rows[0]["rank"], 1)
self.assertEqual(rows[1]["farm"], "poor")
def test_reap_only_drops_idle_offline_farms(self) -> None:
busy = self.manager.get("busy")
idle = self.manager.get("idle")
busy.last_active = time.time() - 9999
idle.last_active = time.time() - 9999
self.manager.idle_timeout = 10
dropped = self.manager.reap(online_map={"busy": 1})
self.assertEqual(dropped, ["idle"])
self.assertIsNotNone(self.manager.find("busy"))
self.assertIsNone(self.manager.find("idle"))
# 被回收的牧场一定已经落盘
self.assertTrue(os.path.exists(os.path.join(self.tmp, "idle.json")))
def test_paused_farm_is_not_reaped(self) -> None:
session = self.manager.get("thinking")
session.engine.pause(True)
session.last_active = time.time() - 9999
self.manager.idle_timeout = 10
self.assertEqual(self.manager.reap(), [])
def test_max_sessions_guard(self) -> None:
manager = SessionManager(save_dir=self.tmp, max_sessions=2)
try:
manager.get("a")
manager.get("b")
with self.assertRaises(GameError) as ctx:
manager.get("c")
self.assertEqual(ctx.exception.code, "too_many_farms")
finally:
manager.shutdown()
# ======================================================================= REST
@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI")
class TestRestAPI(unittest.TestCase):
"""用 TestClient 在进程内验证 HTTP 语义。"""
def setUp(self) -> None:
from farm.server.app import ServerConfig, create_app
self.tmp = tempfile.mkdtemp(prefix="farm-api-")
self.cfg = ServerConfig(host="127.0.0.1", port=8765, save_dir=self.tmp, tick_seconds=60)
self.app = create_app(self.cfg)
self.client = TestClient(self.app)
self.client.__enter__()
def tearDown(self) -> None:
self.client.__exit__(None, None, None)
shutil.rmtree(self.tmp, ignore_errors=True)
# ------------------------------------------------------------ 基础
def test_health_without_token(self) -> None:
data = self.client.get("/api/health").json()
self.assertTrue(data["ok"])
self.assertIn("uptime", data)
def test_dashboard_and_text_index(self) -> None:
page = self.client.get("/")
self.assertEqual(page.status_code, 200)
self.assertIn("SimpleFarm", page.text)
self.assertIn("WebSocket", page.text)
text = self.client.get("/text").text
self.assertIn("/api/actions", text)
def test_help_lists_actions(self) -> None:
data = self.client.get("/api/help").json()
self.assertIn("rules", data)
names = {item["action"] for item in data["actions"]}
self.assertIn("plant", names)
self.assertIn("harvest_all", names)
def test_openapi_schema_available(self) -> None:
schema = self.client.get("/openapi.json").json()
self.assertIn("/api/actions", schema["paths"])
# ------------------------------------------------------------ 牧场读写
def test_state_creates_farm_lazily(self) -> None:
data = self.client.get("/api/state?farm=newbie").json()
self.assertEqual(data["state"]["day"], 1)
self.assertEqual(data["state"]["gold"], config.START_GOLD)
def test_generic_action_entry(self) -> None:
body = {"action": "plant", "params": {"plot": 1, "crop": "wheat"}}
data = self.client.post("/api/actions?farm=one", json=body).json()
self.assertTrue(data["ok"])
self.assertIn("已种下", data["message"])
self.assertEqual(
data["state"]["gold"], config.START_GOLD - config.CROPS["wheat"]["seed_cost"]
)
state = self.client.get("/api/state?farm=one").json()["state"]
self.assertEqual(state["plots"][0]["crop"], "wheat")
def test_flat_params_also_accepted(self) -> None:
"""{"action":"plant","plot":1,"crop":"wheat"} 这种平铺写法也要能用。"""
data = self.client.post(
"/api/actions?farm=flat", json={"action": "plant", "plot": 1, "crop": "wheat"}
).json()
self.assertTrue(data["ok"])
def test_convenience_route(self) -> None:
data = self.client.post("/api/plant?farm=short", json={"plot": 2, "crop": "carrot"}).json()
self.assertTrue(data["ok"])
self.assertEqual(data["action"], "plant")
def test_action_error_shape(self) -> None:
response = self.client.post(
"/api/actions?farm=err", json={"action": "plant", "params": {"plot": 1, "crop": "nope"}}
)
self.assertEqual(response.status_code, 400)
data = response.json()
self.assertFalse(data["ok"])
self.assertEqual(data["error"], "unknown_crop")
self.assertIn("message", data)
def test_out_of_range_plot(self) -> None:
response = self.client.post(
"/api/actions?farm=err", json={"action": "water", "params": {"plot": 999}}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "no_such_plot")
def test_bad_json_body(self) -> None:
response = self.client.post(
"/api/actions?farm=err", content=b"{oops", headers={"Content-Type": "application/json"}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "bad_json")
def test_actions_endpoint_matches_state(self) -> None:
data = self.client.get("/api/actions?farm=solo").json()
names = {item["action"] for item in data["actions"]}
self.assertIn("plant", names) # 有空地 + 有钱 → 可播种
self.assertNotIn("harvest", names) # 还没种东西 → 不可收获
def test_batch_endpoint(self) -> None:
items = {
"actions": [
{"action": "buy_feed", "params": {"quantity": 5}},
{"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
{"action": "harvest", "params": {"plot": 1}}, # 必然失败
]
}
data = self.client.post("/api/batch?farm=multi", json=items).json()
self.assertEqual(data["done"], 2)
self.assertEqual(data["failed"], 1)
self.assertEqual(data["results"][2]["error"], "not_ripe")
def test_log_entries_and_since(self) -> None:
self.client.post(
"/api/actions?farm=logging",
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
)
entries = self.client.get("/api/log?farm=logging").json()["entries"]
self.assertTrue(entries)
last = entries[-1]["seq"]
newer = self.client.get(f"/api/log?farm=logging&since={last}").json()["entries"]
self.assertEqual(newer, [])
self.client.post("/api/actions?farm=logging", json={"action": "save"})
newer = self.client.get(f"/api/log?farm=logging&since={last}").json()["entries"]
self.assertTrue(newer)
def test_farms_list_and_isolation(self) -> None:
self.client.post(
"/api/actions?farm=alice", json={"action": "buy_feed", "params": {"quantity": 10}}
)
self.client.post("/api/actions?farm=bob", json={"action": "save"})
rows = self.client.get("/api/farms").json()["farms"]
by_name = {row["farm"]: row for row in rows}
self.assertIn("alice", by_name)
self.assertEqual(by_name["alice"]["gold"], config.START_GOLD - 10 * config.FEED_UNIT_PRICE)
def test_create_farm_reports_created_flag(self) -> None:
first = self.client.post("/api/farms", json={"farm": "zoe", "owner": "小Z"}).json()
self.assertTrue(first["created"])
self.assertEqual(first["summary"]["owner"], "小Z")
again = self.client.post("/api/farms", json={"farm": "zoe"}).json()
self.assertFalse(again["created"])
def test_bad_farm_id_rejected(self) -> None:
response = self.client.get("/api/state?farm=../evil")
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "bad_farm")
def test_farm_via_header(self) -> None:
data = self.client.get("/api/state", headers={"X-Farm-Id": "header-farm"}).json()
self.assertEqual(data["farm"], "header-farm")
def test_admin_save(self) -> None:
self.client.post(
"/api/actions?farm=saver",
json={"action": "plant", "params": {"plot": 1, "crop": "wheat"}},
)
data = self.client.post("/api/admin/save").json()
self.assertIn("saver", data["farms"])
self.assertTrue(os.path.exists(os.path.join(self.tmp, "saver.json")))
def test_world_view(self) -> None:
self.client.post("/api/actions?farm=w1", json={"action": "save"})
data = self.client.get("/api/world").json()
self.assertEqual(data["farms"], 1)
self.assertEqual(data["leaderboard"][0]["farm"], "w1")
@unittest.skipUnless(HAS_FASTAPI, "未安装 FastAPI")
class TestAuth(unittest.TestCase):
"""令牌鉴权:没带令牌必须 401,带了才能操作。"""
def setUp(self) -> None:
from farm.server.app import ServerConfig, create_app
self.tmp = tempfile.mkdtemp(prefix="farm-auth-")
self.app = create_app(ServerConfig(save_dir=self.tmp, token="s3cret", tick_seconds=60))
self.client = TestClient(self.app)
self.client.__enter__()
def tearDown(self) -> None:
self.client.__exit__(None, None, None)
shutil.rmtree(self.tmp, ignore_errors=True)
def test_health_is_public(self) -> None:
self.assertEqual(self.client.get("/api/health").status_code, 200)
def test_state_requires_token(self) -> None:
response = self.client.get("/api/state")
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["error"], "unauthorized")
def test_token_via_header(self) -> None:
response = self.client.get("/api/state", headers={"X-API-Token": "s3cret"})
self.assertEqual(response.status_code, 200)
def test_token_via_query(self) -> None:
response = self.client.get("/api/state?token=s3cret")
self.assertEqual(response.status_code, 200)
def test_wrong_token(self) -> None:
self.assertEqual(self.client.get("/api/state?token=nope").status_code, 401)
def test_action_blocked_without_token(self) -> None:
response = self.client.post("/api/actions", json={"action": "buy_plot"})
self.assertEqual(response.status_code, 401)
# ======================================================================= 真网络
@unittest.skipUnless(HAS_FASTAPI and HAS_UVICORN, "未安装 FastAPI/uvicorn")
class TestLiveServer(unittest.TestCase):
"""真的起 uvicorn,用真实客户端走 HTTP 与 WebSocket。"""
@classmethod
def setUpClass(cls) -> None:
from farm.server.app import ServerConfig, create_app
cls.tmp = tempfile.mkdtemp(prefix="farm-live-")
cls.port = free_port()
cls.url = f"http://127.0.0.1:{cls.port}"
app = create_app(
ServerConfig(
host="127.0.0.1",
port=cls.port,
save_dir=cls.tmp,
tick_seconds=600,
autosave_interval=3600,
)
)
import uvicorn
cls.server = uvicorn.Server(
uvicorn.Config(
app,
host="127.0.0.1",
port=cls.port,
log_level="warning",
access_log=False,
lifespan="on",
)
)
cls.thread = threading.Thread(target=cls.server.run, name="live-server", daemon=True)
cls.thread.start()
deadline = time.time() + 20
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", cls.port), timeout=0.5):
break
except OSError:
time.sleep(0.2)
else: # pragma: no cover
raise unittest.SkipTest("服务器没能在 20 秒内起来")
@classmethod
def tearDownClass(cls) -> None:
cls.server.should_exit = True
cls.thread.join(timeout=10)
shutil.rmtree(cls.tmp, ignore_errors=True)
def client(self, farm: str, realtime: bool = True):
from farm.client import RemoteFarm
return RemoteFarm(
endpoint=self.url, farm=farm, realtime=realtime, timeout=8.0, autostart=True
)
def test_http_roundtrip_through_real_client(self) -> None:
client = self.client("live-http", realtime=False)
try:
state = client.snapshot()
self.assertEqual(state["farm"], "live-http")
result = client.plant(1, "wheat")
self.assertTrue(result["ok"])
self.assertEqual(client.state.plots[0].crop, "wheat")
self.assertFalse(client.state.plots[0].is_ripe)
with self.assertRaises(GameError) as ctx:
client.plant(1, "carrot") # 地已经占用了
self.assertEqual(ctx.exception.code, "plot_busy")
actions = client.available_actions()
names = {item["action"] for item in actions}
self.assertIn("plant", names) # 还有空地可以种
self.assertNotIn("harvest", names) # 小麦还没成熟
client.water(1) # 播种后水分 2/3,可以补满
self.assertEqual(client.state.plots[0].moisture, config.WATER_CAPACITY)
with self.assertRaises(GameError) as ctx:
client.water(1) # 水分已满,再浇就是错的
self.assertEqual(ctx.exception.code, "already_watered")
catalog = client.catalog()
self.assertTrue(catalog["crops"])
self.assertTrue(client.leaderboard())
self.assertIn("规则速览", client.help_text())
finally:
client.stop()
def test_two_clients_talk_to_the_same_farm(self) -> None:
"""两个客户端改同一个牧场:一方操作,另一方从推送里看到。"""
watcher = self.client("shared")
actor = self.client("shared")
try:
deadline = time.time() + 8
while time.time() < deadline and len(watcher.state.plots) == 0:
time.sleep(0.1)
self.assertTrue(watcher.state.plots)
actor.plant(1, "carrot")
deadline = time.time() + 8
while time.time() < deadline and watcher.state.plots[0].crop != "carrot":
time.sleep(0.1)
self.assertEqual(
watcher.state.plots[0].crop, "carrot", "实时通道应该把另一方的操作推过来"
)
self.assertEqual(watcher.mode, "ws")
finally:
watcher.stop()
actor.stop()
def test_websocket_channel_is_really_used(self) -> None:
"""写了动作应该走 WS 通道,而不是退化到 HTTP。"""
client = self.client("ws-farm")
try:
deadline = time.time() + 8
while time.time() < deadline and client.mode != "ws":
time.sleep(0.1)
self.assertEqual(client.mode, "ws", "WebSocket 没能连上")
client.buy_feed(3)
self.assertEqual(client.stats["ws_actions"], 1)
self.assertEqual(client.stats["http"] > 0, True)
finally:
client.stop()
def test_farms_do_not_leak_into_each_other(self) -> None:
a = self.client("iso-a", realtime=False)
b = self.client("iso-b", realtime=False)
try:
a.plant(1, "wheat")
self.assertIsNone(b.state.plots[0].crop)
self.assertNotEqual(a.state.gold, b.state.gold)
finally:
a.stop()
b.stop()
def test_save_on_server_persists(self) -> None:
client = self.client("persist", realtime=False)
try:
client.plant(1, "wheat")
client.save()
path = os.path.join(self.tmp, "persist.json")
self.assertTrue(os.path.exists(path))
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
self.assertEqual(payload["state"]["plots"][0]["crop"], "wheat")
finally:
client.stop()
def test_bot_can_play_via_the_api(self) -> None:
"""规则机器人只调接口,也应该能真的把农场经营起来。"""
from farm.bot import RuleBot
client = self.client("bot-farm", realtime=False)
bot = RuleBot(client, verbose=False, pause=0.02, name="bot-farm")
try:
summary = bot.run(rounds=12, idle_limit=3)
self.assertGreaterEqual(summary["actions"], 1)
self.assertIn("net_worth", summary)
self.assertEqual(bot.failures, 0)
self.assertTrue(client.available_actions() is not None)
finally:
client.stop()
def test_rate_free_path_uses_ws_and_falls_back(self) -> None:
"""WS 断开后写操作要能自动退回 HTTP,而不是直接失败。"""
client = self.client("fallback")
try:
deadline = time.time() + 8
while time.time() < deadline and client.mode != "ws":
time.sleep(0.1)
self.assertEqual(client.mode, "ws")
# 强行掐断:让后续写操作只能走 HTTP
client._stop.set()
client._ws_socket = None
result = client.buy_feed(1)
self.assertTrue(result["ok"])
finally:
client.stop()
if __name__ == "__main__":
unittest.main(verbosity=2)