b39d918cda
Co-authored-by: exinglang <exinglang@qq.com> Reviewed-on: #218 Co-authored-by: zuochenyong <zuochenyong@wonderable.ai> Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
272 lines
10 KiB
Python
272 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from app.core.limit_policy import MODE_UNLIMITED
|
|
from app.core.rewards import CN_TZ
|
|
from app.core.security import decode_token
|
|
from app.db.session import SessionLocal
|
|
from app.models.comparison import ComparisonRecord
|
|
from app.models.limit_policy import LimitPolicyOverride
|
|
|
|
|
|
def _login(client) -> tuple[str, int]:
|
|
phone = f"137{int(time.time() * 1000) % 100000000:08d}"
|
|
sent = client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
|
assert sent.status_code == 200, sent.text
|
|
logged_in = client.post(
|
|
"/api/v1/auth/sms/login",
|
|
json={"phone": phone, "code": "123456"},
|
|
)
|
|
assert logged_in.status_code == 200, logged_in.text
|
|
token = logged_in.json()["access_token"]
|
|
return token, int(decode_token(token, expected_type="access")["sub"])
|
|
|
|
|
|
def _headers(token: str) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def test_compare_start_requires_login(client) -> None:
|
|
response = client.post(
|
|
"/api/v1/compare/start",
|
|
json={"trace_id": "quota-no-auth", "business_type": "food"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_compare_start_is_idempotent_by_trace_id(client) -> None:
|
|
token, user_id = _login(client)
|
|
payload = {
|
|
"trace_id": f"quota-idempotent-{user_id}",
|
|
"business_type": "ecom",
|
|
"device_id": "quota-device",
|
|
}
|
|
|
|
first = client.post("/api/v1/compare/start", json=payload, headers=_headers(token))
|
|
retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token))
|
|
|
|
assert first.status_code == 200, first.text
|
|
# trace_id 回显客户端带来的值(老协议幂等路径)
|
|
assert first.json() == {
|
|
"limit": 100, "used": 1, "remaining": 99, "trace_id": payload["trace_id"],
|
|
}
|
|
assert retry.status_code == 200, retry.text
|
|
assert retry.json() == first.json()
|
|
with SessionLocal() as db:
|
|
count = db.scalar(
|
|
select(func.count(ComparisonRecord.id)).where(
|
|
ComparisonRecord.trace_id == payload["trace_id"]
|
|
)
|
|
)
|
|
record = db.execute(
|
|
select(ComparisonRecord).where(
|
|
ComparisonRecord.trace_id == payload["trace_id"]
|
|
)
|
|
).scalar_one()
|
|
assert count == 1
|
|
assert record.user_id == user_id
|
|
assert record.status == "running"
|
|
assert record.business_type == "ecom"
|
|
assert record.device_id == "quota-device"
|
|
|
|
|
|
def test_compare_start_issues_trace_id_when_absent(client) -> None:
|
|
"""新客户端不带 trace_id → 服务端签发并随响应返回,running 行以签发 id 建。"""
|
|
token, user_id = _login(client)
|
|
response = client.post(
|
|
"/api/v1/compare/start",
|
|
json={"business_type": "food", "device_id": "quota-device-issue"},
|
|
headers=_headers(token),
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
issued = body["trace_id"]
|
|
assert issued # 非空签发
|
|
assert body["used"] == 1
|
|
with SessionLocal() as db:
|
|
record = db.execute(
|
|
select(ComparisonRecord).where(ComparisonRecord.trace_id == issued)
|
|
).scalar_one()
|
|
assert record.user_id == user_id
|
|
assert record.status == "running"
|
|
assert record.device_id == "quota-device-issue"
|
|
|
|
|
|
def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
|
|
token, user_id = _login(client)
|
|
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
|
with SessionLocal() as db:
|
|
db.add_all(
|
|
[
|
|
ComparisonRecord(
|
|
user_id=user_id,
|
|
trace_id=f"quota-full-{user_id}-{index}",
|
|
status="failed",
|
|
created_at=now,
|
|
)
|
|
for index in range(99)
|
|
]
|
|
)
|
|
db.add(
|
|
ComparisonRecord(
|
|
user_id=user_id,
|
|
trace_id=f"quota-yesterday-{user_id}",
|
|
status="success",
|
|
created_at=now - timedelta(days=1),
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
final_allowed_trace = f"quota-final-allowed-{user_id}"
|
|
allowed = client.post(
|
|
"/api/v1/compare/start",
|
|
json={"trace_id": final_allowed_trace, "business_type": "food"},
|
|
headers=_headers(token),
|
|
)
|
|
assert allowed.status_code == 200, allowed.text
|
|
assert allowed.json() == {
|
|
"limit": 100, "used": 100, "remaining": 0, "trace_id": final_allowed_trace,
|
|
}
|
|
|
|
rejected_trace = f"quota-rejected-{user_id}"
|
|
response = client.post(
|
|
"/api/v1/compare/start",
|
|
json={"trace_id": rejected_trace, "business_type": "food"},
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 429
|
|
assert response.json()["detail"] == "今日比价额度用完啦,明天再来吧~"
|
|
with SessionLocal() as db:
|
|
assert db.scalar(
|
|
select(func.count(ComparisonRecord.id)).where(
|
|
ComparisonRecord.trace_id == rejected_trace
|
|
)
|
|
) == 0
|
|
|
|
|
|
def test_compare_quota_fresh_user(client) -> None:
|
|
"""新用户今天没有比价记录 → exhausted=False, used=0, limit=100。"""
|
|
token, _user_id = _login(client)
|
|
response = client.get("/api/v1/compare/quota", headers=_headers(token))
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
|
|
|
|
|
|
def test_compare_quota_exhausted(client) -> None:
|
|
"""今日已有 100 条记录 → exhausted=True, used=100, limit=100。"""
|
|
token, user_id = _login(client)
|
|
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
|
with SessionLocal() as db:
|
|
db.add_all(
|
|
[
|
|
ComparisonRecord(
|
|
user_id=user_id,
|
|
trace_id=f"quota-exhausted-{user_id}-{i}",
|
|
status="failed",
|
|
created_at=now,
|
|
)
|
|
for i in range(100)
|
|
]
|
|
)
|
|
db.commit()
|
|
response = client.get("/api/v1/compare/quota", headers=_headers(token))
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"exhausted": True, "used": 100, "limit": 100}
|
|
|
|
|
|
def test_compare_quota_yesterday_rows_not_counted(client) -> None:
|
|
"""昨天的记录不计入今日配额 → exhausted=False, used=0。"""
|
|
token, user_id = _login(client)
|
|
yesterday = datetime.now(CN_TZ).replace(tzinfo=None) - timedelta(days=1)
|
|
with SessionLocal() as db:
|
|
db.add_all(
|
|
[
|
|
ComparisonRecord(
|
|
user_id=user_id,
|
|
trace_id=f"quota-yesterday-window-{user_id}-{i}",
|
|
status="success",
|
|
created_at=yesterday,
|
|
)
|
|
for i in range(100)
|
|
]
|
|
)
|
|
db.commit()
|
|
response = client.get("/api/v1/compare/quota", headers=_headers(token))
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
|
|
|
|
|
|
def test_compare_quota_device_whitelist_parity(client) -> None:
|
|
"""device 白名单下 /quota?device_id=X 与 /start 的 policy 完全一致。
|
|
|
|
场景:
|
|
- 设备 whitelisted-device-001 有 unlimited 覆盖 → /quota?device_id= 应报
|
|
exhausted=False, limit=null,哪怕同用户今天已发起 ≥100 次。
|
|
- 不带 device_id(或带非白名单设备) → 同用户走全局 100 上限,exhausted=True。
|
|
"""
|
|
token, user_id = _login(client)
|
|
device_id = f"whitelisted-device-{user_id}"
|
|
|
|
# 种 100 条今日记录:此时不带白名单设备 /quota 应报 exhausted=True
|
|
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
|
with SessionLocal() as db:
|
|
db.add_all(
|
|
[
|
|
ComparisonRecord(
|
|
user_id=user_id,
|
|
trace_id=f"quota-parity-{user_id}-{i}",
|
|
status="failed",
|
|
created_at=now,
|
|
)
|
|
for i in range(100)
|
|
]
|
|
)
|
|
# 种 device 白名单覆盖:unlimited、无失效时间(永久白名单用 expires_at=None)
|
|
# 注意:validate_override 要求 unlimited+有 expires_at,但这里直接写 ORM 跳过
|
|
# 该验证——测试意图是覆盖"设备白名单已存在"的生产状态,expires_at=None 代表永久。
|
|
db.add(
|
|
LimitPolicyOverride(
|
|
subject_type="device",
|
|
subject_value=device_id,
|
|
rule_code="compare.start.daily",
|
|
mode=MODE_UNLIMITED,
|
|
enabled=True,
|
|
expires_at=None,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
# 带白名单 device_id → unlimited,不受 100 条记录限制
|
|
resp_with_device = client.get(
|
|
f"/api/v1/compare/quota?device_id={device_id}",
|
|
headers=_headers(token),
|
|
)
|
|
assert resp_with_device.status_code == 200, resp_with_device.text
|
|
body_with = resp_with_device.json()
|
|
assert body_with["exhausted"] is False, f"whitelisted device should not be exhausted: {body_with}"
|
|
assert body_with["limit"] is None, f"whitelisted device should have null limit: {body_with}"
|
|
assert body_with["used"] == 100
|
|
|
|
# 不带 device_id → 走全局 100 上限,已有 100 条 → exhausted=True
|
|
resp_no_device = client.get("/api/v1/compare/quota", headers=_headers(token))
|
|
assert resp_no_device.status_code == 200, resp_no_device.text
|
|
body_no = resp_no_device.json()
|
|
assert body_no["exhausted"] is True, f"without device should be exhausted: {body_no}"
|
|
assert body_no["limit"] == 100
|
|
assert body_no["used"] == 100
|
|
|
|
# 带非白名单 device_id → 同样走全局 100 上限
|
|
resp_other_device = client.get(
|
|
"/api/v1/compare/quota?device_id=unknown-device-xyz",
|
|
headers=_headers(token),
|
|
)
|
|
assert resp_other_device.status_code == 200, resp_other_device.text
|
|
body_other = resp_other_device.json()
|
|
assert body_other["exhausted"] is True, f"non-whitelisted device should be exhausted: {body_other}"
|
|
assert body_other["limit"] == 100
|