diff --git a/backend/tests/fixtures/dual_check_shop.py b/backend/tests/fixtures/dual_check_shop.py index 6f67f5b2..2b07ba76 100644 --- a/backend/tests/fixtures/dual_check_shop.py +++ b/backend/tests/fixtures/dual_check_shop.py @@ -19,7 +19,6 @@ from __future__ import annotations import json import re import sqlite3 -import uuid from pathlib import Path from langchain_core.messages import AIMessage, ToolMessage @@ -27,6 +26,7 @@ from langchain_core.messages import AIMessage, ToolMessage from app.data import introspect from app.db.base import SessionLocal from app.db.models import DataSource +from tests.fixtures.names import letters ROWS = [ (1, "华东", 101, 100.0, "paid", "2026-08-31", "2026-09-01", "online", 0), @@ -67,7 +67,7 @@ def build(path: Path, *, date_type: str = "DATE") -> Path: async def add_source(path: Path, *, options: dict | None = None) -> tuple[str, str]: """登记成只读的手工源,探查好表结构。返回 (数据源名, id)。""" - name = f"dc_shop_{uuid.uuid4().hex[:8]}" + name = f"dc_shop_{letters(8)}" async with SessionLocal() as session: row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options=dict(options or {})) @@ -135,7 +135,7 @@ class Blind: hook, blind.on_query = blind.on_query, None hook() call = {"name": f"db_query__{blind.source}", "args": {"sql": sqls[done]}, - "id": f"blind-{uuid.uuid4().hex[:6]}"} + "id": f"blind-{letters(6)}"} return AIMessage(content="查一下。", tool_calls=[call]) return AIMessage(content="查完了。") blind.writer_prompts.append(text) diff --git a/backend/tests/fixtures/names.py b/backend/tests/fixtures/names.py new file mode 100644 index 00000000..91910ca4 --- /dev/null +++ b/backend/tests/fixtures/names.py @@ -0,0 +1,19 @@ +"""测试里随机起的名字不带数字。 + +名字会拼进导出的文件、通知、任务说明这类文本,而不少断言写的是「文本里不许出现某个数」(不许出现 330、300、48……): +随机的十六进制里带着那几个数字的概率不大但不是零(8 位里出现「330」约千分之一点五),一撞上就无缘无故地失败。 +十六进制里的数字换成字母(同 test_run_data_versions 的写法),随机性来自 uuid4(不受 pytest-randomly 每个测试重设 +random 种子的影响,不会在不同测试里撞名)。 +""" + +from __future__ import annotations + +import uuid + +#: 十六进制里的数字换成字母:名字里不带数字,数字的断言才只看得见数据 +NO_DIGITS = str.maketrans("0123456789", "ghijklmnop") + + +def letters(n: int = 8) -> str: + """n 个随机小写字母(a–p),n 最多 32。""" + return uuid.uuid4().hex[:n].translate(NO_DIGITS) diff --git a/backend/tests/test_app_spec_tools.py b/backend/tests/test_app_spec_tools.py index d1234746..c67b7d8e 100644 --- a/backend/tests/test_app_spec_tools.py +++ b/backend/tests/test_app_spec_tools.py @@ -6,7 +6,6 @@ from __future__ import annotations import copy -import uuid import pytest @@ -21,6 +20,7 @@ from app.apps.spec import empty_spec, parse_spec from app.core.artifact_store import graph_hash from app.db.base import SessionLocal, utcnow from app.db.models import DataSource, Schedule, Workflow, WorkflowVersion +from tests.fixtures.names import letters def node(nid, ntype, label=None, **config): @@ -57,7 +57,7 @@ def approval(): async def make_workflow(graph, *, versions=((1, "governed"),), current=1, status="governed") -> tuple[str, dict]: async with SessionLocal() as session: - wf = Workflow(name=f"周报-{uuid.uuid4().hex[:6]}", graph=graph, status=status, published_version=current, + wf = Workflow(name=f"周报-{letters(6)}", graph=graph, status=status, published_version=current, version=max(v for v, _ in versions)) session.add(wf) await session.flush() @@ -281,7 +281,7 @@ async def test_process_pins(monkeypatch): async def test_on_demand_views_on_live_sources_need_a_ttl(tmp_path): - name = f"live_{uuid.uuid4().hex[:6]}" + name = f"live_{letters(6)}" async with SessionLocal() as session: session.add(DataSource(name=name, kind="sqlite", database=str(tmp_path / "x.db"), enabled=True)) await session.commit() diff --git a/backend/tests/test_challenge_masking.py b/backend/tests/test_challenge_masking.py index b1d6b89b..839098f4 100644 --- a/backend/tests/test_challenge_masking.py +++ b/backend/tests/test_challenge_masking.py @@ -23,6 +23,7 @@ from app.db.base import SessionLocal from app.db.models import DataSource from app.engine import masking from app.engine.nodes import challenge as challenge_mod +from tests.fixtures.names import letters from tests.test_challenge_node import Models, Play, engine_up, finish, graph, output_of # noqa: F401 - engine_up 是自动用上的夹具 #: 四笔订单,买家手机号设了遮罩;备注没设遮罩,其中一条写着换了写法的号码 @@ -42,7 +43,7 @@ async def masked_shop(tmp_path): db.executescript(SCRIPT) db.commit() db.close() - name = f"shop_{uuid.uuid4().hex[:8]}" + name = f"shop_{letters(8)}" async with SessionLocal() as session: row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options={"mask_columns": ["buyer_phone"]}) @@ -139,7 +140,7 @@ async def big_shop(tmp_path): db.executescript(BIG) db.commit() db.close() - name = f"shop_{uuid.uuid4().hex[:8]}" + name = f"shop_{letters(8)}" async with SessionLocal() as session: row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options={"mask_columns": ["amount", "buyer_name"]}) diff --git a/backend/tests/test_copy_to_postgres.py b/backend/tests/test_copy_to_postgres.py index 1030f46c..5bc37b31 100644 --- a/backend/tests/test_copy_to_postgres.py +++ b/backend/tests/test_copy_to_postgres.py @@ -409,6 +409,7 @@ async def test_an_old_database_with_nulls_and_orphans_imports_cleanly(tmp_path): def test_null_not_null_values_fall_back_to_the_model_default(): + from app.db import models # noqa: F401 表登记进元数据(这个进程里之前没有测试导入过模型时 schedules 还不在) from app.db.base import Base schedules = Base.metadata.tables["schedules"] diff --git a/backend/tests/test_eval_schedule.py b/backend/tests/test_eval_schedule.py index 1bf3c7b3..a999e294 100644 --- a/backend/tests/test_eval_schedule.py +++ b/backend/tests/test_eval_schedule.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio import json +import re import uuid from datetime import timedelta, timezone @@ -218,8 +219,10 @@ async def test_a_regression_is_pushed_without_numbers(client, monkeypatch, recei assert message["event"] == "eval.regressed" and message["title"] == "定时评测「虚构周回归」有退步" text = message["text"] assert "通过:0 / 1 题" in text and "退步:1 题" in text + # 消息里的编号(32 位十六进制)和发送时间是随机的、可能正好带着「310」:去掉再查,查的是写出来的文字 + readable = re.sub(r"[0-9a-f]{32}", "", json.dumps({**message, "sent_at": ""}, ensure_ascii=False)) for secret in ("330", "310", "gmv", "orders", "paid_date"): - assert secret not in json.dumps(message, ensure_ascii=False), f"通知里不该有 {secret}" + assert secret not in readable, f"通知里不该有 {secret}" from app.db.models import NotifyDelivery async with SessionLocal() as session: diff --git a/backend/tests/test_evals_api.py b/backend/tests/test_evals_api.py index c4efa1f5..910938e7 100644 --- a/backend/tests/test_evals_api.py +++ b/backend/tests/test_evals_api.py @@ -7,7 +7,6 @@ from __future__ import annotations import json import sqlite3 -import uuid from datetime import datetime, timedelta from pathlib import Path from urllib.parse import quote @@ -27,6 +26,7 @@ from app.main import app from tests.fixtures import dual_check_shop as shop from tests.fixtures.acceptance import retail from tests.fixtures.catalog.fake_model import FakeModel +from tests.fixtures.names import letters from tests.fixtures.sources import drop_source SUITE_FILE = Path(__file__).parent / "fixtures" / "eval" / "retail_suite.json" @@ -57,7 +57,7 @@ async def source(tmp_path): async def new_suite(client, **body) -> dict: - res = await client.post("/api/evals/suites", json={"name": f"虚构题集_{uuid.uuid4().hex[:6]}", **body}, headers=ACTOR) + res = await client.post("/api/evals/suites", json={"name": f"虚构题集_{letters(6)}", **body}, headers=ACTOR) assert res.status_code == 201, res.text return res.json() @@ -264,7 +264,7 @@ def _weekly(source_name: str, *, refunds: bool = False) -> dict: async def test_caliber_cases_list_and_dispose_upgrades(client, source): async with SessionLocal() as session: - wf = Workflow(name=f"周销售_{uuid.uuid4().hex[:6]}", graph=_weekly(source["name"]), status="published", + wf = Workflow(name=f"周销售_{letters(6)}", graph=_weekly(source["name"]), status="published", published_version=1, version=1) session.add(wf) await session.flush() @@ -329,7 +329,7 @@ async def test_caliber_cases_list_and_dispose_upgrades(client, source): async def test_caliber_cases_pin_only_published_versions(client, source): async with SessionLocal() as session: - wf = Workflow(name=f"草稿口径_{uuid.uuid4().hex[:6]}", graph=_weekly(source["name"]), status="draft", version=1) + wf = Workflow(name=f"草稿口径_{letters(6)}", graph=_weekly(source["name"]), status="draft", version=1) session.add(wf) await session.flush() session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=_weekly(source["name"]))) diff --git a/backend/tests/test_notify_channels.py b/backend/tests/test_notify_channels.py index b5ffd120..b25379c6 100644 --- a/backend/tests/test_notify_channels.py +++ b/backend/tests/test_notify_channels.py @@ -8,7 +8,6 @@ from __future__ import annotations import asyncio import json -import uuid import httpx import pytest @@ -19,6 +18,7 @@ from app.db.base import SessionLocal from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Setting from app.engine.runner import run_manager from app.notify import core, service +from tests.fixtures.names import letters URL = "https://hooks.example.com/agentlab/inbox-7f3a9c2e1b" @@ -141,7 +141,7 @@ async def test_task_opened_is_pushed_to_the_app_subscription_without_numbers(rec async with SessionLocal() as session: channel = NotifyChannel(name="财务群", format="generic", url=core_encrypt(URL), events=[]) session.add(channel) - app = App(name=f"报销-{uuid.uuid4().hex[:6]}", published_version=1) + app = App(name=f"报销-{letters(6)}", published_version=1) session.add(app) await session.flush() session.add(AppVersion(app_id=app.id, version=1, spec_hash="h" * 64, bindings={}, diff --git a/backend/tests/test_notify_run.py b/backend/tests/test_notify_run.py index a57e32ff..0ccf2967 100644 --- a/backend/tests/test_notify_run.py +++ b/backend/tests/test_notify_run.py @@ -10,7 +10,6 @@ from __future__ import annotations import asyncio import json -import uuid import httpx import pytest @@ -21,6 +20,7 @@ from app.db.base import SessionLocal from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, User from app.engine.runner import run_manager from app.notify import core, service +from tests.fixtures.names import letters URL = "https://hooks.example.com/agentlab/run-3b8e1f" @@ -68,9 +68,9 @@ def flow(**human): async def _app(events: list[str]) -> tuple[str, str]: async with SessionLocal() as session: - channel = NotifyChannel(name=f"群-{uuid.uuid4().hex[:6]}", format="generic", url=encrypt(URL), events=[]) + channel = NotifyChannel(name=f"群-{letters(6)}", format="generic", url=encrypt(URL), events=[]) session.add(channel) - app = App(name=f"报销-{uuid.uuid4().hex[:6]}", published_version=1) + app = App(name=f"报销-{letters(6)}", published_version=1) session.add(app) await session.flush() session.add(AppVersion(app_id=app.id, version=1, spec_hash="h" * 64, bindings={}, @@ -180,7 +180,7 @@ async def _finished_instance(app_id: str, *, started_by: str, verified: bool) -> async def test_the_initiator_s_personal_channel_receives_my_instance_finished(received): """B1:个人渠道订阅了 run.finished,发起人自己发起的实例结束了收得到;应用自己没订阅也一样。""" app_id, _ = await _app([]) - name = f"uma{uuid.uuid4().hex[:6]}" + name = f"uma{letters(6)}" mine = await _personal(name, ["run.finished"]) run_id = await _finished_instance(app_id, started_by=name, verified=True) assert await service.notify_run(run_id, "run.finished") == 1 @@ -192,7 +192,7 @@ async def test_the_initiator_s_personal_channel_receives_my_instance_finished(re async def test_a_self_declared_initiator_does_not_reach_a_personal_channel(received): """关登录时署名是自己填的,谁都能填别人的名字:不按它推给那个人的个人渠道。""" app_id, _ = await _app([]) - name = f"vic{uuid.uuid4().hex[:6]}" + name = f"vic{letters(6)}" mine = await _personal(name, ["run.finished"]) run_id = await _finished_instance(app_id, started_by=name, verified=False) assert await service.notify_run(run_id, "run.finished") == 0 diff --git a/backend/tests/test_recipe_imports.py b/backend/tests/test_recipe_imports.py index 74b54535..2f54d653 100644 --- a/backend/tests/test_recipe_imports.py +++ b/backend/tests/test_recipe_imports.py @@ -42,6 +42,7 @@ from app.data.xlsx_scan import Bounds, SheetScan, UnsupportedTable, WorkbookScan from app.db.base import SessionLocal from app.db.models import DataSource, ImportStaging, SourceSnapshot, TableImport, TableRecipe from app.main import app +from tests.fixtures.names import letters FLOW_RECIPE = json.loads((Path(__file__).parent / "fixtures" / "recipes" / "flow_recipe.json").read_text("utf-8")) FLOW_FULL = Recipe.model_validate(FLOW_RECIPE).model_dump(mode="json") @@ -294,7 +295,7 @@ async def client(): def unique(prefix: str = "wp5c") -> str: - return f"{prefix}_{uuid.uuid4().hex[:10]}" + return f"{prefix}_{letters(10)}" def raw_bytes() -> bytes: diff --git a/backend/tests/test_table_imports_api.py b/backend/tests/test_table_imports_api.py index 6146d18d..761cfdbe 100644 --- a/backend/tests/test_table_imports_api.py +++ b/backend/tests/test_table_imports_api.py @@ -11,7 +11,6 @@ from __future__ import annotations import datetime as dt import json -import uuid from pathlib import Path from typing import Any @@ -26,6 +25,7 @@ from app.data.engine import engines, run_query from app.db.base import SessionLocal from app.db.models import DataSource, ImportStaging, SourceSnapshot, TableImport, TableRecipe from app.main import app +from tests.fixtures.names import letters from tests.fixtures.xlsx.flow import FLOW_EXPECT, flow_workbook from tests.test_recipe_imports import ( # noqa: F401 - fake 是夹具,导入即可用 FLOW_FULL, api_stage, fake, raw_bytes, recipe_source, staged, @@ -52,7 +52,7 @@ async def client(): def unique(prefix: str = "wp5c") -> str: - return f"{prefix}_{uuid.uuid4().hex[:10]}" + return f"{prefix}_{letters(10)}" async def stage(client, name: str, raw: bytes, filename: str, **form: str): diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 59470975..792df5c7 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -901,6 +901,7 @@ migrate_rollback() { copy_to_pg() { # --assume-stopped:服务是这里刚停的(强杀留下的 -wal 也不挡);快照放数据目录里(和库同一块盘,空间够)。 + # 内置库是直连(没有 PgBouncer),断点跟着目标地址走,不用 --direct-url。 # 逐表的结果里来源是空表的几十行不列(没什么可核对的),别的原样打出来;退出码照 copy-to-postgres 的(pipefail) docker run --rm --network "$DOCKER_NETWORK" -v "$DATA_ABS:/data" -e "PGPASSFILE=$PG_PASSFILE" \ --entrypoint python "$IMAGE" -m app.db copy-to-postgres /data \