Merge remote-tracking branch 'origin/master'

This commit is contained in:
cnlimiter
2026-03-28 00:48:11 +08:00
21 changed files with 1402 additions and 41 deletions

View File

@@ -5,8 +5,8 @@
# 监听主机(默认 0.0.0.0
# APP_HOST=0.0.0.0
# 监听端口(默认 8000
# APP_PORT=8000
# 监听端口(默认 15555
# APP_PORT=15555
# Web UI 访问密钥(默认 admin123强烈建议修改
# APP_ACCESS_PASSWORD=your_secret_password

View File

@@ -4,12 +4,14 @@ FROM python:3.11-slim
# 设置工作目录
WORKDIR /app
ARG DEFAULT_WEBUI_PORT=15555
# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
# WebUI 默认配置
WEBUI_HOST=0.0.0.0 \
WEBUI_PORT=15555 \
WEBUI_PORT=${DEFAULT_WEBUI_PORT} \
LOG_LEVEL=info \
DEBUG=0
@@ -30,7 +32,7 @@ RUN pip install --no-cache-dir --upgrade pip \
COPY . .
# 暴露端口
EXPOSE 15555
EXPOSE ${DEFAULT_WEBUI_PORT}
# 启动 WebUI
CMD ["python", "webui.py"]

View File

