feat(auth): 测试账号免验证码登录 + 每次走引导 + 每日上限 (#69)

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #69
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
This commit was merged in pull request #69.
This commit is contained in:
2026-06-24 03:53:52 +08:00
committed by marco
parent 4a9d3f1d1a
commit d8c7a6d1ef
6 changed files with 299 additions and 2 deletions
+165
View File
@@ -0,0 +1,165 @@
"""测试账号(app/core/test_account.py)行为测试。
覆盖四件事:
- 免验证码登录(mock 与 real 模式都跳过校验);
- 每次登录都重走新手引导(onboarding_completed 恒 false,即便完成表已有记录);
- 每日登录上限(超过即 429),且只算 login、不算 send;
- 功能关闭(TEST_ACCOUNT_PHONE 留空)时对任何号都不生效,回到原逻辑。
"""
from __future__ import annotations
import pytest
from app.core import test_account
from app.integrations import sms
# 测试自注入的固定号:各用例用 monkeypatch 把它塞进 settings.TEST_ACCOUNT_PHONE,
# 再断言行为。它与 .env 配的值【相互独立】——测试不读 .env(保证确定性 / CI 无 .env 也能跑),
# 写成和 .env 同值只为直观;换任意 11 位号测试照样全过,二者无需保持一致。
TEST_PHONE = "11111111111"
@pytest.fixture()
def enabled(monkeypatch):
"""启用测试账号功能并清空当日计数;monkeypatch 与 _reset 保证用例间隔离。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", TEST_PHONE)
test_account._reset_for_test()
yield
test_account._reset_for_test()
# ============================ 开关 / 识别 ============================
def test_disabled_when_phone_unset(monkeypatch) -> None:
"""TEST_ACCOUNT_PHONE 留空 = 整功能关闭,对测试号也不特殊处理。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", "")
assert test_account.is_enabled() is False
assert test_account.is_test_account(TEST_PHONE) is False
def test_only_configured_phone_is_test_account(enabled) -> None:
assert test_account.is_test_account(TEST_PHONE) is True
assert test_account.is_test_account("13800138000") is False
def test_phone_is_whitespace_trimmed(monkeypatch) -> None:
""".env 里手机号前后带空格也能正确识别。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", f" {TEST_PHONE} ")
assert test_account.is_test_account(TEST_PHONE) is True
# ============================ 免验证码 + 新手引导 ============================
def test_login_skips_code_and_returns_tokens(client, enabled) -> None:
"""测试号任意验证码即可登入(不用等真短信),拿到 JWT,onboarding 为 false。"""
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "000000", "device_id": "dev-a"},
)
assert r.status_code == 200, r.text
data = r.json()
assert data["user"]["phone"] == TEST_PHONE
assert data["user"]["register_channel"] == "sms"
assert data["onboarding_completed"] is False
assert data["force_onboarding"] is True # 测试号:应用内重登也强制重走引导
assert "access_token" in data and "refresh_token" in data
def test_onboarding_always_false_even_if_completed(client, enabled) -> None:
"""即便 onboarding_completion 表已有 (该用户, 该设备) 记录,测试号再登仍返回 false。"""
from app.db.session import SessionLocal
from app.repositories import onboarding as onboarding_repo
device = "dev-onb"
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
)
uid = r.json()["user"]["id"]
db = SessionLocal()
try:
onboarding_repo.mark_completed(db, user_id=uid, device_id=device)
assert onboarding_repo.is_completed(db, user_id=uid, device_id=device) is True
finally:
db.close()
r2 = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
)
assert r2.json()["onboarding_completed"] is False # 仍重走引导
# ============================ real 模式跳过 ============================
def test_login_skips_verify_in_real_mode(client, enabled, monkeypatch) -> None:
"""real 模式(SMS_MOCK=false)普通号要真校验;测试号必须跳过 → 任意码仍 200。"""
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "9999"},
)
assert r.status_code == 200, r.text
def test_send_short_circuits_in_real_mode(client, enabled, monkeypatch) -> None:
"""real 模式下测试号 /sms/send 不真打极光(否则缺号/缺 key 会 503),直接 200 放行进填码界面。"""
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
r = client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE})
assert r.status_code == 200, r.text
body = r.json()
assert body["sent"] is True
assert body["cooldown_sec"] == 0
# ============================ 每日上限 ============================
def test_daily_limit_blocks_after_threshold(client, enabled, monkeypatch) -> None:
"""当日登录数到上限后再登 → 429。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 2)
test_account._reset_for_test()
for _ in range(2):
r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
assert r.status_code == 200, r.text
r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
assert r.status_code == 429
assert "上限" in r.json()["detail"]
def test_send_does_not_consume_quota(client, enabled, monkeypatch) -> None:
"""额度只算 login、不算 send:狂发 send 后那唯一一次 login 仍能成功。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
test_account._reset_for_test()
for _ in range(3):
assert client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE}).status_code == 200
assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 200
# 第二次 login 才超限
assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 429
# ============================ 计数单元逻辑 ============================
def test_quota_resets_across_day(enabled, monkeypatch) -> None:
"""跨天自动归零。"""
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
monkeypatch.setattr(test_account, "_today", lambda: "2026-06-23")
assert test_account.try_consume_quota() is True
assert test_account.try_consume_quota() is False # 当天超限
monkeypatch.setattr(test_account, "_today", lambda: "2026-06-24")
assert test_account.try_consume_quota() is True # 次日归零放行
def test_non_test_phone_unaffected_when_enabled(client, enabled) -> None:
"""功能开启时,普通号走原 mock 流程(任意 6 位通过),不受测试号逻辑影响。"""
phone = "13812341234"
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456", "device_id": "d1"})
assert r.status_code == 200, r.text
assert r.json()["user"]["phone"] == phone
assert r.json()["force_onboarding"] is False # 普通号不强制重走,按原逻辑