From 77def709831655ad37604183359e5262959245b3 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 14 Jul 2026 14:11:31 +0800 Subject: [PATCH] test: cover ai platform demo workflow --- backend/apps/agents/services.py | 2 +- backend/apps/agents/tasks.py | 9 ++++ backend/apps/agents/tests/test_agents_api.py | 28 ++++++++++ backend/apps/agents/tests/test_runtime.py | 52 ++++++++++++++++++- .../apps/modelhub/tests/test_modelhub_api.py | 10 ++++ backend/apps/modelhub/tests/test_services.py | 44 +++++++++++++++- 6 files changed, 142 insertions(+), 3 deletions(-) diff --git a/backend/apps/agents/services.py b/backend/apps/agents/services.py index 4f5ad0b..d6774c9 100644 --- a/backend/apps/agents/services.py +++ b/backend/apps/agents/services.py @@ -242,7 +242,7 @@ def _repair_output( f"校验错误:{validation_error}\n" f"原始输出:{raw_output}" ) - invocation = _invoke_for_step(run, step, prompt, "repair") + invocation = _invoke_for_step(run, step, prompt, "structure_repair") attempt = step.attempts.order_by("-created_at").first() if attempt: attempt.validation_error = validation_error diff --git a/backend/apps/agents/tasks.py b/backend/apps/agents/tasks.py index e3b44f6..5e60b4d 100644 --- a/backend/apps/agents/tasks.py +++ b/backend/apps/agents/tasks.py @@ -33,6 +33,15 @@ def execute_agent_run(self, run_id: int) -> None: "finished_at", ] ) + OperationAuditLog.objects.create( + user=run.created_by, + module="agents", + action="run_finished", + target_type="agent_run", + target_id=str(run.public_id), + request_snapshot={"status": "timed_out"}, + result="timed_out", + ) @shared_task(name="apps.agents.tasks.cleanup_expired_agent_runs") diff --git a/backend/apps/agents/tests/test_agents_api.py b/backend/apps/agents/tests/test_agents_api.py index 0d7b995..f79ab84 100644 --- a/backend/apps/agents/tests/test_agents_api.py +++ b/backend/apps/agents/tests/test_agents_api.py @@ -1,6 +1,8 @@ from django.contrib.auth import get_user_model from django.core.management import call_command +from apps.agents.models import AgentRun +from apps.agents.services import build_config_snapshot from apps.modelhub.services import CompletionResult from .helpers import create_agent, create_deployment @@ -65,6 +67,32 @@ def test_agent_run_permission_is_required(client, db): assert response.status_code == 403 +def test_queued_run_can_be_cancelled(client, db, monkeypatch): + login_admin(client) + definition, version = create_agent(published=True) + run = AgentRun.objects.create( + definition=definition, + agent_version=version, + input_payload={"problem": "无法登录"}, + config_snapshot=build_config_snapshot(version), + total_steps=version.steps.count(), + ) + revoked = [] + monkeypatch.setattr( + "apps.agents.api.AsyncResult.revoke", + lambda self, **kwargs: revoked.append(kwargs), + ) + run.task_id = "queued-task-id" + run.save(update_fields=["task_id"]) + + response = client.post(f"/api/agent-runs/{run.public_id}/cancel") + + assert response.status_code == 200 + assert response.json()["status"] == "cancelled" + assert response.json()["cancellation_requested_at"] is not None + assert revoked == [{"terminate": False}] + + def test_seed_command_creates_support_agent(client, db): deployment = create_deployment() diff --git a/backend/apps/agents/tests/test_runtime.py b/backend/apps/agents/tests/test_runtime.py index 6ac01d3..e9251d5 100644 --- a/backend/apps/agents/tests/test_runtime.py +++ b/backend/apps/agents/tests/test_runtime.py @@ -1,10 +1,13 @@ from datetime import timedelta +from celery.exceptions import SoftTimeLimitExceeded from django.utils import timezone from apps.agents.models import AgentRun from apps.agents.services import build_config_snapshot, execute_agent_run from apps.agents.tasks import cleanup_expired_agent_runs +from apps.agents.tasks import execute_agent_run as execute_agent_run_task +from apps.audit.models import OperationAuditLog from apps.modelhub.services import CompletionResult from .helpers import create_agent @@ -65,7 +68,10 @@ def test_invalid_structure_is_repaired_once(db, monkeypatch): step = run.steps.get() assert run.status == "succeeded" assert step.attempts.count() == 2 - assert list(step.attempts.values_list("attempt_type", flat=True)) == ["initial", "repair"] + assert list(step.attempts.values_list("attempt_type", flat=True)) == [ + "initial", + "structure_repair", + ] def test_second_invalid_structure_fails_and_stops(db, monkeypatch): @@ -103,6 +109,45 @@ def test_cancellation_before_first_step_stops_run(db): assert run.steps.count() == 0 +def test_cancellation_after_model_response_stops_following_steps(db, monkeypatch): + _, version = create_agent() + run = create_run(version) + + def request(*args, **kwargs): + AgentRun.objects.filter(id=run.id).update(cancellation_requested_at=timezone.now()) + return CompletionResult('{"summary":"已完成当前请求"}', 3, 2) + + monkeypatch.setattr("apps.modelhub.services._request_completion", request) + + execute_agent_run(run.id) + + run.refresh_from_db() + assert run.status == "cancelled" + assert list(run.steps.values_list("code", flat=True)) == ["structure"] + assert run.steps.get().status == "succeeded" + assert run.final_output is None + + +def test_soft_time_limit_marks_run_timed_out_and_audits(db, monkeypatch): + _, version = create_agent() + run = create_run(version) + monkeypatch.setattr( + "apps.agents.tasks.execute_agent_run_service", + lambda *_: (_ for _ in ()).throw(SoftTimeLimitExceeded()), + ) + + execute_agent_run_task.run(run.id) + + run.refresh_from_db() + assert run.status == "timed_out" + assert run.error_code == "run_timeout" + assert OperationAuditLog.objects.filter( + target_id=str(run.public_id), + action="run_finished", + result="timed_out", + ).exists() + + def test_cleanup_removes_expired_runs_but_keeps_audit(db, settings): _, version = create_agent() run = create_run(version) @@ -112,3 +157,8 @@ def test_cleanup_removes_expired_runs_but_keeps_audit(db, settings): assert deleted == 1 assert not AgentRun.objects.filter(id=run.id).exists() + assert OperationAuditLog.objects.filter( + module="agents", + action="cleanup_runs", + request_snapshot__deleted_count=1, + ).exists() diff --git a/backend/apps/modelhub/tests/test_modelhub_api.py b/backend/apps/modelhub/tests/test_modelhub_api.py index c6608bd..a836762 100644 --- a/backend/apps/modelhub/tests/test_modelhub_api.py +++ b/backend/apps/modelhub/tests/test_modelhub_api.py @@ -47,6 +47,16 @@ def test_admin_manages_provider_without_exposing_secret(client, db): provider.refresh_from_db() assert decrypt_secret(provider.api_key_encrypted) == "sk-secret-1234" + replaced = client.patch( + f"/api/modelhub/providers/{provider.id}", + data={"api_key": "sk-new-secret-5678"}, + content_type="application/json", + ) + assert replaced.status_code == 200 + provider.refresh_from_db() + assert decrypt_secret(provider.api_key_encrypted) == "sk-new-secret-5678" + assert replaced.json()["credential_masked"] == "****5678" + def test_model_test_runs_through_recorded_invocation(client, db, monkeypatch): login_admin(client) diff --git a/backend/apps/modelhub/tests/test_services.py b/backend/apps/modelhub/tests/test_services.py index 757ac5e..aacfc0b 100644 --- a/backend/apps/modelhub/tests/test_services.py +++ b/backend/apps/modelhub/tests/test_services.py @@ -1,4 +1,6 @@ -from openai import APITimeoutError +import httpx +import pytest +from openai import APITimeoutError, AuthenticationError, InternalServerError, RateLimitError from apps.modelhub.crypto import encrypt_secret from apps.modelhub.models import ModelDeployment, ModelInvocation, ModelProvider @@ -57,3 +59,43 @@ def test_non_transient_error_fails_without_exposing_raw_error(db, monkeypatch): assert invocation.status == "failed" assert invocation.error_code == "unexpected_error" assert "secret provider detail" not in invocation.error_message + + +def provider_error(error_type, status_code): + request = httpx.Request("POST", "https://models.example.com/v1/chat/completions") + response = httpx.Response(status_code, request=request) + return error_type("provider raw error", response=response, body={}) + + +@pytest.mark.parametrize( + ("error", "expected_code", "expected_calls"), + [ + (provider_error(AuthenticationError, 401), "authentication_failed", 1), + (provider_error(RateLimitError, 429), "rate_limited", 2), + (provider_error(InternalServerError, 500), "provider_unavailable", 2), + ], +) +def test_provider_error_classification_and_retry( + db, + monkeypatch, + error, + expected_code, + expected_calls, +): + invocation = create_invocation() + calls = [] + + def request(*args, **kwargs): + calls.append(1) + raise error + + monkeypatch.setattr("apps.modelhub.services._request_completion", request) + monkeypatch.setattr("apps.modelhub.services.sleep", lambda *_: None) + + execute_invocation(invocation, invocation.input_snapshot["messages"]) + + invocation.refresh_from_db() + assert invocation.status == "failed" + assert invocation.error_code == expected_code + assert len(calls) == expected_calls + assert "provider raw error" not in invocation.error_message