test: 全面迁移 pytest 并加入格式门禁
This commit is contained in:
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
push_lines="$(cat)"
|
||||||
|
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||||
|
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
echo "[pre-push] 分支删除推送,跳过测试"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done <<< "$push_lines"
|
||||||
|
|
||||||
|
if [ "${SKIP_TESTS:-0}" = "1" ]; then
|
||||||
|
echo "[pre-push] SKIP_TESTS=1,跳过测试"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python_files=()
|
||||||
|
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||||
|
[ -z "$local_sha" ] && continue
|
||||||
|
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
changed_paths="$(git diff-tree --no-commit-id --name-only -r --root "$local_sha" -- '*.py')"
|
||||||
|
else
|
||||||
|
changed_paths="$(git diff --name-only "$remote_sha..$local_sha" -- '*.py')"
|
||||||
|
fi
|
||||||
|
while IFS= read -r path; do
|
||||||
|
[ -n "$path" ] && python_files+=("$path")
|
||||||
|
done <<< "$changed_paths"
|
||||||
|
done <<< "$push_lines"
|
||||||
|
|
||||||
|
if [ "${#python_files[@]}" -gt 0 ]; then
|
||||||
|
echo "[pre-push] 检查 Python 格式..."
|
||||||
|
uv run --group dev ruff format --check "${python_files[@]}"
|
||||||
|
echo "[pre-push] Python 格式通过"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[pre-push] 运行 pytest..."
|
||||||
|
./dev.sh test -q
|
||||||
|
echo "[pre-push] pytest 通过"
|
||||||
@@ -27,6 +27,7 @@ evidence/
|
|||||||
# ── 逆向实验脚本(保留业务脚本 migrate_sqlite_to_mysql.py)
|
# ── 逆向实验脚本(保留业务脚本 migrate_sqlite_to_mysql.py)
|
||||||
scripts/*
|
scripts/*
|
||||||
!scripts/migrate_sqlite_to_mysql.py
|
!scripts/migrate_sqlite_to_mysql.py
|
||||||
|
!scripts/install-git-hooks.sh
|
||||||
# ── 逆向工具集(frida hook、unidbg hydev、虎牙协议复刻脚本等)
|
# ── 逆向工具集(frida hook、unidbg hydev、虎牙协议复刻脚本等)
|
||||||
tools/
|
tools/
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ uv run --group dev pytest # 或 ./dev.sh test
|
|||||||
# 只跑单个文件 / 用例
|
# 只跑单个文件 / 用例
|
||||||
uv run --group dev pytest tests/test_cookie_operations.py
|
uv run --group dev pytest tests/test_cookie_operations.py
|
||||||
|
|
||||||
|
# 格式化 / 仅检查格式
|
||||||
|
./dev.sh format tests conftest.py
|
||||||
|
./dev.sh format-check tests conftest.py
|
||||||
|
|
||||||
# 容器内回归(镜像内为构建时代码,改动后需重新构建)
|
# 容器内回归(镜像内为构建时代码,改动后需重新构建)
|
||||||
docker compose --profile test run --rm test
|
docker compose --profile test run --rm test
|
||||||
```
|
```
|
||||||
@@ -125,8 +129,14 @@ docker compose --profile test run --rm test
|
|||||||
编译 + 空库整链 `upgrade head` 后与模型元数据表/列/索引对齐;MySQL
|
编译 + 空库整链 `upgrade head` 后与模型元数据表/列/索引对齐;MySQL
|
||||||
专属 DDL 由迁移脚本内的方言防护跳过,真实执行仍以部署流程
|
专属 DDL 由迁移脚本内的方言防护跳过,真实执行仍以部署流程
|
||||||
`docker-entrypoint.sh` / `./deploy.sh` / `./dev.sh` 的 MySQL 为准)。
|
`docker-entrypoint.sh` / `./deploy.sh` / `./dev.sh` 的 MySQL 为准)。
|
||||||
已安装本地 pre-push 钩子:推送前自动跑全量测试,失败阻止推送
|
启用本地 pre-push 门禁(Ruff 格式检查 + pytest):
|
||||||
(紧急绕过:`git push --no-verify` 或 `SKIP_TESTS=1 git push`)。
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-git-hooks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
推送前自动跑全量测试,失败阻止推送(紧急绕过:`git push --no-verify`
|
||||||
|
或 `SKIP_TESTS=1 git push`)。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -9,4 +9,6 @@ web/backend 的模块在 import 阶段就会读取 DATABASE_URL 创建全局引
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
||||||
os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
|
os.environ.setdefault(
|
||||||
|
"APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
|
||||||
|
)
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ if [ "${1:-}" = "test" ]; then
|
|||||||
exec uv run --group dev pytest "$@"
|
exec uv run --group dev pytest "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "${1:-}" = "format" ]; then
|
||||||
|
shift
|
||||||
|
exec uv run --group dev ruff format "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${1:-}" = "format-check" ]; then
|
||||||
|
shift
|
||||||
|
exec uv run --group dev ruff format --check "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
BACKEND_PID=""
|
BACKEND_PID=""
|
||||||
FRONTEND_PID=""
|
FRONTEND_PID=""
|
||||||
WORKER_PID=""
|
WORKER_PID=""
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ testpaths = ["tests"]
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
|
"ruff>=0.12.0",
|
||||||
]
|
]
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
git -C "$ROOT_DIR" config core.hooksPath .githooks
|
||||||
|
echo "已启用 .githooks(Ruff 格式检查 + pytest pre-push 门禁)"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import unittest
|
import pytest
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
@@ -10,22 +10,24 @@ from web.backend.routers.accounts import list_accounts, set_account_tag
|
|||||||
from web.backend.schemas import AccountTag
|
from web.backend.schemas import AccountTag
|
||||||
|
|
||||||
|
|
||||||
class AccountSensitiveFieldsTests(unittest.TestCase):
|
class TestAccountSensitiveFields:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.engine = create_engine("sqlite://")
|
self.engine = create_engine("sqlite://")
|
||||||
Base.metadata.create_all(self.engine)
|
Base.metadata.create_all(self.engine)
|
||||||
self.session = sessionmaker(bind=self.engine)()
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
self.admin = User(username="admin", password_hash="hash", role="super_admin")
|
self.admin = User(username="admin", password_hash="hash", role="super_admin")
|
||||||
self.session.add(self.admin)
|
self.session.add(self.admin)
|
||||||
self.session.add(Account(
|
self.session.add(
|
||||||
|
Account(
|
||||||
username="account",
|
username="account",
|
||||||
password="account-password",
|
password="account-password",
|
||||||
email="account@example.com",
|
email="account@example.com",
|
||||||
email_password="email-password",
|
email_password="email-password",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
def tearDown(self):
|
def teardown_method(self):
|
||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(self.engine)
|
Base.metadata.drop_all(self.engine)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
@@ -44,9 +46,9 @@ class AccountSensitiveFieldsTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
item = result["items"][0]
|
item = result["items"][0]
|
||||||
self.assertIsNone(item.password)
|
assert item.password is None
|
||||||
self.assertIsNone(item.email)
|
assert item.email is None
|
||||||
self.assertIsNone(item.email_password)
|
assert item.email_password is None
|
||||||
|
|
||||||
def test_admin_can_explicitly_request_sensitive_fields(self):
|
def test_admin_can_explicitly_request_sensitive_fields(self):
|
||||||
result = list_accounts(
|
result = list_accounts(
|
||||||
@@ -62,34 +64,42 @@ class AccountSensitiveFieldsTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
item = result["items"][0]
|
item = result["items"][0]
|
||||||
self.assertEqual(item.password, "account-password")
|
assert item.password == "account-password"
|
||||||
self.assertEqual(item.email, "account@example.com")
|
assert item.email == "account@example.com"
|
||||||
self.assertEqual(item.email_password, "email-password")
|
assert item.email_password == "email-password"
|
||||||
|
|
||||||
def test_support_can_only_change_tags_on_assigned_accounts(self):
|
def test_support_can_only_change_tags_on_assigned_accounts(self):
|
||||||
support = User(username="support", password_hash="hash", role="support")
|
support = User(username="support", password_hash="hash", role="support")
|
||||||
other_support = User(username="other-support", password_hash="hash", role="support")
|
other_support = User(
|
||||||
|
username="other-support", password_hash="hash", role="support"
|
||||||
|
)
|
||||||
self.session.add_all([support, other_support])
|
self.session.add_all([support, other_support])
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
assigned = Account(
|
assigned = Account(
|
||||||
username="assigned", password="password", email="assigned@example.com",
|
username="assigned",
|
||||||
email_password="mail-password", assigned_to=support.id,
|
password="password",
|
||||||
|
email="assigned@example.com",
|
||||||
|
email_password="mail-password",
|
||||||
|
assigned_to=support.id,
|
||||||
)
|
)
|
||||||
other = Account(
|
other = Account(
|
||||||
username="other", password="password", email="other@example.com",
|
username="other",
|
||||||
email_password="mail-password", assigned_to=other_support.id,
|
password="password",
|
||||||
|
email="other@example.com",
|
||||||
|
email_password="mail-password",
|
||||||
|
assigned_to=other_support.id,
|
||||||
)
|
)
|
||||||
self.session.add_all([assigned, other])
|
self.session.add_all([assigned, other])
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
set_account_tag(assigned.id, AccountTag(tag="客服组"), db=self.session, current=support)
|
set_account_tag(
|
||||||
|
assigned.id, AccountTag(tag="客服组"), db=self.session, current=support
|
||||||
|
)
|
||||||
self.session.refresh(assigned)
|
self.session.refresh(assigned)
|
||||||
self.assertEqual(assigned.tag, "客服组")
|
assert assigned.tag == "客服组"
|
||||||
|
|
||||||
with self.assertRaises(HTTPException) as context:
|
with pytest.raises(HTTPException) as context:
|
||||||
set_account_tag(other.id, AccountTag(tag="越权"), db=self.session, current=support)
|
set_account_tag(
|
||||||
self.assertEqual(context.exception.status_code, 404)
|
other.id, AccountTag(tag="越权"), db=self.session, current=support
|
||||||
|
)
|
||||||
|
assert context.value.status_code == 404
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
+80
-41
@@ -1,7 +1,8 @@
|
|||||||
"""充值审计日志的权限、查询和脱敏测试。"""
|
"""充值审计日志的权限、查询和脱敏测试。"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import unittest
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -13,89 +14,127 @@ from web.backend.routers.audit import list_audit_logs, require_super_admin
|
|||||||
from web.backend.services.audit_service import record_audit
|
from web.backend.services.audit_service import record_audit
|
||||||
|
|
||||||
|
|
||||||
class AuditLogTests(unittest.TestCase):
|
class TestAuditLog:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.engine = create_engine("sqlite://")
|
self.engine = create_engine("sqlite://")
|
||||||
Base.metadata.create_all(self.engine)
|
Base.metadata.create_all(self.engine)
|
||||||
self.session = sessionmaker(bind=self.engine)()
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
self.admin = User(username="admin", password_hash="hash", role="super_admin")
|
self.admin = User(username="admin", password_hash="hash", role="super_admin")
|
||||||
self.operator = User(
|
self.operator = User(
|
||||||
username="operator", password_hash="hash", role="operation",
|
username="operator",
|
||||||
|
password_hash="hash",
|
||||||
|
role="operation",
|
||||||
custom_permissions=["audit:view"],
|
custom_permissions=["audit:view"],
|
||||||
)
|
)
|
||||||
self.session.add_all([self.admin, self.operator])
|
self.session.add_all([self.admin, self.operator])
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
def tearDown(self):
|
def teardown_method(self):
|
||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(self.engine)
|
Base.metadata.drop_all(self.engine)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
def test_only_super_admin_can_read_audit_logs(self):
|
def test_only_super_admin_can_read_audit_logs(self):
|
||||||
with self.assertRaises(HTTPException) as context:
|
with pytest.raises(HTTPException) as context:
|
||||||
require_super_admin(self.operator)
|
require_super_admin(self.operator)
|
||||||
self.assertEqual(context.exception.status_code, 403)
|
assert context.value.status_code == 403
|
||||||
self.assertIs(require_super_admin(self.admin), self.admin)
|
assert require_super_admin(self.admin) is self.admin
|
||||||
|
|
||||||
def test_list_audit_logs_supports_pagination_and_result_filter(self):
|
def test_list_audit_logs_supports_pagination_and_result_filter(self):
|
||||||
for index in range(3):
|
for index in range(3):
|
||||||
record_audit(
|
record_audit(
|
||||||
self.session, self.admin, action="recharge:yyb:payment",
|
self.session,
|
||||||
target=f"yyb_task:{index}", detail={"task_id": index}, success=index != 1,
|
self.admin,
|
||||||
|
action="recharge:yyb:payment",
|
||||||
|
target=f"yyb_task:{index}",
|
||||||
|
detail={"task_id": index},
|
||||||
|
success=index != 1,
|
||||||
)
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
result = list_audit_logs(
|
result = list_audit_logs(
|
||||||
page=1, page_size=1, username=None, action="recharge:yyb:payment",
|
page=1,
|
||||||
keyword=None, success=True, start_time=None, end_time=None,
|
page_size=1,
|
||||||
db=self.session, _=self.admin,
|
username=None,
|
||||||
|
action="recharge:yyb:payment",
|
||||||
|
keyword=None,
|
||||||
|
success=True,
|
||||||
|
start_time=None,
|
||||||
|
end_time=None,
|
||||||
|
db=self.session,
|
||||||
|
_=self.admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["total"], 2)
|
assert result["total"] == 2
|
||||||
self.assertEqual(len(result["items"]), 1)
|
assert len(result["items"]) == 1
|
||||||
self.assertTrue(result["items"][0]["success"])
|
assert result["items"][0]["success"]
|
||||||
|
|
||||||
def test_audit_detail_masks_sensitive_fields(self):
|
def test_audit_detail_masks_sensitive_fields(self):
|
||||||
record_audit(
|
record_audit(
|
||||||
self.session, self.admin, action="recharge:yyb:payment",
|
self.session,
|
||||||
detail={"token": "token-value", "nested": {"cookie": "cookie-value"}, "order_id": "order-1"},
|
self.admin,
|
||||||
|
action="recharge:yyb:payment",
|
||||||
|
detail={
|
||||||
|
"token": "token-value",
|
||||||
|
"nested": {"cookie": "cookie-value"},
|
||||||
|
"order_id": "order-1",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
entry = self.session.query(AuditLog).one()
|
entry = self.session.query(AuditLog).one()
|
||||||
detail = json.loads(entry.detail)
|
detail = json.loads(entry.detail)
|
||||||
self.assertEqual(detail["token"], "[已脱敏]")
|
assert detail["token"] == "[已脱敏]"
|
||||||
self.assertEqual(detail["nested"]["cookie"], "[已脱敏]")
|
assert detail["nested"]["cookie"] == "[已脱敏]"
|
||||||
self.assertEqual(detail["order_id"], "order-1")
|
assert detail["order_id"] == "order-1"
|
||||||
|
|
||||||
def test_old_douyu_recharge_log_is_enriched_with_account_identity(self):
|
def test_old_douyu_recharge_log_is_enriched_with_account_identity(self):
|
||||||
account = Account(
|
account = Account(
|
||||||
username="douyu-login", password="password", email="mail@example.com",
|
username="douyu-login",
|
||||||
email_password="mail-password", uid="10001", nickname="斗鱼昵称",
|
password="password",
|
||||||
|
email="mail@example.com",
|
||||||
|
email_password="mail-password",
|
||||||
|
uid="10001",
|
||||||
|
nickname="斗鱼昵称",
|
||||||
)
|
)
|
||||||
self.session.add(account)
|
self.session.add(account)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.session.add(DouyuTask(
|
self.session.add(
|
||||||
batch_id="batch-old", account_id=account.id, task_type="create_gold_qr",
|
DouyuTask(
|
||||||
handbook_scope="elite", created_by=self.admin.id,
|
batch_id="batch-old",
|
||||||
))
|
account_id=account.id,
|
||||||
self.session.add(AuditLog(
|
task_type="create_gold_qr",
|
||||||
user_id=self.admin.id, username=self.admin.username,
|
handbook_scope="elite",
|
||||||
action="recharge:douyu:create", target="douyu_batch:batch-old", detail="{}", success=True,
|
created_by=self.admin.id,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
AuditLog(
|
||||||
|
user_id=self.admin.id,
|
||||||
|
username=self.admin.username,
|
||||||
|
action="recharge:douyu:create",
|
||||||
|
target="douyu_batch:batch-old",
|
||||||
|
detail="{}",
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
result = list_audit_logs(
|
result = list_audit_logs(
|
||||||
page=1, page_size=50, username=None, action=None, keyword=None, success=None,
|
page=1,
|
||||||
start_time=None, end_time=None, db=self.session, _=self.admin,
|
page_size=50,
|
||||||
|
username=None,
|
||||||
|
action=None,
|
||||||
|
keyword=None,
|
||||||
|
success=None,
|
||||||
|
start_time=None,
|
||||||
|
end_time=None,
|
||||||
|
db=self.session,
|
||||||
|
_=self.admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
detail = json.loads(result["items"][0]["detail"])
|
detail = json.loads(result["items"][0]["detail"])
|
||||||
self.assertEqual(detail["recharge_accounts"][0]["username"], "douyu-login")
|
assert detail["recharge_accounts"][0]["username"] == "douyu-login"
|
||||||
self.assertEqual(detail["recharge_accounts"][0]["douyu_uid"], "10001")
|
assert detail["recharge_accounts"][0]["douyu_uid"] == "10001"
|
||||||
self.assertEqual(detail["recharge_channel"], "wechat_qr")
|
assert detail["recharge_channel"] == "wechat_qr"
|
||||||
self.assertEqual(detail["payment_method"], "微信扫码支付")
|
assert detail["payment_method"] == "微信扫码支付"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import unittest
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -9,8 +8,8 @@ from web.backend.models import Account, LoginTask, User
|
|||||||
from web.backend.routers.cookies import _order_cookie_tasks
|
from web.backend.routers.cookies import _order_cookie_tasks
|
||||||
|
|
||||||
|
|
||||||
class CustomCookieOrderTests(unittest.TestCase):
|
class TestCustomCookieOrder:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.engine = create_engine("sqlite://")
|
self.engine = create_engine("sqlite://")
|
||||||
Base.metadata.create_all(self.engine)
|
Base.metadata.create_all(self.engine)
|
||||||
self.db = Session(self.engine)
|
self.db = Session(self.engine)
|
||||||
@@ -27,41 +26,32 @@ class CustomCookieOrderTests(unittest.TestCase):
|
|||||||
self.db.flush()
|
self.db.flush()
|
||||||
# 完成时间特意与用户输入顺序相反。
|
# 完成时间特意与用户输入顺序相反。
|
||||||
for index, name in enumerate(("account-a", "account-b", "account-c")):
|
for index, name in enumerate(("account-a", "account-b", "account-c")):
|
||||||
self.db.add(LoginTask(
|
self.db.add(
|
||||||
|
LoginTask(
|
||||||
batch_id=f"batch-{name}",
|
batch_id=f"batch-{name}",
|
||||||
account_id=accounts[name].id,
|
account_id=accounts[name].id,
|
||||||
status="success",
|
status="success",
|
||||||
created_by=self.user.id,
|
created_by=self.user.id,
|
||||||
finished_at=now + timedelta(minutes=index),
|
finished_at=now + timedelta(minutes=index),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def tearDown(self):
|
def teardown_method(self):
|
||||||
self.db.close()
|
self.db.close()
|
||||||
Base.metadata.drop_all(self.engine)
|
Base.metadata.drop_all(self.engine)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
def test_custom_cookie_order_follows_input_not_finished_time(self):
|
def test_custom_cookie_order_follows_input_not_finished_time(self):
|
||||||
selected_names = ["account-c", "account-a", "account-b"]
|
selected_names = ["account-c", "account-a", "account-b"]
|
||||||
tasks = (
|
tasks = _order_cookie_tasks(
|
||||||
_order_cookie_tasks(
|
|
||||||
self.db.query(LoginTask)
|
self.db.query(LoginTask)
|
||||||
.join(Account, LoginTask.account_id == Account.id)
|
.join(Account, LoginTask.account_id == Account.id)
|
||||||
.filter(Account.username.in_(selected_names)),
|
.filter(Account.username.in_(selected_names)),
|
||||||
selected_names,
|
selected_names,
|
||||||
)
|
).all()
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
account_names = {
|
account_names = {
|
||||||
account.id: account.username
|
account.id: account.username for account in self.db.query(Account).all()
|
||||||
for account in self.db.query(Account).all()
|
|
||||||
}
|
}
|
||||||
self.assertEqual(
|
assert [account_names[task.account_id] for task in tasks] == selected_names
|
||||||
[account_names[task.account_id] for task in tasks],
|
|
||||||
selected_names,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import unittest
|
import pytest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -7,7 +7,10 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from web.backend.database import Base
|
from web.backend.database import Base
|
||||||
from web.backend.models import Account, LoginTask, User
|
from web.backend.models import Account, LoginTask, User
|
||||||
from web.backend.services.login_service import LoginBatchRunner, cleanup_orphan_relogin_tasks
|
from web.backend.services.login_service import (
|
||||||
|
LoginBatchRunner,
|
||||||
|
cleanup_orphan_relogin_tasks,
|
||||||
|
)
|
||||||
from web.backend.services.cookie_check_service import check_douyu_cookie
|
from web.backend.services.cookie_check_service import check_douyu_cookie
|
||||||
from web.backend.routers.cookies import (
|
from web.backend.routers.cookies import (
|
||||||
check_cookie_operations,
|
check_cookie_operations,
|
||||||
@@ -19,14 +22,16 @@ from web.backend.routers.cookies import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CookieOperationTests(unittest.TestCase):
|
class TestCookieOperation:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.engine = create_engine("sqlite://")
|
self.engine = create_engine("sqlite://")
|
||||||
Base.metadata.create_all(self.engine)
|
Base.metadata.create_all(self.engine)
|
||||||
self.session = sessionmaker(bind=self.engine)()
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
|
|
||||||
self.support = User(username="support", password_hash="hash", role="support")
|
self.support = User(username="support", password_hash="hash", role="support")
|
||||||
self.other_support = User(username="other", password_hash="hash", role="support")
|
self.other_support = User(
|
||||||
|
username="other", password_hash="hash", role="support"
|
||||||
|
)
|
||||||
self.session.add_all([self.support, self.other_support])
|
self.session.add_all([self.support, self.other_support])
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
@@ -46,15 +51,33 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.session.add_all([owned, other])
|
self.session.add_all([owned, other])
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.session.add_all([
|
self.session.add_all(
|
||||||
LoginTask(batch_id="owned", account_id=owned.id, created_by=self.support.id, status="success", cookie="owned-secret"),
|
[
|
||||||
LoginTask(batch_id="other", account_id=other.id, created_by=self.other_support.id, status="success", cookie="other-secret"),
|
LoginTask(
|
||||||
])
|
batch_id="owned",
|
||||||
|
account_id=owned.id,
|
||||||
|
created_by=self.support.id,
|
||||||
|
status="success",
|
||||||
|
cookie="owned-secret",
|
||||||
|
),
|
||||||
|
LoginTask(
|
||||||
|
batch_id="other",
|
||||||
|
account_id=other.id,
|
||||||
|
created_by=self.other_support.id,
|
||||||
|
status="success",
|
||||||
|
cookie="other-secret",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.owned_task = self.session.query(LoginTask).filter(LoginTask.batch_id == "owned").one()
|
self.owned_task = (
|
||||||
self.other_task = self.session.query(LoginTask).filter(LoginTask.batch_id == "other").one()
|
self.session.query(LoginTask).filter(LoginTask.batch_id == "owned").one()
|
||||||
|
)
|
||||||
|
self.other_task = (
|
||||||
|
self.session.query(LoginTask).filter(LoginTask.batch_id == "other").one()
|
||||||
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def teardown_method(self):
|
||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(self.engine)
|
Base.metadata.drop_all(self.engine)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
@@ -69,8 +92,9 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
current=self.support,
|
current=self.support,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["total"], 1)
|
assert result["total"] == 1
|
||||||
self.assertEqual(result["items"], [{
|
assert result["items"] == [
|
||||||
|
{
|
||||||
"id": self.owned_task.id,
|
"id": self.owned_task.id,
|
||||||
"account_username": "owned-account",
|
"account_username": "owned-account",
|
||||||
"tag": "",
|
"tag": "",
|
||||||
@@ -80,21 +104,26 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
"relogin_status": "",
|
"relogin_status": "",
|
||||||
"relogin_message": "",
|
"relogin_message": "",
|
||||||
"relogin_batch_id": "",
|
"relogin_batch_id": "",
|
||||||
}])
|
}
|
||||||
self.assertNotIn("owned-secret", str(result))
|
]
|
||||||
self.assertNotIn("owned-password", str(result))
|
assert "owned-secret" not in str(result)
|
||||||
|
assert "owned-password" not in str(result)
|
||||||
|
|
||||||
def test_support_cannot_use_cookie_management_endpoints(self):
|
def test_support_cannot_use_cookie_management_endpoints(self):
|
||||||
with self.assertRaisesRegex(Exception, "cookie:view"):
|
with pytest.raises(Exception, match="cookie:view"):
|
||||||
list_cookies(db=self.session, current=self.support)
|
list_cookies(db=self.session, current=self.support)
|
||||||
with self.assertRaisesRegex(Exception, "cookie:view"):
|
with pytest.raises(Exception, match="cookie:view"):
|
||||||
get_cookie(self.owned_task.id, db=self.session, current=self.support)
|
get_cookie(self.owned_task.id, db=self.session, current=self.support)
|
||||||
|
|
||||||
def test_cookie_list_filters_by_account_tag(self):
|
def test_cookie_list_filters_by_account_tag(self):
|
||||||
admin = User(username="admin", password_hash="hash", role="super_admin")
|
admin = User(username="admin", password_hash="hash", role="super_admin")
|
||||||
self.session.add(admin)
|
self.session.add(admin)
|
||||||
self.session.query(Account).filter(Account.id == self.owned_task.account_id).update({"tag": "A组"})
|
self.session.query(Account).filter(
|
||||||
self.session.query(Account).filter(Account.id == self.other_task.account_id).update({"tag": "B组"})
|
Account.id == self.owned_task.account_id
|
||||||
|
).update({"tag": "A组"})
|
||||||
|
self.session.query(Account).filter(
|
||||||
|
Account.id == self.other_task.account_id
|
||||||
|
).update({"tag": "B组"})
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
result = list_cookies(
|
result = list_cookies(
|
||||||
@@ -108,12 +137,16 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
current=admin,
|
current=admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["total"], 1)
|
assert result["total"] == 1
|
||||||
self.assertEqual([item["id"] for item in result["items"]], [self.owned_task.id])
|
assert [item["id"] for item in result["items"]] == [self.owned_task.id]
|
||||||
|
|
||||||
def test_support_operation_tag_filter_and_list_stay_scoped(self):
|
def test_support_operation_tag_filter_and_list_stay_scoped(self):
|
||||||
self.session.query(Account).filter(Account.id == self.owned_task.account_id).update({"tag": "我的标签"})
|
self.session.query(Account).filter(
|
||||||
self.session.query(Account).filter(Account.id == self.other_task.account_id).update({"tag": "别人的标签"})
|
Account.id == self.owned_task.account_id
|
||||||
|
).update({"tag": "我的标签"})
|
||||||
|
self.session.query(Account).filter(
|
||||||
|
Account.id == self.other_task.account_id
|
||||||
|
).update({"tag": "别人的标签"})
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
tags = list_cookie_operation_tags(db=self.session, current=self.support)
|
tags = list_cookie_operation_tags(db=self.session, current=self.support)
|
||||||
@@ -126,8 +159,8 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
current=self.support,
|
current=self.support,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(tags, ["我的标签"])
|
assert tags == ["我的标签"]
|
||||||
self.assertEqual(result["total"], 0)
|
assert result["total"] == 0
|
||||||
|
|
||||||
@patch("web.backend.routers.cookies._check_one_cookie")
|
@patch("web.backend.routers.cookies._check_one_cookie")
|
||||||
def test_support_can_check_only_assigned_cookie(self, check_one_cookie):
|
def test_support_can_check_only_assigned_cookie(self, check_one_cookie):
|
||||||
@@ -147,11 +180,11 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
current=self.support,
|
current=self.support,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual([item["id"] for item in result["results"]], [self.owned_task.id])
|
assert [item["id"] for item in result["results"]] == [self.owned_task.id]
|
||||||
self.session.refresh(self.owned_task)
|
self.session.refresh(self.owned_task)
|
||||||
self.session.refresh(self.other_task)
|
self.session.refresh(self.other_task)
|
||||||
self.assertEqual(self.owned_task.ck_check_status, "valid")
|
assert self.owned_task.ck_check_status == "valid"
|
||||||
self.assertEqual(self.other_task.ck_check_status, "")
|
assert self.other_task.ck_check_status == ""
|
||||||
|
|
||||||
def test_operation_permission_does_not_allow_unrelated_users(self):
|
def test_operation_permission_does_not_allow_unrelated_users(self):
|
||||||
no_permission_user = SimpleNamespace(
|
no_permission_user = SimpleNamespace(
|
||||||
@@ -160,7 +193,7 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
role="support",
|
role="support",
|
||||||
custom_permissions=[],
|
custom_permissions=[],
|
||||||
)
|
)
|
||||||
with self.assertRaisesRegex(Exception, "cookie:operate"):
|
with pytest.raises(Exception, match="cookie:operate"):
|
||||||
list_cookie_operations(
|
list_cookie_operations(
|
||||||
search="",
|
search="",
|
||||||
tag="",
|
tag="",
|
||||||
@@ -176,12 +209,17 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
result = list_cookie_operations(
|
result = list_cookie_operations(
|
||||||
search="", tag="", page=1, page_size=20, db=self.session, current=self.support,
|
search="",
|
||||||
|
tag="",
|
||||||
|
page=1,
|
||||||
|
page_size=20,
|
||||||
|
db=self.session,
|
||||||
|
current=self.support,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["total"], 1)
|
assert result["total"] == 1
|
||||||
self.assertEqual(result["items"][0]["relogin_status"], "relogin_failed")
|
assert result["items"][0]["relogin_status"] == "relogin_failed"
|
||||||
self.assertIn("旧 Cookie 已保留", result["items"][0]["relogin_message"])
|
assert "旧 Cookie 已保留" in result["items"][0]["relogin_message"]
|
||||||
|
|
||||||
def test_service_restart_cleans_orphan_relogin_tasks(self):
|
def test_service_restart_cleans_orphan_relogin_tasks(self):
|
||||||
self.owned_task.status = "relogin_running"
|
self.owned_task.status = "relogin_running"
|
||||||
@@ -189,12 +227,12 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
|
|
||||||
cleaned = cleanup_orphan_relogin_tasks(self.session)
|
cleaned = cleanup_orphan_relogin_tasks(self.session)
|
||||||
|
|
||||||
self.assertEqual(cleaned, 1)
|
assert cleaned == 1
|
||||||
self.session.refresh(self.owned_task)
|
self.session.refresh(self.owned_task)
|
||||||
self.session.refresh(self.other_task)
|
self.session.refresh(self.other_task)
|
||||||
self.assertEqual(self.owned_task.status, "relogin_failed")
|
assert self.owned_task.status == "relogin_failed"
|
||||||
self.assertIn("服务重启", self.owned_task.message)
|
assert "服务重启" in self.owned_task.message
|
||||||
self.assertEqual(self.other_task.status, "success")
|
assert self.other_task.status == "success"
|
||||||
|
|
||||||
def test_runner_copies_proxy_config_before_background_execution(self):
|
def test_runner_copies_proxy_config_before_background_execution(self):
|
||||||
proxy = SimpleNamespace(
|
proxy = SimpleNamespace(
|
||||||
@@ -219,40 +257,46 @@ class CookieOperationTests(unittest.TestCase):
|
|||||||
|
|
||||||
proxy_dict, _ = runner._resolve_static_proxy()
|
proxy_dict, _ = runner._resolve_static_proxy()
|
||||||
|
|
||||||
self.assertEqual(proxy_dict, {
|
assert proxy_dict == {
|
||||||
"http": "http://127.0.0.1:8080",
|
"http": "http://127.0.0.1:8080",
|
||||||
"https": "http://127.0.0.1:8080",
|
"https": "http://127.0.0.1:8080",
|
||||||
})
|
}
|
||||||
|
|
||||||
@patch("web.backend.services.cookie_check_service.requests.get")
|
@patch("web.backend.services.cookie_check_service.requests.get")
|
||||||
def test_cookie_check_uses_new_cookie_result(self, mock_get):
|
def test_cookie_check_uses_new_cookie_result(self, mock_get):
|
||||||
mock_get.side_effect = [
|
mock_get.side_effect = [
|
||||||
SimpleNamespace(json=lambda: {"error": 0, "data": {"count": 9}}),
|
SimpleNamespace(json=lambda: {"error": 0, "data": {"count": 9}}),
|
||||||
SimpleNamespace(json=lambda: {"error": 0, "data": {"nn": "new-name", "lv": 12}}),
|
SimpleNamespace(
|
||||||
|
json=lambda: {"error": 0, "data": {"nn": "new-name", "lv": 12}}
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
result = check_douyu_cookie("new-cookie")
|
result = check_douyu_cookie("new-cookie")
|
||||||
|
|
||||||
self.assertTrue(result["valid"])
|
assert result["valid"]
|
||||||
self.assertEqual(result["fish_ball"], 9)
|
assert result["fish_ball"] == 9
|
||||||
self.assertEqual(result["nickname"], "new-name")
|
assert result["nickname"] == "new-name"
|
||||||
self.assertEqual(result["level"], 12)
|
assert result["level"] == 12
|
||||||
|
|
||||||
@patch("web.backend.routers.cookies._start_relogin_tasks")
|
@patch("web.backend.routers.cookies._start_relogin_tasks")
|
||||||
def test_relogin_invalid_only_targets_support_visible_accounts(self, start_relogin):
|
def test_relogin_invalid_only_targets_support_visible_accounts(self, start_relogin):
|
||||||
self.owned_task.ck_check_status = "invalid"
|
self.owned_task.ck_check_status = "invalid"
|
||||||
self.other_task.ck_check_status = "invalid"
|
self.other_task.ck_check_status = "invalid"
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
start_relogin.return_value = {"batch_id": "batch", "count": 1, "skipped": 0, "success": True}
|
start_relogin.return_value = {
|
||||||
|
"batch_id": "batch",
|
||||||
|
"count": 1,
|
||||||
|
"skipped": 0,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
result = relogin_invalid_cookie_operations(
|
result = relogin_invalid_cookie_operations(
|
||||||
search="", tag="", db=self.session, current=self.support,
|
search="",
|
||||||
|
tag="",
|
||||||
|
db=self.session,
|
||||||
|
current=self.support,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(result["success"])
|
assert result["success"]
|
||||||
selected_tasks = start_relogin.call_args.args[0]
|
selected_tasks = start_relogin.call_args.args[0]
|
||||||
self.assertEqual([task.id for task in selected_tasks], [self.owned_task.id])
|
assert [task.id for task in selected_tasks] == [self.owned_task.id]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"""精英手册兑换:csrf 复用、新链路接口与浏览器状态机路由的测试。"""
|
"""精英手册兑换:csrf 复用、新链路接口与浏览器状态机路由的测试。"""
|
||||||
|
|
||||||
import unittest
|
import pytest
|
||||||
|
|
||||||
from unittest.mock import Mock, call
|
from unittest.mock import Mock, call
|
||||||
|
|
||||||
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
|
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
|
||||||
|
|
||||||
|
|
||||||
class CsrfReuseTests(unittest.TestCase):
|
class TestCsrfReuse:
|
||||||
def test_reuses_cookie_token_without_any_http_call(self):
|
def test_reuses_cookie_token_without_any_http_call(self):
|
||||||
client = DouyuActivityClient("acf_uid=100; cvl_csrf_token=existing-token")
|
client = DouyuActivityClient("acf_uid=100; cvl_csrf_token=existing-token")
|
||||||
client._request_json = Mock()
|
client._request_json = Mock()
|
||||||
|
|
||||||
self.assertEqual(client.csrf_token(), "existing-token")
|
assert client.csrf_token() == "existing-token"
|
||||||
client._request_json.assert_not_called()
|
client._request_json.assert_not_called()
|
||||||
|
|
||||||
def test_generates_csrf_when_cookie_missing(self):
|
def test_generates_csrf_when_cookie_missing(self):
|
||||||
@@ -24,7 +25,7 @@ class CsrfReuseTests(unittest.TestCase):
|
|||||||
|
|
||||||
client._request_json = Mock(side_effect=fake_generate)
|
client._request_json = Mock(side_effect=fake_generate)
|
||||||
|
|
||||||
self.assertEqual(client.csrf_token(), "new-token")
|
assert client.csrf_token() == "new-token"
|
||||||
client._request_json.assert_called_once()
|
client._request_json.assert_called_once()
|
||||||
|
|
||||||
def test_force_refresh_rotates_token(self):
|
def test_force_refresh_rotates_token(self):
|
||||||
@@ -32,45 +33,51 @@ class CsrfReuseTests(unittest.TestCase):
|
|||||||
client._request_json = Mock(return_value={"error": 0})
|
client._request_json = Mock(return_value={"error": 0})
|
||||||
client._set_cookie_value("cvl_csrf_token", "rotated-token")
|
client._set_cookie_value("cvl_csrf_token", "rotated-token")
|
||||||
|
|
||||||
self.assertEqual(client.csrf_token(force_refresh=True), "rotated-token")
|
assert client.csrf_token(force_refresh=True) == "rotated-token"
|
||||||
|
|
||||||
def test_refreshes_csrf_once_on_token_error(self):
|
def test_refreshes_csrf_once_on_token_error(self):
|
||||||
client = DouyuActivityClient("acf_uid=100")
|
client = DouyuActivityClient("acf_uid=100")
|
||||||
client.csrf_token = Mock(side_effect=["bad-token", "good-token"])
|
client.csrf_token = Mock(side_effect=["bad-token", "good-token"])
|
||||||
client._request_json = Mock(side_effect=[
|
client._request_json = Mock(
|
||||||
|
side_effect=[
|
||||||
{"error": 1, "msg": "csrfToken 校验失败"},
|
{"error": 1, "msg": "csrfToken 校验失败"},
|
||||||
{"error": 0, "data": {"orderId": "9", "expireSeconds": "300"}},
|
{"error": 0, "data": {"orderId": "9", "expireSeconds": "300"}},
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
result = client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
|
result = client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
|
||||||
|
|
||||||
self.assertEqual(result["order_id"], "9")
|
assert result["order_id"] == "9"
|
||||||
self.assertEqual(client.csrf_token.call_args_list, [call(), call(force_refresh=True)])
|
assert client.csrf_token.call_args_list == [call(), call(force_refresh=True)]
|
||||||
|
|
||||||
def test_no_refresh_on_regular_error(self):
|
def test_no_refresh_on_regular_error(self):
|
||||||
client = DouyuActivityClient("acf_uid=100")
|
client = DouyuActivityClient("acf_uid=100")
|
||||||
client.csrf_token = Mock(return_value="t")
|
client.csrf_token = Mock(return_value="t")
|
||||||
client._request_json = Mock(return_value={"error": 1, "msg": "需要开通精英手册,才可以兑换本道具哦"})
|
client._request_json = Mock(
|
||||||
|
return_value={"error": 1, "msg": "需要开通精英手册,才可以兑换本道具哦"}
|
||||||
|
)
|
||||||
|
|
||||||
with self.assertRaisesRegex(DouyuActivityError, "需要开通精英手册"):
|
with pytest.raises(DouyuActivityError, match="需要开通精英手册"):
|
||||||
client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
|
client.create_exchange_order(manual_id="m", rid="r", commodity_id="c")
|
||||||
client.csrf_token.assert_called_once_with()
|
client.csrf_token.assert_called_once_with()
|
||||||
|
|
||||||
def test_pay_referer_includes_room_id_like_browser(self):
|
def test_pay_referer_includes_room_id_like_browser(self):
|
||||||
client = DouyuActivityClient("acf_uid=100")
|
client = DouyuActivityClient("acf_uid=100")
|
||||||
client.csrf_token = Mock(return_value="t")
|
client.csrf_token = Mock(return_value="t")
|
||||||
client._request_json = Mock(return_value={"error": 0, "data": {"exchangeId": "11098933"}})
|
client._request_json = Mock(
|
||||||
|
return_value={"error": 0, "data": {"exchangeId": "11098933"}}
|
||||||
|
)
|
||||||
|
|
||||||
client.pay_exchange_order(manual_id="m", order_id="5874", rid="9263298")
|
client.pay_exchange_order(manual_id="m", order_id="5874", rid="9263298")
|
||||||
|
|
||||||
headers = client._request_json.call_args.kwargs["headers"]
|
headers = client._request_json.call_args.kwargs["headers"]
|
||||||
self.assertEqual(
|
assert (
|
||||||
headers["Referer"],
|
headers["Referer"]
|
||||||
"https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298",
|
== "https://www.douyu.com/pages/live-peace-handbook/web/shop?ditchname=pass0&roomId=9263298"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExchangePlanTests(unittest.TestCase):
|
class TestExchangePlan:
|
||||||
"""对齐浏览器兑换按钮状态机($e / Ve / Ge)的路由判定。"""
|
"""对齐浏览器兑换按钮状态机($e / Ve / Ge)的路由判定。"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -92,72 +99,70 @@ class ExchangePlanTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_normal_item_uses_classic_flow(self):
|
def test_normal_item_uses_classic_flow(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail())
|
plan = DouyuActivityClient.resolve_exchange_plan(self.detail())
|
||||||
self.assertEqual(plan["action"], "classic")
|
assert plan["action"] == "classic"
|
||||||
self.assertEqual(plan["max_num"], 1)
|
assert plan["max_num"] == 1
|
||||||
|
|
||||||
def test_pre_exchange_order_still_classic(self):
|
def test_pre_exchange_order_still_classic(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(preExchange={"isExists": 1}))
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
self.assertEqual(plan["action"], "classic")
|
self.detail(preExchange={"isExists": 1})
|
||||||
|
)
|
||||||
|
assert plan["action"] == "classic"
|
||||||
|
|
||||||
def test_reached_exchange_limit_blocked(self):
|
def test_reached_exchange_limit_blocked(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(status=1))
|
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(status=1))
|
||||||
self.assertEqual(plan["action"], "blocked")
|
assert plan["action"] == "blocked"
|
||||||
self.assertIn("上限", plan["text"])
|
assert "上限" in plan["text"]
|
||||||
|
|
||||||
def test_no_stock_blocked(self):
|
def test_no_stock_blocked(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(storeStatus=0))
|
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(storeStatus=0))
|
||||||
self.assertEqual(plan["action"], "blocked")
|
assert plan["action"] == "blocked"
|
||||||
self.assertIn("无库存", plan["text"])
|
assert "无库存" in plan["text"]
|
||||||
|
|
||||||
def test_room_limited_and_not_in_room_blocked(self):
|
def test_room_limited_and_not_in_room_blocked(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
self.detail(roomLimited=1, isLimitRoom=0)
|
self.detail(roomLimited=1, isLimitRoom=0)
|
||||||
)
|
)
|
||||||
self.assertEqual(plan["action"], "blocked")
|
assert plan["action"] == "blocked"
|
||||||
self.assertIn("限定房间", plan["text"])
|
assert "限定房间" in plan["text"]
|
||||||
|
|
||||||
def test_not_open_subscribe_item_subscribes(self):
|
def test_not_open_subscribe_item_subscribes(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
self.detail(openStatus=0, subscribeType=1)
|
self.detail(openStatus=0, subscribeType=1)
|
||||||
)
|
)
|
||||||
self.assertEqual(plan["action"], "subscribe")
|
assert plan["action"] == "subscribe"
|
||||||
|
|
||||||
def test_not_open_pre_exchange_item_pre_exchanges(self):
|
def test_not_open_pre_exchange_item_pre_exchanges(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
self.detail(openStatus=0, subscribeType=2)
|
self.detail(openStatus=0, subscribeType=2)
|
||||||
)
|
)
|
||||||
self.assertEqual(plan["action"], "pre_exchange")
|
assert plan["action"] == "pre_exchange"
|
||||||
|
|
||||||
def test_not_open_plain_item_blocked(self):
|
def test_not_open_plain_item_blocked(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(openStatus=0))
|
plan = DouyuActivityClient.resolve_exchange_plan(self.detail(openStatus=0))
|
||||||
self.assertEqual(plan["action"], "blocked")
|
assert plan["action"] == "blocked"
|
||||||
self.assertIn("未开启", plan["text"])
|
assert "未开启" in plan["text"]
|
||||||
|
|
||||||
def test_elite_limited_requires_manual(self):
|
def test_elite_limited_requires_manual(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
self.detail(eliteLimited=1), manual_type=0
|
self.detail(eliteLimited=1), manual_type=0
|
||||||
)
|
)
|
||||||
self.assertEqual(plan["action"], "blocked")
|
assert plan["action"] == "blocked"
|
||||||
self.assertIn("精英手册", plan["text"])
|
assert "精英手册" in plan["text"]
|
||||||
# 已开通手册(manualType=1)不受精英专享限制
|
# 已开通手册(manualType=1)不受精英专享限制
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
self.detail(eliteLimited=1), manual_type=1
|
self.detail(eliteLimited=1), manual_type=1
|
||||||
)
|
)
|
||||||
self.assertEqual(plan["action"], "classic")
|
assert plan["action"] == "classic"
|
||||||
|
|
||||||
def test_pre_exchanged_waits_for_open(self):
|
def test_pre_exchanged_waits_for_open(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
self.detail(subscribeType=2, exchangeInfo={"userStatus": 1})
|
self.detail(subscribeType=2, exchangeInfo={"userStatus": 1})
|
||||||
)
|
)
|
||||||
self.assertEqual(plan["action"], "wait")
|
assert plan["action"] == "wait"
|
||||||
|
|
||||||
def test_batch_exchange_clamps_num(self):
|
def test_batch_exchange_clamps_num(self):
|
||||||
plan = DouyuActivityClient.resolve_exchange_plan(
|
plan = DouyuActivityClient.resolve_exchange_plan(
|
||||||
self.detail(batchExchange=1), batch_num_limit="5"
|
self.detail(batchExchange=1), batch_num_limit="5"
|
||||||
)
|
)
|
||||||
self.assertEqual(plan["max_num"], 5)
|
assert plan["max_num"] == 5
|
||||||
self.assertTrue(plan["batch_exchange"])
|
assert plan["batch_exchange"]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import unittest
|
import pytest
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -6,8 +6,8 @@ import requests
|
|||||||
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
|
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
|
||||||
|
|
||||||
|
|
||||||
class EliteLockExchangeTests(unittest.TestCase):
|
class TestEliteLockExchange:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.client = DouyuActivityClient("acf_uid=100")
|
self.client = DouyuActivityClient("acf_uid=100")
|
||||||
self.client.csrf_token = Mock(side_effect=["csrf-create", "csrf-pay"])
|
self.client.csrf_token = Mock(side_effect=["csrf-create", "csrf-pay"])
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@ class EliteLockExchangeTests(unittest.TestCase):
|
|||||||
return {key: value[1] for key, value in files.items()}
|
return {key: value[1] for key, value in files.items()}
|
||||||
|
|
||||||
def test_exchange_locks_then_pays_with_fresh_csrf_tokens(self):
|
def test_exchange_locks_then_pays_with_fresh_csrf_tokens(self):
|
||||||
self.client._request_json = Mock(side_effect=[
|
self.client._request_json = Mock(
|
||||||
|
side_effect=[
|
||||||
{
|
{
|
||||||
"error": 0,
|
"error": 0,
|
||||||
"msg": "请求正常",
|
"msg": "请求正常",
|
||||||
@@ -42,7 +43,8 @@ class EliteLockExchangeTests(unittest.TestCase):
|
|||||||
"gType": 2,
|
"gType": 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
result = self.client.exchange_goods(
|
result = self.client.exchange_goods(
|
||||||
manual_id="G4KA4Qnz4LDp7",
|
manual_id="G4KA4Qnz4LDp7",
|
||||||
@@ -50,34 +52,38 @@ class EliteLockExchangeTests(unittest.TestCase):
|
|||||||
commodity_id="20260120QYOOB_bag10",
|
commodity_id="20260120QYOOB_bag10",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["order_id"], "5874")
|
assert result["order_id"] == "5874"
|
||||||
self.assertEqual(result["exchange_id"], "11098933")
|
assert result["exchange_id"] == "11098933"
|
||||||
self.assertEqual(self.client.csrf_token.call_count, 2)
|
assert self.client.csrf_token.call_count == 2
|
||||||
create_call, pay_call = self.client._request_json.call_args_list
|
create_call, pay_call = self.client._request_json.call_args_list
|
||||||
self.assertEqual(create_call.args[:3], (
|
assert create_call.args[:3] == (
|
||||||
"post", self.client.CREATE_EXCHANGE_ORDER_API, "锁定兑换商品",
|
"post",
|
||||||
))
|
self.client.CREATE_EXCHANGE_ORDER_API,
|
||||||
self.assertEqual(self.multipart_values(create_call.kwargs["files"]), {
|
"锁定兑换商品",
|
||||||
|
)
|
||||||
|
assert self.multipart_values(create_call.kwargs["files"]) == {
|
||||||
"manualID": "G4KA4Qnz4LDp7",
|
"manualID": "G4KA4Qnz4LDp7",
|
||||||
"rid": "9263298",
|
"rid": "9263298",
|
||||||
"commodityID": "20260120QYOOB_bag10",
|
"commodityID": "20260120QYOOB_bag10",
|
||||||
"num": "1",
|
"num": "1",
|
||||||
"csrfToken": "csrf-create",
|
"csrfToken": "csrf-create",
|
||||||
})
|
}
|
||||||
self.assertNotIn("Content-Type", create_call.kwargs["headers"])
|
assert "Content-Type" not in create_call.kwargs["headers"]
|
||||||
self.assertEqual(pay_call.args[:3], (
|
assert pay_call.args[:3] == (
|
||||||
"post", self.client.PAY_EXCHANGE_ORDER_API, "支付锁单",
|
"post",
|
||||||
))
|
self.client.PAY_EXCHANGE_ORDER_API,
|
||||||
self.assertEqual(self.multipart_values(pay_call.kwargs["files"]), {
|
"支付锁单",
|
||||||
|
)
|
||||||
|
assert self.multipart_values(pay_call.kwargs["files"]) == {
|
||||||
"orderID": "5874",
|
"orderID": "5874",
|
||||||
"manualID": "G4KA4Qnz4LDp7",
|
"manualID": "G4KA4Qnz4LDp7",
|
||||||
"csrfToken": "csrf-pay",
|
"csrfToken": "csrf-pay",
|
||||||
})
|
}
|
||||||
|
|
||||||
def test_lock_requires_order_id_in_success_response(self):
|
def test_lock_requires_order_id_in_success_response(self):
|
||||||
self.client._request_json = Mock(return_value={"error": 0, "data": {}})
|
self.client._request_json = Mock(return_value={"error": 0, "data": {}})
|
||||||
|
|
||||||
with self.assertRaisesRegex(DouyuActivityError, "没有 orderId"):
|
with pytest.raises(DouyuActivityError, match="没有 orderId"):
|
||||||
self.client.create_exchange_order(
|
self.client.create_exchange_order(
|
||||||
manual_id="manual",
|
manual_id="manual",
|
||||||
rid="9263298",
|
rid="9263298",
|
||||||
@@ -85,32 +91,42 @@ class EliteLockExchangeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_locked_order_list_uses_captured_query_shape(self):
|
def test_locked_order_list_uses_captured_query_shape(self):
|
||||||
self.client._request_json = Mock(return_value={
|
self.client._request_json = Mock(
|
||||||
|
return_value={
|
||||||
"error": 0,
|
"error": 0,
|
||||||
"data": {"total": 1, "list": [{"orderId": "5874"}]},
|
"data": {"total": 1, "list": [{"orderId": "5874"}]},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
result = self.client.exchange_orders(manual_id="manual", page=2, page_size=20)
|
result = self.client.exchange_orders(manual_id="manual", page=2, page_size=20)
|
||||||
|
|
||||||
self.assertEqual(result["total"], 1)
|
assert result["total"] == 1
|
||||||
self.assertEqual(result["orders"], [{"orderId": "5874"}])
|
assert result["orders"] == [{"orderId": "5874"}]
|
||||||
call = self.client._request_json.call_args
|
call = self.client._request_json.call_args
|
||||||
self.assertEqual(call.args[:3], (
|
assert call.args[:3] == (
|
||||||
"get", self.client.EXCHANGE_ORDER_LIST_API, "查询锁单",
|
"get",
|
||||||
))
|
self.client.EXCHANGE_ORDER_LIST_API,
|
||||||
self.assertEqual(call.kwargs["params"], {
|
"查询锁单",
|
||||||
"page": 2, "pageSize": 20, "manualID": "manual",
|
)
|
||||||
})
|
assert call.kwargs["params"] == {
|
||||||
|
"page": 2,
|
||||||
|
"pageSize": 20,
|
||||||
|
"manualID": "manual",
|
||||||
|
}
|
||||||
|
|
||||||
def test_redirect_is_not_followed_and_is_reported_without_query_values(self):
|
def test_redirect_is_not_followed_and_is_reported_without_query_values(self):
|
||||||
response = requests.Response()
|
response = requests.Response()
|
||||||
response.status_code = 302
|
response.status_code = 302
|
||||||
response.url = "https://www.douyu.com/japi/example?request_secret=hidden"
|
response.url = "https://www.douyu.com/japi/example?request_secret=hidden"
|
||||||
response.headers["Location"] = "https://www.douyu.com/member/login?ticket=secret"
|
response.headers["Location"] = (
|
||||||
|
"https://www.douyu.com/member/login?ticket=secret"
|
||||||
|
)
|
||||||
self.client.session.request = Mock(return_value=response)
|
self.client.session.request = Mock(return_value=response)
|
||||||
self.client.logger = Mock()
|
self.client.logger = Mock()
|
||||||
|
|
||||||
with self.assertRaisesRegex(DouyuActivityError, "Cookie 已失效,请重新登录") as caught:
|
with pytest.raises(
|
||||||
|
DouyuActivityError, match="Cookie 已失效,请重新登录"
|
||||||
|
) as caught:
|
||||||
self.client._request(
|
self.client._request(
|
||||||
"get",
|
"get",
|
||||||
"https://www.douyu.com/japi/example?request_secret=hidden",
|
"https://www.douyu.com/japi/example?request_secret=hidden",
|
||||||
@@ -118,15 +134,11 @@ class EliteLockExchangeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.client.session.request.assert_called_once()
|
self.client.session.request.assert_called_once()
|
||||||
self.assertFalse(self.client.session.request.call_args.kwargs["allow_redirects"])
|
assert not self.client.session.request.call_args.kwargs["allow_redirects"]
|
||||||
message = str(caught.exception)
|
message = str(caught.value)
|
||||||
self.assertIn("method=GET", message)
|
assert "method=GET" in message
|
||||||
self.assertIn("url=https://www.douyu.com/japi/example", message)
|
assert "url=https://www.douyu.com/japi/example" in message
|
||||||
self.assertIn("location=https://www.douyu.com/member/login", message)
|
assert "location=https://www.douyu.com/member/login" in message
|
||||||
self.assertNotIn("request_secret", message)
|
assert "request_secret" not in message
|
||||||
self.assertNotIn("ticket=secret", message)
|
assert "ticket=secret" not in message
|
||||||
self.assertIn("已拦截重定向", self.client.logger.call_args.args[0])
|
assert "已拦截重定向" in self.client.logger.call_args.args[0]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import unittest
|
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
@@ -17,8 +16,8 @@ from web.backend.services.audit_service import record_audit
|
|||||||
from core.douyu import FishFinRechargeClient, FishFinRechargeConfig
|
from core.douyu import FishFinRechargeClient, FishFinRechargeConfig
|
||||||
|
|
||||||
|
|
||||||
class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
class TestDouyuGoldRechargeChannel:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.engine = create_engine("sqlite://")
|
self.engine = create_engine("sqlite://")
|
||||||
Base.metadata.create_all(self.engine)
|
Base.metadata.create_all(self.engine)
|
||||||
self.session = sessionmaker(bind=self.engine)()
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
@@ -36,8 +35,12 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
self.session.add(self.account)
|
self.session.add(self.account)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.task = DouyuTask(
|
self.task = DouyuTask(
|
||||||
batch_id="batch", account_id=self.account.id, task_type="create_gold_qr",
|
batch_id="batch",
|
||||||
handbook_scope="elite", status="running", created_by=user.id,
|
account_id=self.account.id,
|
||||||
|
task_type="create_gold_qr",
|
||||||
|
handbook_scope="elite",
|
||||||
|
status="running",
|
||||||
|
created_by=user.id,
|
||||||
)
|
)
|
||||||
self.session.add(self.task)
|
self.session.add(self.task)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
@@ -46,13 +49,15 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
os.environ["FISH_FIN_RECHARGE_APP_ID"] = "test-app"
|
os.environ["FISH_FIN_RECHARGE_APP_ID"] = "test-app"
|
||||||
os.environ["FISH_FIN_RECHARGE_APP_SECRET"] = "test-secret"
|
os.environ["FISH_FIN_RECHARGE_APP_SECRET"] = "test-secret"
|
||||||
|
|
||||||
def tearDown(self):
|
def teardown_method(self):
|
||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(self.engine)
|
Base.metadata.drop_all(self.engine)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
||||||
def test_supplier_channel_creates_order_using_nickname_and_finishes_on_success(self, client_class):
|
def test_supplier_channel_creates_order_using_nickname_and_finishes_on_success(
|
||||||
|
self, client_class
|
||||||
|
):
|
||||||
supplier = Mock()
|
supplier = Mock()
|
||||||
supplier.create_order.return_value = {
|
supplier.create_order.return_value = {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
@@ -69,32 +74,36 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
self.task.result = {"payload": {"amount": 10}}
|
self.task.result = {"payload": {"amount": 10}}
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.runner._execute_create_gold_qr(
|
self.runner._execute_create_gold_qr(
|
||||||
self.session, self.task, self.account, "acf_uid=10001", config,
|
self.session,
|
||||||
|
self.task,
|
||||||
|
self.account,
|
||||||
|
"acf_uid=10001",
|
||||||
|
config,
|
||||||
)
|
)
|
||||||
|
|
||||||
expected_order_id = self.runner._supplier_out_order_id(self.task)
|
expected_order_id = self.runner._supplier_out_order_id(self.task)
|
||||||
supplier.create_order.assert_called_once_with(
|
supplier.create_order.assert_called_once_with(
|
||||||
buy_num=10,
|
buy_num=10,
|
||||||
pay_amount=unittest.mock.ANY,
|
pay_amount=ANY,
|
||||||
out_order_id=expected_order_id,
|
out_order_id=expected_order_id,
|
||||||
product_id="gold-product",
|
product_id="gold-product",
|
||||||
recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "罗炅729"}],
|
recharge_arg=[{"templateName": "斗鱼账号", "templateVal": "罗炅729"}],
|
||||||
order_type=0,
|
order_type=0,
|
||||||
notify_url=unittest.mock.ANY,
|
notify_url=ANY,
|
||||||
)
|
)
|
||||||
self.assertEqual(str(supplier.create_order.call_args.kwargs["pay_amount"]), "10")
|
assert str(supplier.create_order.call_args.kwargs["pay_amount"]) == "10"
|
||||||
self.session.refresh(self.task)
|
self.session.refresh(self.task)
|
||||||
self.assertEqual(self.task.status, "success")
|
assert self.task.status == "success"
|
||||||
self.assertEqual(self.task.result["recharge_channel"], "supplier_api")
|
assert self.task.result["recharge_channel"] == "supplier_api"
|
||||||
self.assertEqual(self.task.result["supplier_order_status"], 2)
|
assert self.task.result["supplier_order_status"] == 2
|
||||||
self.assertEqual(self.task.result["buy_num"], 10)
|
assert self.task.result["buy_num"] == 10
|
||||||
self.assertEqual(self.task.result["out_order_id"], expected_order_id)
|
assert self.task.result["out_order_id"] == expected_order_id
|
||||||
self.assertEqual(self.task.result["recharge_account"], "罗炅729")
|
assert self.task.result["recharge_account"] == "罗炅729"
|
||||||
self.assertEqual(self.task.result["douyu_uid"], "10001")
|
assert self.task.result["douyu_uid"] == "10001"
|
||||||
self.assertEqual(self.task.result["order_id"], "supplier-001")
|
assert self.task.result["order_id"] == "supplier-001"
|
||||||
self.assertEqual(self.task.result["pay_amount"], "10")
|
assert self.task.result["pay_amount"] == "10"
|
||||||
self.assertNotIn("pay_url", self.task.result)
|
assert "pay_url" not in self.task.result
|
||||||
self.assertNotIn("sign", self.task.result["supplier_order"])
|
assert "sign" not in self.task.result["supplier_order"]
|
||||||
|
|
||||||
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
||||||
def test_supplier_channel_rejects_account_without_nickname(self, client_class):
|
def test_supplier_channel_rejects_account_without_nickname(self, client_class):
|
||||||
@@ -107,20 +116,21 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.runner._execute_create_gold_qr(
|
self.runner._execute_create_gold_qr(
|
||||||
self.session, self.task, self.account, "acf_uid=10001", config,
|
self.session,
|
||||||
|
self.task,
|
||||||
|
self.account,
|
||||||
|
"acf_uid=10001",
|
||||||
|
config,
|
||||||
)
|
)
|
||||||
|
|
||||||
client_class.assert_not_called()
|
client_class.assert_not_called()
|
||||||
self.session.refresh(self.task)
|
self.session.refresh(self.task)
|
||||||
self.assertEqual(self.task.status, "failed")
|
assert self.task.status == "failed"
|
||||||
self.assertIn("斗鱼昵称", self.task.message)
|
assert "斗鱼昵称" in self.task.message
|
||||||
|
|
||||||
def test_supplier_out_order_id_reuses_persisted_value(self):
|
def test_supplier_out_order_id_reuses_persisted_value(self):
|
||||||
self.task.supplier_out_order_id = "DYGFHISTORICAL-001"
|
self.task.supplier_out_order_id = "DYGFHISTORICAL-001"
|
||||||
self.assertEqual(
|
assert self.runner._supplier_out_order_id(self.task) == "DYGFHISTORICAL-001"
|
||||||
self.runner._supplier_out_order_id(self.task),
|
|
||||||
"DYGFHISTORICAL-001",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_recharge_channel_can_be_read_without_full_config(self):
|
def test_recharge_channel_can_be_read_without_full_config(self):
|
||||||
config = ensure_douyu_config(self.session)
|
config = ensure_douyu_config(self.session)
|
||||||
@@ -132,7 +142,7 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
current=self.session.get(User, self.task.created_by),
|
current=self.session.get(User, self.task.created_by),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(channel, {"gold_recharge_channel": "supplier_api"})
|
assert channel == {"gold_recharge_channel": "supplier_api"}
|
||||||
|
|
||||||
def test_recharge_audit_detail_includes_safe_account_identity(self):
|
def test_recharge_audit_detail_includes_safe_account_identity(self):
|
||||||
record_audit(
|
record_audit(
|
||||||
@@ -140,22 +150,26 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
self.session.get(User, self.task.created_by),
|
self.session.get(User, self.task.created_by),
|
||||||
action="recharge:douyu:create",
|
action="recharge:douyu:create",
|
||||||
detail={
|
detail={
|
||||||
"recharge_accounts": [{
|
"recharge_accounts": [
|
||||||
|
{
|
||||||
"username": self.account.username,
|
"username": self.account.username,
|
||||||
"douyu_uid": self.account.uid,
|
"douyu_uid": self.account.uid,
|
||||||
"douyu_nickname": self.account.nickname,
|
"douyu_nickname": self.account.nickname,
|
||||||
}],
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
audit = self.session.query(AuditLog).one()
|
audit = self.session.query(AuditLog).one()
|
||||||
self.assertIn("douyu_uid", audit.detail)
|
assert "douyu_uid" in audit.detail
|
||||||
self.assertIn("10001", audit.detail)
|
assert "10001" in audit.detail
|
||||||
self.assertNotIn("password", audit.detail)
|
assert "password" not in audit.detail
|
||||||
|
|
||||||
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
@patch("web.backend.services.douyu_runner_gold.FishFinRechargeClient")
|
||||||
def test_supplier_channel_marks_non_200_create_response_as_failed(self, client_class):
|
def test_supplier_channel_marks_non_200_create_response_as_failed(
|
||||||
|
self, client_class
|
||||||
|
):
|
||||||
supplier = Mock()
|
supplier = Mock()
|
||||||
supplier.create_order.return_value = {"code": 400, "msg": "商品已下架"}
|
supplier.create_order.return_value = {"code": 400, "msg": "商品已下架"}
|
||||||
client_class.return_value = supplier
|
client_class.return_value = supplier
|
||||||
@@ -166,16 +180,22 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.runner._execute_create_gold_qr(
|
self.runner._execute_create_gold_qr(
|
||||||
self.session, self.task, self.account, "acf_uid=10001", config,
|
self.session,
|
||||||
|
self.task,
|
||||||
|
self.account,
|
||||||
|
"acf_uid=10001",
|
||||||
|
config,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.session.refresh(self.task)
|
self.session.refresh(self.task)
|
||||||
self.assertEqual(self.task.status, "failed")
|
assert self.task.status == "failed"
|
||||||
self.assertEqual(self.task.message, "商品已下架")
|
assert self.task.message == "商品已下架"
|
||||||
|
|
||||||
@patch("web.backend.services.douyu_runner.check_douyu_cookie")
|
@patch("web.backend.services.douyu_runner.check_douyu_cookie")
|
||||||
@patch("web.backend.services.douyu_runner.SessionLocal")
|
@patch("web.backend.services.douyu_runner.SessionLocal")
|
||||||
def test_invalid_cookie_skips_activity_and_marks_relogin_required(self, session_local, check_cookie):
|
def test_invalid_cookie_skips_activity_and_marks_relogin_required(
|
||||||
|
self, session_local, check_cookie
|
||||||
|
):
|
||||||
login_task = LoginTask(
|
login_task = LoginTask(
|
||||||
batch_id="login-batch",
|
batch_id="login-batch",
|
||||||
account_id=self.account.id,
|
account_id=self.account.id,
|
||||||
@@ -206,20 +226,30 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
task = verify_session.get(DouyuTask, task_id)
|
task = verify_session.get(DouyuTask, task_id)
|
||||||
checked_login = verify_session.get(LoginTask, login_task_id)
|
checked_login = verify_session.get(LoginTask, login_task_id)
|
||||||
self.assertEqual(task.status, "failed")
|
assert task.status == "failed"
|
||||||
self.assertIn("Cookie 已失效,请重新登录", task.message)
|
assert "Cookie 已失效,请重新登录" in task.message
|
||||||
self.assertEqual(checked_login.ck_check_status, "invalid")
|
assert checked_login.ck_check_status == "invalid"
|
||||||
self.assertEqual(checked_login.ck_check_result["message"], "鱼丸接口: 未登录;等级接口: 未登录")
|
assert (
|
||||||
|
checked_login.ck_check_result["message"]
|
||||||
|
== "鱼丸接口: 未登录;等级接口: 未登录"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
verify_session.close()
|
verify_session.close()
|
||||||
|
|
||||||
def test_supplier_callback_verifies_signature_and_updates_terminal_task(self):
|
def test_supplier_callback_verifies_signature_and_updates_terminal_task(self):
|
||||||
self.task.supplier_out_order_id = f"DYGFBATCHT{self.task.id}"
|
self.task.supplier_out_order_id = f"DYGFBATCHT{self.task.id}"
|
||||||
self.task.result = {"recharge_channel": "supplier_api", "out_order_id": self.task.supplier_out_order_id}
|
self.task.result = {
|
||||||
|
"recharge_channel": "supplier_api",
|
||||||
|
"out_order_id": self.task.supplier_out_order_id,
|
||||||
|
}
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
client = FishFinRechargeClient(FishFinRechargeConfig(
|
client = FishFinRechargeClient(
|
||||||
base_url="https://supplier.example", app_id="test-app", app_secret="test-secret",
|
FishFinRechargeConfig(
|
||||||
))
|
base_url="https://supplier.example",
|
||||||
|
app_id="test-app",
|
||||||
|
app_secret="test-secret",
|
||||||
|
)
|
||||||
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"app_id": "test-app",
|
"app_id": "test-app",
|
||||||
"timestamp": 1786629000,
|
"timestamp": 1786629000,
|
||||||
@@ -235,25 +265,25 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
|
|||||||
|
|
||||||
response = asyncio.run(supplier_recharge_callback(request, self.session))
|
response = asyncio.run(supplier_recharge_callback(request, self.session))
|
||||||
|
|
||||||
self.assertEqual(response["code"], 200)
|
assert response["code"] == 200
|
||||||
self.assertEqual(response["message"], "success")
|
assert response["message"] == "success"
|
||||||
self.assertTrue(client.verify_response_sign(response, "POST"))
|
assert client.verify_response_sign(response, "POST")
|
||||||
self.session.refresh(self.task)
|
self.session.refresh(self.task)
|
||||||
self.assertEqual(self.task.status, "success")
|
assert self.task.status == "success"
|
||||||
self.assertEqual(self.task.message, "供应商直充成功(异步通知)")
|
assert self.task.message == "供应商直充成功(异步通知)"
|
||||||
self.session.refresh(self.account)
|
self.session.refresh(self.account)
|
||||||
self.assertEqual(self.account.bind_status, "gold_recharged")
|
assert self.account.bind_status == "gold_recharged"
|
||||||
self.assertEqual(self.task.result["order_id"], "supplier-001")
|
assert self.task.result["order_id"] == "supplier-001"
|
||||||
self.assertEqual(self.task.result["supplier_order_status"], 2)
|
assert self.task.result["supplier_order_status"] == 2
|
||||||
audit = self.session.query(AuditLog).filter(AuditLog.action == "recharge:douyu:callback").one()
|
audit = (
|
||||||
self.assertEqual(audit.username, "supplier")
|
self.session.query(AuditLog)
|
||||||
self.assertTrue(audit.success)
|
.filter(AuditLog.action == "recharge:douyu:callback")
|
||||||
self.assertNotIn("sign", audit.detail)
|
.one()
|
||||||
|
)
|
||||||
|
assert audit.username == "supplier"
|
||||||
|
assert audit.success
|
||||||
|
assert "sign" not in audit.detail
|
||||||
# 同一终态回调可重复投递,不会改写已结束任务。
|
# 同一终态回调可重复投递,不会改写已结束任务。
|
||||||
asyncio.run(supplier_recharge_callback(request, self.session))
|
asyncio.run(supplier_recharge_callback(request, self.session))
|
||||||
self.session.refresh(self.task)
|
self.session.refresh(self.task)
|
||||||
self.assertEqual(self.task.status, "success")
|
assert self.task.status == "success"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import unittest
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
@@ -6,61 +5,68 @@ from core.douyu.login import DouyuLogin
|
|||||||
from core.douyu.login_api_wgapi import WgapiLoginAPI
|
from core.douyu.login_api_wgapi import WgapiLoginAPI
|
||||||
|
|
||||||
|
|
||||||
class MobileBindSkipTests(unittest.TestCase):
|
class TestMobileBindSkip:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.login = DouyuLogin.__new__(DouyuLogin)
|
self.login = DouyuLogin.__new__(DouyuLogin)
|
||||||
self.login.api = WgapiLoginAPI()
|
self.login.api = WgapiLoginAPI()
|
||||||
self.login.account = SimpleNamespace(username='test-user', password='test-password')
|
self.login.account = SimpleNamespace(
|
||||||
|
username="test-user", password="test-password"
|
||||||
|
)
|
||||||
self.login._request_json = Mock()
|
self.login._request_json = Mock()
|
||||||
|
|
||||||
def test_second_login_skips_mobile_bind_with_unique_key(self):
|
def test_second_login_skips_mobile_bind_with_unique_key(self):
|
||||||
self.login._request_json.side_effect = [
|
self.login._request_json.side_effect = [
|
||||||
{
|
{
|
||||||
'error': 130014,
|
"error": 130014,
|
||||||
'msg': '需要进行手机号绑定',
|
"msg": "需要进行手机号绑定",
|
||||||
'data': {'mobileBind': {'uniqueKey': 'one-time-key'}},
|
"data": {"mobileBind": {"uniqueKey": "one-time-key"}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'error': 0,
|
"error": 0,
|
||||||
'data': {'url': '//www.douyu.com/api/passport/login?code=callback-code'},
|
"data": {
|
||||||
|
"url": "//www.douyu.com/api/passport/login?code=callback-code"
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
next_step, login_url = self.login._second_login(
|
next_step, login_url = self.login._second_login(
|
||||||
'gt', 'challenge', 'validate', 'validate|jordan', 'code-token',
|
"gt",
|
||||||
|
"challenge",
|
||||||
|
"validate",
|
||||||
|
"validate|jordan",
|
||||||
|
"code-token",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(next_step, 'mobile_bind_skip')
|
assert next_step == "mobile_bind_skip"
|
||||||
self.assertEqual(
|
assert (
|
||||||
login_url,
|
login_url == "https://www.douyu.com/api/passport/login?code=callback-code"
|
||||||
'https://www.douyu.com/api/passport/login?code=callback-code',
|
|
||||||
)
|
)
|
||||||
self.assertEqual(self.login._request_json.call_count, 2)
|
assert self.login._request_json.call_count == 2
|
||||||
skip_call = self.login._request_json.call_args_list[1]
|
skip_call = self.login._request_json.call_args_list[1]
|
||||||
self.assertEqual(skip_call.args[:3], (
|
assert skip_call.args[:3] == (
|
||||||
'post',
|
"post",
|
||||||
'https://passport.douyu.com/wgapi/member/passport/login',
|
"https://passport.douyu.com/wgapi/member/passport/login",
|
||||||
'跳过手机号绑定接口',
|
"跳过手机号绑定接口",
|
||||||
))
|
)
|
||||||
self.assertEqual(skip_call.kwargs['data'], {
|
assert skip_call.kwargs["data"] == {
|
||||||
'type': '3',
|
"type": "3",
|
||||||
'uniqueKey': 'one-time-key',
|
"uniqueKey": "one-time-key",
|
||||||
'biz_type': '1',
|
"biz_type": "1",
|
||||||
})
|
}
|
||||||
|
|
||||||
def test_second_login_preserves_remote_email_flow_without_unique_key(self):
|
def test_second_login_preserves_remote_email_flow_without_unique_key(self):
|
||||||
self.login._request_json.return_value = {
|
self.login._request_json.return_value = {
|
||||||
'error': 130014,
|
"error": 130014,
|
||||||
'data': {'remoteLogin': {'code': 'remote-code'}},
|
"data": {"remoteLogin": {"code": "remote-code"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
next_step, remote_code = self.login._second_login(
|
next_step, remote_code = self.login._second_login(
|
||||||
'gt', 'challenge', 'validate', 'validate|jordan', 'code-token',
|
"gt",
|
||||||
|
"challenge",
|
||||||
|
"validate",
|
||||||
|
"validate|jordan",
|
||||||
|
"code-token",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual((next_step, remote_code), ('remote_email', 'remote-code'))
|
assert (next_step, remote_code) == ("remote_email", "remote-code")
|
||||||
self.assertEqual(self.login._request_json.call_count, 1)
|
assert self.login._request_json.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""斗鱼任务代理接入三模式行为测试(静态 / API / 关闭)+ 写读分离。"""
|
"""斗鱼任务代理接入三模式行为测试(静态 / API / 关闭)+ 写读分离。"""
|
||||||
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from core.douyu.activity_client import DouyuActivityClient
|
from core.douyu.activity_client import DouyuActivityClient
|
||||||
from web.backend.services.douyu_runner import DouyuBatchRunner
|
from web.backend.services.douyu_runner import DouyuBatchRunner
|
||||||
from web.backend.services.douyu_runner_core import DouyuBatchRunnerCore, DOUYU_PROXY_TASK_TYPES
|
from web.backend.services.douyu_runner_core import (
|
||||||
|
DouyuBatchRunnerCore,
|
||||||
|
DOUYU_PROXY_TASK_TYPES,
|
||||||
|
)
|
||||||
from web.backend.models import ProxyConfig
|
from web.backend.models import ProxyConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -36,90 +38,104 @@ def _runner(db_cfg: ProxyConfig, task_type: str = "query_points"):
|
|||||||
return runner
|
return runner
|
||||||
|
|
||||||
|
|
||||||
class DouyuProxyModeTests(unittest.TestCase):
|
class TestDouyuProxyMode:
|
||||||
def test_disabled_mode_goes_direct(self):
|
def test_disabled_mode_goes_direct(self):
|
||||||
runner = _runner(_cfg(enabled=False), task_type="exchange_goods")
|
runner = _runner(_cfg(enabled=False), task_type="exchange_goods")
|
||||||
self.assertIsNone(runner._proxies_for_task())
|
assert runner._proxies_for_task() is None
|
||||||
self.assertIsNone(runner._proxy_fetcher)
|
assert runner._proxy_fetcher is None
|
||||||
client = runner._client("acf_uid=100")
|
client = runner._client("acf_uid=100")
|
||||||
self.assertEqual(client.session.proxies, {})
|
assert client.session.proxies == {}
|
||||||
# 未开启代理时 execute 客户端不带 proxies 参数
|
# 未开启代理时 execute 客户端不带 proxies 参数
|
||||||
self.assertIsInstance(client, DouyuActivityClient)
|
assert isinstance(client, DouyuActivityClient)
|
||||||
|
|
||||||
def test_static_mode_uses_configured_proxies_for_write_tasks(self):
|
def test_static_mode_uses_configured_proxies_for_write_tasks(self):
|
||||||
runner = _runner(_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="exchange_goods")
|
runner = _runner(
|
||||||
|
_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="exchange_goods"
|
||||||
|
)
|
||||||
proxies = runner._proxies_for_task()
|
proxies = runner._proxies_for_task()
|
||||||
self.assertEqual(proxies, {"http": "http://10.0.0.1:8080", "https": "http://10.0.0.1:8080"})
|
assert proxies == {
|
||||||
|
"http": "http://10.0.0.1:8080",
|
||||||
|
"https": "http://10.0.0.1:8080",
|
||||||
|
}
|
||||||
client = runner._client("acf_uid=100")
|
client = runner._client("acf_uid=100")
|
||||||
self.assertEqual(client.session.proxies["https"], "http://10.0.0.1:8080")
|
assert client.session.proxies["https"] == "http://10.0.0.1:8080"
|
||||||
# 每次任务都取同一静态代理
|
# 每次任务都取同一静态代理
|
||||||
self.assertEqual(runner._proxies_for_task(), proxies)
|
assert runner._proxies_for_task() == proxies
|
||||||
|
|
||||||
def test_api_mode_fetches_new_proxy_per_write_task(self):
|
def test_api_mode_fetches_new_proxy_per_write_task(self):
|
||||||
cfg = _cfg(enabled=True, api_url="https://proxy-api.example/fetch")
|
cfg = _cfg(enabled=True, api_url="https://proxy-api.example/fetch")
|
||||||
runner = _runner(cfg, task_type="exchange_goods")
|
runner = _runner(cfg, task_type="exchange_goods")
|
||||||
self.assertIsNotNone(runner._proxy_fetcher)
|
assert runner._proxy_fetcher is not None
|
||||||
fake = Mock()
|
fake = Mock()
|
||||||
fake.fetch_new_proxy.return_value = "http://10.0.0.2:3128"
|
fake.fetch_new_proxy.return_value = "http://10.0.0.2:3128"
|
||||||
runner._proxy_fetcher = fake
|
runner._proxy_fetcher = fake
|
||||||
|
|
||||||
proxies = runner._proxies_for_task()
|
proxies = runner._proxies_for_task()
|
||||||
self.assertEqual(proxies, {"http": "http://10.0.0.2:3128", "https": "http://10.0.0.2:3128"})
|
assert proxies == {
|
||||||
|
"http": "http://10.0.0.2:3128",
|
||||||
|
"https": "http://10.0.0.2:3128",
|
||||||
|
}
|
||||||
client = runner._client("acf_uid=100")
|
client = runner._client("acf_uid=100")
|
||||||
self.assertEqual(client.session.proxies["http"], "http://10.0.0.2:3128")
|
assert client.session.proxies["http"] == "http://10.0.0.2:3128"
|
||||||
|
|
||||||
# 每任务取新代理:_client 也会再 fetch 一次(客户端构造即取代理)
|
# 每任务取新代理:_client 也会再 fetch 一次(客户端构造即取代理)
|
||||||
fake.fetch_new_proxy.return_value = "http://10.0.0.3:3128"
|
fake.fetch_new_proxy.return_value = "http://10.0.0.3:3128"
|
||||||
self.assertEqual(runner._proxies_for_task()["http"], "http://10.0.0.3:3128")
|
assert runner._proxies_for_task()["http"] == "http://10.0.0.3:3128"
|
||||||
self.assertEqual(fake.fetch_new_proxy.call_count, 3)
|
assert fake.fetch_new_proxy.call_count == 3
|
||||||
|
|
||||||
def test_api_mode_failure_falls_back_to_direct(self):
|
def test_api_mode_failure_falls_back_to_direct(self):
|
||||||
runner = _runner(_cfg(enabled=True, api_url="https://proxy-api.example/fetch"), task_type="exchange_goods")
|
runner = _runner(
|
||||||
|
_cfg(enabled=True, api_url="https://proxy-api.example/fetch"),
|
||||||
|
task_type="exchange_goods",
|
||||||
|
)
|
||||||
runner._proxy_fetcher = Mock()
|
runner._proxy_fetcher = Mock()
|
||||||
runner._proxy_fetcher.fetch_new_proxy.return_value = None
|
runner._proxy_fetcher.fetch_new_proxy.return_value = None
|
||||||
runner._push_log = Mock()
|
runner._push_log = Mock()
|
||||||
|
|
||||||
self.assertIsNone(runner._proxies_for_task())
|
assert runner._proxies_for_task() is None
|
||||||
client = runner._client("acf_uid=100")
|
client = runner._client("acf_uid=100")
|
||||||
self.assertEqual(client.session.proxies, {})
|
assert client.session.proxies == {}
|
||||||
runner._push_log.assert_called()
|
runner._push_log.assert_called()
|
||||||
|
|
||||||
def test_read_tasks_stay_direct_even_with_proxy_enabled(self):
|
def test_read_tasks_stay_direct_even_with_proxy_enabled(self):
|
||||||
"""写读分离:查询/刷新/绑定等读任务即使代理开启也直连,不消耗代理额度。"""
|
"""写读分离:查询/刷新/绑定等读任务即使代理开启也直连,不消耗代理额度。"""
|
||||||
runner = _runner(_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="query_points")
|
runner = _runner(
|
||||||
self.assertIsNotNone(runner._static_proxies)
|
_cfg(enabled=True, http="http://10.0.0.1:8080"), task_type="query_points"
|
||||||
self.assertIsNone(runner._proxies_for_task())
|
)
|
||||||
|
assert runner._static_proxies is not None
|
||||||
|
assert runner._proxies_for_task() is None
|
||||||
client = runner._client("acf_uid=100")
|
client = runner._client("acf_uid=100")
|
||||||
self.assertEqual(client.session.proxies, {})
|
assert client.session.proxies == {}
|
||||||
|
|
||||||
# API 模式同理:读任务不触发 fetch
|
# API 模式同理:读任务不触发 fetch
|
||||||
runner = _runner(_cfg(enabled=True, api_url="https://proxy-api.example/fetch"), task_type="query_exchange_records")
|
runner = _runner(
|
||||||
|
_cfg(enabled=True, api_url="https://proxy-api.example/fetch"),
|
||||||
|
task_type="query_exchange_records",
|
||||||
|
)
|
||||||
runner._proxy_fetcher = Mock()
|
runner._proxy_fetcher = Mock()
|
||||||
runner._proxy_fetcher.fetch_new_proxy.side_effect = AssertionError("读任务不应取代理")
|
runner._proxy_fetcher.fetch_new_proxy.side_effect = AssertionError(
|
||||||
self.assertIsNone(runner._proxies_for_task())
|
"读任务不应取代理"
|
||||||
|
)
|
||||||
|
assert runner._proxies_for_task() is None
|
||||||
|
|
||||||
def test_proxy_task_types_covers_all_write_tasks(self):
|
def test_proxy_task_types_covers_all_write_tasks(self):
|
||||||
"""白名单集合应覆盖全部会消耗/下单的写操作任务,且不含查询类。"""
|
"""白名单集合应覆盖全部会消耗/下单的写操作任务,且不含查询类。"""
|
||||||
self.assertIn("exchange_goods", DOUYU_PROXY_TASK_TYPES)
|
assert "exchange_goods" in DOUYU_PROXY_TASK_TYPES
|
||||||
self.assertIn("lock_goods", DOUYU_PROXY_TASK_TYPES)
|
assert "lock_goods" in DOUYU_PROXY_TASK_TYPES
|
||||||
self.assertIn("pay_locked_order", DOUYU_PROXY_TASK_TYPES)
|
assert "pay_locked_order" in DOUYU_PROXY_TASK_TYPES
|
||||||
self.assertIn("exchange_esports_goods", DOUYU_PROXY_TASK_TYPES)
|
assert "exchange_esports_goods" in DOUYU_PROXY_TASK_TYPES
|
||||||
self.assertIn("exchange_xpd_goods", DOUYU_PROXY_TASK_TYPES)
|
assert "exchange_xpd_goods" in DOUYU_PROXY_TASK_TYPES
|
||||||
self.assertIn("create_gold_qr", DOUYU_PROXY_TASK_TYPES)
|
assert "create_gold_qr" in DOUYU_PROXY_TASK_TYPES
|
||||||
self.assertIn("donate_elite_gift", DOUYU_PROXY_TASK_TYPES)
|
assert "donate_elite_gift" in DOUYU_PROXY_TASK_TYPES
|
||||||
self.assertNotIn("query_points", DOUYU_PROXY_TASK_TYPES)
|
assert "query_points" not in DOUYU_PROXY_TASK_TYPES
|
||||||
self.assertNotIn("refresh_goods", DOUYU_PROXY_TASK_TYPES)
|
assert "refresh_goods" not in DOUYU_PROXY_TASK_TYPES
|
||||||
self.assertNotIn("get_bind_qr", DOUYU_PROXY_TASK_TYPES)
|
assert "get_bind_qr" not in DOUYU_PROXY_TASK_TYPES
|
||||||
|
|
||||||
def test_activity_client_accepts_proxies_at_session_level(self):
|
def test_activity_client_accepts_proxies_at_session_level(self):
|
||||||
client = DouyuActivityClient(
|
client = DouyuActivityClient(
|
||||||
"acf_uid=100",
|
"acf_uid=100",
|
||||||
proxies={"http": "http://10.0.0.9:7890", "https": "http://10.0.0.9:7890"},
|
proxies={"http": "http://10.0.0.9:7890", "https": "http://10.0.0.9:7890"},
|
||||||
)
|
)
|
||||||
self.assertEqual(client.session.proxies["http"], "http://10.0.0.9:7890")
|
assert client.session.proxies["http"] == "http://10.0.0.9:7890"
|
||||||
# trust_env=False 维持不变:不受环境变量代理影响
|
# trust_env=False 维持不变:不受环境变量代理影响
|
||||||
self.assertFalse(client.session.trust_env)
|
assert not client.session.trust_env
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
import unittest
|
import pytest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from sqlalchemy import create_engine, event
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from web.backend.database import Base
|
from web.backend.database import Base
|
||||||
from web.backend.models import Account, DouyuTask, DouyuWorkbench, DouyuWorkbenchAccount, LoginTask, User
|
from web.backend.models import (
|
||||||
|
Account,
|
||||||
|
DouyuTask,
|
||||||
|
DouyuWorkbench,
|
||||||
|
DouyuWorkbenchAccount,
|
||||||
|
LoginTask,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from web.backend.routers.accounts import delete_account
|
from web.backend.routers.accounts import delete_account
|
||||||
from web.backend.routers.douyu import list_tasks, list_workbench_accounts, update_workbench_accounts
|
from web.backend.routers.douyu import (
|
||||||
|
list_tasks,
|
||||||
|
list_workbench_accounts,
|
||||||
|
update_workbench_accounts,
|
||||||
|
)
|
||||||
from web.backend.schemas import DouyuWorkbenchAccountsUpdate
|
from web.backend.schemas import DouyuWorkbenchAccountsUpdate
|
||||||
from web.backend.services.douyu_service import create_douyu_planned_tasks
|
from web.backend.services.douyu_service import create_douyu_planned_tasks
|
||||||
|
|
||||||
|
|
||||||
class DouyuWorkbenchScopeTests(unittest.TestCase):
|
class TestDouyuWorkbenchScope:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.engine = create_engine("sqlite://")
|
self.engine = create_engine("sqlite://")
|
||||||
|
|
||||||
@event.listens_for(self.engine, "connect")
|
@event.listens_for(self.engine, "connect")
|
||||||
@@ -25,65 +36,113 @@ class DouyuWorkbenchScopeTests(unittest.TestCase):
|
|||||||
self.user = User(username="operator", password_hash="hash", role="super_admin")
|
self.user = User(username="operator", password_hash="hash", role="super_admin")
|
||||||
self.session.add(self.user)
|
self.session.add(self.user)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.account = Account(username="douyu-user", password="password", email="mail@example.com", email_password="mail-password")
|
self.account = Account(
|
||||||
|
username="douyu-user",
|
||||||
|
password="password",
|
||||||
|
email="mail@example.com",
|
||||||
|
email_password="mail-password",
|
||||||
|
)
|
||||||
self.session.add(self.account)
|
self.session.add(self.account)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.session.add(LoginTask(batch_id="login", account_id=self.account.id, created_by=self.user.id, status="success", cookie="acf_uid=1"))
|
self.session.add(
|
||||||
|
LoginTask(
|
||||||
|
batch_id="login",
|
||||||
|
account_id=self.account.id,
|
||||||
|
created_by=self.user.id,
|
||||||
|
status="success",
|
||||||
|
cookie="acf_uid=1",
|
||||||
|
)
|
||||||
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.current = SimpleNamespace(id=self.user.id, username=self.user.username, role=self.user.role, custom_permissions=None)
|
self.current = SimpleNamespace(
|
||||||
|
id=self.user.id,
|
||||||
|
username=self.user.username,
|
||||||
|
role=self.user.role,
|
||||||
|
custom_permissions=None,
|
||||||
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def teardown_method(self):
|
||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(self.engine)
|
Base.metadata.drop_all(self.engine)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
def test_workbench_accounts_are_per_user_and_scope(self):
|
def test_workbench_accounts_are_per_user_and_scope(self):
|
||||||
update_workbench_accounts(
|
update_workbench_accounts(
|
||||||
DouyuWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[self.account.id]),
|
DouyuWorkbenchAccountsUpdate(
|
||||||
|
handbook_scope="elite", account_ids=[self.account.id]
|
||||||
|
),
|
||||||
db=self.session,
|
db=self.session,
|
||||||
current=self.current,
|
current=self.current,
|
||||||
)
|
)
|
||||||
elite = list_workbench_accounts("elite", db=self.session, current=self.current)
|
elite = list_workbench_accounts("elite", db=self.session, current=self.current)
|
||||||
esports = list_workbench_accounts("esports", db=self.session, current=self.current)
|
esports = list_workbench_accounts(
|
||||||
self.assertEqual(elite["account_ids"], [self.account.id])
|
"esports", db=self.session, current=self.current
|
||||||
self.assertEqual(esports["account_ids"], [])
|
)
|
||||||
self.assertTrue(elite["configured"])
|
assert elite["account_ids"] == [self.account.id]
|
||||||
self.assertFalse(esports["configured"])
|
assert esports["account_ids"] == []
|
||||||
self.assertEqual(self.session.query(DouyuWorkbenchAccount).count(), 1)
|
assert elite["configured"]
|
||||||
|
assert not esports["configured"]
|
||||||
|
assert self.session.query(DouyuWorkbenchAccount).count() == 1
|
||||||
|
|
||||||
update_workbench_accounts(
|
update_workbench_accounts(
|
||||||
DouyuWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[]),
|
DouyuWorkbenchAccountsUpdate(handbook_scope="elite", account_ids=[]),
|
||||||
db=self.session,
|
db=self.session,
|
||||||
current=self.current,
|
current=self.current,
|
||||||
)
|
)
|
||||||
empty_elite = list_workbench_accounts("elite", db=self.session, current=self.current)
|
empty_elite = list_workbench_accounts(
|
||||||
self.assertEqual(empty_elite["account_ids"], [])
|
"elite", db=self.session, current=self.current
|
||||||
self.assertTrue(empty_elite["configured"])
|
)
|
||||||
self.assertEqual(self.session.query(DouyuWorkbench).count(), 1)
|
assert empty_elite["account_ids"] == []
|
||||||
|
assert empty_elite["configured"]
|
||||||
|
assert self.session.query(DouyuWorkbench).count() == 1
|
||||||
|
|
||||||
def test_task_scope_is_persisted_and_filters_shared_task_types(self):
|
def test_task_scope_is_persisted_and_filters_shared_task_types(self):
|
||||||
elite_batch, _ = create_douyu_planned_tasks(
|
elite_batch, _ = create_douyu_planned_tasks(
|
||||||
self.session, [self.account.id], "create_gold_qr", "elite", self.user.id,
|
self.session,
|
||||||
|
[self.account.id],
|
||||||
|
"create_gold_qr",
|
||||||
|
"elite",
|
||||||
|
self.user.id,
|
||||||
)
|
)
|
||||||
esports_batch, _ = create_douyu_planned_tasks(
|
esports_batch, _ = create_douyu_planned_tasks(
|
||||||
self.session, [self.account.id], "create_gold_qr", "esports", self.user.id,
|
self.session,
|
||||||
|
[self.account.id],
|
||||||
|
"create_gold_qr",
|
||||||
|
"esports",
|
||||||
|
self.user.id,
|
||||||
)
|
)
|
||||||
elite = list_tasks(
|
elite = list_tasks(
|
||||||
batch_id=None, handbook_scope="elite", include_detail=False, page=None, page_size=100,
|
batch_id=None,
|
||||||
db=self.session, current=self.current,
|
handbook_scope="elite",
|
||||||
|
include_detail=False,
|
||||||
|
page=None,
|
||||||
|
page_size=100,
|
||||||
|
db=self.session,
|
||||||
|
current=self.current,
|
||||||
)
|
)
|
||||||
esports = list_tasks(
|
esports = list_tasks(
|
||||||
batch_id=None, handbook_scope="esports", include_detail=False, page=None, page_size=100,
|
batch_id=None,
|
||||||
db=self.session, current=self.current,
|
handbook_scope="esports",
|
||||||
|
include_detail=False,
|
||||||
|
page=None,
|
||||||
|
page_size=100,
|
||||||
|
db=self.session,
|
||||||
|
current=self.current,
|
||||||
)
|
)
|
||||||
self.assertEqual({item.batch_id for item in elite}, {elite_batch})
|
assert {item.batch_id for item in elite} == {elite_batch}
|
||||||
self.assertEqual({item.batch_id for item in esports}, {esports_batch})
|
assert {item.batch_id for item in esports} == {esports_batch}
|
||||||
self.assertEqual({task.handbook_scope for task in self.session.query(DouyuTask).all()}, {"elite", "esports"})
|
assert {
|
||||||
|
task.handbook_scope for task in self.session.query(DouyuTask).all()
|
||||||
|
} == {"elite", "esports"}
|
||||||
|
|
||||||
def test_rejects_task_from_wrong_workbench(self):
|
def test_rejects_task_from_wrong_workbench(self):
|
||||||
with self.assertRaisesRegex(ValueError, "不属于当前工作台"):
|
with pytest.raises(ValueError, match="不属于当前工作台"):
|
||||||
create_douyu_planned_tasks(
|
create_douyu_planned_tasks(
|
||||||
self.session, [self.account.id], "get_xpd_bind_qr", "elite", self.user.id,
|
self.session,
|
||||||
|
[self.account.id],
|
||||||
|
"get_xpd_bind_qr",
|
||||||
|
"elite",
|
||||||
|
self.user.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_elite_workbench_accepts_lock_goods_task(self):
|
def test_elite_workbench_accepts_lock_goods_task(self):
|
||||||
@@ -96,10 +155,12 @@ class DouyuWorkbenchScopeTests(unittest.TestCase):
|
|||||||
{"commodity_id": "goods-1"},
|
{"commodity_id": "goods-1"},
|
||||||
)
|
)
|
||||||
|
|
||||||
task = self.session.query(DouyuTask).filter(DouyuTask.batch_id == batch_id).one()
|
task = (
|
||||||
self.assertEqual(count, 1)
|
self.session.query(DouyuTask).filter(DouyuTask.batch_id == batch_id).one()
|
||||||
self.assertEqual(task.task_type, "lock_goods")
|
)
|
||||||
self.assertEqual(task.result, {"payload": {"commodity_id": "goods-1"}})
|
assert count == 1
|
||||||
|
assert task.task_type == "lock_goods"
|
||||||
|
assert task.result == {"payload": {"commodity_id": "goods-1"}}
|
||||||
|
|
||||||
_, pay_count = create_douyu_planned_tasks(
|
_, pay_count = create_douyu_planned_tasks(
|
||||||
self.session,
|
self.session,
|
||||||
@@ -109,20 +170,18 @@ class DouyuWorkbenchScopeTests(unittest.TestCase):
|
|||||||
self.user.id,
|
self.user.id,
|
||||||
{"order_id": "5874", "commodity_id": "goods-1"},
|
{"order_id": "5874", "commodity_id": "goods-1"},
|
||||||
)
|
)
|
||||||
self.assertEqual(pay_count, 1)
|
assert pay_count == 1
|
||||||
|
|
||||||
def test_account_deletion_removes_workbench_membership(self):
|
def test_account_deletion_removes_workbench_membership(self):
|
||||||
self.session.add(DouyuWorkbenchAccount(
|
self.session.add(
|
||||||
|
DouyuWorkbenchAccount(
|
||||||
user_id=self.user.id,
|
user_id=self.user.id,
|
||||||
handbook_scope="elite",
|
handbook_scope="elite",
|
||||||
account_id=self.account.id,
|
account_id=self.account.id,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
delete_account(self.account.id, db=self.session, current=self.current)
|
delete_account(self.account.id, db=self.session, current=self.current)
|
||||||
|
|
||||||
self.assertEqual(self.session.query(DouyuWorkbenchAccount).count(), 0)
|
assert self.session.query(DouyuWorkbenchAccount).count() == 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
+178
-127
@@ -1,202 +1,253 @@
|
|||||||
|
import pytest
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import unittest
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
|
from core.douyu.activity_client import DouyuActivityClient, DouyuActivityError
|
||||||
|
|
||||||
|
|
||||||
class XpdExchangeTests(unittest.TestCase):
|
class TestXpdExchange:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.client = DouyuActivityClient(
|
self.client = DouyuActivityClient(
|
||||||
'acf_uid=100; acf_stk=stk; acf_ltkid=ltkid',
|
"acf_uid=100; acf_stk=stk; acf_ltkid=ltkid",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_exchange_uses_fresh_embed_authorization_and_point_price(self):
|
def test_exchange_uses_fresh_embed_authorization_and_point_price(self):
|
||||||
self.client._request = Mock(return_value=SimpleNamespace(
|
self.client._request = Mock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
text='var buyInfo={"ret":"0","msg":"ok","serial":"order-1","newBalance":88};',
|
text='var buyInfo={"ret":"0","msg":"ok","serial":"order-1","newBalance":88};',
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
result = self.client.xpd_exchange_goods(
|
result = self.client.xpd_exchange_goods(
|
||||||
embed_query={
|
embed_query={
|
||||||
'gameId': 'cjm', 'actId': '46195', 'appId': 'bp_cf',
|
"gameId": "cjm",
|
||||||
'livePlatId': 'douyu', 'code': 'fresh-code',
|
"actId": "46195",
|
||||||
'timestamp': '123', 'v': '2.0', 'sig': 'fresh-signature',
|
"appId": "bp_cf",
|
||||||
|
"livePlatId": "douyu",
|
||||||
|
"code": "fresh-code",
|
||||||
|
"timestamp": "123",
|
||||||
|
"v": "2.0",
|
||||||
|
"sig": "fresh-signature",
|
||||||
},
|
},
|
||||||
act_id='46195',
|
act_id="46195",
|
||||||
rid='9263298',
|
rid="9263298",
|
||||||
commodity_id='goods-1',
|
commodity_id="goods-1",
|
||||||
price=1200,
|
price=1200,
|
||||||
picture='https://cdn.example/item.jpg',
|
picture="https://cdn.example/item.jpg",
|
||||||
pay_type=1,
|
pay_type=1,
|
||||||
action_id='46195',
|
action_id="46195",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result['serial'], 'order-1')
|
assert result["serial"] == "order-1"
|
||||||
self.assertEqual(result['new_balance'], 88)
|
assert result["new_balance"] == 88
|
||||||
call = self.client._request.call_args
|
call = self.client._request.call_args
|
||||||
self.assertEqual(call.args[:2], ('get', self.client.XPD_LIVE_BUY_API))
|
assert call.args[:2] == ("get", self.client.XPD_LIVE_BUY_API)
|
||||||
params = call.kwargs['params']
|
params = call.kwargs["params"]
|
||||||
self.assertEqual(params['code'], 'fresh-code')
|
assert params["code"] == "fresh-code"
|
||||||
self.assertEqual(params['sig'], 'fresh-signature')
|
assert params["sig"] == "fresh-signature"
|
||||||
self.assertEqual(params['propid'], 'goods-1')
|
assert params["propid"] == "goods-1"
|
||||||
self.assertEqual(params['paytype'], '1')
|
assert params["paytype"] == "1"
|
||||||
self.assertEqual(json.loads(params['appext']), {
|
assert json.loads(params["appext"]) == {
|
||||||
'user_price': 1200,
|
"user_price": 1200,
|
||||||
'sGoodsPic': 'https://cdn.example/item.jpg',
|
"sGoodsPic": "https://cdn.example/item.jpg",
|
||||||
'paytype': 1,
|
"paytype": 1,
|
||||||
})
|
}
|
||||||
|
|
||||||
def test_role_and_balance_preserve_ios_platform_zero(self):
|
def test_role_and_balance_preserve_ios_platform_zero(self):
|
||||||
self.client._request = Mock(return_value=SimpleNamespace(
|
self.client._request = Mock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
text=(
|
text=(
|
||||||
'var info={"gameOpenId":"openid-ios","roleId":"role-ios",'
|
'var info={"gameOpenId":"openid-ios","roleId":"role-ios",'
|
||||||
'"roleName":"iOS角色","type":"wx","platId":0,"area":1};'
|
'"roleName":"iOS角色","type":"wx","platId":0,"area":1};'
|
||||||
),
|
),
|
||||||
))
|
|
||||||
role = self.client.xpd_get_role(embed_query={}, act_id='46195', rid='9263298')
|
|
||||||
self.assertEqual(role['plat_id'], '0')
|
|
||||||
self.assertEqual(role['area'], '1')
|
|
||||||
|
|
||||||
self.client._request = Mock(return_value=SimpleNamespace(
|
|
||||||
text='var banlanceInfo={"ret":0,"balance":2199};',
|
|
||||||
))
|
|
||||||
result = self.client.xpd_balance(
|
|
||||||
embed_query={}, act_id='46195', openid='openid-ios',
|
|
||||||
roleid='role-ios', plat='0', areaid='1',
|
|
||||||
)
|
)
|
||||||
params = self.client._request.call_args.kwargs['params']
|
)
|
||||||
self.assertEqual(params['plat'], '0')
|
role = self.client.xpd_get_role(embed_query={}, act_id="46195", rid="9263298")
|
||||||
self.assertTrue(re.fullmatch(r'\d{13}', params['_time']))
|
assert role["plat_id"] == "0"
|
||||||
self.assertEqual(result['balance'], 2199)
|
assert role["area"] == "1"
|
||||||
|
|
||||||
|
self.client._request = Mock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
text='var banlanceInfo={"ret":0,"balance":2199};',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = self.client.xpd_balance(
|
||||||
|
embed_query={},
|
||||||
|
act_id="46195",
|
||||||
|
openid="openid-ios",
|
||||||
|
roleid="role-ios",
|
||||||
|
plat="0",
|
||||||
|
areaid="1",
|
||||||
|
)
|
||||||
|
params = self.client._request.call_args.kwargs["params"]
|
||||||
|
assert params["plat"] == "0"
|
||||||
|
assert re.fullmatch(r"\d{13}", params["_time"])
|
||||||
|
assert result["balance"] == 2199
|
||||||
|
|
||||||
def test_exchange_surfaces_daoju_failure_message(self):
|
def test_exchange_surfaces_daoju_failure_message(self):
|
||||||
self.client._request = Mock(return_value=SimpleNamespace(
|
self.client._request = Mock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
text='var buyInfo={"ret":"-1","msg":"点券不足"};',
|
text='var buyInfo={"ret":"-1","msg":"点券不足"};',
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
with self.assertRaisesRegex(DouyuActivityError, '点券不足'):
|
with pytest.raises(DouyuActivityError, match="点券不足"):
|
||||||
self.client.xpd_exchange_goods(
|
self.client.xpd_exchange_goods(
|
||||||
embed_query={'code': 'fresh-code'},
|
embed_query={"code": "fresh-code"},
|
||||||
act_id='46195',
|
act_id="46195",
|
||||||
rid='9263298',
|
rid="9263298",
|
||||||
commodity_id='goods-1',
|
commodity_id="goods-1",
|
||||||
price=1200,
|
price=1200,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_list_goods_requests_and_preserves_inventory(self):
|
def test_list_goods_requests_and_preserves_inventory(self):
|
||||||
self.client._request = Mock(return_value=SimpleNamespace(
|
self.client._request = Mock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
text=(
|
text=(
|
||||||
'var recommend={"data":{"client_data":{"itemsdetail":[{'
|
'var recommend={"data":{"client_data":{"itemsdetail":[{'
|
||||||
'"iGoodsId":"goods-1","sGoodsName":"测试商品","iPrice":"6800",'
|
'"iGoodsId":"goods-1","sGoodsName":"测试商品","iPrice":"6800",'
|
||||||
'"iJb2Price":"0","iGoodsLeft":"0"}]}}};'
|
'"iJb2Price":"0","iGoodsLeft":"0"}]}}};'
|
||||||
),
|
),
|
||||||
))
|
)
|
||||||
|
|
||||||
result = self.client.xpd_list_goods(
|
|
||||||
embed_query={}, act_id='46195', openid='openid', roleid='roleid',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result['goods'][0]['price'], 6800)
|
result = self.client.xpd_list_goods(
|
||||||
self.assertEqual(result['goods'][0]['goods_left'], 0)
|
embed_query={},
|
||||||
params = self.client._request.call_args.kwargs['params']
|
act_id="46195",
|
||||||
self.assertEqual(params['excludeFields'], 'iCategoryId_76')
|
openid="openid",
|
||||||
self.assertNotIn('category', params)
|
roleid="roleid",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["goods"][0]["price"] == 6800
|
||||||
|
assert result["goods"][0]["goods_left"] == 0
|
||||||
|
params = self.client._request.call_args.kwargs["params"]
|
||||||
|
assert params["excludeFields"] == "iCategoryId_76"
|
||||||
|
assert "category" not in params
|
||||||
|
|
||||||
def test_list_goods_fetches_every_page_and_deduplicates_goods(self):
|
def test_list_goods_fetches_every_page_and_deduplicates_goods(self):
|
||||||
first_page = [
|
first_page = [
|
||||||
{'iGoodsId': f'goods-{index}', 'sGoodsName': f'商品 {index}'}
|
{"iGoodsId": f"goods-{index}", "sGoodsName": f"商品 {index}"}
|
||||||
for index in range(10)
|
for index in range(10)
|
||||||
]
|
]
|
||||||
second_page = [
|
second_page = [
|
||||||
{'iGoodsId': 'goods-9', 'sGoodsName': '重复商品'},
|
{"iGoodsId": "goods-9", "sGoodsName": "重复商品"},
|
||||||
{'iGoodsId': 'goods-10', 'sGoodsName': '商品 10'},
|
{"iGoodsId": "goods-10", "sGoodsName": "商品 10"},
|
||||||
|
]
|
||||||
|
self.client._request = Mock(
|
||||||
|
side_effect=[
|
||||||
|
SimpleNamespace(
|
||||||
|
text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(first_page)}}}}}}};'
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(second_page)}}}}}}};'
|
||||||
|
),
|
||||||
]
|
]
|
||||||
self.client._request = Mock(side_effect=[
|
|
||||||
SimpleNamespace(text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(first_page)}}}}}}};'),
|
|
||||||
SimpleNamespace(text=f'var recommend={{"errcode":0,"data":{{"client_data":{{"itemsdetail":{json.dumps(second_page)}}}}}}};'),
|
|
||||||
])
|
|
||||||
|
|
||||||
result = self.client.xpd_list_goods(
|
|
||||||
embed_query={}, act_id='46195', openid='openid', roleid='roleid',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(len(result['goods']), 11)
|
result = self.client.xpd_list_goods(
|
||||||
self.assertEqual([item['commodity_id'] for item in result['goods']][-1], 'goods-10')
|
embed_query={},
|
||||||
self.assertEqual(self.client._request.call_count, 2)
|
act_id="46195",
|
||||||
first_params = self.client._request.call_args_list[0].kwargs['params']
|
openid="openid",
|
||||||
second_params = self.client._request.call_args_list[1].kwargs['params']
|
roleid="roleid",
|
||||||
self.assertEqual((first_params['page_begin'], first_params['page_num']), ('0', '1'))
|
)
|
||||||
self.assertEqual((second_params['page_begin'], second_params['page_num']), ('10', '2'))
|
|
||||||
|
assert len(result["goods"]) == 11
|
||||||
|
assert [item["commodity_id"] for item in result["goods"]][-1] == "goods-10"
|
||||||
|
assert self.client._request.call_count == 2
|
||||||
|
first_params = self.client._request.call_args_list[0].kwargs["params"]
|
||||||
|
second_params = self.client._request.call_args_list[1].kwargs["params"]
|
||||||
|
assert (first_params["page_begin"], first_params["page_num"]) == ("0", "1")
|
||||||
|
assert (second_params["page_begin"], second_params["page_num"]) == ("10", "2")
|
||||||
|
|
||||||
def test_purchase_records_parses_order_list_and_paginates(self):
|
def test_purchase_records_parses_order_list_and_paginates(self):
|
||||||
first_record = {
|
first_record = {
|
||||||
'sSerialNum': 'order-1',
|
"sSerialNum": "order-1",
|
||||||
'dtBuyTime': '2026-08-08 01:00:42',
|
"dtBuyTime": "2026-08-08 01:00:42",
|
||||||
'dtPayTime': '2026-08-08 01:00:55',
|
"dtPayTime": "2026-08-08 01:00:55",
|
||||||
'iStatus': '3',
|
"iStatus": "3",
|
||||||
'iPrice': '800',
|
"iPrice": "800",
|
||||||
'iPayAmount': '800',
|
"iPayAmount": "800",
|
||||||
'sRoleName': '测试角色',
|
"sRoleName": "测试角色",
|
||||||
'sGoodsInfo': json.dumps({
|
"sGoodsInfo": json.dumps(
|
||||||
'list': [{'sGoodsName': '改名卡', 'sGoodsPic': 'https://cdn.example/item.jpg'}],
|
{
|
||||||
}),
|
"list": [
|
||||||
|
{
|
||||||
|
"sGoodsName": "改名卡",
|
||||||
|
"sGoodsPic": "https://cdn.example/item.jpg",
|
||||||
}
|
}
|
||||||
self.client._request = Mock(side_effect=[
|
],
|
||||||
SimpleNamespace(text=f'var info={{"ret":"0","msg":"ok","data":{{"cnt":11,"list":{json.dumps([first_record])}}}}};'),
|
}
|
||||||
SimpleNamespace(text='var info={"ret":"0","msg":"ok","data":{"cnt":11,"list":[]}};'),
|
),
|
||||||
])
|
}
|
||||||
|
self.client._request = Mock(
|
||||||
|
side_effect=[
|
||||||
|
SimpleNamespace(
|
||||||
|
text=f'var info={{"ret":"0","msg":"ok","data":{{"cnt":11,"list":{json.dumps([first_record])}}}}};'
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
text='var info={"ret":"0","msg":"ok","data":{"cnt":11,"list":[]}};'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
result = self.client.xpd_purchase_records(
|
result = self.client.xpd_purchase_records(
|
||||||
embed_query={
|
embed_query={
|
||||||
'gameId': 'cjm', 'actId': '18882', 'appId': 'bp_cf',
|
"gameId": "cjm",
|
||||||
'livePlatId': 'douyu', 'code': 'fresh-code',
|
"actId": "18882",
|
||||||
'timestamp': '123', 'v': '2.0', 'sig': 'fresh-signature',
|
"appId": "bp_cf",
|
||||||
|
"livePlatId": "douyu",
|
||||||
|
"code": "fresh-code",
|
||||||
|
"timestamp": "123",
|
||||||
|
"v": "2.0",
|
||||||
|
"sig": "fresh-signature",
|
||||||
},
|
},
|
||||||
act_id='46195',
|
act_id="46195",
|
||||||
page_size=1,
|
page_size=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result['total'], 11)
|
assert result["total"] == 11
|
||||||
self.assertEqual(result['records'][0]['goods_name'], '改名卡')
|
assert result["records"][0]["goods_name"] == "改名卡"
|
||||||
self.assertEqual(result['records'][0]['serial'], 'order-1')
|
assert result["records"][0]["serial"] == "order-1"
|
||||||
self.assertEqual(self.client._request.call_count, 2)
|
assert self.client._request.call_count == 2
|
||||||
params = self.client._request.call_args_list[0].kwargs['params']
|
params = self.client._request.call_args_list[0].kwargs["params"]
|
||||||
self.assertEqual(params['_service'], 'order.list')
|
assert params["_service"] == "order.list"
|
||||||
self.assertEqual(params['pn'], '1')
|
assert params["pn"] == "1"
|
||||||
self.assertEqual(params['code'], 'fresh-code')
|
assert params["code"] == "fresh-code"
|
||||||
|
|
||||||
|
|
||||||
def test_exchange_keeps_raw_price_from_goods_list(self):
|
def test_exchange_keeps_raw_price_from_goods_list(self):
|
||||||
self.client._request = Mock(side_effect=[
|
self.client._request = Mock(
|
||||||
|
side_effect=[
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
text='var recommend={"errcode":0,"data":{"client_data":{"itemsdetail":[{'
|
text='var recommend={"errcode":0,"data":{"client_data":{"itemsdetail":[{'
|
||||||
'"iGoodsId":"goods-1","sGoodsName":"测试商品","iPrice":"6800",'
|
'"iGoodsId":"goods-1","sGoodsName":"测试商品","iPrice":"6800",'
|
||||||
'"iJb2Price":"0","iGoodsLeft":"-1"}]}}};',
|
'"iJb2Price":"0","iGoodsLeft":"-1"}]}}};',
|
||||||
),
|
),
|
||||||
SimpleNamespace(text='var buyInfo={"ret":"0","msg":"ok","serial":"order-1","newBalance":88};'),
|
SimpleNamespace(
|
||||||
])
|
text='var buyInfo={"ret":"0","msg":"ok","serial":"order-1","newBalance":88};'
|
||||||
|
),
|
||||||
goods = self.client.xpd_list_goods(
|
]
|
||||||
embed_query={}, act_id='46195', openid='openid', roleid='roleid',
|
|
||||||
)['goods']
|
|
||||||
result = self.client.xpd_exchange_goods(
|
|
||||||
embed_query={'code': 'fresh-code'},
|
|
||||||
act_id='46195',
|
|
||||||
rid='9263298',
|
|
||||||
commodity_id=goods[0]['commodity_id'],
|
|
||||||
price=goods[0]['price'],
|
|
||||||
picture='',
|
|
||||||
pay_type=1,
|
|
||||||
action_id='46195',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(goods[0]['price'], 6800)
|
goods = self.client.xpd_list_goods(
|
||||||
self.assertEqual(result['serial'], 'order-1')
|
embed_query={},
|
||||||
params = self.client._request.call_args.kwargs['params']
|
act_id="46195",
|
||||||
self.assertEqual(json.loads(params['appext'])['user_price'], 6800)
|
openid="openid",
|
||||||
|
roleid="roleid",
|
||||||
|
)["goods"]
|
||||||
|
result = self.client.xpd_exchange_goods(
|
||||||
|
embed_query={"code": "fresh-code"},
|
||||||
|
act_id="46195",
|
||||||
|
rid="9263298",
|
||||||
|
commodity_id=goods[0]["commodity_id"],
|
||||||
|
price=goods[0]["price"],
|
||||||
|
picture="",
|
||||||
|
pay_type=1,
|
||||||
|
action_id="46195",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goods[0]["price"] == 6800
|
||||||
if __name__ == '__main__':
|
assert result["serial"] == "order-1"
|
||||||
unittest.main()
|
params = self.client._request.call_args.kwargs["params"]
|
||||||
|
assert json.loads(params["appext"])["user_price"] == 6800
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""鱼翅直充供应商 API 客户端测试。"""
|
"""鱼翅直充供应商 API 客户端测试。"""
|
||||||
|
|
||||||
import unittest
|
import pytest
|
||||||
|
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from core.douyu.recharge_api import (
|
from core.douyu.recharge_api import (
|
||||||
@@ -10,8 +11,8 @@ from core.douyu.recharge_api import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FishFinRechargeClientTests(unittest.TestCase):
|
class TestFishFinRechargeClient:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.config = FishFinRechargeConfig(
|
self.config = FishFinRechargeConfig(
|
||||||
base_url="https://supplier.example/",
|
base_url="https://supplier.example/",
|
||||||
app_id="15945681",
|
app_id="15945681",
|
||||||
@@ -21,7 +22,8 @@ class FishFinRechargeClientTests(unittest.TestCase):
|
|||||||
self.client = FishFinRechargeClient(self.config, session=self.session)
|
self.client = FishFinRechargeClient(self.config, session=self.session)
|
||||||
|
|
||||||
def test_sign_matches_document_example(self):
|
def test_sign_matches_document_example(self):
|
||||||
sign = self.client.sign({
|
sign = self.client.sign(
|
||||||
|
{
|
||||||
"app_id": "15945681 ",
|
"app_id": "15945681 ",
|
||||||
"timestamp": 1740916504,
|
"timestamp": 1740916504,
|
||||||
"charge_account": "18888888888",
|
"charge_account": "18888888888",
|
||||||
@@ -31,8 +33,10 @@ class FishFinRechargeClientTests(unittest.TestCase):
|
|||||||
"product_id": "66668888",
|
"product_id": "66668888",
|
||||||
"empty": " \n",
|
"empty": " \n",
|
||||||
"sign": "ignored",
|
"sign": "ignored",
|
||||||
}, "POST")
|
},
|
||||||
self.assertEqual(sign, "4087a959f3488ecb13efe6ef58e3bc67")
|
"POST",
|
||||||
|
)
|
||||||
|
assert sign == "4087a959f3488ecb13efe6ef58e3bc67"
|
||||||
|
|
||||||
def test_create_order_posts_new_document_payload(self):
|
def test_create_order_posts_new_document_payload(self):
|
||||||
response = Mock()
|
response = Mock()
|
||||||
@@ -48,40 +52,52 @@ class FishFinRechargeClientTests(unittest.TestCase):
|
|||||||
ext_arg={"skuid": 12},
|
ext_arg={"skuid": 12},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["code"], 200)
|
assert result["code"] == 200
|
||||||
kwargs = self.session.post.call_args.kwargs
|
kwargs = self.session.post.call_args.kwargs
|
||||||
self.assertEqual(self.session.post.call_args.args[0], "https://supplier.example/adapter-apiaccess/open/api/createOrderV2")
|
assert (
|
||||||
self.assertEqual(kwargs["json"]["pay_amount"], 1.2)
|
self.session.post.call_args.args[0]
|
||||||
self.assertEqual(kwargs["json"]["out_order_id"], "OUT202503010010208888")
|
== "https://supplier.example/adapter-apiaccess/open/api/createOrderV2"
|
||||||
self.assertEqual(kwargs["json"]["order_type"], 0)
|
)
|
||||||
self.assertEqual(kwargs["json"]["recharge_arg"], '[{"templateName":"斗鱼账号","templateVal":"10001"}]')
|
assert kwargs["json"]["pay_amount"] == 1.2
|
||||||
self.assertEqual(kwargs["json"]["ext_arg"], '{"skuid":12}')
|
assert kwargs["json"]["out_order_id"] == "OUT202503010010208888"
|
||||||
self.assertNotIn("charge_account", kwargs["json"])
|
assert kwargs["json"]["order_type"] == 0
|
||||||
self.assertNotIn("customer_price", kwargs["json"])
|
assert (
|
||||||
self.assertNotIn("customer_order_no", kwargs["json"])
|
kwargs["json"]["recharge_arg"]
|
||||||
self.assertEqual(kwargs["json"]["sign"], self.client.sign(kwargs["json"], "POST"))
|
== '[{"templateName":"斗鱼账号","templateVal":"10001"}]'
|
||||||
self.assertEqual(kwargs["timeout"], (8, 20))
|
)
|
||||||
|
assert kwargs["json"]["ext_arg"] == '{"skuid":12}'
|
||||||
|
assert "charge_account" not in kwargs["json"]
|
||||||
|
assert "customer_price" not in kwargs["json"]
|
||||||
|
assert "customer_order_no" not in kwargs["json"]
|
||||||
|
assert kwargs["json"]["sign"] == self.client.sign(kwargs["json"], "POST")
|
||||||
|
assert kwargs["timeout"] == (8, 20)
|
||||||
|
|
||||||
def test_create_order_omits_empty_optional_fields_and_serializes_integer_price(self):
|
def test_create_order_omits_empty_optional_fields_and_serializes_integer_price(
|
||||||
|
self,
|
||||||
|
):
|
||||||
response = Mock()
|
response = Mock()
|
||||||
response.json.return_value = {"code": 200}
|
response.json.return_value = {"code": 200}
|
||||||
self.session.post.return_value = response
|
self.session.post.return_value = response
|
||||||
|
|
||||||
self.client.create_order(
|
self.client.create_order(
|
||||||
buy_num=1, pay_amount="1.0",
|
buy_num=1,
|
||||||
out_order_id="merchant-001", product_id="111570",
|
pay_amount="1.0",
|
||||||
|
out_order_id="merchant-001",
|
||||||
|
product_id="111570",
|
||||||
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
|
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
|
||||||
)
|
)
|
||||||
|
|
||||||
body = self.session.post.call_args.kwargs["json"]
|
body = self.session.post.call_args.kwargs["json"]
|
||||||
self.assertEqual(body["pay_amount"], 1)
|
assert body["pay_amount"] == 1
|
||||||
self.assertNotIn("notify_url", body)
|
assert "notify_url" not in body
|
||||||
self.assertEqual(body["recharge_arg"], '[{"templateName":"斗鱼UID","templateVal":"10001"}]')
|
assert (
|
||||||
self.assertNotIn("ext_arg", body)
|
body["recharge_arg"] == '[{"templateName":"斗鱼UID","templateVal":"10001"}]'
|
||||||
self.assertEqual(body["sign"], self.client.sign(body, "POST"))
|
)
|
||||||
|
assert "ext_arg" not in body
|
||||||
|
assert body["sign"] == self.client.sign(body, "POST")
|
||||||
|
|
||||||
def test_query_order_requires_identifier_and_uses_get_params(self):
|
def test_query_order_requires_identifier_and_uses_get_params(self):
|
||||||
with self.assertRaisesRegex(ValueError, "out_order_id"):
|
with pytest.raises(ValueError, match="out_order_id"):
|
||||||
self.client.query_order("")
|
self.client.query_order("")
|
||||||
|
|
||||||
response = Mock()
|
response = Mock()
|
||||||
@@ -90,13 +106,18 @@ class FishFinRechargeClientTests(unittest.TestCase):
|
|||||||
self.client.query_order("OUT202503010010208888")
|
self.client.query_order("OUT202503010010208888")
|
||||||
|
|
||||||
kwargs = self.session.get.call_args.kwargs
|
kwargs = self.session.get.call_args.kwargs
|
||||||
self.assertEqual(self.session.get.call_args.args[0], "https://supplier.example/adapter-apiaccess/open/api/queryOrderV2")
|
assert (
|
||||||
self.assertEqual(kwargs["params"]["out_order_id"], "OUT202503010010208888")
|
self.session.get.call_args.args[0]
|
||||||
self.assertEqual(kwargs["params"]["sign"], self.client.sign(kwargs["params"], "GET"))
|
== "https://supplier.example/adapter-apiaccess/open/api/queryOrderV2"
|
||||||
|
)
|
||||||
|
assert kwargs["params"]["out_order_id"] == "OUT202503010010208888"
|
||||||
|
assert kwargs["params"]["sign"] == self.client.sign(kwargs["params"], "GET")
|
||||||
|
|
||||||
def test_missing_configuration_is_rejected_before_request(self):
|
def test_missing_configuration_is_rejected_before_request(self):
|
||||||
with self.assertRaises(FishFinRechargeConfigError):
|
with pytest.raises(FishFinRechargeConfigError):
|
||||||
FishFinRechargeClient(FishFinRechargeConfig(base_url="", app_id="", app_secret=""))
|
FishFinRechargeClient(
|
||||||
|
FishFinRechargeConfig(base_url="", app_id="", app_secret="")
|
||||||
|
)
|
||||||
|
|
||||||
def test_trace_excludes_replayable_signature_and_nested_account_data(self):
|
def test_trace_excludes_replayable_signature_and_nested_account_data(self):
|
||||||
response = Mock()
|
response = Mock()
|
||||||
@@ -104,47 +125,62 @@ class FishFinRechargeClientTests(unittest.TestCase):
|
|||||||
response.json.return_value = {"code": 200, "result": {"order_status": 0}}
|
response.json.return_value = {"code": 200, "result": {"order_status": 0}}
|
||||||
self.session.post.return_value = response
|
self.session.post.return_value = response
|
||||||
events = []
|
events = []
|
||||||
client = FishFinRechargeClient(self.config, session=self.session, trace=events.append)
|
client = FishFinRechargeClient(
|
||||||
|
self.config, session=self.session, trace=events.append
|
||||||
|
)
|
||||||
|
|
||||||
client.create_order(
|
client.create_order(
|
||||||
buy_num=1, pay_amount="1",
|
buy_num=1,
|
||||||
out_order_id="merchant-001", product_id="111570",
|
pay_amount="1",
|
||||||
|
out_order_id="merchant-001",
|
||||||
|
product_id="111570",
|
||||||
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
|
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual([event["stage"] for event in events], ["request", "response"])
|
assert [event["stage"] for event in events] == ["request", "response"]
|
||||||
self.assertNotIn("sign", events[0]["params"])
|
assert "sign" not in events[0]["params"]
|
||||||
self.assertNotIn("recharge_arg", events[0]["params"])
|
assert "recharge_arg" not in events[0]["params"]
|
||||||
self.assertEqual(events[0]["params"]["pay_amount"], 1)
|
assert events[0]["params"]["pay_amount"] == 1
|
||||||
|
|
||||||
def test_debug_trace_includes_complete_request_and_response_bodies(self):
|
def test_debug_trace_includes_complete_request_and_response_bodies(self):
|
||||||
response = Mock()
|
response = Mock()
|
||||||
response.status_code = 200
|
response.status_code = 200
|
||||||
response.headers = {"Content-Type": "application/json", "X-Request-Id": "request-1"}
|
response.headers = {
|
||||||
response.text = '{"code":1000,"message":"未传递支付金额","sign":"response-sign"}'
|
"Content-Type": "application/json",
|
||||||
response.json.return_value = {"code": 1000, "message": "未传递支付金额", "sign": "response-sign"}
|
"X-Request-Id": "request-1",
|
||||||
|
}
|
||||||
|
response.text = (
|
||||||
|
'{"code":1000,"message":"未传递支付金额","sign":"response-sign"}'
|
||||||
|
)
|
||||||
|
response.json.return_value = {
|
||||||
|
"code": 1000,
|
||||||
|
"message": "未传递支付金额",
|
||||||
|
"sign": "response-sign",
|
||||||
|
}
|
||||||
self.session.post.return_value = response
|
self.session.post.return_value = response
|
||||||
events = []
|
events = []
|
||||||
config = FishFinRechargeConfig(
|
config = FishFinRechargeConfig(
|
||||||
base_url=self.config.base_url, app_id=self.config.app_id,
|
base_url=self.config.base_url,
|
||||||
app_secret=self.config.app_secret, debug=True,
|
app_id=self.config.app_id,
|
||||||
|
app_secret=self.config.app_secret,
|
||||||
|
debug=True,
|
||||||
|
)
|
||||||
|
client = FishFinRechargeClient(
|
||||||
|
config, session=self.session, trace=events.append
|
||||||
)
|
)
|
||||||
client = FishFinRechargeClient(config, session=self.session, trace=events.append)
|
|
||||||
|
|
||||||
client.create_order(
|
client.create_order(
|
||||||
buy_num=1, pay_amount="1",
|
buy_num=1,
|
||||||
out_order_id="merchant-001", product_id="111570",
|
pay_amount="1",
|
||||||
|
out_order_id="merchant-001",
|
||||||
|
product_id="111570",
|
||||||
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
|
recharge_arg=[{"templateName": "斗鱼UID", "templateVal": "10001"}],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(events[0]["json_body"]["sign"], "<redacted>")
|
assert events[0]["json_body"]["sign"] == "<redacted>"
|
||||||
self.assertEqual(events[0]["json_body"]["pay_amount"], 1)
|
assert events[0]["json_body"]["pay_amount"] == 1
|
||||||
self.assertNotIn("sign", events[0]["sign_params"])
|
assert "sign" not in events[0]["sign_params"]
|
||||||
self.assertNotIn(self.config.app_secret, str(events[0]))
|
assert self.config.app_secret not in str(events[0])
|
||||||
self.assertIn("sign_source_digest", events[0])
|
assert "sign_source_digest" in events[0]
|
||||||
self.assertEqual(events[1]["response_body"]["sign"], "<redacted>")
|
assert events[1]["response_body"]["sign"] == "<redacted>"
|
||||||
self.assertNotIn(self.config.app_secret, str(events[1]))
|
assert self.config.app_secret not in str(events[1])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""虎牙 App 密码登录及相关组件测试。"""
|
"""虎牙 App 密码登录及相关组件测试。"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -32,16 +33,18 @@ from core.huya.wup_encoder import build_password_login_wup
|
|||||||
|
|
||||||
from web.backend.database import Base
|
from web.backend.database import Base
|
||||||
from web.backend.models import User, HuyaAccount
|
from web.backend.models import User, HuyaAccount
|
||||||
from web.backend.schemas import HuyaAppPasswordLoginRequest, HuyaPasswordLoginSelectedRequest
|
from web.backend.schemas import (
|
||||||
|
HuyaAppPasswordLoginRequest,
|
||||||
|
HuyaPasswordLoginSelectedRequest,
|
||||||
|
)
|
||||||
from web.backend.routers.huya import (
|
from web.backend.routers.huya import (
|
||||||
app_password_login_account,
|
app_password_login_account,
|
||||||
app_password_login_selected_accounts,
|
app_password_login_selected_accounts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestHuyaAppLogin(unittest.TestCase):
|
class TestHuyaAppLogin:
|
||||||
|
def setup_method(self):
|
||||||
def setUp(self):
|
|
||||||
self.engine = create_engine("sqlite://")
|
self.engine = create_engine("sqlite://")
|
||||||
Base.metadata.create_all(self.engine)
|
Base.metadata.create_all(self.engine)
|
||||||
self.session = sessionmaker(bind=self.engine)()
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
@@ -49,7 +52,7 @@ class TestHuyaAppLogin(unittest.TestCase):
|
|||||||
self.session.add(self.admin)
|
self.session.add(self.admin)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
def tearDown(self):
|
def teardown_method(self):
|
||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(self.engine)
|
Base.metadata.drop_all(self.engine)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
@@ -59,54 +62,54 @@ class TestHuyaAppLogin(unittest.TestCase):
|
|||||||
plain = b"Hello, Huya App Login Protocol!"
|
plain = b"Hello, Huya App Login Protocol!"
|
||||||
enc = udb_encrypt(key, plain)
|
enc = udb_encrypt(key, plain)
|
||||||
dec = udb_decrypt(key, enc)
|
dec = udb_decrypt(key, enc)
|
||||||
self.assertEqual(dec, plain)
|
assert dec == plain
|
||||||
|
|
||||||
def test_cert_and_nonce_forge(self):
|
def test_cert_and_nonce_forge(self):
|
||||||
uid = 1199666914671
|
uid = 1199666914671
|
||||||
rnd = gen_nonce(uid, K1_DEFAULT)
|
rnd = gen_nonce(uid, K1_DEFAULT)
|
||||||
self.assertEqual(len(rnd), 20)
|
assert len(rnd) == 20
|
||||||
|
|
||||||
fp = b"02df398797432eadefcc12767119ad5e80999389"
|
fp = b"02df398797432eadefcc12767119ad5e80999389"
|
||||||
cred = b"\x0a" + os.urandom(113)
|
cred = b"\x0a" + os.urandom(113)
|
||||||
p1 = build_p1(b"5008", fp, cred, rnd=rnd)
|
p1 = build_p1(b"5008", fp, cred, rnd=rnd)
|
||||||
self.assertEqual(len(p1), 187)
|
assert len(p1) == 187
|
||||||
|
|
||||||
cert = forge_cert(p1)
|
cert = forge_cert(p1)
|
||||||
dec = decrypt_cert(cert)
|
dec = decrypt_cert(cert)
|
||||||
parsed = parse_p1(dec)
|
parsed = parse_p1(dec)
|
||||||
self.assertEqual(parsed["rnd"], rnd)
|
assert parsed["rnd"] == rnd
|
||||||
self.assertEqual(parsed["fingerprint"], fp)
|
assert parsed["fingerprint"] == fp
|
||||||
self.assertEqual(parsed["cred"], cred)
|
assert parsed["cred"] == cred
|
||||||
|
|
||||||
def test_envelope_patching(self):
|
def test_envelope_patching(self):
|
||||||
env = Envelope.load()
|
env = Envelope.load()
|
||||||
self.assertGreater(env.uid, 0)
|
assert env.uid > 0
|
||||||
self.assertEqual(len(env.cert_b64), 260)
|
assert len(env.cert_b64) == 260
|
||||||
|
|
||||||
new_uid = 1199666911746
|
new_uid = 1199666911746
|
||||||
env.patch_uid(new_uid)
|
env.patch_uid(new_uid)
|
||||||
self.assertEqual(env.uid, new_uid)
|
assert env.uid == new_uid
|
||||||
|
|
||||||
fake_cert = bytes([0x0C, 0x20]) + os.urandom(192)
|
fake_cert = bytes([0x0C, 0x20]) + os.urandom(192)
|
||||||
env.patch_cert(fake_cert)
|
env.patch_cert(fake_cert)
|
||||||
self.assertEqual(len(env.cert_b64), 260)
|
assert len(env.cert_b64) == 260
|
||||||
|
|
||||||
wup_b64 = env.wup_b64()
|
wup_b64 = env.wup_b64()
|
||||||
self.assertGreater(len(wup_b64), 0)
|
assert len(wup_b64) > 0
|
||||||
|
|
||||||
def test_device_profile_generation(self):
|
def test_device_profile_generation(self):
|
||||||
p1 = generate_profile()
|
p1 = generate_profile()
|
||||||
self.assertEqual(p1["os"], "android")
|
assert p1["os"] == "android"
|
||||||
self.assertEqual(len(p1["fingerprint"]), 40)
|
assert len(p1["fingerprint"]) == 40
|
||||||
self.assertEqual(len(p1["device_id"]), 40)
|
assert len(p1["device_id"]) == 40
|
||||||
self.assertEqual(p1["hdid"], "ed0db8334cadd236c00cadf7e11ab5a5")
|
assert p1["hdid"] == "ed0db8334cadd236c00cadf7e11ab5a5"
|
||||||
# 画像不再承载 safedeviceid:该令牌由 dfp_register 注册链每次登录前签发
|
# 画像不再承载 safedeviceid:该令牌由 dfp_register 注册链每次登录前签发
|
||||||
self.assertNotIn("safedeviceid", p1)
|
assert "safedeviceid" not in p1
|
||||||
self.assertNotIn("safedeviceid", DEFAULT_GOLDEN_DEV)
|
assert "safedeviceid" not in DEFAULT_GOLDEN_DEV
|
||||||
|
|
||||||
p2 = get_profile("test_user_account_123")
|
p2 = get_profile("test_user_account_123")
|
||||||
p3 = get_profile("test_user_account_123")
|
p3 = get_profile("test_user_account_123")
|
||||||
self.assertEqual(p2["fingerprint"], p3["fingerprint"])
|
assert p2["fingerprint"] == p3["fingerprint"]
|
||||||
|
|
||||||
def test_wup_encoder_output(self):
|
def test_wup_encoder_output(self):
|
||||||
dev = generate_profile()
|
dev = generate_profile()
|
||||||
@@ -121,18 +124,22 @@ class TestHuyaAppLogin(unittest.TestCase):
|
|||||||
user_action_json="{}",
|
user_action_json="{}",
|
||||||
device_info=dev,
|
device_info=dev,
|
||||||
)
|
)
|
||||||
self.assertGreater(len(pkt), 500)
|
assert len(pkt) > 500
|
||||||
total_len = struct.unpack(">I", pkt[:4])[0]
|
total_len = struct.unpack(">I", pkt[:4])[0]
|
||||||
self.assertEqual(total_len, len(pkt))
|
assert total_len == len(pkt)
|
||||||
|
|
||||||
# ---- 新设备注册链 (core/huya/dfp_register) 生产接入测试 ----
|
# ---- 新设备注册链 (core/huya/dfp_register) 生产接入测试 ----
|
||||||
|
|
||||||
def test_wup_login_skips_registration_when_safedeviceid_given(self):
|
def test_wup_login_skips_registration_when_safedeviceid_given(self):
|
||||||
"""显式传入 safedeviceid 时不再触发注册链(风控重试复用同一注册结果)。"""
|
"""显式传入 safedeviceid 时不再触发注册链(风控重试复用同一注册结果)。"""
|
||||||
with patch("core.huya.app_login.register_device") as m_reg, \
|
with (
|
||||||
patch("core.huya.app_login.build_password_login_wup", return_value=b"pkt"), \
|
patch("core.huya.app_login.register_device") as m_reg,
|
||||||
patch("core.huya.app_login.requests.post",
|
patch("core.huya.app_login.build_password_login_wup", return_value=b"pkt"),
|
||||||
return_value=MagicMock(status_code=200, content=b"")):
|
patch(
|
||||||
|
"core.huya.app_login.requests.post",
|
||||||
|
return_value=MagicMock(status_code=200, content=b""),
|
||||||
|
),
|
||||||
|
):
|
||||||
wup_password_login_raw("300023887", "pw", safedeviceid="A" * 180)
|
wup_password_login_raw("300023887", "pw", safedeviceid="A" * 180)
|
||||||
m_reg.assert_not_called()
|
m_reg.assert_not_called()
|
||||||
|
|
||||||
@@ -146,37 +153,48 @@ class TestHuyaAppLogin(unittest.TestCase):
|
|||||||
captured["args"] = args
|
captured["args"] = args
|
||||||
return b"pkt"
|
return b"pkt"
|
||||||
|
|
||||||
with patch("core.huya.app_login.register_device",
|
with (
|
||||||
return_value=("a" * 32, new_action, new_device_id)) as m_reg, \
|
patch(
|
||||||
patch("core.huya.app_login.build_password_login_wup", side_effect=fake_build), \
|
"core.huya.app_login.register_device",
|
||||||
patch("core.huya.app_login.requests.post",
|
return_value=("a" * 32, new_action, new_device_id),
|
||||||
return_value=MagicMock(status_code=200, content=b"")):
|
) as m_reg,
|
||||||
|
patch(
|
||||||
|
"core.huya.app_login.build_password_login_wup", side_effect=fake_build
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"core.huya.app_login.requests.post",
|
||||||
|
return_value=MagicMock(status_code=200, content=b""),
|
||||||
|
),
|
||||||
|
):
|
||||||
wup_password_login_raw("300023887", "pw")
|
wup_password_login_raw("300023887", "pw")
|
||||||
m_reg.assert_called_once()
|
m_reg.assert_called_once()
|
||||||
# args: uid_str, sha1, safedeviceid, hdid, session, traceId, ua, dev
|
# args: uid_str, sha1, safedeviceid, hdid, session, traceId, ua, dev
|
||||||
self.assertEqual(captured["args"][2], new_action)
|
assert captured["args"][2] == new_action
|
||||||
self.assertEqual(captured["args"][7]["device_id"], new_device_id)
|
assert captured["args"][7]["device_id"] == new_device_id
|
||||||
# 画像默认值里的旧 device_id 被注册结果覆盖,而非沿用
|
# 画像默认值里的旧 device_id 被注册结果覆盖,而非沿用
|
||||||
self.assertNotEqual(
|
assert captured["args"][7]["device_id"] != DEFAULT_GOLDEN_DEV["device_id"]
|
||||||
captured["args"][7]["device_id"],
|
|
||||||
DEFAULT_GOLDEN_DEV["device_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_wup_login_registration_failure_is_explicit(self):
|
def test_wup_login_registration_failure_is_explicit(self):
|
||||||
"""注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。"""
|
"""注册失败必须抛错终止,禁止静默回退旧链(不发任何登录请求)。"""
|
||||||
with patch("core.huya.app_login.register_device",
|
with (
|
||||||
side_effect=DfpRegistrationError("注册链超时")) as m_reg, \
|
patch(
|
||||||
patch("core.huya.app_login.requests.post") as m_post:
|
"core.huya.app_login.register_device",
|
||||||
with self.assertRaises(HuyaAppLoginError):
|
side_effect=DfpRegistrationError("注册链超时"),
|
||||||
|
) as m_reg,
|
||||||
|
patch("core.huya.app_login.requests.post") as m_post,
|
||||||
|
):
|
||||||
|
with pytest.raises(HuyaAppLoginError):
|
||||||
wup_password_login_raw("300023887", "pw")
|
wup_password_login_raw("300023887", "pw")
|
||||||
m_reg.assert_called_once()
|
m_reg.assert_called_once()
|
||||||
m_post.assert_not_called()
|
m_post.assert_not_called()
|
||||||
|
|
||||||
def test_login_cred_flow_registration_failure_is_explicit(self):
|
def test_login_cred_flow_registration_failure_is_explicit(self):
|
||||||
"""login_cred_with_flow 注册失败同样包装为 HuyaAppLoginError 显式失败。"""
|
"""login_cred_with_flow 注册失败同样包装为 HuyaAppLoginError 显式失败。"""
|
||||||
with patch("core.huya.app_login.register_device",
|
with patch(
|
||||||
side_effect=DfpRegistrationError("注册链 HTTP 500")):
|
"core.huya.app_login.register_device",
|
||||||
with self.assertRaisesRegex(HuyaAppLoginError, "注册失败"):
|
side_effect=DfpRegistrationError("注册链 HTTP 500"),
|
||||||
|
):
|
||||||
|
with pytest.raises(HuyaAppLoginError, match="注册失败"):
|
||||||
login_cred_with_flow("300023887", "pw")
|
login_cred_with_flow("300023887", "pw")
|
||||||
|
|
||||||
def test_router_functions(self):
|
def test_router_functions(self):
|
||||||
@@ -187,26 +205,32 @@ class TestHuyaAppLogin(unittest.TestCase):
|
|||||||
sdid="mock_sdid_123",
|
sdid="mock_sdid_123",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("web.backend.routers.huya.login_huya_app_password", return_value=mock_res):
|
with patch(
|
||||||
|
"web.backend.routers.huya.login_huya_app_password", return_value=mock_res
|
||||||
|
):
|
||||||
req = HuyaAppPasswordLoginRequest(
|
req = HuyaAppPasswordLoginRequest(
|
||||||
username="mock_test_huya_user",
|
username="mock_test_huya_user",
|
||||||
password="mock_password_123",
|
password="mock_password_123",
|
||||||
tag="test_tag",
|
tag="test_tag",
|
||||||
)
|
)
|
||||||
resp = app_password_login_account(req=req, db=self.session, current=self.admin)
|
resp = app_password_login_account(
|
||||||
self.assertTrue(resp["success"])
|
req=req, db=self.session, current=self.admin
|
||||||
self.assertIn("App", resp["message"])
|
)
|
||||||
self.assertEqual(resp["account"].username, "mock_test_huya_user")
|
assert resp["success"]
|
||||||
|
assert "App" in resp["message"]
|
||||||
|
assert resp["account"].username == "mock_test_huya_user"
|
||||||
|
|
||||||
# Test batch app login router function
|
# Test batch app login router function
|
||||||
acc = self.session.query(HuyaAccount).filter(HuyaAccount.username == "mock_test_huya_user").first()
|
acc = (
|
||||||
|
self.session.query(HuyaAccount)
|
||||||
|
.filter(HuyaAccount.username == "mock_test_huya_user")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id])
|
batch_req = HuyaPasswordLoginSelectedRequest(account_ids=[acc.id])
|
||||||
batch_resp = app_password_login_selected_accounts(req=batch_req, db=self.session, current=self.admin)
|
batch_resp = app_password_login_selected_accounts(
|
||||||
self.assertTrue(batch_resp["success"])
|
req=batch_req, db=self.session, current=self.admin
|
||||||
self.assertEqual(batch_resp["count"], 1)
|
)
|
||||||
self.assertEqual(len(batch_resp["results"]), 1)
|
assert batch_resp["success"]
|
||||||
self.assertTrue(batch_resp["results"][0]["success"])
|
assert batch_resp["count"] == 1
|
||||||
|
assert len(batch_resp["results"]) == 1
|
||||||
|
assert batch_resp["results"][0]["success"]
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
注册结果三元组返回;
|
注册结果三元组返回;
|
||||||
* 失败语义: 模板缺失/HTTP 异常 -> DfpRegistrationError (不静默、不发登录帧)。
|
* 失败语义: 模板缺失/HTTP 异常 -> DfpRegistrationError (不静默、不发登录帧)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -29,9 +30,7 @@ TAIL = bytes.fromhex("3600400c0b8c980ca80c")
|
|||||||
|
|
||||||
def _fake_resp() -> bytes:
|
def _fake_resp() -> bytes:
|
||||||
"""合成 dfpReport 响应: t1(32hex) + t2(180B base64 action) + t5(40hex)。"""
|
"""合成 dfpReport 响应: t1(32hex) + t2(180B base64 action) + t5(40hex)。"""
|
||||||
return (b"\x16\x20" + b"a" * 32
|
return b"\x16\x20" + b"a" * 32 + b"\x26\xb4" + b"A" * 180 + b"\x56\x28" + b"b" * 40
|
||||||
+ b"\x26\xb4" + b"A" * 180
|
|
||||||
+ b"\x56\x28" + b"b" * 40)
|
|
||||||
|
|
||||||
|
|
||||||
_GOLDEN_FP = b"02df398797432eadefcc12767119ad5e80999389"
|
_GOLDEN_FP = b"02df398797432eadefcc12767119ad5e80999389"
|
||||||
@@ -46,28 +45,26 @@ def _dummy_chain() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestRandomDfpBody(unittest.TestCase):
|
class TestRandomDfpBody:
|
||||||
|
|
||||||
def test_body_shape(self):
|
def test_body_shape(self):
|
||||||
body = _build_random_dfp_body()
|
body = _build_random_dfp_body()
|
||||||
self.assertEqual(len(body), 4226)
|
assert len(body) == 4226
|
||||||
cw = body[-CW_LEN:]
|
cw = body[-CW_LEN:]
|
||||||
self.assertEqual(len(cw), CW_LEN) # 4146
|
assert len(cw) == CW_LEN # 4146
|
||||||
self.assertTrue(cw.endswith(TAIL), "cw 尾部应为固定 10B")
|
assert cw.endswith(TAIL)
|
||||||
|
|
||||||
def test_body_is_randomized(self):
|
def test_body_is_randomized(self):
|
||||||
b1 = _build_random_dfp_body()
|
b1 = _build_random_dfp_body()
|
||||||
b2 = _build_random_dfp_body()
|
b2 = _build_random_dfp_body()
|
||||||
self.assertNotEqual(b1, b2, "每次注册应生成不同的随机 cw")
|
assert b1 != b2, "每次注册应生成不同的随机 cw"
|
||||||
|
|
||||||
|
|
||||||
class TestParseResponse(unittest.TestCase):
|
class TestParseResponse:
|
||||||
|
|
||||||
def test_parse_ok(self):
|
def test_parse_ok(self):
|
||||||
t1, t2, t5 = _parse_response(_fake_resp())
|
t1, t2, t5 = _parse_response(_fake_resp())
|
||||||
self.assertEqual(t1, "a" * 32)
|
assert t1 == "a" * 32
|
||||||
self.assertEqual(t2, "A" * 180)
|
assert t2 == "A" * 180
|
||||||
self.assertEqual(t5, "b" * 40)
|
assert t5 == "b" * 40
|
||||||
|
|
||||||
def test_parse_golden_evidence(self):
|
def test_parse_golden_evidence(self):
|
||||||
"""真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。"""
|
"""真实抓包模板响应必须可解析 (证据: evidence/dfp_chain_golden.json)。"""
|
||||||
@@ -76,77 +73,87 @@ class TestParseResponse(unittest.TestCase):
|
|||||||
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
|
data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
|
||||||
resp = base64.b64decode(data["dfpReport"]["resp_b64"])
|
resp = base64.b64decode(data["dfpReport"]["resp_b64"])
|
||||||
t1, t2, t5 = _parse_response(resp)
|
t1, t2, t5 = _parse_response(resp)
|
||||||
self.assertEqual(len(t1), 32)
|
assert len(t1) == 32
|
||||||
self.assertEqual(len(t2), 180)
|
assert len(t2) == 180
|
||||||
self.assertEqual(len(t5), 40)
|
assert len(t5) == 40
|
||||||
|
|
||||||
def test_parse_missing_fields_raises(self):
|
def test_parse_missing_fields_raises(self):
|
||||||
with self.assertRaises(DfpRegistrationError):
|
with pytest.raises(DfpRegistrationError):
|
||||||
_parse_response(b"\x16\x20" + b"a" * 32) # 缺 t2/t5
|
_parse_response(b"\x16\x20" + b"a" * 32) # 缺 t2/t5
|
||||||
with self.assertRaises(DfpRegistrationError):
|
with pytest.raises(DfpRegistrationError):
|
||||||
_parse_response(b"\x26\xb4" + b"A" * 180) # 缺 t1/t5
|
_parse_response(b"\x26\xb4" + b"A" * 180) # 缺 t1/t5
|
||||||
with self.assertRaises(DfpRegistrationError):
|
with pytest.raises(DfpRegistrationError):
|
||||||
_parse_response(b"\x56\x28" + b"b" * 40) # 缺 t1/t2
|
_parse_response(b"\x56\x28" + b"b" * 40) # 缺 t1/t2
|
||||||
|
|
||||||
|
|
||||||
class TestRegisterDevice(unittest.TestCase):
|
class TestRegisterDevice:
|
||||||
|
|
||||||
def test_flow_three_steps_and_returns_triple(self):
|
def test_flow_three_steps_and_returns_triple(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def spy_post(body, content_type="application/octet-stream",
|
def spy_post(
|
||||||
timeout=20, proxies=None):
|
body, content_type="application/octet-stream", timeout=20, proxies=None
|
||||||
|
):
|
||||||
calls.append(content_type)
|
calls.append(content_type)
|
||||||
return _fake_resp()
|
return _fake_resp()
|
||||||
|
|
||||||
with patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()), \
|
with (
|
||||||
patch("core.huya.dfp_register._post", side_effect=spy_post):
|
patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()),
|
||||||
|
patch("core.huya.dfp_register._post", side_effect=spy_post),
|
||||||
|
):
|
||||||
t1, t2, t5 = register_device(fingerprint=None)
|
t1, t2, t5 = register_device(fingerprint=None)
|
||||||
self.assertEqual((t1, t2, t5), ("a" * 32, "A" * 180, "b" * 40))
|
assert (t1, t2, t5) == ("a" * 32, "A" * 180, "b" * 40)
|
||||||
# getDfpConfig -> selectOperator -> dfpReport 的 content-type 序列
|
# getDfpConfig -> selectOperator -> dfpReport 的 content-type 序列
|
||||||
self.assertEqual(
|
assert calls == [
|
||||||
calls,
|
"application/octet-stream",
|
||||||
["application/octet-stream", "application/x-wup", "application/octet-stream"],
|
"application/x-wup",
|
||||||
)
|
"application/octet-stream",
|
||||||
|
]
|
||||||
|
|
||||||
def test_select_operator_injects_fingerprint(self):
|
def test_select_operator_injects_fingerprint(self):
|
||||||
captured = []
|
captured = []
|
||||||
|
|
||||||
def spy_post(body, content_type="application/octet-stream",
|
def spy_post(
|
||||||
timeout=20, proxies=None):
|
body, content_type="application/octet-stream", timeout=20, proxies=None
|
||||||
|
):
|
||||||
captured.append((body, content_type))
|
captured.append((body, content_type))
|
||||||
return _fake_resp()
|
return _fake_resp()
|
||||||
|
|
||||||
new_fp = b"0" * 20 + b"f" * 20
|
new_fp = b"0" * 20 + b"f" * 20
|
||||||
with patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()), \
|
with (
|
||||||
patch("core.huya.dfp_register._post", side_effect=spy_post):
|
patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()),
|
||||||
|
patch("core.huya.dfp_register._post", side_effect=spy_post),
|
||||||
|
):
|
||||||
register_device(fingerprint=new_fp.decode("ascii"))
|
register_device(fingerprint=new_fp.decode("ascii"))
|
||||||
sel_body, sel_ct = captured[1]
|
sel_body, sel_ct = captured[1]
|
||||||
self.assertEqual(sel_ct, "application/x-wup")
|
assert sel_ct == "application/x-wup"
|
||||||
self.assertIn(new_fp, sel_body, "selectOperator 应注入当前账号画像 fingerprint")
|
assert new_fp in sel_body, "selectOperator 应注入当前账号画像 fingerprint"
|
||||||
self.assertNotIn(_GOLDEN_FP, sel_body)
|
assert _GOLDEN_FP not in sel_body
|
||||||
# dfpReport 请求体为随机 cw 结构 (零设备生成)
|
# dfpReport 请求体为随机 cw 结构 (零设备生成)
|
||||||
dfp_body, dfp_ct = captured[2]
|
dfp_body, dfp_ct = captured[2]
|
||||||
self.assertEqual(len(dfp_body), 4226)
|
assert len(dfp_body) == 4226
|
||||||
self.assertEqual(dfp_ct, "application/octet-stream")
|
assert dfp_ct == "application/octet-stream"
|
||||||
|
|
||||||
def test_chain_template_missing_raises(self):
|
def test_chain_template_missing_raises(self):
|
||||||
"""模板缺失 -> 注册链直接报错, 不应发起任何网络请求。"""
|
"""模板缺失 -> 注册链直接报错, 不应发起任何网络请求。"""
|
||||||
with patch("core.huya.dfp_register.CHAIN_FILE",
|
with (
|
||||||
Path("/tmp/definitely_missing_chain.json")), \
|
patch(
|
||||||
patch("core.huya.dfp_register._post") as m_post:
|
"core.huya.dfp_register.CHAIN_FILE",
|
||||||
with self.assertRaises(DfpRegistrationError):
|
Path("/tmp/definitely_missing_chain.json"),
|
||||||
|
),
|
||||||
|
patch("core.huya.dfp_register._post") as m_post,
|
||||||
|
):
|
||||||
|
with pytest.raises(DfpRegistrationError):
|
||||||
register_device()
|
register_device()
|
||||||
m_post.assert_not_called()
|
m_post.assert_not_called()
|
||||||
|
|
||||||
def test_http_failure_raises(self):
|
def test_http_failure_raises(self):
|
||||||
"""HTTP 层异常由 _post 转换为 DfpRegistrationError 后向上传播 (不静默)。"""
|
"""HTTP 层异常由 _post 转换为 DfpRegistrationError 后向上传播 (不静默)。"""
|
||||||
with patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()), \
|
with (
|
||||||
patch("core.huya.dfp_register.requests.post",
|
patch("core.huya.dfp_register._load_chain", return_value=_dummy_chain()),
|
||||||
side_effect=requests.RequestException("connection reset")):
|
patch(
|
||||||
with self.assertRaises(DfpRegistrationError):
|
"core.huya.dfp_register.requests.post",
|
||||||
|
side_effect=requests.RequestException("connection reset"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(DfpRegistrationError):
|
||||||
register_device()
|
register_device()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
+25
-20
@@ -3,23 +3,28 @@
|
|||||||
import gzip
|
import gzip
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from utils.logger import _SensitiveDataFilter, _SizeAndDayRotatingFileHandler, _parse_size
|
from utils.logger import (
|
||||||
|
_SensitiveDataFilter,
|
||||||
|
_SizeAndDayRotatingFileHandler,
|
||||||
|
_parse_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoggerTests(unittest.TestCase):
|
class TestLogger:
|
||||||
def test_size_parser_accepts_human_readable_values(self):
|
def test_size_parser_accepts_human_readable_values(self):
|
||||||
self.assertEqual(_parse_size("2K"), 2 * 1024)
|
assert _parse_size("2K") == 2 * 1024
|
||||||
self.assertEqual(_parse_size("3MiB"), 3 * 1024 * 1024)
|
assert _parse_size("3MiB") == 3 * 1024 * 1024
|
||||||
self.assertEqual(_parse_size("invalid", default=123), 123)
|
assert _parse_size("invalid", default=123) == 123
|
||||||
|
|
||||||
def test_file_log_redacts_credentials_and_compresses_rotation(self):
|
def test_file_log_redacts_credentials_and_compresses_rotation(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
log_path = Path(tmpdir) / "app-2026-08-28.log"
|
log_path = Path(tmpdir) / "app-2026-08-28.log"
|
||||||
handler = _SizeAndDayRotatingFileHandler(log_path, max_bytes=1, retention_days=14)
|
handler = _SizeAndDayRotatingFileHandler(
|
||||||
|
log_path, max_bytes=1, retention_days=14
|
||||||
|
)
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
handler.addFilter(_SensitiveDataFilter())
|
handler.addFilter(_SensitiveDataFilter())
|
||||||
test_logger = logging.getLogger("tests.logger.redaction")
|
test_logger = logging.getLogger("tests.logger.redaction")
|
||||||
@@ -34,20 +39,24 @@ class LoggerTests(unittest.TestCase):
|
|||||||
test_logger.handlers = []
|
test_logger.handlers = []
|
||||||
|
|
||||||
archives = list(Path(tmpdir).glob("app-2026-08-28.log.*.gz"))
|
archives = list(Path(tmpdir).glob("app-2026-08-28.log.*.gz"))
|
||||||
self.assertEqual(len(archives), 1)
|
assert len(archives) == 1
|
||||||
archived_content = gzip.open(archives[0], "rt", encoding="utf-8").read()
|
archived_content = gzip.open(archives[0], "rt", encoding="utf-8").read()
|
||||||
current_content = log_path.read_text(encoding="utf-8")
|
current_content = log_path.read_text(encoding="utf-8")
|
||||||
combined = archived_content + current_content
|
combined = archived_content + current_content
|
||||||
self.assertNotIn("super-secret", combined)
|
assert "super-secret" not in combined
|
||||||
self.assertNotIn("top-secret-token", combined)
|
assert "top-secret-token" not in combined
|
||||||
self.assertIn("Cookie: [REDACTED]", combined)
|
assert "Cookie: [REDACTED]" in combined
|
||||||
self.assertIn("Authorization: [REDACTED]", combined)
|
assert "Authorization: [REDACTED]" in combined
|
||||||
|
|
||||||
def test_daily_log_switches_to_a_new_dated_file(self):
|
def test_daily_log_switches_to_a_new_dated_file(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
today = datetime.now().date()
|
today = datetime.now().date()
|
||||||
old_path = Path(tmpdir) / f"app-{(today - timedelta(days=1)).isoformat()}.log"
|
old_path = (
|
||||||
handler = _SizeAndDayRotatingFileHandler(old_path, max_bytes=1024, retention_days=14)
|
Path(tmpdir) / f"app-{(today - timedelta(days=1)).isoformat()}.log"
|
||||||
|
)
|
||||||
|
handler = _SizeAndDayRotatingFileHandler(
|
||||||
|
old_path, max_bytes=1024, retention_days=14
|
||||||
|
)
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
handler._active_day = today - timedelta(days=1)
|
handler._active_day = today - timedelta(days=1)
|
||||||
test_logger = logging.getLogger("tests.logger.daily")
|
test_logger = logging.getLogger("tests.logger.daily")
|
||||||
@@ -61,9 +70,5 @@ class LoggerTests(unittest.TestCase):
|
|||||||
test_logger.handlers = []
|
test_logger.handlers = []
|
||||||
|
|
||||||
current_path = Path(tmpdir) / f"app-{today.isoformat()}.log"
|
current_path = Path(tmpdir) / f"app-{today.isoformat()}.log"
|
||||||
self.assertTrue(current_path.exists())
|
assert current_path.exists()
|
||||||
self.assertEqual(current_path.read_text(encoding="utf-8").strip(), "today")
|
assert current_path.read_text(encoding="utf-8").strip() == "today"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
+19
-22
@@ -11,7 +11,6 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -42,28 +41,24 @@ def _migration_config() -> Config:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
class MigrationSmokeTests(unittest.TestCase):
|
class TestMigrationSmoke:
|
||||||
def test_chain_is_linear_and_head_matches_latest_file(self):
|
def test_chain_is_linear_and_head_matches_latest_file(self):
|
||||||
directory = ScriptDirectory.from_config(_migration_config())
|
directory = ScriptDirectory.from_config(_migration_config())
|
||||||
|
|
||||||
heads = directory.get_heads()
|
heads = directory.get_heads()
|
||||||
self.assertEqual(len(heads), 1, f"迁移链出现多个头版本(存在分叉): {heads}")
|
assert len(heads) == 1, f"迁移链出现多个头版本(存在分叉): {heads}"
|
||||||
self.assertEqual(heads[0], HEAD_REVISION, f"头版本 {heads[0]} 与最新迁移不一致")
|
assert heads[0] == HEAD_REVISION, f"头版本 {heads[0]} 与最新迁移不一致"
|
||||||
|
|
||||||
version_files = sorted(path.name for path in VERSIONS_DIR.glob("*.py"))
|
version_files = sorted(path.name for path in VERSIONS_DIR.glob("*.py"))
|
||||||
revisions = list(directory.walk_revisions())
|
revisions = list(directory.walk_revisions())
|
||||||
revision_ids = {revision.revision for revision in revisions}
|
revision_ids = {revision.revision for revision in revisions}
|
||||||
self.assertEqual(
|
assert len(revision_ids) == len(version_files), (
|
||||||
len(revision_ids),
|
"迁移文件数量与已加载修订数量不一致(存在无法加载的脚本)"
|
||||||
len(version_files),
|
|
||||||
"迁移文件数量与已加载修订数量不一致(存在无法加载的脚本)",
|
|
||||||
)
|
)
|
||||||
for revision in revisions:
|
for revision in revisions:
|
||||||
if revision.down_revision is not None:
|
if revision.down_revision is not None:
|
||||||
self.assertIn(
|
assert revision.down_revision in revision_ids, (
|
||||||
revision.down_revision,
|
f"{revision.revision} 引用了不存在的父版本 {revision.down_revision}"
|
||||||
revision_ids,
|
|
||||||
f"{revision.revision} 引用了不存在的父版本 {revision.down_revision}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_all_migration_scripts_compile(self):
|
def test_all_migration_scripts_compile(self):
|
||||||
@@ -86,18 +81,20 @@ class MigrationSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
inspector = inspect(create_engine(f"sqlite:///{db_path}"))
|
inspector = inspect(create_engine(f"sqlite:///{db_path}"))
|
||||||
db_tables = set(inspector.get_table_names())
|
db_tables = set(inspector.get_table_names())
|
||||||
self.assertTrue(db_tables, "迁移链未创建任何表")
|
assert db_tables
|
||||||
|
|
||||||
model_tables = Base.metadata.tables
|
model_tables = Base.metadata.tables
|
||||||
missing_tables = set(model_tables) - db_tables
|
missing_tables = set(model_tables) - db_tables
|
||||||
self.assertFalse(missing_tables, f"模型要求但迁移未创建的表: {missing_tables}")
|
assert not missing_tables
|
||||||
|
|
||||||
all_db_indexes = set()
|
all_db_indexes = set()
|
||||||
for name, table in model_tables.items():
|
for name, table in model_tables.items():
|
||||||
db_columns = {column["name"] for column in inspector.get_columns(name)}
|
db_columns = {
|
||||||
|
column["name"] for column in inspector.get_columns(name)
|
||||||
|
}
|
||||||
model_columns = set(table.columns.keys())
|
model_columns = set(table.columns.keys())
|
||||||
missing_columns = model_columns - db_columns
|
missing_columns = model_columns - db_columns
|
||||||
self.assertFalse(missing_columns, f"{name} 迁移后缺少列: {missing_columns}")
|
assert not missing_columns
|
||||||
|
|
||||||
db_indexes = {
|
db_indexes = {
|
||||||
index["name"]
|
index["name"]
|
||||||
@@ -105,16 +102,16 @@ class MigrationSmokeTests(unittest.TestCase):
|
|||||||
if not index["name"].startswith("sqlite_autoindex")
|
if not index["name"].startswith("sqlite_autoindex")
|
||||||
}
|
}
|
||||||
all_db_indexes |= db_indexes
|
all_db_indexes |= db_indexes
|
||||||
model_indexes = {index.name for index in table.indexes if index.name}
|
model_indexes = {
|
||||||
|
index.name for index in table.indexes if index.name
|
||||||
|
}
|
||||||
missing_indexes = model_indexes - db_indexes
|
missing_indexes = model_indexes - db_indexes
|
||||||
self.assertFalse(
|
assert not missing_indexes
|
||||||
missing_indexes, f"{name} 迁移后缺少索引: {missing_indexes}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 迁移新增的复合查询索引(不入模型,但应存在)。
|
# 迁移新增的复合查询索引(不入模型,但应存在)。
|
||||||
for index_name in EXTRA_INDEXES:
|
for index_name in EXTRA_INDEXES:
|
||||||
self.assertIn(
|
assert index_name in all_db_indexes, (
|
||||||
index_name, all_db_indexes, f"迁移链未创建索引 {index_name}"
|
f"迁移链未创建索引 {index_name}"
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
database_module.DATABASE_URL = original_url
|
database_module.DATABASE_URL = original_url
|
||||||
+28
-28
@@ -1,4 +1,4 @@
|
|||||||
import unittest
|
import pytest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from sqlalchemy import create_engine, event
|
from sqlalchemy import create_engine, event
|
||||||
@@ -10,8 +10,8 @@ from web.backend.routers.users import delete_user, list_users, rename_user
|
|||||||
from web.backend.schemas import UserRename
|
from web.backend.schemas import UserRename
|
||||||
|
|
||||||
|
|
||||||
class UserDeletionTests(unittest.TestCase):
|
class TestUserDeletion:
|
||||||
def setUp(self):
|
def setup_method(self):
|
||||||
self.engine = create_engine("sqlite://")
|
self.engine = create_engine("sqlite://")
|
||||||
|
|
||||||
@event.listens_for(self.engine, "connect")
|
@event.listens_for(self.engine, "connect")
|
||||||
@@ -24,23 +24,27 @@ class UserDeletionTests(unittest.TestCase):
|
|||||||
self.user = User(username="support", password_hash="hash", role="support")
|
self.user = User(username="support", password_hash="hash", role="support")
|
||||||
self.session.add_all([self.admin, self.user])
|
self.session.add_all([self.admin, self.user])
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.session.add(Account(
|
self.session.add(
|
||||||
|
Account(
|
||||||
username="douyu-user",
|
username="douyu-user",
|
||||||
password="password",
|
password="password",
|
||||||
email="mail@example.com",
|
email="mail@example.com",
|
||||||
email_password="mail-password",
|
email_password="mail-password",
|
||||||
assigned_to=self.user.id,
|
assigned_to=self.user.id,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
account = self.session.query(Account).one()
|
account = self.session.query(Account).one()
|
||||||
self.session.add(LoginTask(
|
self.session.add(
|
||||||
|
LoginTask(
|
||||||
batch_id="batch-1",
|
batch_id="batch-1",
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
created_by=self.user.id,
|
created_by=self.user.id,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
def tearDown(self):
|
def teardown_method(self):
|
||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(self.engine)
|
Base.metadata.drop_all(self.engine)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
@@ -54,19 +58,19 @@ class UserDeletionTests(unittest.TestCase):
|
|||||||
|
|
||||||
deleted = self.session.get(User, self.user.id)
|
deleted = self.session.get(User, self.user.id)
|
||||||
account = self.session.query(Account).one()
|
account = self.session.query(Account).one()
|
||||||
self.assertTrue(result["success"])
|
assert result["success"]
|
||||||
self.assertEqual(result["released_accounts"], 1)
|
assert result["released_accounts"] == 1
|
||||||
self.assertFalse(deleted.is_active)
|
assert not deleted.is_active
|
||||||
self.assertIsNotNone(deleted.deleted_at)
|
assert deleted.deleted_at is not None
|
||||||
self.assertEqual(deleted.deleted_username, "support")
|
assert deleted.deleted_username == "support"
|
||||||
self.assertTrue(deleted.username.startswith(f"deleted-{deleted.id}-"))
|
assert deleted.username.startswith(f"deleted-{deleted.id}-")
|
||||||
self.assertIsNone(account.assigned_to)
|
assert account.assigned_to is None
|
||||||
self.assertEqual(self.session.query(AuditLog).one().target, "support")
|
assert self.session.query(AuditLog).one().target == "support"
|
||||||
self.assertEqual(self.session.query(LoginTask).one().created_by, deleted.id)
|
assert self.session.query(LoginTask).one().created_by == deleted.id
|
||||||
self.assertEqual([item.id for item in list_users(db=self.session)], [self.admin.id])
|
assert [item.id for item in list_users(db=self.session)] == [self.admin.id]
|
||||||
|
|
||||||
def test_cannot_delete_current_user(self):
|
def test_cannot_delete_current_user(self):
|
||||||
with self.assertRaisesRegex(Exception, "不能删除当前登录用户"):
|
with pytest.raises(Exception, match="不能删除当前登录用户"):
|
||||||
delete_user(
|
delete_user(
|
||||||
self.user.id,
|
self.user.id,
|
||||||
db=self.session,
|
db=self.session,
|
||||||
@@ -84,12 +88,8 @@ class UserDeletionTests(unittest.TestCase):
|
|||||||
renamed = self.session.get(User, self.user.id)
|
renamed = self.session.get(User, self.user.id)
|
||||||
account = self.session.query(Account).one()
|
account = self.session.query(Account).one()
|
||||||
task = self.session.query(LoginTask).one()
|
task = self.session.query(LoginTask).one()
|
||||||
self.assertEqual(result.username, "renamed-support")
|
assert result.username == "renamed-support"
|
||||||
self.assertEqual(renamed.id, self.user.id)
|
assert renamed.id == self.user.id
|
||||||
self.assertEqual(account.assigned_to, self.user.id)
|
assert account.assigned_to == self.user.id
|
||||||
self.assertEqual(task.created_by, self.user.id)
|
assert task.created_by == self.user.id
|
||||||
self.assertEqual(self.session.query(AuditLog).one().target, "support -> renamed-support")
|
assert self.session.query(AuditLog).one().target == "support -> renamed-support"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ dependencies = [
|
|||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -283,7 +284,10 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "pytest", specifier = ">=9.1.1" }]
|
dev = [
|
||||||
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
|
{ name = "ruff", specifier = ">=0.12.0" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecdsa"
|
name = "ecdsa"
|
||||||
@@ -751,6 +755,31 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.16.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scipy"
|
name = "scipy"
|
||||||
version = "1.18.0"
|
version = "1.18.0"
|
||||||
|
|||||||
+65
-12
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, Boolean, Text, DateTime, ForeignKey, JSON, UniqueConstraint,
|
Column,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Boolean,
|
||||||
|
Text,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
JSON,
|
||||||
|
UniqueConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from .database import Base
|
from .database import Base
|
||||||
@@ -23,27 +31,39 @@ def _utcnow():
|
|||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""系统用户"""
|
"""系统用户"""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
username = Column(String(64), unique=True, nullable=False, index=True)
|
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||||
password_hash = Column(String(256), nullable=False)
|
password_hash = Column(String(256), nullable=False)
|
||||||
role = Column(String(32), nullable=False, default="support") # super_admin / operation / support
|
role = Column(
|
||||||
|
String(32), nullable=False, default="support"
|
||||||
|
) # super_admin / operation / support
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
remark = Column(String(256), default="")
|
remark = Column(String(256), default="")
|
||||||
custom_permissions = Column(JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限")
|
custom_permissions = Column(
|
||||||
|
JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限"
|
||||||
|
)
|
||||||
created_at = Column(DateTime, default=_utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||||
deleted_at = Column(DateTime, nullable=True, index=True)
|
deleted_at = Column(DateTime, nullable=True, index=True)
|
||||||
deleted_username = Column(String(64), default="")
|
deleted_username = Column(String(64), default="")
|
||||||
|
|
||||||
# 客服被分配的账号
|
# 客服被分配的账号
|
||||||
assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to")
|
assigned_accounts = relationship(
|
||||||
huya_accounts = relationship("HuyaAccount", back_populates="assigned_user", foreign_keys="HuyaAccount.assigned_to")
|
"Account", back_populates="assigned_user", foreign_keys="Account.assigned_to"
|
||||||
|
)
|
||||||
|
huya_accounts = relationship(
|
||||||
|
"HuyaAccount",
|
||||||
|
back_populates="assigned_user",
|
||||||
|
foreign_keys="HuyaAccount.assigned_to",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Account(Base):
|
class Account(Base):
|
||||||
"""斗鱼账号"""
|
"""斗鱼账号"""
|
||||||
|
|
||||||
__tablename__ = "accounts"
|
__tablename__ = "accounts"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -83,19 +103,24 @@ class Account(Base):
|
|||||||
created_at = Column(DateTime, default=_utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
assigned_user = relationship("User", back_populates="assigned_accounts", foreign_keys=[assigned_to])
|
assigned_user = relationship(
|
||||||
|
"User", back_populates="assigned_accounts", foreign_keys=[assigned_to]
|
||||||
|
)
|
||||||
login_tasks = relationship("LoginTask", back_populates="account")
|
login_tasks = relationship("LoginTask", back_populates="account")
|
||||||
douyu_tasks = relationship("DouyuTask", back_populates="account")
|
douyu_tasks = relationship("DouyuTask", back_populates="account")
|
||||||
|
|
||||||
|
|
||||||
class LoginTask(Base):
|
class LoginTask(Base):
|
||||||
"""单次登录任务"""
|
"""单次登录任务"""
|
||||||
|
|
||||||
__tablename__ = "login_tasks"
|
__tablename__ = "login_tasks"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
batch_id = Column(String(64), nullable=False, index=True) # 批次ID
|
batch_id = Column(String(64), nullable=False, index=True) # 批次ID
|
||||||
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
|
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
|
||||||
status = Column(String(32), default="pending") # pending / running / success / failed / error
|
status = Column(
|
||||||
|
String(32), default="pending"
|
||||||
|
) # pending / running / success / failed / error
|
||||||
cookie = Column(EncryptedText(), default="")
|
cookie = Column(EncryptedText(), default="")
|
||||||
message = Column(String(512), default="")
|
message = Column(String(512), default="")
|
||||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
@@ -103,7 +128,9 @@ class LoginTask(Base):
|
|||||||
finished_at = Column(DateTime, nullable=True)
|
finished_at = Column(DateTime, nullable=True)
|
||||||
# Cookie 有效性检测结果(持久化,刷新不丢失)
|
# Cookie 有效性检测结果(持久化,刷新不丢失)
|
||||||
ck_check_status = Column(String(16), default="") # valid / invalid / 空=未检测
|
ck_check_status = Column(String(16), default="") # valid / invalid / 空=未检测
|
||||||
ck_check_result = Column(JSON, nullable=True) # {fish_ball, nickname, level, message}
|
ck_check_result = Column(
|
||||||
|
JSON, nullable=True
|
||||||
|
) # {fish_ball, nickname, level, message}
|
||||||
ck_checked_at = Column(DateTime, nullable=True)
|
ck_checked_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
account = relationship("Account", back_populates="login_tasks")
|
account = relationship("Account", back_populates="login_tasks")
|
||||||
@@ -111,6 +138,7 @@ class LoginTask(Base):
|
|||||||
|
|
||||||
class DouyuTask(Base):
|
class DouyuTask(Base):
|
||||||
"""斗鱼业务任务"""
|
"""斗鱼业务任务"""
|
||||||
|
|
||||||
__tablename__ = "douyu_tasks"
|
__tablename__ = "douyu_tasks"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -133,6 +161,7 @@ class DouyuTask(Base):
|
|||||||
|
|
||||||
class DouyuWorkbenchAccount(Base):
|
class DouyuWorkbenchAccount(Base):
|
||||||
"""用户在指定斗鱼工作台中启用的账号,跨浏览器同步。"""
|
"""用户在指定斗鱼工作台中启用的账号,跨浏览器同步。"""
|
||||||
|
|
||||||
__tablename__ = "douyu_workbench_accounts"
|
__tablename__ = "douyu_workbench_accounts"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -142,12 +171,15 @@ class DouyuWorkbenchAccount(Base):
|
|||||||
created_at = Column(DateTime, default=_utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"),
|
UniqueConstraint(
|
||||||
|
"user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DouyuWorkbench(Base):
|
class DouyuWorkbench(Base):
|
||||||
"""工作台配置哨兵,令空账号集合也能跨浏览器同步。"""
|
"""工作台配置哨兵,令空账号集合也能跨浏览器同步。"""
|
||||||
|
|
||||||
__tablename__ = "douyu_workbenches"
|
__tablename__ = "douyu_workbenches"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -165,6 +197,7 @@ class YybRechargeTask(Base):
|
|||||||
|
|
||||||
YYB 登录身份与斗鱼账号无关,因此任务只关联创建者,不复用 Account。
|
YYB 登录身份与斗鱼账号无关,因此任务只关联创建者,不复用 Account。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "yyb_recharge_tasks"
|
__tablename__ = "yyb_recharge_tasks"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -197,6 +230,7 @@ class YybRechargeTask(Base):
|
|||||||
|
|
||||||
class DouyuConfig(Base):
|
class DouyuConfig(Base):
|
||||||
"""斗鱼业务配置"""
|
"""斗鱼业务配置"""
|
||||||
|
|
||||||
__tablename__ = "douyu_config"
|
__tablename__ = "douyu_config"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -228,6 +262,7 @@ class DouyuConfig(Base):
|
|||||||
|
|
||||||
class DouyuGoodsSnapshot(Base):
|
class DouyuGoodsSnapshot(Base):
|
||||||
"""斗鱼兑换商品快照"""
|
"""斗鱼兑换商品快照"""
|
||||||
|
|
||||||
__tablename__ = "douyu_goods_snapshot"
|
__tablename__ = "douyu_goods_snapshot"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -241,6 +276,7 @@ class DouyuGoodsSnapshot(Base):
|
|||||||
|
|
||||||
class DouyuXpdGoodsSnapshot(Base):
|
class DouyuXpdGoodsSnapshot(Base):
|
||||||
"""斗鱼和平小店商品快照"""
|
"""斗鱼和平小店商品快照"""
|
||||||
|
|
||||||
__tablename__ = "douyu_xpd_goods_snapshot"
|
__tablename__ = "douyu_xpd_goods_snapshot"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -256,6 +292,7 @@ class DouyuXpdGoodsSnapshot(Base):
|
|||||||
|
|
||||||
class DouyuEsportsGoodsSnapshot(Base):
|
class DouyuEsportsGoodsSnapshot(Base):
|
||||||
"""斗鱼电竞手册皮肤商城商品快照"""
|
"""斗鱼电竞手册皮肤商城商品快照"""
|
||||||
|
|
||||||
__tablename__ = "douyu_esports_goods_snapshot"
|
__tablename__ = "douyu_esports_goods_snapshot"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -269,6 +306,7 @@ class DouyuEsportsGoodsSnapshot(Base):
|
|||||||
|
|
||||||
class HuyaAccount(Base):
|
class HuyaAccount(Base):
|
||||||
"""虎牙账号"""
|
"""虎牙账号"""
|
||||||
|
|
||||||
__tablename__ = "huya_accounts"
|
__tablename__ = "huya_accounts"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -292,12 +330,15 @@ class HuyaAccount(Base):
|
|||||||
created_at = Column(DateTime, default=_utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
assigned_user = relationship("User", back_populates="huya_accounts", foreign_keys=[assigned_to])
|
assigned_user = relationship(
|
||||||
|
"User", back_populates="huya_accounts", foreign_keys=[assigned_to]
|
||||||
|
)
|
||||||
tasks = relationship("HuyaTask", back_populates="account")
|
tasks = relationship("HuyaTask", back_populates="account")
|
||||||
|
|
||||||
|
|
||||||
class HuyaRegisterBatch(Base):
|
class HuyaRegisterBatch(Base):
|
||||||
"""虎牙自动注册批次(持久化元数据)"""
|
"""虎牙自动注册批次(持久化元数据)"""
|
||||||
|
|
||||||
__tablename__ = "huya_register_batches"
|
__tablename__ = "huya_register_batches"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -320,15 +361,20 @@ class HuyaRegisterBatch(Base):
|
|||||||
started_at = Column(DateTime, nullable=True)
|
started_at = Column(DateTime, nullable=True)
|
||||||
finished_at = Column(DateTime, nullable=True)
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
items = relationship("HuyaRegisterItem", back_populates="batch", cascade="all, delete-orphan")
|
items = relationship(
|
||||||
|
"HuyaRegisterItem", back_populates="batch", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HuyaRegisterItem(Base):
|
class HuyaRegisterItem(Base):
|
||||||
"""虎牙自动注册批次内单条手机号状态"""
|
"""虎牙自动注册批次内单条手机号状态"""
|
||||||
|
|
||||||
__tablename__ = "huya_register_items"
|
__tablename__ = "huya_register_items"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
batch_db_id = Column(Integer, ForeignKey("huya_register_batches.id"), nullable=False, index=True)
|
batch_db_id = Column(
|
||||||
|
Integer, ForeignKey("huya_register_batches.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
batch_id = Column(String(64), nullable=False, index=True)
|
batch_id = Column(String(64), nullable=False, index=True)
|
||||||
line = Column(Integer, nullable=False)
|
line = Column(Integer, nullable=False)
|
||||||
phone = Column(String(64), default="", index=True)
|
phone = Column(String(64), default="", index=True)
|
||||||
@@ -354,6 +400,7 @@ class HuyaRegisterItem(Base):
|
|||||||
|
|
||||||
class HuyaRegisterSuccessLog(Base):
|
class HuyaRegisterSuccessLog(Base):
|
||||||
"""虎牙自动注册成功流水(只追加,供随时导出)"""
|
"""虎牙自动注册成功流水(只追加,供随时导出)"""
|
||||||
|
|
||||||
__tablename__ = "huya_register_success_logs"
|
__tablename__ = "huya_register_success_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -373,6 +420,7 @@ class HuyaRegisterSuccessLog(Base):
|
|||||||
|
|
||||||
class HuyaTask(Base):
|
class HuyaTask(Base):
|
||||||
"""虎牙业务任务"""
|
"""虎牙业务任务"""
|
||||||
|
|
||||||
__tablename__ = "huya_tasks"
|
__tablename__ = "huya_tasks"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -391,6 +439,7 @@ class HuyaTask(Base):
|
|||||||
|
|
||||||
class HuyaConfig(Base):
|
class HuyaConfig(Base):
|
||||||
"""虎牙业务配置"""
|
"""虎牙业务配置"""
|
||||||
|
|
||||||
__tablename__ = "huya_config"
|
__tablename__ = "huya_config"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -404,6 +453,7 @@ class HuyaConfig(Base):
|
|||||||
|
|
||||||
class HuyaGoodsSnapshot(Base):
|
class HuyaGoodsSnapshot(Base):
|
||||||
"""虎牙兑换商品快照"""
|
"""虎牙兑换商品快照"""
|
||||||
|
|
||||||
__tablename__ = "huya_goods_snapshot"
|
__tablename__ = "huya_goods_snapshot"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -417,6 +467,7 @@ class HuyaGoodsSnapshot(Base):
|
|||||||
|
|
||||||
class HuyaRechargeGoodsSnapshot(Base):
|
class HuyaRechargeGoodsSnapshot(Base):
|
||||||
"""虎牙充值商品快照"""
|
"""虎牙充值商品快照"""
|
||||||
|
|
||||||
__tablename__ = "huya_recharge_goods_snapshot"
|
__tablename__ = "huya_recharge_goods_snapshot"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -436,6 +487,7 @@ class HuyaRechargeGoodsSnapshot(Base):
|
|||||||
|
|
||||||
class ProxyConfig(Base):
|
class ProxyConfig(Base):
|
||||||
"""代理配置(全局单条记录)"""
|
"""代理配置(全局单条记录)"""
|
||||||
|
|
||||||
__tablename__ = "proxy_config"
|
__tablename__ = "proxy_config"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -454,6 +506,7 @@ class ProxyConfig(Base):
|
|||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
"""操作审计日志"""
|
"""操作审计日志"""
|
||||||
|
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user