Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28a2322f73 |
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios"
|
||||
import apiClient from "./client"
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 注册
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ========== 微信登录 ========== */
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
|
||||
// 获取微信授权链接
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 微信回调登录
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 绑定联系方式
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
import type { SendVerificationCodeRequest, BindContactRequest, BindContactResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
*/
|
||||
export const sendVerificationCode = async (
|
||||
data: SendVerificationCodeRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式
|
||||
*/
|
||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||
const response = await apiClient.post("/auth/bind-contact", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
import type { User, UserResponse } from "./types"
|
||||
import { normalizeUser } from "./user"
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*/
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 验证邮箱
|
||||
*/
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
* 保持向后兼容,从子模块 re-export
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
User,
|
||||
UserResponse,
|
||||
WechatAuthUrlResponse,
|
||||
WechatCallbackResponse,
|
||||
SendVerificationCodeRequest,
|
||||
BindContactRequest,
|
||||
BindContactResponse,
|
||||
} from "./types"
|
||||
|
||||
// 用户工具函数
|
||||
export { normalizeUser } from "./user"
|
||||
|
||||
// 登录/注册/登出/刷新
|
||||
export { login, refreshAccessToken, register, logout } from "./login"
|
||||
|
||||
// 当前用户
|
||||
export { getCurrentUser } from "./currentUser"
|
||||
|
||||
// 密码重置
|
||||
export { requestPasswordReset, resetPassword } from "./password"
|
||||
|
||||
// 邮箱验证
|
||||
export { verifyEmail } from "./email"
|
||||
|
||||
// 微信登录
|
||||
export { getWechatAuthUrl, wechatCallback } from "./wechat"
|
||||
|
||||
// 联系方式
|
||||
export { sendVerificationCode, bindContact } from "./contact"
|
||||
@@ -1,37 +0,0 @@
|
||||
import axios from "axios"
|
||||
import apiClient from "../client"
|
||||
import type { LoginRequest, LoginResponse, RegisterRequest } from "./types"
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
*/
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
|
||||
/**
|
||||
* 请求密码重置
|
||||
*/
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface WechatAuthUrlResponse {
|
||||
auth_url: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface WechatCallbackResponse {
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
user_id: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
is_new_user: boolean
|
||||
binding_complete: boolean
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
target: "email" | "phone"
|
||||
value: string
|
||||
purpose: "bind" | "login" | "reset_password"
|
||||
}
|
||||
|
||||
export interface BindContactRequest {
|
||||
email?: string
|
||||
email_code?: string
|
||||
phone?: string
|
||||
phone_code?: string
|
||||
}
|
||||
|
||||
export interface BindContactResponse {
|
||||
success: boolean
|
||||
user: User
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { User, UserResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 规范化用户数据,兼容不同后端返回格式
|
||||
*/
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import apiClient from "../client"
|
||||
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
|
||||
|
||||
/**
|
||||
* 获取微信授权链接
|
||||
*/
|
||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||
const response = await apiClient.get("/auth/wechat/url")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回调登录
|
||||
*/
|
||||
export const wechatCallback = async (
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<WechatCallbackResponse> => {
|
||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||
return response.data
|
||||
}
|
||||
+83
-20
@@ -1,20 +1,84 @@
|
||||
import React from "react"
|
||||
import { Modal, Tabs, Form, Input, Button } from "antd"
|
||||
import { useBindContactForm } from "./hooks/useBindContactForm"
|
||||
import type { BindContactModalProps } from "./types"
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Modal, Tabs, Form, Input, Button, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
|
||||
interface BindContactModalProps {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
} = useBindContactForm({ open, onSuccess, onCancel })
|
||||
const [activeTab, setActiveTab] = useState<"email" | "phone">("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
setCountdown(0)
|
||||
}
|
||||
}, [open, form])
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const value = form.getFieldValue(activeTab === "email" ? "email" : "phone")
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
setCountdown(60)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.code }
|
||||
: { phone: values.phone, phone_code: values.code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch (error) {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -47,7 +111,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入邮箱地址" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email_code"
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -57,7 +121,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={codeDisabled}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -82,7 +146,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
<Input placeholder="请输入手机号" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone_code"
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: "请输入验证码" }]}
|
||||
>
|
||||
@@ -92,7 +156,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
size="large"
|
||||
onClick={handleSendCode}
|
||||
loading={codeLoading}
|
||||
disabled={codeDisabled}
|
||||
disabled={countdown > 0}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||
</Button>
|
||||
@@ -114,4 +178,3 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
||||
}
|
||||
|
||||
export default BindContactModal
|
||||
export type { BindContactModalProps } from "./types"
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Form, message } from "antd"
|
||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||
import { useCountdown } from "./useCountdown"
|
||||
|
||||
type ContactType = "email" | "phone"
|
||||
|
||||
interface UseBindContactFormOptions {
|
||||
open: boolean
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定联系方式表单 Hook
|
||||
* 封装表单状态、验证码发送、提交绑定等业务逻辑
|
||||
*/
|
||||
export const useBindContactForm = ({ open, onSuccess }: UseBindContactFormOptions) => {
|
||||
const [activeTab, setActiveTab] = useState<ContactType>("email")
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeLoading, setCodeLoading] = useState(false)
|
||||
const { countdown, isRunning, start: startCountdown, reset: resetCountdown } = useCountdown(60)
|
||||
|
||||
/* ── 打开时重置表单 ── */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields()
|
||||
resetCountdown()
|
||||
}
|
||||
}, [open, form, resetCountdown])
|
||||
|
||||
/* ── 发送验证码 ── */
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const fieldName = activeTab === "email" ? "email" : "phone"
|
||||
const value = form.getFieldValue(fieldName)
|
||||
if (!value) {
|
||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||
return
|
||||
}
|
||||
setCodeLoading(true)
|
||||
await sendVerificationCode({
|
||||
target: activeTab,
|
||||
value,
|
||||
purpose: "bind",
|
||||
})
|
||||
message.success("验证码已发送")
|
||||
startCountdown()
|
||||
} catch {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
setCodeLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提交绑定 ── */
|
||||
const handleSubmit = async () => {
|
||||
// 1. 表单验证(失败时 Ant Design Form 会自动展示错误信息)
|
||||
let values
|
||||
try {
|
||||
values = await form.validateFields()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 调用绑定接口
|
||||
setLoading(true)
|
||||
try {
|
||||
const payload =
|
||||
activeTab === "email"
|
||||
? { email: values.email, email_code: values.email_code }
|
||||
: { phone: values.phone, phone_code: values.phone_code }
|
||||
|
||||
const result = await bindContact(payload)
|
||||
|
||||
message.success("绑定成功")
|
||||
onSuccess?.(result.user)
|
||||
} catch {
|
||||
// API 错误由全局拦截器统一处理提示
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
form,
|
||||
loading,
|
||||
codeLoading,
|
||||
countdown,
|
||||
codeDisabled: isRunning,
|
||||
handleSendCode,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 倒计时 Hook
|
||||
* 用于验证码发送等场景的倒计时控制
|
||||
*/
|
||||
export const useCountdown = (initialSeconds = 60) => {
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1)
|
||||
}, 1000)
|
||||
} else if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
const start = useCallback(() => {
|
||||
setCountdown(initialSeconds)
|
||||
}, [initialSeconds])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCountdown(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
countdown,
|
||||
isRunning: countdown > 0,
|
||||
start,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { BindContactResponse } from "@/api/auth"
|
||||
|
||||
/** BindContactModal 组件属性 */
|
||||
export interface BindContactModalProps {
|
||||
/** 是否打开 */
|
||||
open: boolean
|
||||
/** 绑定成功回调 */
|
||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||
/** 取消回调 */
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/** 联系方式类型 */
|
||||
export type ContactType = "email" | "phone"
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from "react"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { TEXT_PRESET_STYLES } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface StickerPreviewProps {
|
||||
sticker: StickerItem
|
||||
}
|
||||
|
||||
export const StickerPreview: React.FC<StickerPreviewProps> = ({ sticker }) => (
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.height}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
Executable → Regular
+75
-5
@@ -2,9 +2,11 @@
|
||||
* 选中贴纸的属性编辑器
|
||||
*/
|
||||
import React from "react"
|
||||
import type { StickerItem } from "@/pages/editing-planner/types"
|
||||
import { StickerPreview } from "./StickerPreview"
|
||||
import { TextStickerPropsEditor } from "./TextStickerPropsEditor"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import {
|
||||
TEXT_PRESET_STYLES,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface StickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
@@ -119,10 +121,78 @@ const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
<TextStickerPropsEditor sticker={sticker} onUpdate={onUpdate} />
|
||||
{sticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预览 */}
|
||||
<StickerPreview sticker={sticker} />
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${sticker.x}%`,
|
||||
top: `${sticker.y}%`,
|
||||
width: `${sticker.width}%`,
|
||||
height: `${sticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
|
||||
opacity: sticker.opacity / 100,
|
||||
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
|
||||
...TEXT_PRESET_STYLES[sticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{sticker.type === "emoji" && sticker.content}
|
||||
{sticker.type === "text" && sticker.content}
|
||||
{sticker.type === "image" && (
|
||||
<img
|
||||
src={sticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from "react"
|
||||
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
|
||||
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/constants/sticker"
|
||||
|
||||
interface TextStickerPropsEditorProps {
|
||||
sticker: StickerItem
|
||||
onUpdate: (id: string, partial: Partial<StickerItem>) => void
|
||||
}
|
||||
|
||||
export const TextStickerPropsEditor: React.FC<TextStickerPropsEditorProps> = ({
|
||||
sticker,
|
||||
onUpdate,
|
||||
}) => {
|
||||
if (sticker.type !== "text") return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={sticker.text_preset}
|
||||
onChange={(e) =>
|
||||
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
|
||||
}
|
||||
>
|
||||
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={sticker.font_size}
|
||||
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="sticker-prop-value">{sticker.font_size}px</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={sticker.text_color}
|
||||
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Auth API 测试
|
||||
* 对应 api/auth/ 目录化后的模块
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
normalizeUser,
|
||||
@@ -14,6 +10,7 @@ import {
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
} from "@/api/auth"
|
||||
|
||||
const mockPost = vi.fn()
|
||||
const mockGet = vi.fn()
|
||||
const mockAxiosPost = vi.fn()
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import BindContactModal from "@/components/auth/BindContactModal"
|
||||
import type { BindContactModalProps, ContactType } from "@/components/auth/BindContactModal/types"
|
||||
import { useBindContactForm } from "@/components/auth/BindContactModal/hooks/useBindContactForm"
|
||||
import { useCountdown } from "@/components/auth/BindContactModal/hooks/useCountdown"
|
||||
|
||||
describe("BindContactModal", () => {
|
||||
it("主组件可正常导入", () => {
|
||||
expect(BindContactModal).toBeDefined()
|
||||
})
|
||||
|
||||
it("Hook 可正常导入", () => {
|
||||
expect(useBindContactForm).toBeDefined()
|
||||
expect(useCountdown).toBeDefined()
|
||||
})
|
||||
|
||||
it("类型导出正常", () => {
|
||||
const props: BindContactModalProps = { open: false }
|
||||
expect(props.open).toBe(false)
|
||||
|
||||
const contactType: ContactType = "email"
|
||||
expect(contactType).toBe("email")
|
||||
})
|
||||
})
|
||||
Executable → Regular
-2
@@ -61,8 +61,6 @@ import "@/pages/editing-planner/components/pip-config/LayerConfig"
|
||||
import "@/pages/editing-planner/components/sticker/StickerLibrary"
|
||||
import "@/pages/editing-planner/components/sticker/StickerList"
|
||||
import "@/pages/editing-planner/components/sticker/StickerPropsEditor"
|
||||
import "@/pages/editing-planner/components/sticker/StickerPreview"
|
||||
import "@/pages/editing-planner/components/sticker/TextStickerPropsEditor"
|
||||
import "@/pages/editing-planner/components/filter/FilterPresetGrid"
|
||||
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
|
||||
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
"""edit_template 剪辑模板实体单测."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from domain.editing_mode import EditingMode
|
||||
|
||||
# ── EditTemplateStatus 枚举 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateStatus:
|
||||
"""EditTemplateStatus 枚举"""
|
||||
|
||||
def test_enum_values(self):
|
||||
assert EditTemplateStatus.ACTIVE.value == "active"
|
||||
assert EditTemplateStatus.INACTIVE.value == "inactive"
|
||||
|
||||
def test_is_str_enum(self):
|
||||
assert isinstance(EditTemplateStatus.ACTIVE, str)
|
||||
assert EditTemplateStatus.ACTIVE == "active"
|
||||
|
||||
def test_from_string(self):
|
||||
assert EditTemplateStatus("active") == EditTemplateStatus.ACTIVE
|
||||
assert EditTemplateStatus("inactive") == EditTemplateStatus.INACTIVE
|
||||
|
||||
def test_invalid_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
EditTemplateStatus("deleted")
|
||||
|
||||
|
||||
# ── EditTemplate.create 工厂方法 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateCreate:
|
||||
"""EditTemplate.create 工厂方法"""
|
||||
|
||||
def test_minimal_create(self):
|
||||
t = EditTemplate.create("测试模板")
|
||||
assert t.id is not None
|
||||
assert len(t.id) == 32 # uuid4 hex
|
||||
assert t.name == "测试模板"
|
||||
assert t.description == ""
|
||||
assert t.template_type == "default"
|
||||
assert t.editing_mode == "one_take"
|
||||
assert t.config == {}
|
||||
assert t.preview_url == ""
|
||||
assert t.sort_weight == 0
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
assert t.version == 1
|
||||
|
||||
def test_unique_ids(self):
|
||||
t1 = EditTemplate.create("模板A")
|
||||
t2 = EditTemplate.create("模板B")
|
||||
assert t1.id != t2.id
|
||||
|
||||
def test_custom_fields(self):
|
||||
t = EditTemplate.create(
|
||||
"自定义模板",
|
||||
description="这是一个自定义模板",
|
||||
template_type="story",
|
||||
editing_mode="one_take",
|
||||
config={"key": "value"},
|
||||
preview_url="https://example.com/preview.mp4",
|
||||
sort_weight=100,
|
||||
status=EditTemplateStatus.INACTIVE,
|
||||
version=2,
|
||||
)
|
||||
assert t.name == "自定义模板"
|
||||
assert t.description == "这是一个自定义模板"
|
||||
assert t.template_type == "story"
|
||||
assert t.editing_mode == "one_take"
|
||||
assert t.config == {"key": "value"}
|
||||
assert t.preview_url == "https://example.com/preview.mp4"
|
||||
assert t.sort_weight == 100
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
assert t.version == 2
|
||||
|
||||
def test_name_stripped(self):
|
||||
t = EditTemplate.create(" 带空格的模板 ")
|
||||
assert t.name == "带空格的模板"
|
||||
|
||||
def test_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="名称"):
|
||||
EditTemplate.create("")
|
||||
|
||||
def test_whitespace_only_name_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
EditTemplate.create(" ")
|
||||
|
||||
def test_invalid_editing_mode_raises(self):
|
||||
with pytest.raises(ValueError, match="editing_mode"):
|
||||
EditTemplate.create("测试", editing_mode="invalid_mode")
|
||||
|
||||
def test_empty_editing_mode_falls_back_to_default(self):
|
||||
t = EditTemplate.create("测试", editing_mode="")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_whitespace_editing_mode_falls_back(self):
|
||||
t = EditTemplate.create("测试", editing_mode=" ")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_editing_mode_stripped(self):
|
||||
t = EditTemplate.create("测试", editing_mode=" one_take ")
|
||||
assert t.editing_mode == "one_take"
|
||||
|
||||
def test_description_stripped(self):
|
||||
t = EditTemplate.create("测试", description=" 描述 ")
|
||||
assert t.description == "描述"
|
||||
|
||||
def test_template_type_stripped(self):
|
||||
t = EditTemplate.create("测试", template_type=" vlog ")
|
||||
assert t.template_type == "vlog"
|
||||
|
||||
def test_empty_template_type_falls_back(self):
|
||||
t = EditTemplate.create("测试", template_type="")
|
||||
assert t.template_type == "default"
|
||||
|
||||
def test_none_config_becomes_empty_dict(self):
|
||||
t = EditTemplate.create("测试", config=None)
|
||||
assert t.config == {}
|
||||
assert isinstance(t.config, dict)
|
||||
|
||||
def test_preview_url_stripped(self):
|
||||
t = EditTemplate.create("测试", preview_url=" https://x.com/a.mp4 ")
|
||||
assert t.preview_url == "https://x.com/a.mp4"
|
||||
|
||||
def test_timestamps_are_utc(self):
|
||||
t = EditTemplate.create("测试")
|
||||
assert t.created_at.tzinfo is not None
|
||||
assert t.updated_at.tzinfo is not None
|
||||
|
||||
def test_created_at_equals_updated_at_on_create(self):
|
||||
t = EditTemplate.create("测试")
|
||||
# 创建时两个时间应该非常接近
|
||||
diff = abs((t.updated_at - t.created_at).total_seconds())
|
||||
assert diff < 1.0
|
||||
|
||||
|
||||
# ── 状态操作 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateStatusOperations:
|
||||
"""EditTemplate 状态操作"""
|
||||
|
||||
def test_activate_sets_active(self):
|
||||
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
|
||||
t.activate()
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
assert t.is_active is True
|
||||
|
||||
def test_deactivate_sets_inactive(self):
|
||||
t = EditTemplate.create("测试")
|
||||
t.deactivate()
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
assert t.is_active is False
|
||||
|
||||
def test_is_active_true(self):
|
||||
t = EditTemplate.create("测试")
|
||||
assert t.is_active is True
|
||||
|
||||
def test_is_active_false(self):
|
||||
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
|
||||
assert t.is_active is False
|
||||
|
||||
def test_activate_updates_updated_at(self):
|
||||
t = EditTemplate.create("测试", status=EditTemplateStatus.INACTIVE)
|
||||
old_updated = t.updated_at
|
||||
t.activate()
|
||||
assert t.updated_at >= old_updated
|
||||
|
||||
def test_deactivate_updates_updated_at(self):
|
||||
t = EditTemplate.create("测试")
|
||||
old_updated = t.updated_at
|
||||
t.deactivate()
|
||||
assert t.updated_at >= old_updated
|
||||
|
||||
|
||||
# ── 版本操作 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateVersion:
|
||||
"""EditTemplate 版本操作"""
|
||||
|
||||
def test_bump_version_increments(self):
|
||||
t = EditTemplate.create("测试")
|
||||
assert t.version == 1
|
||||
t.bump_version()
|
||||
assert t.version == 2
|
||||
|
||||
def test_bump_version_multiple(self):
|
||||
t = EditTemplate.create("测试", version=5)
|
||||
t.bump_version()
|
||||
t.bump_version()
|
||||
t.bump_version()
|
||||
assert t.version == 8
|
||||
|
||||
def test_bump_version_updates_updated_at(self):
|
||||
t = EditTemplate.create("测试")
|
||||
old_updated = t.updated_at
|
||||
t.bump_version()
|
||||
assert t.updated_at >= old_updated
|
||||
|
||||
|
||||
# ── dataclass 基础特性 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplateBasics:
|
||||
"""EditTemplate 基础特性"""
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
t = EditTemplate.create("测试")
|
||||
with pytest.raises(AttributeError):
|
||||
t.nonexistent_field = "value"
|
||||
|
||||
def test_direct_construction_minimal(self):
|
||||
# 最小构造:仅必填字段 + 状态,其余走默认值
|
||||
t = EditTemplate(
|
||||
id="custom_id",
|
||||
name="直接构造",
|
||||
status=EditTemplateStatus.ACTIVE,
|
||||
)
|
||||
assert t.id == "custom_id"
|
||||
assert t.name == "直接构造"
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
# 默认值检查
|
||||
assert t.description == ""
|
||||
assert t.config == {}
|
||||
assert t.version == 1
|
||||
assert t.editing_mode == EditingMode.ONE_TAKE.value
|
||||
assert isinstance(t.created_at, datetime)
|
||||
assert isinstance(t.updated_at, datetime)
|
||||
|
||||
def test_direct_construction_full(self):
|
||||
# 完整构造:所有字段都传
|
||||
now = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
t = EditTemplate(
|
||||
id="full_id",
|
||||
name="完整构造",
|
||||
description="测试描述",
|
||||
template_type="custom",
|
||||
editing_mode=EditingMode.PIP.value,
|
||||
config={"key": "value"},
|
||||
preview_url="https://example.com/preview.jpg",
|
||||
sort_weight=100,
|
||||
status=EditTemplateStatus.INACTIVE,
|
||||
version=3,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
assert t.id == "full_id"
|
||||
assert t.name == "完整构造"
|
||||
assert t.description == "测试描述"
|
||||
assert t.template_type == "custom"
|
||||
assert t.editing_mode == EditingMode.PIP.value
|
||||
assert t.config == {"key": "value"}
|
||||
assert t.preview_url == "https://example.com/preview.jpg"
|
||||
assert t.sort_weight == 100
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
assert t.version == 3
|
||||
assert t.created_at == now
|
||||
assert t.updated_at == now
|
||||
|
||||
def test_config_is_independent(self):
|
||||
# 不同实例的 config 应该是独立的 dict
|
||||
t1 = EditTemplate.create("模板1")
|
||||
t2 = EditTemplate.create("模板2")
|
||||
t1.config["key"] = "value"
|
||||
assert "key" not in t2.config
|
||||
|
||||
def test_equality(self):
|
||||
# 两个不同实例即使内容相同也不等(id不同)
|
||||
t1 = EditTemplate.create("同名模板")
|
||||
t2 = EditTemplate.create("同名模板")
|
||||
assert t1 != t2
|
||||
|
||||
def test_same_id_equal(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
t1 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
|
||||
t2 = EditTemplate(id="same", name="同名", created_at=now, updated_at=now)
|
||||
assert t1 == t2
|
||||
@@ -1,7 +1,6 @@
|
||||
"""验证码服务单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -9,9 +8,9 @@ import pytest
|
||||
|
||||
from packages.application.auth.verification_code_service import (
|
||||
CODE_TYPE_EMAIL_BIND,
|
||||
CODE_TYPE_EMAIL_LOGIN,
|
||||
CODE_TYPE_PHONE_BIND,
|
||||
DAILY_LIMIT,
|
||||
DEFAULT_TTL_SECONDS,
|
||||
MAX_ATTEMPTS,
|
||||
RESEND_COOLDOWN_SECONDS,
|
||||
VerificationCodeService,
|
||||
@@ -21,298 +20,560 @@ from packages.application.auth.verification_code_service import (
|
||||
)
|
||||
from packages.domain.verification_code import VerificationCode
|
||||
|
||||
# ── Test Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo():
|
||||
return MagicMock()
|
||||
"""mock 验证码仓储."""
|
||||
repo = MagicMock()
|
||||
repo.find_latest.return_value = None
|
||||
repo.count_today.return_value = 0
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def code_service(mock_repo):
|
||||
return VerificationCodeService(mock_repo)
|
||||
def service(mock_repo):
|
||||
"""验证码服务实例."""
|
||||
return VerificationCodeService(repo=mock_repo)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_code():
|
||||
code = VerificationCode.create(
|
||||
recipient="test@example.com",
|
||||
code_type=CODE_TYPE_EMAIL_BIND,
|
||||
ttl_seconds=300,
|
||||
def _make_code(
|
||||
recipient="test@example.com",
|
||||
code_type=CODE_TYPE_EMAIL_BIND,
|
||||
code="123456",
|
||||
ttl=300,
|
||||
used=False,
|
||||
attempts=0,
|
||||
created_at=None,
|
||||
):
|
||||
"""创建一个测试用验证码实体."""
|
||||
now = created_at or datetime.now(timezone.utc)
|
||||
vc = VerificationCode(
|
||||
id="test-code-id",
|
||||
recipient=recipient,
|
||||
code=code,
|
||||
code_type=code_type,
|
||||
expires_at=now + timedelta(seconds=ttl),
|
||||
used_at=now if used else None,
|
||||
attempts=attempts,
|
||||
created_at=now,
|
||||
)
|
||||
return code
|
||||
return vc
|
||||
|
||||
|
||||
class TestVerificationCodeServiceGenerate:
|
||||
# ── generate 方法测试 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
"""generate 方法测试"""
|
||||
|
||||
def test_generate_success(self, code_service, mock_repo, sample_code):
|
||||
"""生成验证码成功"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
mock_repo.save.return_value = None
|
||||
|
||||
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
def test_generate_success(self, service, mock_repo):
|
||||
"""成功生成验证码."""
|
||||
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert error is None
|
||||
assert code is not None
|
||||
assert code.recipient == "test@example.com"
|
||||
assert code.recipient == "user@example.com"
|
||||
assert code.code_type == CODE_TYPE_EMAIL_BIND
|
||||
assert len(code.code) == 6
|
||||
assert code.code.isdigit()
|
||||
assert not code.is_used
|
||||
mock_repo.save.assert_called_once()
|
||||
|
||||
def test_generate_empty_recipient(self, code_service):
|
||||
"""空接收方返回错误"""
|
||||
code, error = code_service.generate("", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
assert "接收方不能为空" in error
|
||||
def test_generate_with_custom_code(self, service, mock_repo):
|
||||
"""使用自定义验证码."""
|
||||
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_LOGIN, custom_code="999999")
|
||||
|
||||
def test_generate_invalid_type(self, code_service):
|
||||
"""无效验证码类型返回错误"""
|
||||
code, error = code_service.generate("test@example.com", "invalid_type")
|
||||
assert error is None
|
||||
assert code.code == "999999"
|
||||
|
||||
def test_generate_custom_ttl(self, service, mock_repo):
|
||||
"""自定义 TTL."""
|
||||
code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=600)
|
||||
delta = code.expires_at - code.created_at
|
||||
assert delta.total_seconds() == 600
|
||||
|
||||
def test_generate_default_ttl(self, service, mock_repo):
|
||||
"""默认 TTL."""
|
||||
code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
|
||||
delta = code.expires_at - code.created_at
|
||||
assert delta.total_seconds() == 300 # 默认5分钟
|
||||
|
||||
def test_generate_empty_recipient(self, service):
|
||||
"""空接收方."""
|
||||
code, error = service.generate("", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
assert "不能为空" in error
|
||||
|
||||
def test_generate_whitespace_recipient(self, service):
|
||||
"""全空白接收方."""
|
||||
code, error = service.generate(" ", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
assert "不能为空" in error
|
||||
|
||||
def test_generate_invalid_type(self, service):
|
||||
"""无效验证码类型."""
|
||||
code, error = service.generate("u@e.com", "invalid_type")
|
||||
assert code is None
|
||||
assert "无效的验证码类型" in error
|
||||
|
||||
def test_generate_cooldown(self, code_service, mock_repo, sample_code):
|
||||
"""冷却期内返回频控错误"""
|
||||
# 最新的验证码刚创建10秒前
|
||||
sample_code.created_at = datetime.now(timezone.utc) - timedelta(seconds=10)
|
||||
mock_repo.find_latest.return_value = sample_code
|
||||
mock_repo.count_today.return_value = 1
|
||||
def test_generate_recipient_stripped(self, service, mock_repo):
|
||||
"""接收方前后空格会被清理."""
|
||||
code, _ = service.generate(" user@e.com ", CODE_TYPE_EMAIL_BIND)
|
||||
assert code.recipient == "user@e.com"
|
||||
|
||||
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
def test_generate_phone_code(self, service, mock_repo):
|
||||
"""手机验证码生成."""
|
||||
code, error = service.generate("13800138000", CODE_TYPE_PHONE_BIND)
|
||||
assert error is None
|
||||
assert code.code_type == CODE_TYPE_PHONE_BIND
|
||||
assert len(code.code) == 6
|
||||
|
||||
|
||||
# ── generate 频控测试 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateRateLimit:
|
||||
"""generate 频控测试"""
|
||||
|
||||
def test_cooldown_active_rejects(self, service, mock_repo):
|
||||
"""冷却期内拒绝重发."""
|
||||
recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
|
||||
mock_repo.find_latest.return_value = recent
|
||||
|
||||
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
assert "发送太频繁" in error
|
||||
assert "秒后再试" in error
|
||||
# 等待时间应该接近 50 秒 (60-10)
|
||||
match = re.search(r"(\d+)\s*秒", error)
|
||||
assert match
|
||||
wait = int(match.group(1))
|
||||
assert 45 <= wait <= 55
|
||||
|
||||
def test_generate_daily_limit_exceeded(self, code_service, mock_repo):
|
||||
"""超过每日上限返回错误"""
|
||||
mock_repo.find_latest.return_value = None # 没有冷却期问题
|
||||
def test_cooldown_expired_allows(self, service, mock_repo):
|
||||
"""冷却期过后允许重发."""
|
||||
old = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=120))
|
||||
mock_repo.find_latest.return_value = old
|
||||
mock_repo.count_today.return_value = 1
|
||||
|
||||
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
|
||||
assert error is None
|
||||
assert code is not None
|
||||
|
||||
def test_daily_limit_reached(self, service, mock_repo):
|
||||
"""达到每日上限."""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = DAILY_LIMIT
|
||||
|
||||
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
assert "今日发送次数已达上限" in error
|
||||
|
||||
def test_generate_recipient_stripped(self, code_service, mock_repo, sample_code):
|
||||
"""recipient 会被 strip"""
|
||||
def test_daily_limit_one_below_allows(self, service, mock_repo):
|
||||
"""未达到上限时允许."""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = DAILY_LIMIT - 1
|
||||
|
||||
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
|
||||
assert error is None
|
||||
assert code is not None
|
||||
|
||||
def test_custom_daily_limit(self, mock_repo):
|
||||
"""自定义每日上限."""
|
||||
svc = VerificationCodeService(repo=mock_repo, daily_limit=3)
|
||||
mock_repo.count_today.return_value = 3
|
||||
|
||||
code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
assert "已达上限" in error
|
||||
|
||||
def test_custom_cooldown(self, mock_repo):
|
||||
"""自定义冷却时间."""
|
||||
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=30)
|
||||
recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
|
||||
mock_repo.find_latest.return_value = recent
|
||||
|
||||
code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
match = re.search(r"(\d+)\s*秒", error)
|
||||
assert match
|
||||
wait = int(match.group(1))
|
||||
assert 15 <= wait <= 25
|
||||
|
||||
def test_cooldown_different_types_independent(self, service, mock_repo):
|
||||
"""不同类型的验证码冷却独立."""
|
||||
# email_bind 类型有一个近期验证码
|
||||
recent = _make_code(code_type=CODE_TYPE_EMAIL_BIND)
|
||||
mock_repo.find_latest.side_effect = lambda r, t: recent if t == CODE_TYPE_EMAIL_BIND else None
|
||||
mock_repo.count_today.return_value = 0
|
||||
mock_repo.save.return_value = None
|
||||
|
||||
code_service.generate(" test@example.com ", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
# 传给 repo 的应该是 strip 后的值
|
||||
save_call = mock_repo.save.call_args[0][0]
|
||||
assert save_call.recipient == "test@example.com"
|
||||
|
||||
def test_generate_custom_code(self, code_service, mock_repo):
|
||||
"""使用自定义验证码"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
mock_repo.save.return_value = None
|
||||
|
||||
code, _ = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, custom_code="123456")
|
||||
assert code.code == "123456"
|
||||
|
||||
def test_generate_custom_ttl(self, code_service, mock_repo):
|
||||
"""自定义 TTL"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
mock_repo.save.return_value = None
|
||||
|
||||
code, _ = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=600)
|
||||
# email_login 类型应该可以正常发送
|
||||
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_LOGIN)
|
||||
assert error is None
|
||||
assert code is not None
|
||||
|
||||
|
||||
class TestVerificationCodeServiceVerify:
|
||||
# ── verify 方法测试 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerify:
|
||||
"""verify 方法测试"""
|
||||
|
||||
def test_verify_success(self, code_service, mock_repo, sample_code):
|
||||
"""验证成功"""
|
||||
mock_repo.find_latest.return_value = sample_code
|
||||
def test_verify_success(self, service, mock_repo):
|
||||
"""验证码正确."""
|
||||
code = _make_code(code="654321")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
|
||||
|
||||
assert success is True
|
||||
ok, error = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "654321")
|
||||
assert ok is True
|
||||
assert error is None
|
||||
assert sample_code.is_used is True
|
||||
assert code.is_used # 标记为已使用
|
||||
assert mock_repo.save.call_count >= 2 # increment + mark_used
|
||||
|
||||
def test_verify_wrong_code(self, code_service, mock_repo, sample_code):
|
||||
"""验证码错误"""
|
||||
mock_repo.find_latest.return_value = sample_code
|
||||
def test_verify_wrong_code(self, service, mock_repo):
|
||||
"""验证码错误."""
|
||||
code = _make_code(code="123456")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrongcode")
|
||||
|
||||
assert success is False
|
||||
ok, error = service.verify("test@e.com", CODE_TYPE_EMAIL_BIND, "000000")
|
||||
assert ok is False
|
||||
assert "验证码错误" in error
|
||||
assert not code.is_used # 不标记为已使用
|
||||
assert code.attempts == 1 # 尝试次数+1
|
||||
|
||||
def test_verify_not_found(self, code_service, mock_repo):
|
||||
"""验证码不存在"""
|
||||
def test_verify_no_code_found(self, service, mock_repo):
|
||||
"""找不到验证码."""
|
||||
mock_repo.find_latest.return_value = None
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
|
||||
assert success is False
|
||||
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
assert ok is False
|
||||
assert "不存在或已过期" in error
|
||||
|
||||
def test_verify_expired(self, code_service, mock_repo):
|
||||
"""验证码已过期"""
|
||||
expired_code = VerificationCode.create(
|
||||
recipient="test@example.com",
|
||||
code_type=CODE_TYPE_EMAIL_BIND,
|
||||
ttl_seconds=1, # 1秒过期
|
||||
)
|
||||
# 手动设置过期时间
|
||||
expired_code.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10)
|
||||
mock_repo.find_latest.return_value = expired_code
|
||||
def test_verify_empty_params(self, service):
|
||||
"""参数为空."""
|
||||
ok, error = service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
assert ok is False
|
||||
assert "参数不完整" in error
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, expired_code.code)
|
||||
ok2, error2 = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "")
|
||||
assert ok2 is False
|
||||
assert "参数不完整" in error2
|
||||
|
||||
assert success is False
|
||||
assert "已过期" in error
|
||||
def test_verify_whitespace_params(self, service, mock_repo):
|
||||
"""参数前后空格会被清理."""
|
||||
code = _make_code(recipient="u@e.com", code="111111")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
def test_verify_already_used(self, code_service, mock_repo, sample_code):
|
||||
"""验证码已使用"""
|
||||
sample_code.mark_used()
|
||||
mock_repo.find_latest.return_value = sample_code
|
||||
ok, error = service.verify(" u@e.com ", CODE_TYPE_EMAIL_BIND, " 111111 ")
|
||||
assert ok is True
|
||||
assert error is None
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
|
||||
def test_verify_already_used(self, service, mock_repo):
|
||||
"""验证码已使用."""
|
||||
code = _make_code(used=True)
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
assert success is False
|
||||
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
|
||||
assert ok is False
|
||||
assert "已使用" in error
|
||||
|
||||
def test_verify_max_attempts_exceeded(self, code_service, mock_repo, sample_code):
|
||||
"""尝试次数过多"""
|
||||
# 先把尝试次数加到超过上限
|
||||
for _ in range(MAX_ATTEMPTS + 1):
|
||||
sample_code.increment_attempts()
|
||||
mock_repo.find_latest.return_value = sample_code
|
||||
def test_verify_expired(self, service, mock_repo):
|
||||
"""验证码已过期."""
|
||||
code = _make_code(ttl=-60) # 已过期
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
|
||||
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
|
||||
assert ok is False
|
||||
assert "已过期" in error
|
||||
|
||||
assert success is False
|
||||
def test_verify_too_many_attempts(self, service, mock_repo):
|
||||
"""尝试次数过多."""
|
||||
code = _make_code(attempts=MAX_ATTEMPTS + 1)
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
|
||||
assert ok is False
|
||||
assert "验证次数过多" in error
|
||||
|
||||
def test_verify_empty_params(self, code_service):
|
||||
"""空参数返回错误"""
|
||||
success, error = code_service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
assert success is False
|
||||
assert "参数不完整" in error
|
||||
def test_verify_attempts_increment_each_time(self, service, mock_repo):
|
||||
"""每次错误尝试都增加尝试次数."""
|
||||
code = _make_code(code="123456", attempts=0)
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "")
|
||||
assert success is False
|
||||
assert "参数不完整" in error
|
||||
for _ in range(3):
|
||||
service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "wrong")
|
||||
|
||||
def test_verify_increments_attempts(self, code_service, mock_repo, sample_code):
|
||||
"""验证会增加尝试次数"""
|
||||
initial_attempts = sample_code.attempts
|
||||
mock_repo.find_latest.return_value = sample_code
|
||||
assert code.attempts == 3
|
||||
|
||||
code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrong")
|
||||
def test_verify_without_consume(self, service, mock_repo):
|
||||
"""验证成功但不标记为已使用(consume=False)."""
|
||||
code = _make_code(code="999999")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
assert sample_code.attempts == initial_attempts + 1
|
||||
|
||||
def test_verify_no_consume(self, code_service, mock_repo, sample_code):
|
||||
"""consume=False 时不标记为已使用"""
|
||||
mock_repo.find_latest.return_value = sample_code
|
||||
|
||||
success, _ = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code, consume=False)
|
||||
|
||||
assert success is True
|
||||
assert sample_code.is_used is False
|
||||
|
||||
|
||||
class TestVerifyPhone:
|
||||
"""validate_phone 函数测试"""
|
||||
|
||||
def test_valid_phone(self):
|
||||
"""有效手机号"""
|
||||
ok, err = validate_phone("13800000001")
|
||||
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "999999", consume=False)
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
assert error is None
|
||||
assert not code.is_used # 不标记为已使用
|
||||
|
||||
def test_valid_phone_with_plus86(self):
|
||||
"""带 +86 前缀的手机号"""
|
||||
ok, err = validate_phone("+8613800000001")
|
||||
def test_verify_consume_default_true(self, service, mock_repo):
|
||||
"""默认 consume=True."""
|
||||
code = _make_code(code="123456")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
assert code.is_used
|
||||
|
||||
def test_verify_used_checked_before_attempts(self, service, mock_repo):
|
||||
"""已使用优先于其他检查."""
|
||||
code = _make_code(used=True, attempts=0)
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
|
||||
assert ok is False
|
||||
assert "已使用" in error
|
||||
# attempts 会被 increment,但错误原因是已使用
|
||||
assert code.attempts == 1
|
||||
|
||||
def test_custom_max_attempts(self, mock_repo):
|
||||
"""自定义最大尝试次数."""
|
||||
svc = VerificationCodeService(repo=mock_repo, max_attempts=2)
|
||||
code = _make_code(attempts=2)
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
ok, error = svc.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
|
||||
assert ok is False
|
||||
assert "验证次数过多" in error
|
||||
|
||||
|
||||
# ── validate_phone 测试 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidatePhone:
|
||||
"""手机号格式校验测试"""
|
||||
|
||||
def test_valid_11_digit(self):
|
||||
"""标准11位手机号."""
|
||||
ok, msg = validate_phone("13800138000")
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_valid_with_plus_86(self):
|
||||
"""带+86前缀."""
|
||||
ok, msg = validate_phone("+8613800138000")
|
||||
assert ok is True
|
||||
|
||||
def test_invalid_phone_short(self):
|
||||
"""太短的手机号"""
|
||||
ok, err = validate_phone("123")
|
||||
def test_invalid_too_short(self):
|
||||
"""位数不足."""
|
||||
ok, msg = validate_phone("1380013800")
|
||||
assert ok is False
|
||||
assert "格式不正确" in err
|
||||
assert "格式不正确" in msg
|
||||
|
||||
def test_invalid_phone_wrong_prefix(self):
|
||||
"""号段不对的手机号"""
|
||||
ok, err = validate_phone("11000000000")
|
||||
def test_invalid_too_long(self):
|
||||
"""位数过多."""
|
||||
ok, msg = validate_phone("138001380001")
|
||||
assert ok is False
|
||||
|
||||
def test_empty_phone(self):
|
||||
"""空手机号"""
|
||||
ok, err = validate_phone("")
|
||||
def test_invalid_starts_with_2(self):
|
||||
"""开头不是1."""
|
||||
ok, msg = validate_phone("23800138000")
|
||||
assert ok is False
|
||||
assert "不能为空" in err
|
||||
|
||||
def test_phone_with_spaces(self):
|
||||
"""带空格的手机号会被 strip"""
|
||||
ok, _ = validate_phone(" 13800000001 ")
|
||||
def test_invalid_starts_with_12(self):
|
||||
"""第二位不在3-9."""
|
||||
ok, msg = validate_phone("12800138000")
|
||||
assert ok is False
|
||||
|
||||
def test_invalid_empty(self):
|
||||
"""空字符串."""
|
||||
ok, msg = validate_phone("")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
|
||||
def test_invalid_whitespace_only(self):
|
||||
"""仅空白."""
|
||||
ok, msg = validate_phone(" ")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
|
||||
def test_valid_all_prefixes_3_to_9(self):
|
||||
"""第二位3-9都有效."""
|
||||
for n in range(3, 10):
|
||||
ok, _ = validate_phone(f"1{n}800138000")
|
||||
assert ok is True, f"1{n} prefix should be valid"
|
||||
|
||||
def test_invalid_contains_letters(self):
|
||||
"""包含字母."""
|
||||
ok, msg = validate_phone("13800abc000")
|
||||
assert ok is False
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
"""前后空格会被清理."""
|
||||
ok, msg = validate_phone(" 13800138000 ")
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ── normalize_phone 测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizePhone:
|
||||
"""normalize_phone 函数测试"""
|
||||
"""手机号标准化测试"""
|
||||
|
||||
def test_removes_plus86(self):
|
||||
"""去掉 +86 前缀"""
|
||||
assert normalize_phone("+8613800000001") == "13800000001"
|
||||
def test_strip_plus_86(self):
|
||||
"""去掉+86前缀."""
|
||||
assert normalize_phone("+8613800138000") == "13800138000"
|
||||
|
||||
def test_no_prefix_stays_same(self):
|
||||
"""没有前缀保持不变"""
|
||||
assert normalize_phone("13800000001") == "13800000001"
|
||||
"""无前缀保持不变."""
|
||||
assert normalize_phone("13800138000") == "13800138000"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
"""去掉两端空白"""
|
||||
assert normalize_phone(" 13800000001 ") == "13800000001"
|
||||
"""清理前后空格."""
|
||||
assert normalize_phone(" 13800138000 ") == "13800138000"
|
||||
|
||||
def test_plus_86_with_spaces(self):
|
||||
"""带空格的+86."""
|
||||
assert normalize_phone(" +8613800138000 ") == "13800138000"
|
||||
|
||||
|
||||
# ── validate_email 测试 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateEmail:
|
||||
"""validate_email 函数测试"""
|
||||
"""邮箱格式校验测试"""
|
||||
|
||||
def test_valid_email(self):
|
||||
"""有效邮箱"""
|
||||
ok, err = validate_email("test@example.com")
|
||||
def test_valid_simple(self):
|
||||
"""标准邮箱."""
|
||||
ok, msg = validate_email("user@example.com")
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
assert msg == ""
|
||||
|
||||
def test_valid_email_with_subdomain(self):
|
||||
"""带子域名的邮箱"""
|
||||
ok, _ = validate_email("user@mail.example.com")
|
||||
def test_valid_with_dots(self):
|
||||
"""带点号的用户名."""
|
||||
ok, _ = validate_email("user.name@example.com")
|
||||
assert ok is True
|
||||
|
||||
def test_valid_email_with_plus(self):
|
||||
"""带 + 号的邮箱"""
|
||||
def test_valid_with_plus(self):
|
||||
"""带加号的邮箱."""
|
||||
ok, _ = validate_email("user+tag@example.com")
|
||||
assert ok is True
|
||||
|
||||
def test_invalid_email_no_at(self):
|
||||
"""没有 @ 的邮箱"""
|
||||
ok, err = validate_email("notanemail")
|
||||
assert ok is False
|
||||
assert "格式不正确" in err
|
||||
|
||||
def test_invalid_email_no_domain(self):
|
||||
"""没有域名的邮箱"""
|
||||
ok, err = validate_email("user@")
|
||||
assert ok is False
|
||||
|
||||
def test_empty_email(self):
|
||||
"""空邮箱"""
|
||||
ok, err = validate_email("")
|
||||
assert ok is False
|
||||
assert "不能为空" in err
|
||||
|
||||
def test_email_with_spaces(self):
|
||||
"""带空格的邮箱会被 strip"""
|
||||
ok, _ = validate_email(" test@example.com ")
|
||||
def test_valid_with_underscore(self):
|
||||
"""带下划线."""
|
||||
ok, _ = validate_email("user_name@example.com")
|
||||
assert ok is True
|
||||
|
||||
def test_valid_subdomain(self):
|
||||
"""多级域名."""
|
||||
ok, _ = validate_email("user@mail.example.com")
|
||||
assert ok is True
|
||||
|
||||
def test_invalid_no_at(self):
|
||||
"""没有@."""
|
||||
ok, msg = validate_email("userexample.com")
|
||||
assert ok is False
|
||||
assert "格式不正确" in msg
|
||||
|
||||
def test_invalid_empty_local(self):
|
||||
"""@前为空."""
|
||||
ok, _ = validate_email("@example.com")
|
||||
assert ok is False
|
||||
|
||||
def test_invalid_empty_domain(self):
|
||||
"""@后为空."""
|
||||
ok, _ = validate_email("user@")
|
||||
assert ok is False
|
||||
|
||||
def test_invalid_no_tld(self):
|
||||
"""没有顶级域名."""
|
||||
ok, _ = validate_email("user@example")
|
||||
assert ok is False
|
||||
|
||||
def test_invalid_empty(self):
|
||||
"""空字符串."""
|
||||
ok, msg = validate_email("")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
|
||||
def test_invalid_spaces_only(self):
|
||||
"""仅空白."""
|
||||
ok, msg = validate_email(" ")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
"""前后空格会被清理."""
|
||||
ok, msg = validate_email(" user@e.com ")
|
||||
assert ok is True
|
||||
|
||||
def test_invalid_special_chars(self):
|
||||
"""特殊字符."""
|
||||
ok, _ = validate_email("user name@e.com")
|
||||
assert ok is False
|
||||
|
||||
def test_valid_numbers(self):
|
||||
"""数字邮箱."""
|
||||
ok, _ = validate_email("12345@example.com")
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ── VerificationCode 实体辅助验证 ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVerificationCodeEntity:
|
||||
"""VerificationCode 实体属性测试"""
|
||||
|
||||
def test_is_expired_false_when_fresh(self):
|
||||
code = _make_code(ttl=300)
|
||||
assert code.is_expired is False
|
||||
|
||||
def test_is_expired_true_when_past(self):
|
||||
code = _make_code(ttl=-1)
|
||||
assert code.is_expired is True
|
||||
|
||||
def test_is_used_false_initially(self):
|
||||
code = _make_code()
|
||||
assert code.is_used is False
|
||||
|
||||
def test_is_used_after_mark_used(self):
|
||||
code = _make_code()
|
||||
code.mark_used()
|
||||
assert code.is_used is True
|
||||
assert code.used_at is not None
|
||||
|
||||
def test_is_valid_fresh(self):
|
||||
code = _make_code()
|
||||
assert code.is_valid is True
|
||||
|
||||
def test_is_valid_when_expired(self):
|
||||
code = _make_code(ttl=-100)
|
||||
assert code.is_valid is False
|
||||
|
||||
def test_is_valid_when_used(self):
|
||||
code = _make_code(used=True)
|
||||
assert code.is_valid is False
|
||||
|
||||
def test_increment_attempts(self):
|
||||
code = _make_code(attempts=0)
|
||||
code.increment_attempts()
|
||||
assert code.attempts == 1
|
||||
code.increment_attempts()
|
||||
assert code.attempts == 2
|
||||
|
||||
def test_create_generates_6_digit_code(self):
|
||||
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND)
|
||||
assert len(code.code) == 6
|
||||
assert code.code.isdigit()
|
||||
|
||||
def test_create_custom_code(self):
|
||||
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, custom_code="555555")
|
||||
assert code.code == "555555"
|
||||
|
||||
def test_create_strips_recipient(self):
|
||||
code = VerificationCode.create(" u@e.com ", CODE_TYPE_EMAIL_BIND)
|
||||
assert code.recipient == "u@e.com"
|
||||
|
||||
def test_create_sets_expiry(self):
|
||||
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=120)
|
||||
delta = code.expires_at - code.created_at
|
||||
assert delta.total_seconds() == 120
|
||||
|
||||
Reference in New Issue
Block a user