mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:21:59 +00:00
86d2840e41
邮件(04 细案 B2,集成-I4 / I5,改写应用平台 Q6): - notify/mail.py:标准库 smtplib 放进 asyncio.to_thread,单次超时 10 秒;4xx 和连不上按 1/2/4 秒重试,5xx 不重试; 报错只写主机名和服务器回话,口令一律抹掉;纯文本 + 转义过的 HTML,带 List-Unsubscribe 和 Auto-Submitted;测试可换假的 SMTP 工厂。模块说明写清改写 Q6 的理由:SMTP 是可选配置、退信不追踪照实说「已交给邮件服务器」 - 通知渠道多一种格式「邮件」(core.CHANNEL_FORMATS;定时任务的 Webhook 只会发 HTTP,FORMATS 不加,免得定时任务能选 邮件):个人邮件渠道不收地址,只发到名录里的账号邮箱,没有邮箱记一条失败投递并写明去「设置 → 身份」填;全局、应用的填 固定地址,最多 20 个;邮件服务没启用时不收新的邮件渠道;渠道列表另给 mail(启没启用、本人的账号邮箱),投递记录带格式 - 设置的 mail 一组(只有管理员能改):口令用 core/crypto 加密,读的时候只说配没配;启用时要配全;发件人名称、主机不许 换行;「发测试邮件」只发到管理员自己的账号邮箱,关登录、没有邮箱时 422 说清怎么办 - mail.default_for_assignees(缺省关):开了之后第一次派到待办的人自动有一个个人邮件渠道(订阅待办打开、动作结果未知、 审核有结果),本人删了不再建回来;关登录时不建 待办汇总与密钥到期(04 细案 B3): - notify/digest.py 接进调度循环的步骤列表:每天到 notify.digest_at(缺省 09:00,服务端时区)给订阅了 task.digest 的 个人渠道发汇总(条数、最早时限、最多 5 条节点名、去待办箱的链接,没有待办不发);按日期在 settings 表里插一行认领, 重启不重发;认领行和自动建邮件渠道的名单是通知自己记的账,设置接口不列不收 - key.expiring:到期前 7 天、1 天各推一次,个人密钥推本人的个人渠道、服务账号的推全局渠道,推之前记审计、按审计去重; 轮换下来的旧密钥、撤销了的、建的时候就在这一档里的不推 - 两个事件推送实现了,才在 notify/service.EVENTS 打开它们的订阅范围 设置的 notify 一组加 digest_at;只给一部分时补上存着的另几项;环境变量锁住对外访问地址时只核对那一项,汇总时间照常能改。 apps 一组加 share_links(分享链接的开关,缺省关,下一个提交用)。测试:假 SMTP 工厂下的收件人、失败投递、重试、口令、 设置与权限、自动建渠道;汇总按时、只发一次、重启不重发;密钥两档、谁收、不该推的几种。
101 lines
4.9 KiB
Python
101 lines
4.9 KiB
Python
"""通知设置(api/settings.py 的 notify 一组):定时任务的 Webhook 里拼运行链接用的对外访问地址,和每天的待办汇总几点发
|
||
(digest_at,缺省 09:00,04 细案 B3)。
|
||
|
||
- 默认没填(null),通知里就不附运行链接
|
||
- 只收 http(s) 开头的完整地址;末尾的 / 去掉、首尾空白去掉,空串当没填;带 ? 或 # 的拒收
|
||
- 写错整个请求回 422、detail 是中文,一项都不落库
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from sqlalchemy import delete
|
||
|
||
from app.api.settings import NOTIFY_GROUP, notify_settings, notify_values
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Setting
|
||
from app.main import app
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def clean():
|
||
async with SessionLocal() as session:
|
||
await session.execute(delete(Setting).where(Setting.key.in_([NOTIFY_GROUP, "run"])))
|
||
await session.commit()
|
||
yield
|
||
|
||
|
||
def client() -> AsyncClient:
|
||
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
||
|
||
|
||
async def test_default_is_no_link():
|
||
async with client() as c:
|
||
body = (await c.get("/api/settings")).json()
|
||
assert body["notify"] == {"public_base_url": None, "digest_at": "09:00"}
|
||
async with SessionLocal() as session:
|
||
assert await notify_settings(session) == {"public_base_url": None, "digest_at": "09:00"}
|
||
|
||
|
||
@pytest.mark.parametrize("given, stored", [
|
||
("https://agentlab.example.com/", "https://agentlab.example.com"),
|
||
(" http://10.20.30.40:8080 ", "http://10.20.30.40:8080"),
|
||
("https://intra.example.com/agentlab/", "https://intra.example.com/agentlab"),
|
||
("", None),
|
||
(None, None),
|
||
])
|
||
async def test_saved_and_normalized(given, stored):
|
||
async with client() as c:
|
||
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": given}}})
|
||
assert put.status_code == 200, put.text
|
||
assert put.json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"}
|
||
assert (await c.get("/api/settings")).json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"}
|
||
async with SessionLocal() as session:
|
||
assert await notify_settings(session) == {"public_base_url": stored, "digest_at": "09:00"}
|
||
|
||
|
||
@pytest.mark.parametrize("value, fragment", [
|
||
({"public_base_url": "agentlab.example.com"}, "http:// 或 https://"),
|
||
({"public_base_url": "ftp://files.example.com"}, "http:// 或 https://"),
|
||
({"public_base_url": "https://"}, "http:// 或 https://"),
|
||
({"public_base_url": 8080}, "http:// 或 https://"),
|
||
({"public_base_url": "https://agentlab.example.com/?from=bot"}, "? 或 #"),
|
||
({"public_url": "https://agentlab.example.com"}, "不支持「public_url」"),
|
||
("https://agentlab.example.com", "格式有误"),
|
||
])
|
||
async def test_invalid_values_are_refused_in_chinese(value, fragment):
|
||
async with client() as c:
|
||
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: value, "run": {"stream_tokens": False}}})
|
||
assert put.status_code == 422
|
||
assert fragment in put.json()["detail"]
|
||
assert "对外访问地址" in put.json()["detail"] or "通知" in put.json()["detail"]
|
||
# 先查后写:同一请求里别的组也没有落库
|
||
assert (await c.get("/api/settings")).json()["run"]["stream_tokens"] is True
|
||
|
||
|
||
def test_broken_stored_value_reads_as_unset():
|
||
assert notify_values({"public_base_url": "不是地址", "digest_at": "25:00"}) == {"public_base_url": None,
|
||
"digest_at": "09:00"}
|
||
assert notify_values("坏掉的") == {"public_base_url": None, "digest_at": "09:00"}
|
||
|
||
|
||
@pytest.mark.parametrize("given, ok", [("08:30", True), ("23:59", True), ("00:00", True), ("24:00", False),
|
||
("9:00", False), ("09:60", False), (900, False), ("", False)])
|
||
async def test_digest_time_is_hh_mm(given, ok):
|
||
async with client() as c:
|
||
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": given}}})
|
||
if ok:
|
||
assert put.status_code == 200, put.text
|
||
assert put.json()["notify"]["digest_at"] == given
|
||
else:
|
||
assert put.status_code == 422
|
||
assert "待办汇总的发送时间" in put.json()["detail"]
|
||
|
||
|
||
async def test_changing_digest_time_keeps_the_public_address():
|
||
"""只给汇总时间的一组:对外访问地址不被冲成没填(设置页的通知分别保存这两项)。"""
|
||
async with client() as c:
|
||
await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": "https://agentlab.example.com"}}})
|
||
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": "17:45"}}})
|
||
assert put.json()["notify"] == {"public_base_url": "https://agentlab.example.com", "digest_at": "17:45"}
|