| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- import asyncio
- import hashlib
- import os
- from uuid import UUID, uuid4
- import pytest
- from langchain_core.messages import HumanMessage, message_to_dict
- from sqlalchemy import func, select
- from tests.test_after_sales import scenario # noqa: F401
- from zhifu.modules.agent_runtime.config import AgentSettings
- from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt
- from zhifu.modules.agent_runtime.service import queue_run
- from zhifu.modules.conversations.api import Message, accept_message
- from zhifu.modules.conversations.models import Conversation
- from zhifu.modules.identity.access_models import Principal, StoreGrant
- from zhifu.modules.identity.auth import authenticate_hash
- from zhifu.modules.merchant.models import StaffInvitation
- pytestmark = pytest.mark.integration
- async def grant_management(s):
- async with s["app"].state.sessions.begin() as session:
- for permission in ["stores:manage", "staff:manage", "channels:manage", "agent:publish"]:
- session.add(StoreGrant(tenant_id=s["tenant"].bytes, store_id=s["store"].bytes,
- principal_id=s["accounts"]["agent"].bytes,
- permission=permission))
- async def test_staff_store_permissions_versions_and_sessions(scenario): # noqa: F811
- s = scenario
- await grant_management(s)
- call = s["call"]
- base = f"/admin/v1/stores/{s['store']}"
- assert (await call("GET", base, "customer")).status_code == 403
- assert (await call("GET", base, "reviewer")).status_code == 404
- staff = await call("GET", base + "/staff", "agent")
- assert staff.status_code == 200, staff.text
- row = next(item for item in staff.json()["items"] if item["account"] == "reviewer")
- path = base + f"/staff/{row['id']}/status"
- change = {"expected_version": row["version"], "enabled": False}
- key = str(uuid4())
- disabled = await call("PUT", path, "agent", change, key)
- assert disabled.status_code == 200, disabled.text
- assert (await call("PUT", path, "agent", change, key)).json() == disabled.json()
- assert (await call("GET", "/openapi/v1/auth/me", "reviewer")).status_code == 401
- assert (await call("PUT", path, "agent", change)).status_code == 409
- assert (await call("PUT", path, "agent", {
- "expected_version": row["version"] + 1, "enabled": True
- })).status_code == 200
- # 启用后原会话保持撤销,员工需要重新验证身份。
- assert (await call("GET", "/openapi/v1/auth/me", "reviewer")).status_code == 401
- store = (await call("GET", base, "agent")).json()
- assert (await call("PUT", base + "/status", "agent", {
- "expected_version": store["version"], "enabled": False
- })).status_code == 200
- assert (await call("GET", base + "/exchange-rules", "agent")).status_code == 404
- assert (await call("GET", base, "agent")).status_code == 200
- assert (await call("PUT", base + "/status", "agent", {
- "expected_version": store["version"] + 1, "enabled": True
- })).status_code == 200
- async def test_invitation_persistence_and_no_implicit_login(scenario): # noqa: F811
- s = scenario
- await grant_management(s)
- base = f"/admin/v1/stores/{s['store']}"
- body = {"account": "invited.staff", "role": "staff"}
- key = str(uuid4())
- response = await s["call"]("POST", base + "/staff/invitations", "agent", body, key)
- assert response.status_code == 201, response.text
- assert response.json()["invited"] and not response.json()["enabled"]
- repeated = await s["call"]("POST", base + "/staff/invitations", "agent", body, key)
- assert repeated.json() == response.json()
- assert (await s["call"]("POST", base + "/staff/invitations", "agent", body)).status_code == 409
- async with s["app"].state.sessions.begin() as session:
- invitation = await session.scalar(select(StaffInvitation).where(
- StaffInvitation.tenant_id == s["tenant"].bytes,
- StaffInvitation.account == body["account"]))
- assert invitation.status == "PENDING"
- assert await session.scalar(select(Principal).where(
- Principal.tenant_id == s["tenant"].bytes,
- Principal.username == body["account"])) is None
- async def test_rule_draft_publish_and_stale_action(scenario): # noqa: F811
- s = scenario
- base = f"/admin/v1/stores/{s['store']}/exchange-rules"
- call = s["call"]
- state = (await call("GET", base, "agent")).json()
- assert state["current"]["version"] == 1 and state["draft"] is None
- body = {"expected_version": 0, "base_version": 1, "window_days": 14,
- "product_types": ["keyboard"], "citation": "签收十四天内功能故障可申请换货。"}
- saved = await call("PUT", base + "/draft", "agent", body)
- assert saved.status_code == 200, saved.text
- assert (await call("GET", base, "agent")).json()["draft"]["window_days"] == 14
- assert (await call("PUT", base + "/draft", "agent", body)).status_code == 409
- publish = {"expected_version": saved.json()["version"], "base_version": 1}
- key = str(uuid4())
- response = await call("POST", base + "/draft/publish", "agent", publish, key)
- assert response.status_code == 200, response.text
- assert response.json()["version"] == 2
- repeated = await call("POST", base + "/draft/publish", "agent", publish, key)
- assert repeated.json() == response.json()
- public = await call("GET", f"/openapi/v1/stores/{s['store']}/exchange-rules/current")
- assert public.json()["window_days"] == 14
- saved = await call("PUT", base + "/draft", "agent", {**body, "base_version": 2})
- assert saved.status_code == 200
- assert (await call("POST", base + "/draft/publish", "agent", publish)).status_code == 409
- async def test_channel_validation_and_release_requirements(scenario): # noqa: F811
- s = scenario
- await grant_management(s)
- base = f"/admin/v1/stores/{s['store']}"
- body = {"expected_version": 0, "name": "渠道配置验证", "scope_confirmed": True,
- "callback_url": "http://127.0.0.1/events"}
- assert (await s["call"]("PUT", base + "/channels", "agent", body)).status_code == 422
- assert (await s["call"]("PUT", base + "/channels", "agent", {
- **body, "callback_url": "https://127.0.0.1/events"
- })).status_code == 422
- channels = (await s["call"]("GET", base + "/channels", "agent")).json()
- assert channels["channel"] is None
- assert channels["store"] == (await s["call"]("GET", base, "agent")).json()
- candidate = await s["call"]("GET", base + "/agent-releases", "agent")
- assert candidate.status_code == 200, candidate.text
- assert candidate.json()["current"] is None and candidate.json()["errors"]
- release = await s["call"]("POST", base + "/agent-releases", "agent", {
- "expected_version": 0, "candidate_hash": candidate.json()["candidate_hash"],
- "regression_checked": True, "reason": "核对配置完整性"
- })
- assert release.status_code == 409 and release.json()["code"] == "RELEASE_NOT_READY"
- async def persist_cancelled_snapshot(s, conversation_id=None, key=None):
- # 队列记录与取消状态在同一事务提交,工作进程无法看到可执行任务。
- token = s["headers"]["customer"]["Authorization"].removeprefix("Bearer ")
- async with s["app"].state.sessions.begin() as session:
- auth = await authenticate_hash(
- session, hashlib.sha256(token.encode()).digest(), str(uuid4())
- )
- conversation = await session.get(
- Conversation, UUID(conversation_id).bytes, with_for_update=True
- ) if conversation_id else None
- request_key = key or str(uuid4())
- if conversation:
- event = await accept_message(
- session, auth, conversation,
- Message(text="查询当前店铺规则",
- expected_control_version=conversation.control_version),
- request_key,
- )
- run = await session.get(AgentRun, UUID(event["payload"]["run_id"]).bytes)
- conversation.active_run_id = None
- else:
- run = await queue_run(
- session, auth, s["store"].bytes, request_key,
- hashlib.sha256(request_key.encode()).digest(),
- [message_to_dict(HumanMessage(content="查询当前店铺规则"))],
- )
- run.status = "CANCELLED"
- return run.id
- async def test_agent_release_success_and_execution_snapshots(scenario): # noqa: F811
- s = scenario
- settings = AgentSettings()
- if not os.getenv(settings.credential_env):
- pytest.skip("发布成功验收需要配置真实模型凭据")
- await grant_management(s)
- async with s["app"].state.sessions.begin() as session:
- session.add(StoreGrant(
- tenant_id=s["tenant"].bytes, store_id=s["store"].bytes,
- principal_id=s["accounts"]["agent"].bytes, permission="knowledge:write",
- ))
- call = s["call"]
- base = f"/admin/v1/stores/{s['store']}"
- document_id = uuid4()
- document_path = base + f"/knowledge/{document_id}"
- release_path = base + "/agent-releases"
- async def create_conversation():
- response = await call(
- "POST", "/openapi/v1/conversations",
- payload={"store_id": str(s["store"])},
- )
- assert response.status_code == 201, response.text
- return response.json()["conversation_id"]
- try:
- draft = await call("PUT", document_path, "agent", {
- "expected_version": 0, "title": "键盘售后服务说明",
- "text": "键盘出现功能故障时,请查询订单和当前换货规则,由客户确认后交主管审批。",
- })
- assert draft.status_code == 200, draft.text
- published = await call("POST", document_path + "/publish", "agent", {
- "expected_version": draft.json()["version"],
- })
- assert published.status_code == 200, published.text
- assert published.json()["status"] == "PUBLISHED"
- first_candidate = (await call("GET", release_path, "agent")).json()
- assert first_candidate["errors"] == []
- body = {
- "expected_version": 0, "candidate_hash": first_candidate["candidate_hash"],
- "regression_checked": True, "reason": "核对发布条件、规则与知识版本",
- }
- key = str(uuid4())
- first_response = await call("POST", release_path, "agent", body, key)
- assert first_response.status_code == 201, first_response.text
- first = first_response.json()
- assert first["version"] == 1
- assert first["manifest"]["knowledge"][0]["document_id"] == str(document_id)
- assert first["manifest"]["knowledge"][0]["version"] == published.json()["version"]
- repeated = await call("POST", release_path, "agent", body, key)
- assert repeated.status_code == 201 and repeated.json() == first
- conflicting = await call(
- "POST", release_path, "agent", {**body, "reason": "其他发布说明"}, key
- )
- assert conflicting.status_code == 409
- assert conflicting.json()["code"] == "IDEMPOTENCY_CONFLICT"
- stale = await call("POST", release_path, "agent", body)
- assert stale.status_code == 409 and stale.json()["code"] == "VERSION_CONFLICT"
- old_conversation = await create_conversation()
- async with s["app"].state.sessions.begin() as session:
- old = await session.get(Conversation, UUID(old_conversation).bytes)
- assert old.agent_release_version is None
- # 保留升级前会话可能存在的历史列值,验证它不再参与后续运行的版本选择。
- old.agent_release_version = 1
- first_message_key = str(uuid4())
- first_run_id = await persist_cancelled_snapshot(
- s, old_conversation, first_message_key
- )
- revised = await call("PUT", document_path, "agent", {
- "expected_version": published.json()["version"], "title": "键盘售后服务说明",
- "text": "键盘功能故障需要核对订单及有效换货规则;运输中请保护键盘并保留包装。",
- })
- assert revised.status_code == 200, revised.text
- republished = await call("POST", document_path + "/publish", "agent", {
- "expected_version": revised.json()["version"],
- })
- assert republished.status_code == 200, republished.text
- second_candidate = (await call("GET", release_path, "agent")).json()
- assert second_candidate["errors"] == []
- assert second_candidate["candidate_hash"] != first_candidate["candidate_hash"]
- changed = await call("POST", release_path, "agent", {**body, "expected_version": 1})
- assert changed.status_code == 409 and changed.json()["code"] == "VERSION_CONFLICT"
- second_response = await call("POST", release_path, "agent", {
- **body, "expected_version": 1,
- "candidate_hash": second_candidate["candidate_hash"],
- })
- assert second_response.status_code == 201, second_response.text
- second = second_response.json()
- assert second["version"] == 2
- assert second["manifest"]["knowledge"][0]["version"] == republished.json()["version"]
- # 同一消息重试继续返回原运行,不因中途发布而重复创建运行或替换快照。
- assert await persist_cancelled_snapshot(
- s, old_conversation, first_message_key
- ) == first_run_id
- new_conversation = await create_conversation()
- old_run_id = await persist_cancelled_snapshot(s, old_conversation)
- new_run_id = await persist_cancelled_snapshot(s, new_conversation)
- independent_run_id = await persist_cancelled_snapshot(s)
- async with s["app"].state.sessions.begin() as session:
- old = await session.get(Conversation, UUID(old_conversation).bytes)
- new = await session.get(Conversation, UUID(new_conversation).bytes)
- assert old.agent_release_version == 1 and new.agent_release_version is None
- for run_id, expected in [
- (first_run_id, first), (old_run_id, second),
- (new_run_id, second), (independent_run_id, second),
- ]:
- stored = await session.get(AgentRun, run_id)
- assert stored.release_snapshot == expected["manifest"]
- assert stored.graph_version == expected["manifest"]["graph_version"]
- assert stored.model_name == settings.model_name
- assert stored.model_url == settings.model_url
- assert stored.model_credential_env == settings.credential_env
- assert stored.status == "CANCELLED" and stored.model_calls == 0
- continued = await session.get(AgentRun, old_run_id)
- assert continued.conversation_id == old.id
- assert [item["data"]["content"] for item in continued.messages] == [
- "查询当前店铺规则", "查询当前店铺规则",
- ]
- assert old.sequence == 2 and old.active_run_id is None
- attempts = await session.scalar(select(func.count()).select_from(ModelAttempt).where(
- ModelAttempt.tenant_id == s["tenant"].bytes,
- ))
- assert attempts == 0
- current = (await call("GET", release_path, "agent")).json()["current"]
- assert current == second
- finally:
- # 只清理本次随机租户与资料标识对应的向量,MySQL 由 scenario 清理。
- knowledge = s["app"].state.knowledge
- await asyncio.to_thread(
- knowledge.milvus.delete, knowledge.settings.collection,
- filter=f'tenant == "{s["tenant"].hex}" and document == "{document_id.hex}"',
- timeout=knowledge.settings.timeout,
- )
|