Files
live-hub-py/tests/test_account_overview.py
T
yml2213 aea05952af refactor(account): 一键查询不该重复展示账号字段 —— 改成刷新它们,只留账号表里没有的
用户反馈:「一键查询也太重复了,直接更新账号信息不行吗?为啥很多都要再展示一次?
那建立账号信息表有啥用」——**说得对,是我设计错了**。

## 问题

面板把鱼翅 / 积分 / 钱包余额 / 精英宝典 / 电竞手册又原样列了一遍,
而这些账号表(`accounts`)本来就有对应列,账号详情也在显示同一批值 ——
同一屏出现两份,既冗余又让人分不清哪份是准的。

## 改法:查询 = 刷新,不是另开副本

| 内容 | 处理 |
| --- | --- |
| 鱼翅 / 积分 / 交易余额 / 开通状态 | 写回**账号表**(`_refresh_*` 本来就在写),由账号详情已有界面展示 —— **面板不再重复** |
| **积分到期时间** | 有 `accounts.points_expire_at` 列,但**查询漏了没写、界面也没显示** → 现在写回,并加到账号信息区展示 |
| **高价值套装是否已获得** | 账号表没有 → **只有这个该单独展示** |
| **官方兑换记录** | 账号表没有 → 单独展示 |

面板因此瘦身成只含「🎯 高价值套装」+「兑换记录」(+ 错误提示 + 查询时间),
标题也改成「高价值套装与兑换记录(一键查询)」,不再叫"账号信息"。

## 修了一个连带 bug

积分到期时间一直没被查询写回账号(只在快照里躺着),界面也没展示 ——
现在 `_parse_expire_at` 后写入 `accounts.points_expire_at`,
且斗鱼返回 `0` 时写 `None` 而不是 `0`(否则又显示 1970/1/1)。

## 测试(+3,共 41)

`TestOverviewRefreshesAccountFields`:资产/开通状态确实写回账号列、
**积分到期时间写回**、`willExpireTime="0"` 时清成 None 而非 0。

## 验证

`TZ=Asia/Shanghai` **593 passed / 6 skipped**;ruff 全绿;前端 build 通过、lint 0 errors。
2026-10-04 19:22:34 +08:00

