Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements. Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate. Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable. Refs: Versioned dependencies U1/U3; feature/dependency-workspace
149 lines
5.4 KiB
Go
149 lines
5.4 KiB
Go
package surfacesvc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store"
|
|
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
|
|
)
|
|
|
|
func openSvcStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
return st
|
|
}
|
|
|
|
func validSpecJSON() json.RawMessage {
|
|
return json.RawMessage(`{"title":"选型对比","nodes":[
|
|
{"type":"section","id":"n1","title":"概览","children":[
|
|
{"type":"markdown","id":"n1a","source":["doc:a"],"markdown":"结论先行。"}]},
|
|
{"type":"table","id":"n2","source":["doc:a","doc:b"],"table":{
|
|
"columns":[{"key":"k","label":"维度"}],
|
|
"rows":[{"cells":[["k","价格"]]}]}}]}`)
|
|
}
|
|
|
|
func newSvc(t *testing.T) (*Service, *[]core.StreamEventType) {
|
|
st := openSvcStore(t)
|
|
var events []core.StreamEventType
|
|
return New(st, func(conversationID, runID string, typ core.StreamEventType, payload any) error {
|
|
events = append(events, typ)
|
|
return nil
|
|
}), &events
|
|
}
|
|
|
|
func TestSubmitGuards(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newSvc(t)
|
|
|
|
// 默认关闭:新插件无 activation 行 → 拒绝
|
|
if _, err := svc.Submit(ctx, "alice", "genui", SubmitRequest{Spec: validSpecJSON()}); !errors.Is(err, ErrDisabled) {
|
|
t.Fatalf("default-disabled should reject, got %v", err)
|
|
}
|
|
// 启用后提交成功
|
|
if _, err := svc.SetActivation(ctx, "alice", "genui", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snap, err := svc.Submit(ctx, "alice", "genui", SubmitRequest{ConversationID: "c1", Spec: validSpecJSON()})
|
|
if err != nil || snap.Revision != 1 || snap.Identity.Owner != "alice" || snap.Identity.PluginID != "genui" {
|
|
t.Fatalf("submit: %+v err=%v", snap, err)
|
|
}
|
|
// epoch 失配:观察值与当前不一致(禁用/重启用后的在途结果)→ 拒绝
|
|
if _, err := svc.Submit(ctx, "alice", "genui", SubmitRequest{
|
|
SurfaceID: snap.Identity.SurfaceID, BaseRevision: 1, ObservedEpoch: 99, Spec: validSpecJSON(),
|
|
}); !errors.Is(err, ErrStaleEpoch) {
|
|
t.Fatalf("stale epoch should reject, got %v", err)
|
|
}
|
|
// 规格违规:未知类型 + 字段级清单
|
|
bad := json.RawMessage(`{"title":"x","nodes":[{"type":"hologram"}]}`)
|
|
_, err = svc.Submit(ctx, "alice", "genui", SubmitRequest{Spec: bad})
|
|
var inv *ErrInvalidSpec
|
|
if !errors.As(err, &inv) || len(inv.Errs) == 0 {
|
|
t.Fatalf("invalid spec should carry field errors, got %v", err)
|
|
}
|
|
// 严格解码:未知字段拒绝(目录纪律)
|
|
extra := json.RawMessage(`{"title":"x","nodes":[{"type":"list","items":["a"],"color":"red"}]}`)
|
|
if _, err := svc.Submit(ctx, "alice", "genui", SubmitRequest{Spec: extra}); err == nil {
|
|
t.Fatal("unknown field should be rejected")
|
|
}
|
|
// 禁用后:提交拒绝、读取保留(KTD7 历史页面不丢)
|
|
if _, err := svc.SetActivation(ctx, "alice", "genui", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.Submit(ctx, "alice", "genui", SubmitRequest{Spec: validSpecJSON()}); !errors.Is(err, ErrDisabled) {
|
|
t.Fatalf("disabled submit should reject, got %v", err)
|
|
}
|
|
if _, err := svc.Get(ctx, "alice", snap.Identity.SurfaceID); err != nil {
|
|
t.Fatalf("disabled should not block reads, got %v", err)
|
|
}
|
|
// owner 隔离
|
|
if _, err := svc.Get(ctx, "bob", snap.Identity.SurfaceID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("cross-owner get should be NotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSubmitConflictSurfacesCurrent(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc, _ := newSvc(t)
|
|
svc.SetActivation(ctx, "alice", "genui", true)
|
|
s1, err := svc.Submit(ctx, "alice", "genui", SubmitRequest{ConversationID: "c1", Spec: validSpecJSON()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s2, err := svc.Submit(ctx, "alice", "genui", SubmitRequest{
|
|
SurfaceID: s1.Identity.SurfaceID, BaseRevision: 1, ConversationID: "c1", Spec: validSpecJSON(),
|
|
})
|
|
if err != nil || s2.Revision != 2 {
|
|
t.Fatalf("concurrent second writer: %+v err=%v", s2, err)
|
|
}
|
|
// 用过期 base 的第三方写入 → 冲突
|
|
_, err = svc.Submit(ctx, "alice", "genui", SubmitRequest{
|
|
SurfaceID: s1.Identity.SurfaceID, BaseRevision: 1, ConversationID: "c1", Spec: validSpecJSON(),
|
|
})
|
|
var conflict *store.ErrRevisionConflict
|
|
if !errors.As(err, &conflict) || conflict.Current != 2 {
|
|
t.Fatalf("conflict should surface current=2, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEventPublishFailureRecovery(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := openSvcStore(t)
|
|
fail := true
|
|
var emitted int
|
|
svc := New(st, func(conversationID, runID string, typ core.StreamEventType, payload any) error {
|
|
if fail {
|
|
return errors.New("bus down")
|
|
}
|
|
emitted++
|
|
return nil
|
|
})
|
|
svc.SetActivation(ctx, "alice", "genui", true)
|
|
// 快照保存成功、事件发布失败 → 待恢复标记
|
|
snap, err := svc.Submit(ctx, "alice", "genui", SubmitRequest{ConversationID: "c1", Spec: validSpecJSON()})
|
|
if err != nil {
|
|
t.Fatalf("snapshot must survive event failure, got %v", err)
|
|
}
|
|
pend, _ := st.ListSurfaceEventsPending(10)
|
|
if len(pend) != 1 {
|
|
t.Fatalf("event should be pending, got %d", len(pend))
|
|
}
|
|
// 恢复:事件通道恢复后重发并清除
|
|
fail = false
|
|
n, err := svc.RecoverPendingEvents(ctx)
|
|
if err != nil || n != 1 || emitted != 1 {
|
|
t.Fatalf("recovery: n=%d emitted=%d err=%v", n, emitted, err)
|
|
}
|
|
if pend, _ := st.ListSurfaceEventsPending(10); len(pend) != 0 {
|
|
t.Fatalf("pending should clear, got %d", len(pend))
|
|
}
|
|
_ = snap
|
|
}
|