feat(#1718): PATCH /auth/me 资料更新接口 + profile_completed 字段 (#1728)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m6s
AI Code Review / AI Code Review (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Validate - Security (push) Successful in 4m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 8m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 11m48s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m6s
AI Code Review / AI Code Review (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Validate - Security (push) Successful in 4m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 8m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 11m48s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #1728.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""add profile_completed to users
|
||||
|
||||
Issue #1718:微信新用户首次登录需设置昵称(PATCH /auth/me)。
|
||||
- users.profile_completed:资料是否已完善;存量行默认 True(不触发引导),
|
||||
微信新建用户在应用层置 False。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "068_user_profile_completed"
|
||||
down_revision = "067_celery_task_id"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("profile_completed", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "profile_completed")
|
||||
@@ -1,4 +1,5 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
Canonical authentication API routes.
|
||||
|
||||
The route layer is intentionally thin: repository construction lives in
|
||||
@@ -15,7 +16,7 @@ from app.config import settings
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore
|
||||
from packages.adapters.smtp import NoopEmailService
|
||||
@@ -85,6 +86,22 @@ class CurrentUserResponse(BaseModel):
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
profile_completed: bool = True
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
"""用户资料负载(PATCH /me、绑定/解绑接口复用;字段与 GET /auth/me 一致,前端 normalizeUser 直接消费)"""
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
profile_completed: bool = True
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
@@ -274,9 +291,51 @@ async def get_current_user_info(
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
profile_completed=user.profile_completed,
|
||||
)
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""更新个人资料请求(当前仅支持昵称)"""
|
||||
|
||||
display_name: str
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def _validate_display_name(cls, v: str) -> str:
|
||||
name = (v or "").strip()
|
||||
if not name:
|
||||
raise ValueError("昵称不能为空白")
|
||||
if len(name) > 20:
|
||||
raise ValueError("昵称长度需在 1-20 个字符之间")
|
||||
return name
|
||||
|
||||
|
||||
class UpdateProfileResponse(BaseModel):
|
||||
"""更新资料响应:前端 normalizeUser(response.user) 直接消费"""
|
||||
|
||||
user: UserProfileResponse
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UpdateProfileResponse)
|
||||
async def update_current_user_profile(
|
||||
request: UpdateProfileRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> UpdateProfileResponse:
|
||||
"""更新当前登录用户昵称(微信新用户首次设置昵称后置 profile_completed=True)。"""
|
||||
user = current_user.user
|
||||
user.display_name = request.display_name # 已 strip(validator)
|
||||
if not user.profile_completed:
|
||||
user.profile_completed = True
|
||||
user_repository.save(user)
|
||||
|
||||
logger.info("[资料更新] 用户 %s 更新昵称,profile_completed=%s", user.id, user.profile_completed)
|
||||
# 重新读取,确保返回的是持久化后的最新状态
|
||||
fresh = user_repository.find_by_id(user.id) or user
|
||||
return UpdateProfileResponse(user=_user_profile(fresh))
|
||||
|
||||
|
||||
class _NoopSessionStore(NoopSessionStore):
|
||||
pass
|
||||
|
||||
@@ -507,34 +566,20 @@ class WechatBindCompleteRequest(BaseModel):
|
||||
state: str = ""
|
||||
|
||||
|
||||
class WechatBindUserProfile(BaseModel):
|
||||
"""绑定/解绑后返回的用户信息(字段对齐 /auth/me,前端 normalizeUser 直接消费)"""
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
|
||||
|
||||
class WechatBindCompleteResponse(BaseModel):
|
||||
success: bool
|
||||
user: WechatBindUserProfile
|
||||
user: UserProfileResponse
|
||||
|
||||
|
||||
class WechatUnbindResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
def _wechat_user_profile(user) -> WechatBindUserProfile:
|
||||
def _user_profile(user) -> UserProfileResponse:
|
||||
binding_complete = bool(
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
return WechatBindUserProfile(
|
||||
return UserProfileResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
@@ -544,6 +589,7 @@ def _wechat_user_profile(user) -> WechatBindUserProfile:
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
profile_completed=user.profile_completed,
|
||||
)
|
||||
|
||||
|
||||
@@ -589,7 +635,7 @@ async def wechat_bind(
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
|
||||
return WechatBindCompleteResponse(success=True, user=_wechat_user_profile(result.user))
|
||||
return WechatBindCompleteResponse(success=True, user=_user_profile(result.user))
|
||||
|
||||
|
||||
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
|
||||
|
||||
@@ -38,6 +38,7 @@ class UserModel(Base):
|
||||
phone = Column(String(32), nullable=True, unique=True, index=True)
|
||||
phone_verified = Column(Boolean, nullable=False, default=False)
|
||||
binding_completed_at = Column(DateTime, nullable=True)
|
||||
profile_completed = Column(Boolean, nullable=False, default=True, server_default="true")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
model.phone = user.phone
|
||||
model.phone_verified = user.phone_verified
|
||||
model.binding_completed_at = user.binding_completed_at
|
||||
model.profile_completed = user.profile_completed
|
||||
model.created_at = user.created_at
|
||||
|
||||
self.session.commit()
|
||||
@@ -113,5 +114,6 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
phone=model.phone,
|
||||
phone_verified=model.phone_verified or False,
|
||||
binding_completed_at=model.binding_completed_at,
|
||||
profile_completed=model.profile_completed if model.profile_completed is not None else True,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -200,6 +200,8 @@ class WechatSyncUseCase:
|
||||
email_verified=True, # 微信登录视为已验证
|
||||
wechat_openid=request.openid,
|
||||
wechat_unionid=request.unionid or None,
|
||||
# 微信新建用户首次登录需引导设置昵称
|
||||
profile_completed=False,
|
||||
)
|
||||
|
||||
self.user_repository.save(user)
|
||||
|
||||
@@ -57,6 +57,8 @@ class User:
|
||||
phone: str | None = None
|
||||
phone_verified: bool = False
|
||||
binding_completed_at: datetime | None = None
|
||||
# 资料是否已完善(微信新用户首次设置昵称后置 True;邮箱注册默认 True)
|
||||
profile_completed: bool = True
|
||||
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""#1718:PATCH /auth/me 资料更新接口测试。
|
||||
|
||||
覆盖:
|
||||
- 正常更新昵称并落库
|
||||
- strip 生效(前后空白去除)
|
||||
- 纯空白/超长 -> 422(pydantic 校验)
|
||||
- 首次设置昵称 profile_completed False->True
|
||||
- 已完成用户重复提交幂等(仍 True)
|
||||
- 未登录由 get_current_user 依赖保证 401(框架行为,这里验证路由声明了该依赖)
|
||||
- 响应结构 {user: {...}} 含 wechat_bound/profile_completed 全字段
|
||||
- 微信新建用户 profile_completed 默认 False(wechat_sync _create_wechat_user)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes import auth as auth_route # noqa: E402
|
||||
|
||||
from packages.adapters.in_memory.user_repository import InMemoryUserRepository # noqa: E402
|
||||
from packages.domain.entities import User # noqa: E402
|
||||
|
||||
|
||||
def _auth_user(user):
|
||||
return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth")
|
||||
|
||||
|
||||
def _make_user(**kw):
|
||||
defaults = dict(
|
||||
id="u-1",
|
||||
email="user@example.com",
|
||||
username="user",
|
||||
display_name="微信用户",
|
||||
password_hash="x",
|
||||
email_verified=True,
|
||||
profile_completed=False,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
# ---------- 请求体校验 ----------
|
||||
|
||||
|
||||
def test_display_name_strips_whitespace():
|
||||
req = auth_route.UpdateProfileRequest(display_name=" ying123 ")
|
||||
assert req.display_name == "ying123"
|
||||
|
||||
|
||||
def test_display_name_blank_rejected():
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
auth_route.UpdateProfileRequest(display_name=" ")
|
||||
assert "空白" in str(exc.value)
|
||||
|
||||
|
||||
def test_display_name_empty_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
auth_route.UpdateProfileRequest(display_name="")
|
||||
|
||||
|
||||
def test_display_name_too_long_rejected():
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
auth_route.UpdateProfileRequest(display_name="甲" * 21)
|
||||
assert "1-20" in str(exc.value)
|
||||
|
||||
|
||||
def test_display_name_max_length_accepted():
|
||||
req = auth_route.UpdateProfileRequest(display_name="甲" * 20)
|
||||
assert req.display_name == "甲" * 20
|
||||
|
||||
|
||||
# ---------- 路由逻辑 ----------
|
||||
|
||||
|
||||
def test_patch_me_updates_display_name_and_persists():
|
||||
user = _make_user()
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(user)
|
||||
|
||||
resp = asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name=" ying123 "),
|
||||
current_user=_auth_user(user),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
assert resp.user.display_name == "ying123"
|
||||
assert resp.user.profile_completed is True
|
||||
assert resp.user.wechat_bound is False
|
||||
# 落库验证
|
||||
fresh = repo.find_by_id("u-1")
|
||||
assert fresh.display_name == "ying123"
|
||||
assert fresh.profile_completed is True
|
||||
|
||||
|
||||
def test_patch_me_first_time_sets_profile_completed_true():
|
||||
user = _make_user(profile_completed=False)
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(user)
|
||||
assert repo.find_by_id("u-1").profile_completed is False
|
||||
|
||||
asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name="小虾"),
|
||||
current_user=_auth_user(user),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
assert repo.find_by_id("u-1").profile_completed is True
|
||||
|
||||
|
||||
def test_patch_me_idempotent_for_completed_user():
|
||||
user = _make_user(display_name="老名字", profile_completed=True)
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(user)
|
||||
|
||||
resp = asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name="新名字"),
|
||||
current_user=_auth_user(user),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
assert resp.user.profile_completed is True
|
||||
assert resp.user.display_name == "新名字"
|
||||
# 再提交一次同样内容,不报错、状态稳定
|
||||
resp2 = asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name="新名字"),
|
||||
current_user=_auth_user(repo.find_by_id("u-1")),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
assert resp2.user.profile_completed is True
|
||||
|
||||
|
||||
def test_patch_me_response_contains_all_me_fields():
|
||||
user = _make_user(wechat_openid="wx-1", phone="13800000000", phone_verified=True)
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(user)
|
||||
|
||||
resp = asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name="昵称"),
|
||||
current_user=_auth_user(user),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
payload = resp.user.model_dump()
|
||||
for field in (
|
||||
"user_id",
|
||||
"email",
|
||||
"username",
|
||||
"display_name",
|
||||
"email_verified",
|
||||
"phone",
|
||||
"phone_verified",
|
||||
"binding_complete",
|
||||
"wechat_bound",
|
||||
"profile_completed",
|
||||
):
|
||||
assert field in payload, f"missing field {field}"
|
||||
assert payload["wechat_bound"] is True
|
||||
assert payload["phone"] == "13800000000"
|
||||
|
||||
|
||||
def test_get_me_includes_profile_completed_flag():
|
||||
# 未完成
|
||||
u = _make_user(profile_completed=False)
|
||||
resp = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(u)))
|
||||
assert resp.profile_completed is False
|
||||
assert resp.wechat_bound is False
|
||||
|
||||
# 已完成 + 已绑微信
|
||||
u2 = _make_user(profile_completed=True, wechat_openid="wx-9")
|
||||
resp2 = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(u2)))
|
||||
assert resp2.profile_completed is True
|
||||
assert resp2.wechat_bound is True
|
||||
|
||||
|
||||
def test_patch_me_requires_auth_dependency():
|
||||
# 路由签名必须依赖 get_current_user,未携带 token 时框架返回 401
|
||||
params = (
|
||||
auth_route.update_current_user_profile.__wrapped__
|
||||
if hasattr(auth_route.update_current_user_profile, "__wrapped__")
|
||||
else auth_route.update_current_user_profile
|
||||
)
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(params)
|
||||
dep = sig.parameters.get("current_user")
|
||||
assert dep is not None
|
||||
assert dep.default is not None and getattr(dep.default, "dependency", None) is auth_route.get_current_user
|
||||
|
||||
|
||||
def test_wechat_new_user_created_with_profile_completed_false():
|
||||
# 微信同步建号:新用户 profile_completed=False(需引导设置昵称)
|
||||
from packages.application.auth.wechat_sync_use_case import (
|
||||
WechatSyncRequest,
|
||||
WechatSyncUseCase,
|
||||
)
|
||||
|
||||
repo = InMemoryUserRepository()
|
||||
# session_store 用 mock,不依赖 redis
|
||||
use_case = WechatSyncUseCase(user_repository=repo, session_store=MagicMock(), jwt_secret_key="test-secret")
|
||||
resp, err = use_case.execute(WechatSyncRequest(openid="wx-new-openid", nickname="微信测试", source="web"))
|
||||
assert err is None
|
||||
user = repo.find_by_id(resp.user_id)
|
||||
assert user.profile_completed is False
|
||||
@@ -38,6 +38,7 @@ def _auth_user(user_id="u-1", openid=None):
|
||||
display_name="用户",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
profile_completed=True,
|
||||
)
|
||||
return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth")
|
||||
|
||||
@@ -112,6 +113,7 @@ def test_bind_success_returns_user_with_wechat_bound():
|
||||
display_name="用户",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
profile_completed=True,
|
||||
)
|
||||
|
||||
import packages.application.auth.wechat_oauth_service as oauth_mod
|
||||
|
||||
Reference in New Issue
Block a user