684 lines
26 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.
"""账号「一键查询」:判定规则、失败隔离、只读性。
判定规则用的是**生产镜像库的真实返回样本**(见 `SAMPLES`),
不是我自己编的 JSON —— 这样官方字段改名/结构变动时用例才会红。
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent, DouyuTask, User
from web.backend.services.douyu_runner_overview import (
DEFAULT_HIGH_VALUE_GOODS,
OverviewMixin,
_goods_config,
extract_high_value_goods,
)
RABBIT = "兔兔白日梦"
RABBIT_ID = "20260120QYOOB_bag10"
#: 账号 15407(未兑换)的真实商品项,取自生产镜像库任务 253579
GOODS_AVAILABLE = {
"commodityId": RABBIT_ID,
"commodityName": "套装-兔兔白日梦",
"count": 96982,
"countLimited": 1,
"foreverCount": 96982,
"openStatus": 1,
"score": 1280,
"startTime": "1769153181",
"endTime": "1795967999",
"storeStatus": 1,
"pic": "https://sta-op.douyucdn.cn/x.png",
}
#: 账号 558(已兑换)的同一商品项,只有这三项不同
GOODS_OWNED = {**GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0}
class TestOwnedJudgement:
"""「是否已获得」的判定 —— 本功能的核心,也是最容易判错的地方。
⚠️ **判定只看官方兑换记录**。第一版拿限兑商品列表的 `count`/`storeStatus` 判,
依据是「558 兑换过 count=0 / 15407 没兑换 count=96982」——
那两条记录相隔两个月,我把**时间差异**当成了**账号差异**。
同一时刻对比才发现:三个账号(两个兑换过、一个没有)的商品项**字段完全一致**,
`count`/`foreverCount`/`storeStatus` 是**全局库存**,不暴露账号维度信息。
改用官方兑换记录后完美区分(558/607 有「破片手榴弹-兔兔白日梦」,15507 是 0 条)。
"""
def test_owned_comes_from_official_records(self):
got = extract_high_value_goods(
[GOODS_AVAILABLE], [RABBIT], [{"commodityName": "破片手榴弹-兔兔白日梦"}]
)
assert got[0]["owned"] is True
assert got[0]["obtained_names"] == ["破片手榴弹-兔兔白日梦"]
def test_not_owned_when_records_empty(self):
got = extract_high_value_goods([GOODS_AVAILABLE], [RABBIT], [])
assert got[0]["owned"] is False
def test_global_stock_never_implies_owned(self):
"""**回归用例**:全局库存为 0 或售罄,都不能推出「这个账号已拥有」。
这正是第一版踩的坑 —— 用全局字段判账号维度的事。
"""
sold_out = {**GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0}
got = extract_high_value_goods([sold_out], [RABBIT], [])
assert got[0]["owned"] is False, "全局售罄不等于该账号拥有"
def test_stock_and_owned_are_independent(self):
"""展示用的全局信息与账号判定互不影响。"""
sold_out = {**GOODS_AVAILABLE, "count": 0, "storeStatus": 0}
got = extract_high_value_goods(
[sold_out], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}]
)
assert got[0]["owned"] is True
assert got[0]["count"] == 0 # 全局库存如实展示
def test_records_match_by_substring_family(self):
"""同一件东西有多个商品名(套装- / 破片手榴弹-),都要算数。"""
got = extract_high_value_goods(
[GOODS_AVAILABLE],
[RABBIT],
[{"commodityName": "破片手榴弹-兔兔白日梦"}, {"commodityName": "别的"}],
)
assert got[0]["owned"] is True
assert len(got[0]["obtained_names"]) == 1
def test_missing_goods_is_not_silently_dropped(self):
"""既不在商品列表、也不在兑换记录里的,必须留 missing。"""
got = extract_high_value_goods([GOODS_AVAILABLE], [RABBIT, "不存在的套装"], [])
assert len(got) == 2
assert got[1]["missing"] is True
assert "都没有这个商品" in got[1]["reason"]
def test_goods_in_records_but_not_list_is_not_missing(self):
"""兑换记录里有、但当前限兑列表里没有 —— 不是 missing,是已获得。
实测就有这种情况(活动窗口结束但记录仍在),不能误报成「没这个商品」。
"""
got = extract_high_value_goods(
[], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}]
)
assert got[0]["owned"] is True
assert got[0]["missing"] is False
assert got[0]["in_goods_list"] is False
def test_empty_list_and_no_records_keeps_all_names(self):
got = extract_high_value_goods([], DEFAULT_HIGH_VALUE_GOODS, [])
assert [g["name"] for g in got] == list(DEFAULT_HIGH_VALUE_GOODS)
assert all(g["missing"] for g in got)
def test_both_suits(self):
items = [
{**GOODS_AVAILABLE, "commodityName": "套装-兔兔白日梦"},
{**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔", "commodityId": "X"},
]
got = extract_high_value_goods(
items, list(DEFAULT_HIGH_VALUE_GOODS), [{"commodityName": "套装-狂战萌兔"}]
)
assert [g["owned"] for g in got] == [False, True]
def test_tolerates_garbage(self):
got = extract_high_value_goods(
[None, "x", 1, GOODS_AVAILABLE],
[RABBIT],
[None, "y", {"commodityName": ""}],
)
assert got[0]["owned"] is False
def test_custom_goods_config_overrides_default(self):
assert _goods_config({"high_value_goods": ["A", "B"]}) == ["A", "B"]
assert _goods_config({"high_value_goods": "A,B"}) == ["A", "B"]
assert _goods_config({"high_value_goods": '["A", "B"]'}) == ["A", "B"]
assert _goods_config({"high_value_goods": "A,B"}) == ["A", "B"]
assert _goods_config({}) == list(DEFAULT_HIGH_VALUE_GOODS)
assert _goods_config({"high_value_goods": []}) == list(DEFAULT_HIGH_VALUE_GOODS)
class _FakeClient:
"""最小假客户端:只实现聚合用到的几个方法。"""
def __init__(self, *, fail: set[str] | None = None):
self.fail = fail or set()
def _maybe(self, key: str):
if key in self.fail:
raise RuntimeError(f"模拟 {key} 失败")
def acf_ccn(self, **_):
return "ctn"
def query_limited_goods(self, **_):
self._maybe("goods")
return {
"limited_goods": [
GOODS_OWNED,
{**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔"},
]
}
def exchange_records(self, **_):
self._maybe("records")
return {
"records": [{"commodityName": "破片手榴弹-兔兔白日梦", "createTime": "1"}]
}
class _Harness(OverviewMixin):
"""把聚合 handler 单独跑起来:假客户端 + 假的 refresh helper。"""
def __init__(self, client: _FakeClient, *, fail_helpers: set[str] | None = None):
self.client = client
self.fail_helpers = fail_helpers or set()
self.marked: dict = {}
def _client(self, cookie: str):
return self.client
def _mark_task(self, db, task, status, message, result=None):
self.marked = {
"status": status,
"message": message,
"result": result,
}
def _push_log(self, *a, **k):
pass
def _refresh_account_gold_balance(self, client, account):
if "gold" in self.fail_helpers:
raise RuntimeError("模拟鱼翅查询失败")
return {"gold_balance": 38, "exchange_balance": 120}
def _refresh_account_points(self, client, account, cookie, *, ctn=None):
if "points" in self.fail_helpers:
raise RuntimeError("模拟积分查询失败")
return {
"points": 990,
"points_query": {"raw": {"data": {"willExpireTime": "1795967999"}}},
}
def _refresh_elite_handbook(self, client, account, *, manual_id, rid=""):
if "elite" in self.fail_helpers:
raise RuntimeError("模拟精英查询失败")
return {
"elite_opened": True,
"elite_expire_time": 1795967999,
"elite_manual_score": 990,
}
def _refresh_esports_handbook(self, client, account, *, manual_id):
if "esports" in self.fail_helpers:
raise RuntimeError("模拟电竞查询失败(账号没绑电竞手册)")
return {"esports_manual_type": 1, "esports_manual_score": 10}
class TestOverviewAggregation:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="ov-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="ov-acc", password="p", email="o@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
self.task = DouyuTask(
batch_id="ov-batch",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.user.id,
)
self.session.add(self.task)
self.session.commit()
def teardown_method(self):
self.session.close()
def _run(self, client=None, **kw):
h = _Harness(client or _FakeClient(), **kw)
h._execute_query_account_overview(
self.session,
self.task,
self.account,
"cookie",
{"manual_id": "M1", "esports_manual_id": "M2", "rid": "R1"},
)
return h
def test_aggregates_assets_and_open_states(self):
h = self._run()
r = h.marked["result"]
assert h.marked["status"] == "success"
assert r["assets"]["gold_balance"] == 38
assert r["assets"]["exchange_balance"] == 120
assert r["assets"]["points"] == 990
assert r["assets"]["points_expire_at"] == "1795967999"
assert r["elite"]["opened"] is True
assert r["esports"]["opened"] is True
assert r["errors"] == {}
def test_high_value_goods_are_flagged(self):
r = self._run().marked["result"]
goods = {g["name"]: g for g in r["high_value_goods"]}
assert goods["套装-兔兔白日梦"]["owned"] is True # 兑换记录里有
assert goods["套装-狂战萌兔"]["owned"] is False # 记录里没有
assert "兔兔白日梦" in r["high_value_goods"][0]["name"]
def test_single_item_failure_does_not_fail_the_batch(self):
"""账号没绑电竞手册是常态 —— 不该让整次查询失败。"""
r = self._run(fail_helpers={"esports"}).marked["result"]
assert r["assets"]["points"] == 990 # 其余照常
assert "esports" in r["errors"]
assert r["high_value_goods"] # 关键信息仍在
def test_all_failures_still_return_success_with_errors(self):
r = self._run(fail_helpers={"gold", "points", "elite", "esports"}).marked[
"result"
]
assert set(r["errors"]) >= {"gold", "points", "elite", "esports"}
assert self._run(fail_helpers={"gold"}).marked["status"] == "success"
def test_missing_manual_id_is_reported_not_crashed(self):
h = _Harness(_FakeClient())
h._execute_query_account_overview(
self.session, self.task, self.account, "cookie", {}
)
r = h.marked["result"]
assert "未配置精英宝典 manualID" in r["errors"]["elite"]
assert "未配置精英宝典 manualID" in r["errors"]["high_value_goods"]
assert h.marked["status"] == "success"
assert r["high_value_goods"] == []
def test_message_counts_owned_suits(self):
assert "兔兔白日梦" in self._run().marked["message"]
assert "已拥有 1 个" in self._run().marked["message"]
def test_is_read_only_no_events_and_no_status_change(self):
"""纯查询不是「事实」—— 不能进时间线,也不能改生命周期状态。"""
before = self.session.query(AccountEvent).count()
status_before = self.account.status
self._run()
self.session.commit()
assert self.session.query(AccountEvent).count() == before, "一键查询不该发事件"
self.session.refresh(self.account)
assert self.account.status == status_before
class TestOverviewTaskTypeWiring:
def test_declared_for_all_workbenches(self):
from web.backend.services.douyu_service import (
DOUYU_HANDBOOK_TASK_TYPES,
SUPPORTED_DOUYU_TASK_TYPES,
)
assert "query_account_overview" in SUPPORTED_DOUYU_TASK_TYPES
for scope in ("elite", "esports", "peace"):
assert "query_account_overview" in DOUYU_HANDBOOK_TASK_TYPES[scope]
def test_handler_is_dispatched(self):
from web.backend.services.douyu_runner import DouyuBatchRunner
handler = getattr(DouyuBatchRunner, "_execute_query_account_overview", None)
assert callable(handler)
def test_batch_creates_one_task_per_account(self):
"""批量是天然支持的:`create_douyu_planned_tasks` 就是每账号一条任务。"""
from tests.support.db import get_test_engine as eng
from web.backend.services.douyu_service import create_douyu_planned_tasks
session = make_test_session(eng())
try:
# 自己造夹具,别依赖别的用例留下的数据(依赖就会 skip,skip 等于没测)
user = User(
username="ov-batch-admin", password_hash="h", role="super_admin"
)
session.add(user)
session.flush()
accs = [
Account(
username=f"ov-batch-{i}",
password="p",
email=f"b{i}@e.com",
email_password="ep",
# 必须带 CK:一键查询要用该账号的 CK 去查斗鱼,
# `visible_douyu_task_accounts` 会把没 CK 的账号过滤掉。
cookie=f"acf_uid=1{i}; acf_username=u{i}; acf_ccn=c{i}",
)
for i in range(3)
]
session.add_all(accs)
session.commit()
batch_id, count = create_douyu_planned_tasks(
session,
[a.id for a in accs],
"query_account_overview",
"elite",
user.id,
)
assert count == len(accs)
created = (
session.query(DouyuTask)
.filter(
DouyuTask.batch_id == batch_id,
DouyuTask.task_type == "query_account_overview",
)
.all()
)
assert len(created) == len(accs)
finally:
session.close()
def test_queried_at_is_utc_isoformat():
got = extract_high_value_goods([GOODS_OWNED], [RABBIT])
assert got and isinstance(got, list)
# 顺手确认 UTC 约定没被破坏
assert datetime.now(UTC).tzinfo is not None
class TestTimestampNormalisation:
"""斗鱼用 `0` / `"0"` / 空串表示「没有这个时间」。
直接透传的后果:前端把 `0` 当 Unix 时间戳 → 显示 **1970/1/1 08:00:00**,
运营会以为"到期时间是 1970 年"。(这个 bug 真出现过,用户报的。)
"""
@pytest.mark.parametrize("bad", [0, "0", "", " ", None, -1, "-1", False])
def test_no_value_becomes_none(self, bad):
from web.backend.services.douyu_runner_overview import _ts
assert _ts(bad) is None, f"{bad!r} 应归成 None,否则会显示成 1970"
@pytest.mark.parametrize("good", [1795967999, "1795967999", 1795967999000])
def test_real_timestamps_pass_through(self, good):
from web.backend.services.douyu_runner_overview import _ts
assert _ts(good) == good
class TestSnapshotIsWrittenToAccount:
"""结果要落到**账号**上,不是只留在任务里。
任务会被保留策略清掉,而「这个账号现在什么情况」是账号的信息 ——
应该在账号管理里看,不该依赖一条日志的寿命。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="snap-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="snap-acc", password="p", email="s@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _run(self, client=None):
from web.backend.services.douyu_runner_overview import OverviewMixin
class _H(OverviewMixin):
def _client(self, cookie):
return client or _FakeClient()
def _mark_task(self, *a, **k):
pass
def _push_log(self, *a, **k):
pass
def _refresh_account_gold_balance(self, c, a):
return {"gold_balance": 7, "exchange_balance": 1}
def _refresh_account_points(self, c, a, cookie, *, ctn=None):
# 斗鱼返回 0 表示没有到期时间 —— 这里模拟它
return {
"points": 5,
"points_query": {"raw": {"data": {"willExpireTime": "0"}}},
}
def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""):
return {
"elite_opened": False,
"elite_expire_time": 0,
"elite_manual_score": 0,
}
def _refresh_esports_handbook(self, c, a, *, manual_id):
return {"esports_manual_type": 0, "esports_manual_score": 0}
task = DouyuTask(
batch_id="snap",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.user.id,
)
self.session.add(task)
self.session.commit()
_H()._execute_query_account_overview(
self.session,
task,
self.account,
"c",
{"manual_id": "M", "esports_manual_id": "E", "rid": "R"},
)
self.session.commit()
self.session.refresh(self.account)
return task
def test_snapshot_and_time_are_stored_on_account(self):
self._run()
assert self.account.overview_snapshot is not None
assert self.account.overview_queried_at is not None
assert self.account.overview_snapshot["assets"]["gold_balance"] == 7
def test_no_expiry_is_stored_as_none_not_zero(self):
"""回归:`willExpireTime = "0"` 不能原样落库成 0(会显示 1970)。"""
self._run()
snap = self.account.overview_snapshot
assert snap["assets"]["points_expire_at"] is None
assert snap["elite"]["expire_time"] is None
def test_snapshot_is_overwritten_not_appended(self):
self._run()
first = dict(self.account.overview_snapshot)
self._run()
self.session.refresh(self.account)
assert self.account.overview_snapshot["assets"]["gold_balance"] == 7
assert self.account.overview_snapshot is not None
assert first["assets"]["gold_balance"] == 7
class TestSnapshotExposedOnDetailOnly:
"""快照必须出现在**详情**接口,**不能**出现在列表接口。
这里踩过一个坑:字段被加到了列表接口而不是详情接口,后果有两层 ——
1. 详情不返回 `overview_queried_at` → 前端轮询永远检测不到变化 →
一直刷请求到 120 秒超时(用户报的「点一键查询就一直循环」);
2. 列表**每个账号**都带一整份 JSON 快照 → 15000 个账号的响应体积爆炸。
所以两头都要钉住。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="snapx-admin", password_hash="h", role="super_admin")
self.session.add(self.admin)
self.session.flush()
self.account = Account(
username="snapx-acc",
password="p",
email="x@e.com",
email_password="ep",
cookie="acf_uid=1; acf_ccn=c",
overview_snapshot={"assets": {"gold_balance": 3}},
overview_queried_at=datetime.now(UTC),
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def test_detail_returns_snapshot_and_timestamp(self):
from web.backend.routers.accounts import account_overview
result = account_overview(self.account.id, db=self.session, current=self.admin)
assert "overview_snapshot" in result, "详情必须返回快照,否则前端没法展示"
assert "overview_queried_at" in result, (
"详情必须返回查询时间 —— 前端靠它判断「查完了没有」,"
"不返回就会一直轮询到超时"
)
assert result["overview_queried_at"] is not None
def test_list_does_not_carry_the_snapshot(self):
"""列表里带整份快照会让 15000 个账号的响应爆炸。"""
from web.backend.routers.accounts import list_accounts
# 直接调用时**未显式传**的参数拿到的是 FastAPI 的 `Query` 对象,而它是
# truthy 的(`assigned_only=Query(False)` 会被当成 True,只返回分配给
# 自己的账号)—— 所以关键参数都要显式传。
# 另外测试库里有几百个账号,必须用 search 收敛到夹具本身,否则会翻页。
result = list_accounts(
assigned_only=False,
has_cookie=False,
search="snapx-acc",
page=1,
page_size=20,
db=self.session,
current=self.admin,
)
items = result["items"] if isinstance(result, dict) else result
assert items, "夹具账号应出现在列表里"
for item in items:
assert "overview_snapshot" not in item, (
"列表接口不该返回完整快照(每个账号一份 JSON,体积爆炸)"
)
class TestOverviewRefreshesAccountFields:
"""查询的职责是**刷新账号字段**,不是另开一份副本。
用户反馈很直接:「一键查询这也太重复了,直接更新账号信息不行吗?
那建立账号信息表有啥用」。
对 —— 鱼翅 / 积分 / 交易余额 / 开通状态账号表本来就有对应列,
查询把新值写进去、由已有界面展示即可。这里钉住写回行为,
尤其是**积分到期时间**:它有 `accounts.points_expire_at` 列,
但之前忘了写(也没在界面上显示)。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="rf-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="rf-acc", password="p", email="r@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _run(self, *, expire_at: str = "1795967999"):
from web.backend.services.douyu_runner_overview import OverviewMixin
class _H(OverviewMixin):
def _client(self, cookie):
return _FakeClient()
def _mark_task(self, *a, **k):
pass
def _push_log(self, *a, **k):
pass
def _refresh_account_gold_balance(self, c, a):
a.gold_balance = 88
a.exchange_balance = 9
return {"gold_balance": 88, "exchange_balance": 9}
def _refresh_account_points(self, c, a, cookie, *, ctn=None):
a.points = 777
return {
"points": 777,
"points_query": {"raw": {"data": {"willExpireTime": expire_at}}},
}
def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""):
a.elite_opened = True
return {
"elite_opened": True,
"elite_expire_time": 0,
"elite_manual_score": 5,
}
def _refresh_esports_handbook(self, c, a, *, manual_id):
a.esports_opened = True
return {"esports_manual_type": 1, "esports_manual_score": 3}
task = DouyuTask(
batch_id="rf",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.user.id,
)
self.session.add(task)
self.session.commit()
_H()._execute_query_account_overview(
self.session,
task,
self.account,
"c",
{"manual_id": "M", "esports_manual_id": "E", "rid": "R"},
)
self.session.commit()
self.session.refresh(self.account)
def test_assets_are_written_to_account_columns(self):
self._run()
assert self.account.points == 777
assert self.account.gold_balance == 88
assert self.account.exchange_balance == 9
assert self.account.elite_opened is True
assert self.account.esports_opened is True
def test_points_expire_at_is_written(self):
"""积分到期时间有专门的列,查询必须写它(之前漏了)。"""
self._run(expire_at="1795967999")
assert self.account.points_expire_at is not None
assert self.account.points_expire_at.year != 1970
def test_no_expiry_clears_the_column_not_zero(self):
"""斗鱼用 0 表示没有到期时间 —— 不能存成 0(会显示 1970/1/1)。"""
self._run(expire_at="0")
assert self.account.points_expire_at is None
assert self.account.overview_snapshot["assets"]["points_expire_at"] is None