Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd10245ad5 | ||
|
|
1744ff40ce | ||
|
|
13578ba4c2 |
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
"""Pytest 根配置:保证测试运行时 backend 包根目录在 sys.path 中。
|
||||||
|
|
||||||
|
运行方式(容器内,使用项目 venv):
|
||||||
|
cd /app/backend/python
|
||||||
|
/app/backend/python/.venv/bin/python -m pytest
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
|
addopts = -q --strict-markers
|
||||||
|
filterwarnings =
|
||||||
|
ignore::DeprecationWarning
|
||||||
|
ignore::PendingDeprecationWarning
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Phase 0 测试基建 — 开发/测试依赖(不进入生产 requirements.txt)
|
||||||
|
pytest==8.3.4
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
|
httpx==0.27.2
|
||||||
Whitespace-only changes.
@@ -0,0 +1,20 @@
|
|||||||
|
"""应用市场场景分类器回归(稳定模块,无外部依赖)。
|
||||||
|
|
||||||
|
仅校验分类法结构本身不被破坏;不调用 _classify_scene(其内部会请求 LLM)。
|
||||||
|
分类法是前端筛选/徽章的数据源,结构回归会导致全站分类错乱。
|
||||||
|
"""
|
||||||
|
from app.api.market import MARKET_SCENES
|
||||||
|
|
||||||
|
|
||||||
|
def test_market_scenes_is_14_flat():
|
||||||
|
assert len(MARKET_SCENES) == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_market_scenes_unique_ids_and_required_keys():
|
||||||
|
ids = [s["id"] for s in MARKET_SCENES]
|
||||||
|
assert len(set(ids)) == 14 # 无重复 id
|
||||||
|
for s in MARKET_SCENES:
|
||||||
|
assert "id" in s
|
||||||
|
assert "label" in s
|
||||||
|
assert "desc" in s
|
||||||
|
assert isinstance(s["label"], str) and s["label"].strip()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Phase 0 回归用例 R1/R2/R3 — 固化三个反复出现的问题。
|
||||||
|
|
||||||
|
R1: 技能列表不得被硬性截断为 15 条(微信 Bot 曾只返回 15 个技能)。
|
||||||
|
R2: 显式选中技能/MCP 时,system prompt 必须注入「最高优先级覆盖规则」。
|
||||||
|
R3: 媒体生成(图像/视频)必须有超时保护,超时后回写用户可懂的提示而非永久挂起。
|
||||||
|
|
||||||
|
说明:在 chat.py 巨石(10070 行)尚未拆分前,R1/R2 采用「源码契约」守卫,
|
||||||
|
这是最低风险且能切实挡住回归的方式;R3 为行为级单元测试(mock 内部生成调用模拟超时)。
|
||||||
|
Phase 1 抽出 chat_media / chat_mcp / chat_skill 干净 seam 后,R1/R2 应升级为行为测试。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CHAT_PY = os.path.abspath(os.path.join(_HERE, "..", "app", "api", "chat.py"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_chat_source():
|
||||||
|
with open(CHAT_PY, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- R1 ----------
|
||||||
|
def test_r1_skill_list_not_hard_capped_at_15():
|
||||||
|
"""微信 Bot 技能列表曾因 limit(15) 只返回 15 个,必须防止回归。"""
|
||||||
|
src = _read_chat_source()
|
||||||
|
assert "limit(15)" not in src, "检测到 limit(15),技能列表可能被硬性截断"
|
||||||
|
assert "LIMIT 15" not in src, "检测到 LIMIT 15,技能列表可能被硬性截断"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- R2 ----------
|
||||||
|
def test_r2_explicit_selection_override_rule_present():
|
||||||
|
"""显式选中技能/MCP 时,system prompt 必须包含最高优先级覆盖规则。"""
|
||||||
|
src = _read_chat_source()
|
||||||
|
assert "最高优先级覆盖规则" in src, "显式选中工具的覆盖规则文本缺失"
|
||||||
|
assert "只基于上文提供的该工具具体资料作答" in src, "覆盖规则应要求只基于已选工具资料作答"
|
||||||
|
assert "平台知识库" in src and "不要引用" in src, "覆盖规则应明确禁止引用平台知识库通用描述"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- R3 ----------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_r3_media_generation_timeout_returns_user_message(monkeypatch):
|
||||||
|
"""媒体生成超时必须回写用户可懂的超时提示,而非让前端永远『生成中…』。
|
||||||
|
|
||||||
|
注:_generate_media_task 是后台 fire-and-forget 任务(通过 asyncio.create_task 调用),
|
||||||
|
本身返回 None,超时保护体现在把提示回写到消息 content。故此处断言副作用。
|
||||||
|
"""
|
||||||
|
from app.api import chat as chat_mod
|
||||||
|
|
||||||
|
class FakeModel:
|
||||||
|
id = "m_test"
|
||||||
|
capabilities = ["image"] # 图像 -> 90s 超时
|
||||||
|
|
||||||
|
msg_mock = MagicMock() # 捕获被回写的消息对象
|
||||||
|
|
||||||
|
fake_session = MagicMock()
|
||||||
|
|
||||||
|
def _query_side_effect(model_cls):
|
||||||
|
q = MagicMock()
|
||||||
|
if model_cls is chat_mod.AIModel:
|
||||||
|
q.filter.return_value.first.return_value = FakeModel()
|
||||||
|
elif model_cls is chat_mod.Message:
|
||||||
|
q.filter.return_value.first.return_value = msg_mock
|
||||||
|
return q
|
||||||
|
|
||||||
|
fake_session.query.side_effect = _query_side_effect
|
||||||
|
monkeypatch.setattr(chat_mod, "SessionLocal", lambda: fake_session)
|
||||||
|
|
||||||
|
# 内部生成调用直接抛出 TimeoutError,等价模拟「生成耗时超过超时阈值」
|
||||||
|
async def _hang(db, ai_model, prompt, user_id):
|
||||||
|
raise asyncio.TimeoutError()
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_mod, "_generate_media", _hang)
|
||||||
|
|
||||||
|
await chat_mod._generate_media_task("msg1", "m_test", "画一只猫", "u1")
|
||||||
|
|
||||||
|
# 副作用:消息 content 被回写为用户可懂的超时提示
|
||||||
|
assert msg_mock.content is not None, "超时后必须回写消息内容,不能让前端永远『生成中…』"
|
||||||
|
assert "超时" in str(msg_mock.content), "媒体生成超时应回写用户可懂的超时提示"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Phase 0 安全网 — 后端冒烟测试。
|
||||||
|
|
||||||
|
核心价值:证明整个后端(含 chat.py 等巨型模块)在测试环境下能无错误导入并挂载路由。
|
||||||
|
任何破坏 import 的脆弱补丁都会在此立刻变红,从而把"改了没生效 / 启动即崩"挡在部署前。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_imports_and_has_routes():
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
assert app is not None
|
||||||
|
# 真实路由数远超 0;若 chat.py 等模块 import 失败,本测试在 import 阶段即报错
|
||||||
|
assert len(app.routes) > 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_schema_builds():
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
schema = app.openapi()
|
||||||
|
assert "paths" in schema
|
||||||
|
assert len(schema["paths"]) > 0
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""短信宝脚手架回归(稳定模块,纯函数)。
|
||||||
|
|
||||||
|
验证登录密码 MD5 算法与短信宝约定一致(32 位小写十六进制)。
|
||||||
|
这是短信验证码功能(注册/改密/充值)能连通短信宝的前提。
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.sms_client import smsbao_md5
|
||||||
|
|
||||||
|
|
||||||
|
def test_smsbao_md5_matches_hashlib():
|
||||||
|
samples = ["XHR521yhf", "abc123", "密码测试", ""]
|
||||||
|
for pw in samples:
|
||||||
|
assert smsbao_md5(pw) == hashlib.md5(pw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_smsbao_md5_is_32_lower_hex():
|
||||||
|
out = smsbao_md5("XHR521yhf")
|
||||||
|
assert len(out) == 32
|
||||||
|
assert out == out.lower()
|
||||||
|
assert all(c in "0123456789abcdef" for c in out)
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.4.21"
|
"vite": "^5.4.21",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatBjTime } from './datetime';
|
||||||
|
|
||||||
|
// 北京时间渲染曾是反复出现的问题(微信Bot时间错乱、Token驾驶舱时间列不正确等)。
|
||||||
|
// 该测试固化「后端按北京时间存储、前端显式按 Asia/Shanghai 渲染」的契约。
|
||||||
|
describe('formatBjTime', () => {
|
||||||
|
it('空输入返回占位符 -', () => {
|
||||||
|
expect(formatBjTime(undefined)).toBe('-');
|
||||||
|
expect(formatBjTime(null)).toBe('-');
|
||||||
|
expect(formatBjTime('')).toBe('-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无时区标记的时间戳按 Asia/Shanghai (+08:00) 渲染', () => {
|
||||||
|
// 2026-07-18T12:00:00 视为北京时间,渲染应含 12:00:00
|
||||||
|
const out = formatBjTime('2026-07-18T12:00:00');
|
||||||
|
expect(out).toContain('2026');
|
||||||
|
expect(out).toContain('12:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UTC(Z) 时间戳正确转换为北京时间 (+8)', () => {
|
||||||
|
// 2026-07-18T04:00:00Z = 北京时间 12:00:00
|
||||||
|
const out = formatBjTime('2026-07-18T04:00:00Z');
|
||||||
|
expect(out).toContain('12:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非法日期原样返回', () => {
|
||||||
|
expect(formatBjTime('not-a-date')).toBe('not-a-date');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from 'vite';
|
/// <reference types="vitest/config" />
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -17,8 +18,6 @@ export default defineConfig({
|
|||||||
chunkSizeWarningLimit: 1200,
|
chunkSizeWarningLimit: 1200,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
// 仅将 react 核心库单独打包(长期缓存);
|
|
||||||
// 其余第三方库交给 Rollup 自动按需拆分,避免被强制合并成一个大 vendor 在首屏加载
|
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
if (id.includes('node_modules')) {
|
if (id.includes('node_modules')) {
|
||||||
if (
|
if (
|
||||||
@@ -37,4 +36,9 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
},
|
},
|
||||||
|
// Phase 0 测试基建:vitest 配置(纯函数测试用 node 环境,组件测试可改 jsdom)
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user