Files
Agent-System-Backend/internal/surfacesvc/actions_test.go
T
weichen add1fbb045
ci / vet-and-test (pull_request) Successful in 54s
Add project-level surface aggregation and task-phase guidance (U6)
Why: U6 extends pages from conversations into the task/project
workspace - surfaces aggregate across conversations under the owner's
project scope (project resolved authoritatively from the conversation
row, never self-reported by the model), and the catalog guidance now
organizes pages by task phase (requirements/execution/acceptance)
while stating that task facts come only from real task records.

Evidence: build/vet clean; surfacesvc suite green incl. the new
project-aggregation test (owner isolation, cross-owner zero, history
retained post-disable); genui/store suites green.

Impact: New endpoint GET /api/projects/{id}/surfaces; Submit resolves
project from conversation; catalog text guidance. Existing endpoints
unchanged.

Refs: proposal r2 U6; Dev task feature/generative-ui-u6
2026-10-12 02:12:42 +00:00

222 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package surfacesvc
// U5 红测(已批准范围 r2 验收线):启停守卫、旧修订拒绝、白名单拒绝、
// 幂等同参回放/异参拒绝、DangerHigh 经确认卡且执行只由卡回道触发。
// 数据准备走真实 surfacesvc.Submit(与生产同路径)。
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"agent-system-backend/internal/store"
core "agent-system-core"
)
type actionFixture struct {
svc *Service
st *store.Store
surface core.SurfaceIdentity
rev int
projectID string
}
func newActionFixtureWithConversation(t *testing.T, projectID string) *actionFixture {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
svc := New(st, nil)
ctx := context.Background()
if _, err := svc.SetActivation(ctx, "alice", "generative-ui", true); err != nil {
t.Fatal(err)
}
pj, err := st.CreateProject("alice", "U6测试项目")
if err != nil {
t.Fatal(err)
}
projectID = string(pj.ID)
if _, err := st.DB().Exec(`INSERT INTO conversations(id,project_id,owner,title,created_at,updated_at) VALUES(?,?,?,'U6会话',datetime('now'),datetime('now'))`,
"c1", projectID, "alice"); err != nil {
t.Fatal(err)
}
snap, err := svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{
ConversationID: "c1",
Spec: json.RawMessage(`{"title":"需求表","nodes":[{"type":"form","id":"f1","fields":[{"key":"years","label":"年限","type":"select","options":["3","5"]}]},{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`),
})
if err != nil {
t.Fatal(err)
}
return &actionFixture{svc: svc, st: st, surface: snap.Identity, rev: snap.Revision, projectID: projectID}
}
func newActionFixture(t *testing.T) *actionFixture {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
svc := New(st, nil)
ctx := context.Background()
if _, err := svc.SetActivation(ctx, "alice", "generative-ui", true); err != nil {
t.Fatal(err)
}
snap, err := svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{
ConversationID: "c1",
Spec: json.RawMessage(`{"title":"需求表","nodes":[{"type":"form","id":"f1","fields":[{"key":"years","label":"年限","type":"select","options":["3","5"]}]},{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`),
})
if err != nil {
t.Fatal(err)
}
return &actionFixture{svc: svc, st: st, surface: snap.Identity, rev: snap.Revision}
}
func TestSubmitActionGuards(t *testing.T) {
f := newActionFixture(t)
ctx := context.Background()
// 白名单外动作 → 拒绝
if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "shell.exec", Revision: f.rev, RequestID: "r0", Data: map[string]any{}}); !errors.Is(err, ErrUnknownAction) {
t.Fatalf("unknown action should reject, got %v", err)
}
// 旧修订 → 冲突(带当前版本)
_, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "generative-ui.submit", Revision: f.rev - 1, RequestID: "r0b", Data: map[string]any{}})
var conflict *store.ErrRevisionConflict
if !errors.As(err, &conflict) || conflict.Current != f.rev {
t.Fatalf("stale revision should conflict with current=%d, got %v", f.rev, err)
}
// 跨 owner → NotFound
if _, err := f.svc.SubmitAction(ctx, "bob", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "r0c", Data: map[string]any{}}); !errors.Is(err, ErrNotFound) {
t.Fatalf("cross-owner should be NotFound, got %v", err)
}
// 未装配 dispatcher → 能力不可用
if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "r0d", Data: map[string]any{}}); !errors.Is(err, ErrUnavailable) {
t.Fatalf("no dispatcher should be unavailable, got %v", err)
}
}
func TestSubmitActionIdempotency(t *testing.T) {
f := newActionFixture(t)
ctx := context.Background()
var calls int
f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) {
calls++
return ActionOutcome{State: "committed", Receipt: map[string]any{"op": "op_1"}}, nil
})
data := map[string]any{"years": "5"}
r1, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "req-1", Data: data})
if err != nil || r1.State != "committed" {
t.Fatalf("first submit: %+v err=%v", r1, err)
}
// 同 requestId 同参数 → 回放已有回执,不再分发
r2, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "req-1", Data: data})
if err != nil || r2.State != "committed" || calls != 1 {
t.Fatalf("idempotent replay must not redispatch: calls=%d r2=%+v err=%v", calls, r2, err)
}
// 同 requestId 异参数 → 拒绝
if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "req-1",
Data: map[string]any{"years": "3"}}); !errors.Is(err, store.ErrIdempotencyConflict) {
t.Fatalf("same requestId different params must reject, got %v", err)
}
}
func TestSubmitActionDisabledAfterEnable(t *testing.T) {
f := newActionFixture(t)
ctx := context.Background()
f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) {
return ActionOutcome{State: "committed"}, nil
})
// 禁用后:新动作拒绝(KTD7:禁用返回即准入截止点)
if _, err := f.svc.SetActivation(ctx, "alice", "generative-ui", false); err != nil {
t.Fatal(err)
}
if _, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "generative-ui.submit", Revision: f.rev, RequestID: "r9", Data: map[string]any{}}); !errors.Is(err, ErrDisabled) {
t.Fatalf("post-disable action should reject, got %v", err)
}
}
func TestDangerHighGoesThroughConfirmCard(t *testing.T) {
f := newActionFixture(t)
ctx := context.Background()
// 模拟把一条 DangerHigh 命令加入白名单(B 类机制验证;正式白名单由独立评审加)。
prev := AllowedActions["generative-ui.submit"]
saved := map[string]string{"generative-ui.submit": "generative-ui.submit"}
_ = prev
AllowedActions["test.highwrite"] = "test.highwrite"
defer func() {
delete(AllowedActions, "test.highwrite")
_ = saved
}()
var executed int
f.svc.SetDispatcher(func(ctx context.Context, d ActionDispatch) (ActionOutcome, error) {
// 模拟总线行为:DangerHigh 未确认 → 创建确认卡、不执行(真实路径在
// cmdbus 红测;此处验证 surfacesvc 不透传 Confirmed 的等价语义)。
if d.Params["confirmed"] == true {
executed++
return ActionOutcome{State: "committed"}, nil
}
return ActionOutcome{State: "awaiting_confirm", CardID: "card_1"}, nil
})
res, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "test.highwrite", Revision: f.rev, RequestID: "req-h", Data: map[string]any{"x": 1}})
if err != nil || res.State != "awaiting_confirm" || res.CardID == "" {
t.Fatalf("danger-high must await confirm card: %+v err=%v", res, err)
}
if executed != 0 {
t.Fatal("danger-high must not execute without card confirm")
}
// 重放同 requestId: awaiting_confirm 态原样返回(未执行的确认仍挂在卡上)
res2, err := f.svc.SubmitAction(ctx, "alice", SubmitActionRequest{SurfaceID: f.surface.SurfaceID,
ActionID: "test.highwrite", Revision: f.rev, RequestID: "req-h", Data: map[string]any{"x": 1}})
if err != nil || res2.State != "awaiting_confirm" {
t.Fatalf("replay should return awaiting_confirm, got %+v err=%v", res2, err)
}
}
func TestProjectAggregationOwnerIsolation(t *testing.T) {
// U6/R8 验收:项目级跨会话聚合按 owner 隔离(跨 owner 不可见);
// 禁用不阻断读取(历史页面保留)。
f := newActionFixtureWithConversation(t, "prj_u6")
ctx := context.Background()
snap2, err := f.svc.Submit(ctx, "alice", "generative-ui", SubmitRequest{
ConversationID: "c1",
Spec: json.RawMessage(`{"title":"执行进度页","nodes":[{"type":"steps","id":"s1","steps":{"current":1,"steps":[{"label":"需求冻结","status":"done"},{"label":"实施","status":"active"}]}}]}`),
})
if err != nil {
t.Fatal(err)
}
metas, err := f.svc.ListByProject(ctx, "alice", core.ID(f.projectID))
if err != nil {
t.Fatal(err)
}
if len(metas) != 2 {
t.Fatalf("alice should see her project surfaces, got %+v", metas)
}
// 跨 owner 隔离:bob 看不到 alice 的项目页面
bobMetas, err := f.svc.ListByProject(ctx, "bob", core.ID(f.projectID))
if err != nil || len(bobMetas) != 0 {
t.Fatalf("bob must see zero surfaces, got %+v err=%v", bobMetas, err)
}
// 禁用后读取保留
if _, err := f.svc.SetActivation(ctx, "alice", "generative-ui", false); err != nil {
t.Fatal(err)
}
after, err := f.svc.ListByProject(ctx, "alice", core.ID(f.projectID))
if err != nil || len(after) != 2 {
t.Fatalf("post-disable read must retain history, got %+v err=%v", after, err)
}
_ = snap2
}