@@ -104,6 +104,20 @@ cp .env.example .env
> 优先级:命令行参数 > 环境变量(`.env`> 数据库设置 > 默认值
### 修改端口
默认端口是 `15555`。现在已经收敛到少数几个固定入口:
- 本地临时启动改端口:直接用 `python webui.py --port 18080`
- 本地通过 `.env` 改端口:设置 `APP_PORT=18080`
- 源码里的默认端口:修改 `src/config/constants.py` 里的 `DEFAULT_WEBUI_PORT`
- Docker Compose 默认端口:修改 `docker-compose.yml` 顶部的 `x-webui-port`
- Docker 镜像构建默认端口:修改 `Dockerfile` 里的 `ARG DEFAULT_WEBUI_PORT`
补充说明:
- `src/config/constants.py``DEFAULT_WEBUI_PORT` 会同时影响默认 Web UI 端口、默认回调地址和 e2e 脚本默认地址。
- `docker-compose.yml` 里已经把端口映射、容器内 `WEBUI_PORT` 和健康检查统一绑到同一个 `x-webui-port`,改一处就够。
### 启动 Web UI
```bash
@@ -141,6 +155,18 @@ docker-compose up -d
```
你可以在 `docker-compose.yml` 中修改相关的环境变量,例如配置端口或者设置 `WEBUI_ACCESS_PASSWORD` 访问密码。
如果要修改 Docker Compose 对外端口,直接改文件顶部这一行即可:
```yaml
x-webui-port: &webui-port 15555
```
这一个值会同时同步到:
- 宿主机端口映射
- 容器内 `WEBUI_PORT`
- 健康检查访问地址
#### 直接使用 docker run
如果你不想使用 docker-compose也可以直接拉取并运行镜像
@@ -165,6 +191,19 @@ docker run -d \
> **注意**`-v $(pwd)/data:/app/data` 挂载参数非常重要,它确保了你的数据库文件和账户信息在容器重启或更新后不会丢失。
如果你要把容器端口改成 `18080``-p``WEBUI_PORT` 需要一起改:
```bash
docker run -d \
-p 18080:18080 \
-e WEBUI_HOST=0.0.0.0 \
-e WEBUI_PORT=18080 \
-e WEBUI_ACCESS_PASSWORD=your_secure_password \
-v $(pwd)/data:/app/data \
--name codex-register \
ghcr.io/yunxilyf/codex-register:latest
```
### 使用远程 PostgreSQL
通过环境变量指定数据库连接字符串:
@@ -335,7 +374,7 @@ docker-compose up -d
### 配置说明
**端口映射**:默认 `15555` 端口,可在 `docker-compose.yml` 中修改
**端口映射**:默认 `15555` 端口,修改 `docker-compose.yml` 顶部的 `x-webui-port` 即可
**数据持久化**
```yaml
@@ -347,9 +386,9 @@ volumes:
**环境变量配置**
```yaml
environment:
- APP_ACCESS_PASSWORD=mypassword
- APP_HOST=0.0.0.0
- APP_PORT=15555
WEBUI_ACCESS_PASSWORD: mypassword
WEBUI_HOST: 0.0.0.0
WEBUI_PORT: 15555
```
### 常用命令

View File

@@ -1,24 +1,26 @@
x-webui-port: &webui-port 15555
services:
webui:
build: .
ports:
- "15555:15555"
- target: *webui-port
published: *webui-port
protocol: tcp
environment:
- WEBUI_HOST=0.0.0.0
- WEBUI_PORT=15555
- WEBUI_ACCESS_PASSWORD=admin123
- DEBUG=0
- LOG_LEVEL=info
WEBUI_HOST: 0.0.0.0
WEBUI_PORT: *webui-port
WEBUI_ACCESS_PASSWORD: admin123
DEBUG: 0
LOG_LEVEL: info
volumes:
# 挂载数据目录以持久化数据库和日志
- ./data:/app/data
- ./logs:/app/logs
healthcheck:
test:
- CMD
- python
- -c
- import urllib.request; urllib.request.urlopen('http://127.0.0.1:15555/', timeout=5).read()
- CMD-SHELL
- python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ['WEBUI_PORT'] + '/', timeout=5).read()"
interval: 10s
timeout: 5s
retries: 5

View File

@@ -48,6 +48,25 @@ class EmailServiceType(str, Enum):
APP_NAME = "OpenAI/Codex CLI 自动注册系统"
APP_VERSION = "2.0.0"
APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统"
DEFAULT_WEBUI_HOST = "0.0.0.0"
DEFAULT_WEBUI_PORT = 15555
DEFAULT_WEBUI_LOCAL_HOST = "127.0.0.1"
def build_http_url(host: str, port: int, path: str = "") -> str:
"""构造本地 HTTP URL。"""
normalized_path = path if not path or path.startswith("/") else f"/{path}"
return f"http://{host}:{port}{normalized_path}"
def build_ws_url(host: str, port: int, path: str = "") -> str:
"""构造本地 WebSocket URL。"""
normalized_path = path if not path or path.startswith("/") else f"/{path}"
return f"ws://{host}:{port}{normalized_path}"
DEFAULT_WEBUI_BASE_URL = build_http_url(DEFAULT_WEBUI_LOCAL_HOST, DEFAULT_WEBUI_PORT)
DEFAULT_WEBUI_WS_BASE_URL = build_ws_url(DEFAULT_WEBUI_LOCAL_HOST, DEFAULT_WEBUI_PORT)
# ============================================================================
# OpenAI OAuth 相关常量
@@ -57,7 +76,7 @@ APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统"
OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
OAUTH_AUTH_URL = "https://auth.openai.com/oauth/authorize"
OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
OAUTH_REDIRECT_URI = "http://localhost:15555/auth/callback"
OAUTH_REDIRECT_URI = build_http_url("localhost", DEFAULT_WEBUI_PORT, "/auth/callback")
OAUTH_SCOPE = "openid email profile offline_access"
# Codex CLI 专用 OAuth 参数(用于生成 Codex 兼容的 auth.json
@@ -280,8 +299,8 @@ DEFAULT_SETTINGS = [
("registration.max_retries", "3", "最大重试次数", "registration"),
("registration.timeout", "120", "超时时间(秒)", "registration"),
("registration.default_password_length", "12", "默认密码长度", "registration"),
("webui.host", "0.0.0.0", "Web UI 监听主机", "webui"),
("webui.port", "15555", "Web UI 监听端口", "webui"),
("webui.host", DEFAULT_WEBUI_HOST, "Web UI 监听主机", "webui"),
("webui.port", str(DEFAULT_WEBUI_PORT), "Web UI 监听端口", "webui"),
("webui.debug", "true", "调试模式", "webui"),
]

View File

@@ -10,6 +10,8 @@ from pydantic import BaseModel, field_validator
from pydantic.types import SecretStr
from dataclasses import dataclass
from .constants import APP_NAME, APP_VERSION, DEFAULT_WEBUI_HOST, DEFAULT_WEBUI_PORT
class SettingCategory(str, Enum):
"""设置分类"""
@@ -42,13 +44,13 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
# 应用信息
"app_name": SettingDefinition(
db_key="app.name",
default_value="OpenAI/Codex CLI 自动注册系统",
default_value=APP_NAME,
category=SettingCategory.GENERAL,
description="应用名称"
),
"app_version": SettingDefinition(
db_key="app.version",
default_value="2.0.0",
default_value=APP_VERSION,
category=SettingCategory.GENERAL,
description="应用版本"
),
@@ -70,13 +72,13 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
# Web UI 配置
"webui_host": SettingDefinition(
db_key="webui.host",
default_value="0.0.0.0",
default_value=DEFAULT_WEBUI_HOST,
category=SettingCategory.WEBUI,
description="Web UI 监听地址"
),
"webui_port": SettingDefinition(
db_key="webui.port",
default_value=15555,
default_value=DEFAULT_WEBUI_PORT,
category=SettingCategory.WEBUI,
description="Web UI 监听端口"
),
@@ -591,8 +593,8 @@ class Settings(BaseModel):
"""
# 应用信息
app_name: str = "OpenAI/Codex CLI 自动注册系统"
app_version: str = "2.0.0"
app_name: str = APP_NAME
app_version: str = APP_VERSION
debug: bool = False
# 数据库配置
@@ -615,8 +617,8 @@ class Settings(BaseModel):
return v
# Web UI 配置
webui_host: str = "0.0.0.0"
webui_port: int = 15555
webui_host: str = DEFAULT_WEBUI_HOST
webui_port: int = DEFAULT_WEBUI_PORT
webui_secret_key: SecretStr = SecretStr("your-secret-key-change-in-production")
webui_access_password: SecretStr = SecretStr("admin123")

View File

@@ -204,7 +204,7 @@ def generate_team_link(
"&elements_session_client[is_aggregation_expected]=false"
"&client_attribution_metadata[merchant_integration_additional_elements][0]=payment"
"&client_attribution_metadata[merchant_integration_additional_elements][1]=address"
f"&key={data["publishable_key"]}"
f"&key={data['publishable_key']}"
,
proxies=_build_proxies(proxy),
timeout=30,

View File

@@ -1693,6 +1693,10 @@ class RegistrationEngine:
try:
# 获取默认 client_id
settings = get_settings()
metadata = dict(result.metadata or {})
verification_state = self.email_service.export_verification_state(result.email or self.email)
if verification_state["used_codes"] or verification_state["seen_messages"]:
metadata["verification_state"] = verification_state
with get_db() as db:
# 保存账户信息
@@ -1711,7 +1715,7 @@ class RegistrationEngine:
id_token=result.id_token,
cookies=result.cookies,
proxy_used=self.proxy_url,
extra_data=result.metadata,
extra_data=metadata,
source=result.source
)

View File

@@ -8,6 +8,7 @@ import logging
import re
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Dict, Any, List
from enum import Enum
@@ -146,6 +147,8 @@ class BaseEmailService(abc.ABC):
self._status = EmailServiceStatus.HEALTHY
self._last_error = None
self._provider_backoff = reset_adaptive_backoff()
self._used_verification_codes: Dict[str, set] = {}
self._seen_verification_messages: Dict[str, set] = {}
_EMAIL_ADDRESS_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
@@ -299,6 +302,140 @@ class BaseEmailService(abc.ABC):
return None
def _get_used_verification_codes(self, email: str) -> set:
"""获取邮箱对应的已使用验证码集合。"""
key = str(email or "").strip().lower()
if key not in self._used_verification_codes:
self._used_verification_codes[key] = set()
return self._used_verification_codes[key]
def _get_seen_verification_messages(self, email: str) -> set:
"""获取邮箱对应的已处理消息标识集合。"""
key = str(email or "").strip().lower()
if key not in self._seen_verification_messages:
self._seen_verification_messages[key] = set()
return self._seen_verification_messages[key]
def load_verification_state(
self,
email: str,
used_codes: Optional[List[str]] = None,
seen_messages: Optional[List[str]] = None,
) -> None:
"""将持久化的验证码状态恢复到当前服务实例。"""
if used_codes:
self._get_used_verification_codes(email).update(
str(code) for code in used_codes if code
)
if seen_messages:
self._get_seen_verification_messages(email).update(
str(marker) for marker in seen_messages if marker
)
def export_verification_state(self, email: str) -> Dict[str, List[str]]:
"""导出当前邮箱的验证码状态,用于跨请求复用。"""
return {
"used_codes": sorted(self._get_used_verification_codes(email)),
"seen_messages": sorted(self._get_seen_verification_messages(email)),
}
def _remember_verification_code(self, email: str, code: str) -> bool:
"""记录验证码;若已用过则返回 False。"""
used_codes = self._get_used_verification_codes(email)
if code in used_codes:
return False
used_codes.add(code)
return True
def _remember_verification_message(self, email: str, message_marker: Optional[str]) -> bool:
"""记录消息标识;若已处理过则返回 False。"""
if not message_marker:
return True
seen_messages = self._get_seen_verification_messages(email)
if message_marker in seen_messages:
return False
seen_messages.add(message_marker)
return True
def _accept_verification_code(
self,
email: str,
code: str,
message_marker: Optional[str] = None,
) -> bool:
"""
决定是否接受验证码。
若有可靠的新邮件标识,优先按消息去重,这样新邮件即便验证码重复也能被接受;
否则退回到按验证码去重,避免旧码被重复消费。
"""
if message_marker:
if not self._remember_verification_message(email, message_marker):
return False
self._get_used_verification_codes(email).add(code)
return True
return self._remember_verification_code(email, code)
def _parse_message_timestamp(self, value: Any) -> Optional[float]:
"""将常见邮件时间字段解析为 Unix 时间戳。"""
if value is None or value == "":
return None
if isinstance(value, datetime):
return value.timestamp()
if isinstance(value, (int, float)):
return self._normalize_unix_timestamp(float(value))
text = str(value).strip()
if not text:
return None
try:
return self._normalize_unix_timestamp(float(text))
except ValueError:
pass
normalized = text.replace("Z", "+00:00") if text.endswith("Z") else text
try:
return datetime.fromisoformat(normalized).timestamp()
except ValueError:
return None
def _normalize_unix_timestamp(self, value: float) -> float:
"""将秒/毫秒/微秒级 Unix 时间统一归一到秒。"""
absolute = abs(value)
if absolute >= 1e14:
return value / 1_000_000
if absolute >= 1e11:
return value / 1_000
return value
def _is_message_before_otp(self, message_time: Any, otp_sent_at: Optional[float], tolerance_seconds: int = 1) -> bool:
"""
判断邮件是否早于当前 OTP 发送窗口。
允许少量时钟误差,避免接口时间与本地时间有轻微偏移时误伤新邮件。
"""
if not otp_sent_at:
return False
message_ts = self._parse_message_timestamp(message_time)
if message_ts is None:
return False
return message_ts + tolerance_seconds < otp_sent_at
def _sort_items_by_message_time(self, items: List[Any], value_getter) -> List[Any]:
"""按邮件时间倒序排列,优先处理最新邮件。"""
return sorted(
items,
key=lambda item: self._parse_message_timestamp(value_getter(item)) or float("-inf"),
reverse=True,
)
def wait_for_email(
self,
email: str,

View File

@@ -271,7 +271,12 @@ class DuckMailService(BaseEmailService):
)
messages = response.get("hydra:member", [])
for message in messages:
ordered_messages = self._sort_items_by_message_time(
messages,
lambda item: item.get("createdAt") if isinstance(item, dict) else None,
)
for message in ordered_messages:
message_id = str(message.get("id") or "").strip()
if not message_id or message_id in seen_message_ids:
continue
@@ -281,6 +286,7 @@ class DuckMailService(BaseEmailService):
continue
seen_message_ids.add(message_id)
message_marker = f"id:{message_id}"
detail = self._make_request(
"GET",
f"/messages/{message_id}",
@@ -293,8 +299,11 @@ class DuckMailService(BaseEmailService):
match = re.search(pattern, content)
if match:
code = match.group(1)
if not self._accept_verification_code(email, code, message_marker):
continue
self.update_status(True)
return match.group(1)
return code
except Exception as e:
logger.debug(f"DuckMail 轮询验证码失败: {e}")

View File

@@ -221,12 +221,29 @@ class FreemailService(BaseEmailService):
time.sleep(3)
continue
for mail in mails:
ordered_mails = self._sort_items_by_message_time(
mails,
lambda item: (
item.get("created_at")
or item.get("createdAt")
or item.get("received_at")
or item.get("receivedAt")
) if isinstance(item, dict) else None,
)
for mail in ordered_mails:
mail_id = mail.get("id")
if not mail_id or mail_id in seen_mail_ids:
continue
seen_mail_ids.add(mail_id)
message_marker = f"id:{mail_id}"
if self._is_message_before_otp(
mail.get("created_at") or mail.get("createdAt") or mail.get("received_at") or mail.get("receivedAt"),
otp_sent_at,
):
continue
sender = str(mail.get("sender", "")).lower()
subject = str(mail.get("subject", ""))
@@ -239,25 +256,30 @@ class FreemailService(BaseEmailService):
code = self._extract_otp_from_text(content, pattern)
if code:
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
self.update_status(True)
return code
v_code = str(mail.get("verification_code") or "").strip()
# 如果依然未找到,获取邮件详情进行匹配
try:
detail = self._make_request("GET", f"/api/email/{mail_id}")
full_content = str(detail.get("content", "")) + "\n" + str(detail.get("html_content", ""))
code = self._extract_otp_from_text(full_content, pattern)
if code:
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
self.update_status(True)
return code
except Exception as e:
logger.debug(f"获取 Freemail 邮件详情失败: {e}")
v_code = str(mail.get("verification_code") or "").strip()
if re.fullmatch(r"\d{6}", v_code):
if not self._accept_verification_code(email, v_code, message_marker):
continue
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {v_code}")
self.update_status(True)
return v_code

View File

@@ -316,12 +316,29 @@ class MeoMailEmailService(BaseEmailService):
time.sleep(3)
continue
for message in messages:
ordered_messages = self._sort_items_by_message_time(
messages,
lambda item: (
item.get("created_at")
or item.get("createdAt")
or item.get("received_at")
or item.get("receivedAt")
) if isinstance(item, dict) else None,
)
for message in ordered_messages:
message_id = message.get("id")
if not message_id or message_id in seen_message_ids:
continue
seen_message_ids.add(message_id)
message_marker = f"id:{message_id}"
if self._is_message_before_otp(
message.get("created_at") or message.get("createdAt") or message.get("received_at") or message.get("receivedAt"),
otp_sent_at,
):
continue
# 检查是否是目标邮件
sender = str(message.get("from_address", "")).lower()
@@ -343,6 +360,8 @@ class MeoMailEmailService(BaseEmailService):
match = re.search(pattern, re.sub(email_pattern, "", content))
if match:
code = match.group(1)
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"从自定义域名邮箱 {email} 找到验证码: {code}")
self.update_status(True)
return code

View File

@@ -335,12 +335,29 @@ class TempMailService(BaseEmailService):
time.sleep(3)
continue
for mail in mails:
ordered_mails = self._sort_items_by_message_time(
mails,
lambda item: (
item.get("createdAt")
or item.get("created_at")
or item.get("receivedAt")
or item.get("received_at")
) if isinstance(item, dict) else None,
)
for mail in ordered_mails:
mail_id = mail.get("id")
if not mail_id or mail_id in seen_mail_ids:
continue
seen_mail_ids.add(mail_id)
message_marker = f"id:{mail_id}"
if self._is_message_before_otp(
mail.get("createdAt") or mail.get("created_at") or mail.get("receivedAt") or mail.get("received_at"),
otp_sent_at,
):
continue
parsed = self._extract_mail_fields(mail)
sender = parsed["sender"].lower()
@@ -355,6 +372,8 @@ class TempMailService(BaseEmailService):
code = self._extract_otp_from_text(content, pattern)
if code:
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"从 TempMail 邮箱 {email} 找到验证码: {code}")
self.update_status(True)
return code

View File

@@ -241,7 +241,12 @@ class TempmailService(BaseEmailService):
time.sleep(3)
continue
for msg in email_list:
ordered_emails = self._sort_items_by_message_time(
email_list,
lambda item: item.get("date") if isinstance(item, dict) else None,
)
for msg in ordered_emails:
if not isinstance(msg, dict):
continue
@@ -260,6 +265,7 @@ class TempmailService(BaseEmailService):
if not message_id or message_id in seen_ids:
continue
seen_ids.add(message_id)
message_marker = f"id:{message_id}"
sender = str(msg.get("from", "")).lower()
subject = str(msg.get("subject", ""))
@@ -276,6 +282,8 @@ class TempmailService(BaseEmailService):
match = re.search(pattern, content)
if match:
code = match.group(1)
if not self._accept_verification_code(email, code, message_marker):
continue
logger.info(f"找到验证码: {code}")
self.update_status(True)
return code

View File

@@ -1566,6 +1566,29 @@ def _build_inbox_config(db, service_type, email: str) -> dict:
return cfg
def _load_account_verification_state(account: Account) -> dict:
"""从账号扩展信息中读取验证码去重状态。"""
extra = account.extra_data or {}
state = extra.get("verification_state") if isinstance(extra, dict) else {}
if not isinstance(state, dict):
state = {}
return {
"used_codes": [str(code) for code in (state.get("used_codes") or []) if code],
"seen_messages": [str(marker) for marker in (state.get("seen_messages") or []) if marker],
}
def _save_account_verification_state(db, account: Account, service) -> None:
"""将当前收件箱消费状态持久化到账号表,支持跨请求延续。"""
state = service.export_verification_state(account.email)
if not state["used_codes"] and not state["seen_messages"]:
return
extra = dict(account.extra_data or {})
extra["verification_state"] = state
crud.update_account(db, account.id, extra_data=extra)
@router.post("/{account_id}/inbox-code")
async def get_account_inbox_code(account_id: int):
"""查询账号邮箱收件箱最新验证码"""
@@ -1587,6 +1610,10 @@ async def get_account_inbox_code(account_id: int):
try:
svc = EmailServiceFactory.create(service_type, config)
svc.load_verification_state(
account.email,
**_load_account_verification_state(account),
)
code = svc.get_verification_code(
account.email,
email_id=account.email_service_id,
@@ -1598,4 +1625,6 @@ async def get_account_inbox_code(account_id: int):
if not code:
return {"success": False, "error": "未收到验证码邮件"}
_save_account_verification_state(db, account, svc)
return {"success": True, "code": code, "email": account.email}

View File

@@ -10,6 +10,8 @@ from typing import Any, Dict, List
import httpx
import websockets
from src.config.constants import DEFAULT_WEBUI_BASE_URL, DEFAULT_WEBUI_WS_BASE_URL
STALE_ERROR = "服务启动时检测到未完成的历史任务,已标记失败,请重新发起。"
@@ -254,8 +256,8 @@ def run_verify_recovery_mode(base_url: str, db_path: Path, state_path: Path, rep
def main() -> None:
parser = argparse.ArgumentParser(description="真实服务功能可用性验证脚本")
parser.add_argument("--mode", choices=["live", "prepare-recovery", "verify-recovery"], required=True)
parser.add_argument("--base-url", default="http://127.0.0.1:15555")
parser.add_argument("--ws-url", default="ws://127.0.0.1:15555")
parser.add_argument("--base-url", default=DEFAULT_WEBUI_BASE_URL)
parser.add_argument("--ws-url", default=DEFAULT_WEBUI_WS_BASE_URL)
parser.add_argument("--db-path", required=True)
parser.add_argument("--report-path", default="tests_runtime/runtime_functionality_report.json")
parser.add_argument("--state-path", default="tests_runtime/runtime_recovery_state.json")

View File

@@ -0,0 +1,156 @@
import asyncio
from contextlib import contextmanager
from pathlib import Path
from src.config.constants import EmailServiceType
from src.core.register import RegistrationEngine, RegistrationResult
from src.database.models import Account, Base
from src.database.session import DatabaseSessionManager
from src.services.base import BaseEmailService
from src.web.routes import accounts as accounts_routes
class DummySettings:
openai_client_id = "client-1"
openai_auth_url = "https://auth.openai.test/authorize"
openai_token_url = "https://auth.openai.test/token"
openai_redirect_uri = "https://localhost/callback"
openai_scope = "openid profile email offline_access"
tempmail_base_url = "https://api.tempmail.test"
tempmail_timeout = 30
tempmail_max_retries = 3
class FakeStatefulTempmailService(BaseEmailService):
def __init__(self, config=None, name=None):
super().__init__(EmailServiceType.TEMPMAIL, name)
self.messages = [
("id:msg-1", "111111"),
("id:msg-2", "222222"),
]
def create_email(self, config=None):
return {"email": "tester@example.com", "service_id": "token-1"}
def get_verification_code(
self,
email: str,
email_id: str = None,
timeout: int = 120,
pattern: str = r"(?<!\d)(\d{6})(?!\d)",
otp_sent_at=None,
):
for marker, code in self.messages:
if self._accept_verification_code(email, code, marker):
return code
return None
def list_emails(self, **kwargs):
return []
def delete_email(self, email_id: str) -> bool:
return True
def check_health(self) -> bool:
return True
def _build_test_db(name: str) -> DatabaseSessionManager:
runtime_dir = Path("tests_runtime")
runtime_dir.mkdir(exist_ok=True)
db_path = runtime_dir / name
if db_path.exists():
db_path.unlink()
manager = DatabaseSessionManager(f"sqlite:///{db_path}")
Base.metadata.create_all(bind=manager.engine)
return manager
def test_account_inbox_code_persists_verification_state_across_requests(monkeypatch):
manager = _build_test_db("account_inbox_code_state.db")
with manager.session_scope() as session:
account = Account(
email="tester@example.com",
email_service="tempmail",
email_service_id="token-1",
status="active",
extra_data={},
)
session.add(account)
session.commit()
session.refresh(account)
account_id = account.id
@contextmanager
def fake_get_db():
session = manager.SessionLocal()
try:
yield session
finally:
session.close()
monkeypatch.setattr(accounts_routes, "get_db", fake_get_db)
monkeypatch.setattr(accounts_routes, "get_settings", lambda: DummySettings())
monkeypatch.setattr(
"src.services.base.EmailServiceFactory.create",
lambda service_type, config, name=None: FakeStatefulTempmailService(config, name),
)
first = asyncio.run(accounts_routes.get_account_inbox_code(account_id))
second = asyncio.run(accounts_routes.get_account_inbox_code(account_id))
assert first["success"] is True
assert first["code"] == "111111"
assert second["success"] is True
assert second["code"] == "222222"
with manager.session_scope() as session:
saved = session.query(Account).filter(Account.id == account_id).first()
verification_state = (saved.extra_data or {}).get("verification_state") or {}
assert verification_state["used_codes"] == ["111111", "222222"]
assert verification_state["seen_messages"] == ["id:msg-1", "id:msg-2"]
def test_save_to_database_persists_verification_state(monkeypatch):
manager = _build_test_db("registration_verification_state.db")
@contextmanager
def fake_get_db():
session = manager.SessionLocal()
try:
yield session
finally:
session.close()
monkeypatch.setattr("src.core.register.get_db", fake_get_db)
monkeypatch.setattr("src.core.register.get_settings", lambda: DummySettings())
email_service = FakeStatefulTempmailService()
email_service._accept_verification_code("tester@example.com", "111111", "id:msg-1")
engine = RegistrationEngine(email_service=email_service, proxy_url="http://proxy.test")
engine.email_info = {"service_id": "token-1"}
result = RegistrationResult(
success=True,
email="tester@example.com",
password="secret",
account_id="acct-1",
workspace_id="ws-1",
access_token="access-token",
refresh_token="refresh-token",
id_token="id-token",
session_token="session-token",
metadata={"registered_at": "2026-03-26T00:00:00"},
source="register",
)
assert engine.save_to_database(result) is True
with manager.session_scope() as session:
saved = session.query(Account).filter(Account.email == "tester@example.com").first()
verification_state = (saved.extra_data or {}).get("verification_state") or {}
assert verification_state["used_codes"] == ["111111"]
assert verification_state["seen_messages"] == ["id:msg-1"]

View File

@@ -0,0 +1,19 @@
from src.config.constants import (
DEFAULT_SETTINGS,
DEFAULT_WEBUI_BASE_URL,
DEFAULT_WEBUI_PORT,
DEFAULT_WEBUI_WS_BASE_URL,
OAUTH_REDIRECT_URI,
)
from src.config.settings import SETTING_DEFINITIONS, Settings
def test_default_webui_port_is_shared_from_one_constant():
default_settings_map = {key: value for key, value, *_ in DEFAULT_SETTINGS}
assert SETTING_DEFINITIONS["webui_port"].default_value == DEFAULT_WEBUI_PORT
assert Settings().webui_port == DEFAULT_WEBUI_PORT
assert default_settings_map["webui.port"] == str(DEFAULT_WEBUI_PORT)
assert DEFAULT_WEBUI_BASE_URL == f"http://127.0.0.1:{DEFAULT_WEBUI_PORT}"
assert DEFAULT_WEBUI_WS_BASE_URL == f"ws://127.0.0.1:{DEFAULT_WEBUI_PORT}"
assert OAUTH_REDIRECT_URI == f"http://localhost:{DEFAULT_WEBUI_PORT}/auth/callback"

View File

@@ -0,0 +1,523 @@
from src.services.duck_mail import DuckMailService
from src.services.freemail import FreemailService
from src.services.moe_mail import MeoMailEmailService
from src.services.temp_mail import TempMailService
from src.services.tempmail import TempmailService
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeRequestHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append({
"method": method,
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: {method} {url}")
return self.responses.pop(0)
class FakeGetHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def get(self, url, **kwargs):
self.calls.append({
"method": "GET",
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: GET {url}")
return self.responses.pop(0)
def test_tempmail_service_skips_code_returned_by_previous_fetch():
service = TempmailService({"base_url": "https://api.tempmail.test"})
service.http_client = FakeGetHTTPClient([
FakeResponse(
payload={
"emails": [
{
"date": 1000,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
}
]
}
),
FakeResponse(
payload={
"emails": [
{
"date": 1000,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
},
{
"date": 1003,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
},
]
}
),
])
first_code = service.get_verification_code(
email="tester@example.com",
email_id="token-1",
timeout=1,
otp_sent_at=1000,
)
second_code = service.get_verification_code(
email="tester@example.com",
email_id="token-1",
timeout=1,
otp_sent_at=1002,
)
assert first_code == "111111"
assert second_code == "654321"
def test_temp_mail_service_skips_code_returned_by_previous_fetch():
service = TempMailService({
"base_url": "https://mail.example.com",
"admin_password": "admin-secret",
"domain": "example.com",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
}
]
}
),
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
])
first_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378400,
)
second_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "654321"
def test_temp_mail_service_accepts_same_code_from_newer_message():
service = TempMailService({
"base_url": "https://mail.example.com",
"admin_password": "admin-secret",
"domain": "example.com",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
}
]
}
),
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
])
first_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378400,
)
second_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "111111"
def test_freemail_service_skips_code_returned_by_previous_fetch():
service = FreemailService({
"base_url": "https://mail.example.com",
"admin_token": "jwt-token",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload=[
{
"id": "msg-1",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 111111",
"verification_code": "111111",
"created_at": "2026-03-19T10:00:00Z",
}
]
),
FakeResponse(
payload=[
{
"id": "msg-1",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 111111",
"verification_code": "111111",
"created_at": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 654321",
"verification_code": "654321",
"created_at": "2026-03-19T10:00:03Z",
},
]
),
])
first_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378400,
)
second_code = service.get_verification_code(
email="tester@example.com",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "654321"
def test_duck_mail_service_skips_previously_used_code_even_with_small_timestamp_gap():
service = DuckMailService({
"base_url": "https://api.duckmail.test",
"default_domain": "duckmail.sbs",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:01Z",
}
]
}
),
FakeResponse(
payload={
"id": "msg-1",
"text": "Your OpenAI verification code is 111111",
"html": [],
}
),
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:01Z",
},
{
"id": "msg-2",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
FakeResponse(
payload={
"id": "msg-2",
"text": "Your OpenAI verification code is 654321",
"html": [],
}
),
FakeResponse(
payload={
"id": "msg-1",
"text": "Your OpenAI verification code is 111111",
"html": [],
}
),
])
service._accounts_by_email["tester@duckmail.sbs"] = {
"email": "tester@duckmail.sbs",
"service_id": "account-1",
"account_id": "account-1",
"token": "token-123",
}
first_code = service.get_verification_code(
email="tester@duckmail.sbs",
email_id="account-1",
timeout=1,
otp_sent_at=1742378401,
)
second_code = service.get_verification_code(
email="tester@duckmail.sbs",
email_id="account-1",
timeout=1,
otp_sent_at=1742378402,
)
assert first_code == "111111"
assert second_code == "654321"
def test_moe_mail_service_filters_old_messages_with_millisecond_timestamps():
service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
def fake_make_request(method, endpoint, **kwargs):
if endpoint == "/api/emails/email-1":
return {
"messages": [
{
"id": "msg-old",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
{
"id": "msg-new",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378403000,
},
]
}
if endpoint == "/api/emails/email-1/msg-old":
return {
"message": {
"content": "Your OpenAI verification code is 111111",
}
}
if endpoint == "/api/emails/email-1/msg-new":
return {
"message": {
"content": "Your OpenAI verification code is 654321",
}
}
raise AssertionError(f"未准备响应: {method} {endpoint}")
service._make_request = fake_make_request
code = service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
otp_sent_at=1742378402,
)
assert code == "654321"
def test_moe_mail_service_cross_request_state_prefers_latest_of_three_messages():
first_service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
first_responses = [
{
"messages": [
{
"id": "msg-1",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
]
},
{
"message": {
"content": "Your OpenAI verification code is 111111",
}
},
]
def fake_make_request_first(method, endpoint, **kwargs):
if not first_responses:
raise AssertionError(f"未准备响应: {method} {endpoint}")
return first_responses.pop(0)
first_service._make_request = fake_make_request_first
first_code = first_service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
)
state = first_service.export_verification_state("tester@example.com")
second_service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
second_service.load_verification_state("tester@example.com", **state)
second_calls = []
second_responses = {
"/api/emails/email-1": {
"messages": [
{
"id": "msg-1",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
{
"id": "msg-2",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378403000,
},
{
"id": "msg-3",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378406000,
},
]
},
"/api/emails/email-1/msg-3": {
"message": {
"content": "Your OpenAI verification code is 333333",
}
},
"/api/emails/email-1/msg-2": {
"message": {
"content": "Your OpenAI verification code is 222222",
}
},
"/api/emails/email-1/msg-1": {
"message": {
"content": "Your OpenAI verification code is 111111",
}
},
}
def fake_make_request_second(method, endpoint, **kwargs):
second_calls.append(endpoint)
if endpoint not in second_responses:
raise AssertionError(f"未准备响应: {method} {endpoint}")
return second_responses[endpoint]
second_service._make_request = fake_make_request_second
second_code = second_service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
)
assert first_code == "111111"
assert state == {
"used_codes": ["111111"],
"seen_messages": ["id:msg-1"],
}
assert second_code == "333333"
assert second_calls == [
"/api/emails/email-1",
"/api/emails/email-1/msg-3",
]

View File

@@ -0,0 +1,258 @@
from src.services.duck_mail import DuckMailService
from src.services.freemail import FreemailService
from src.services.moe_mail import MeoMailEmailService
from src.services.temp_mail import TempMailService
from src.services.tempmail import TempmailService
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeRequestHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append({
"method": method,
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: {method} {url}")
return self.responses.pop(0)
class FakeGetHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def get(self, url, **kwargs):
self.calls.append({
"method": "GET",
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"未准备响应: GET {url}")
return self.responses.pop(0)
def test_tempmail_service_prefers_latest_matching_message_without_otp_timestamp():
service = TempmailService({"base_url": "https://api.tempmail.test"})
service.http_client = FakeGetHTTPClient([
FakeResponse(
payload={
"emails": [
{
"date": 1000,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
},
{
"date": 1003,
"from": "noreply@openai.com",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
},
]
}
),
])
code = service.get_verification_code(
email="tester@example.com",
email_id="token-1",
timeout=1,
)
assert code == "654321"
def test_temp_mail_service_prefers_latest_matching_message_without_otp_timestamp():
service = TempMailService({
"base_url": "https://mail.example.com",
"admin_password": "admin-secret",
"domain": "example.com",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"results": [
{
"id": "msg-1",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 111111",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"source": "OpenAI <noreply@openai.com>",
"subject": "Your verification code",
"body": "Your OpenAI verification code is 654321",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
])
code = service.get_verification_code(
email="tester@example.com",
timeout=1,
)
assert code == "654321"
def test_moe_mail_service_prefers_latest_matching_message_without_otp_timestamp():
service = MeoMailEmailService({
"base_url": "https://mail.example.com",
"api_key": "api-key",
})
def fake_make_request(method, endpoint, **kwargs):
if endpoint == "/api/emails/email-1":
return {
"messages": [
{
"id": "msg-1",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378400000,
},
{
"id": "msg-2",
"from_address": "noreply@openai.com",
"subject": "Your verification code",
"received_at": 1742378403000,
},
]
}
if endpoint == "/api/emails/email-1/msg-1":
return {
"message": {
"content": "Your OpenAI verification code is 111111",
}
}
if endpoint == "/api/emails/email-1/msg-2":
return {
"message": {
"content": "Your OpenAI verification code is 654321",
}
}
raise AssertionError(f"未准备响应: {method} {endpoint}")
service._make_request = fake_make_request
code = service.get_verification_code(
email="tester@example.com",
email_id="email-1",
timeout=1,
)
assert code == "654321"
def test_freemail_service_prefers_latest_matching_message_without_otp_timestamp():
service = FreemailService({
"base_url": "https://mail.example.com",
"admin_token": "jwt-token",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload=[
{
"id": "msg-1",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 111111",
"verification_code": "111111",
"created_at": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"sender": "noreply@openai.com",
"subject": "Your verification code",
"preview": "Your OpenAI verification code is 654321",
"verification_code": "654321",
"created_at": "2026-03-19T10:00:03Z",
},
]
),
])
code = service.get_verification_code(
email="tester@example.com",
timeout=1,
)
assert code == "654321"
def test_duck_mail_service_prefers_latest_matching_message_without_otp_timestamp():
service = DuckMailService({
"base_url": "https://api.duckmail.test",
"default_domain": "duckmail.sbs",
})
service.http_client = FakeRequestHTTPClient([
FakeResponse(
payload={
"hydra:member": [
{
"id": "msg-1",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:00Z",
},
{
"id": "msg-2",
"from": {
"name": "OpenAI",
"address": "noreply@openai.com",
},
"subject": "Your verification code",
"createdAt": "2026-03-19T10:00:03Z",
},
]
}
),
FakeResponse(
payload={
"id": "msg-2",
"text": "Your OpenAI verification code is 654321",
"html": [],
}
),
])
service._accounts_by_email["tester@duckmail.sbs"] = {
"email": "tester@duckmail.sbs",
"service_id": "account-1",
"account_id": "account-1",
"token": "token-123",
}
code = service.get_verification_code(
email="tester@duckmail.sbs",
email_id="account-1",
timeout=1,
)
assert code == "654321"

View File

@@ -0,0 +1,92 @@
import json
from types import SimpleNamespace
from src.core.register import RegistrationEngine
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
self.headers = {}
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeSession:
def __init__(self):
self.get_calls = []
self.post_calls = []
self.cookies = SimpleNamespace(get=lambda name: None)
def get(self, url, **kwargs):
self.get_calls.append({
"url": url,
"kwargs": kwargs,
})
return FakeResponse(status_code=200)
def post(self, url, **kwargs):
self.post_calls.append({
"url": url,
"kwargs": kwargs,
})
if url.endswith("/email-otp/validate"):
code = json.loads(kwargs["data"])["code"]
return FakeResponse(status_code=200 if code == "654321" else 401)
return FakeResponse(status_code=200)
class FakeEmailService:
def __init__(self):
self.calls = []
def get_verification_code(self, **kwargs):
self.calls.append(kwargs)
if len(self.calls) == 1:
return None
return "654321"
def test_registration_engine_resend_flow_propagates_new_otp_timestamp_and_validates_code(monkeypatch):
engine = RegistrationEngine.__new__(RegistrationEngine)
engine.logs = []
engine._log = lambda message, level="info": None
engine.email = "tester@example.com"
engine.email_info = {"service_id": "email-1"}
engine.email_service = FakeEmailService()
engine.session = FakeSession()
engine._otp_sent_at = None
engine.phase_history = []
issued_timestamps = iter([1000.0, 1000.0, 1000.0, 1005.0, 1005.0, 1005.0])
monkeypatch.setattr("src.core.register.time.time", lambda: next(issued_timestamps))
assert engine._send_verification_code() is True
first_otp_sent_at = engine._otp_sent_at
first_code = engine._get_verification_code()
assert first_otp_sent_at == 1000.0
assert first_code is None
assert engine._send_verification_code() is True
second_otp_sent_at = engine._otp_sent_at
second_code = engine._get_verification_code()
assert second_otp_sent_at == 1005.0
assert second_code == "654321"
assert engine._validate_verification_code(second_code) is True
assert len(engine.email_service.calls) == 2
assert engine.email_service.calls[0]["otp_sent_at"] == 1000.0
assert engine.email_service.calls[1]["otp_sent_at"] == 1005.0
assert engine.email_service.calls[0]["email_id"] == "email-1"
assert engine.email_service.calls[1]["email_id"] == "email-1"
validate_call = engine.session.post_calls[-1]
assert validate_call["url"].endswith("/email-otp/validate")
assert json.loads(validate_call["kwargs"]["data"]) == {"code": "654321"}