Files
ttstd-infinite-canvas/backend/internal/skills/skill_curation_test.go
2026-10-06 09:24:21 +08:00

167 lines
6.5 KiB
Go

package skills
import (
"encoding/json"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"reflect"
"testing"
)
func TestSkillCurationManagementAvailable(t *testing.T) {
if _, ok := reflect.TypeOf((*Service)(nil)).MethodByName("UpdateSkillCuration"); !ok {
t.Fatal("missing platform curation management: skill categories are fixed and cannot be maintained by an administrator")
}
}
func newCurationTest(t *testing.T) (*Service, *gorm.DB, *model.User) {
t.Helper()
svc, db := newSkillLibraryCategoryTestService(t)
if err := db.AutoMigrate(&model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.SkillCurationSetting{ID: 1}).Error; err != nil {
t.Fatal(err)
}
actor := &model.User{ID: "curator", Role: model.UserRoleAdmin, Status: model.UserStatusActive, Username: "curator"}
if err := db.Create(actor).Error; err != nil {
t.Fatal(err)
}
return svc, db, actor
}
func TestSkillCurationDisabledSnapshot(t *testing.T) {
svc, db, actor := newCurationTest(t)
// Snapshot of the original empty /skills data and fixed category order.
const original = `{"skills":[],"totalCount":0,"hasMore":false,"nextOffset":0,"page":1,"pageSize":20,"categories":[{"value":"drama","label":"短剧影视"},{"value":"ecommerce","label":"电商营销"},{"value":"creative","label":"创意设计"},{"value":"social","label":"社媒内容"},{"value":"others","label":"其他"}]}`
if err := db.Create(&model.SkillCurationCategory{ID: "hidden", RootTag: "drama", Name: "Hidden", NormalizedName: "hidden", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
for _, req := range []SkillListRequest{{}, {PlatformCategoryID: "invalid", PlatformUncategorized: true}} {
result, err := svc.Skills(actor.ID, req)
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
if string(data) != original {
t.Fatalf("legacy response changed:\n%s", data)
}
}
result, err := svc.SkillCuration(actor, false)
if err != nil {
t.Fatal(err)
}
if result.Enabled || len(result.Categories) != 0 || len(result.Assignments) != 0 {
t.Fatalf("disabled mode leaked curation: %#v", result)
}
}
func TestSkillCurationLifecycleAndGuards(t *testing.T) {
svc, db, actor := newCurationTest(t)
update := func(req SkillCurationUpdate) *SkillCuration {
t.Helper()
state, err := svc.SkillCuration(actor, true)
if err != nil {
t.Fatal(err)
}
req.ExpectedRevision = &state.Revision
out, err := svc.UpdateSkillCuration(actor, req, "test-request")
if err != nil {
t.Fatal(err)
}
return out
}
on := true
update(SkillCurationUpdate{Enabled: &on})
state := update(SkillCurationUpdate{Category: &model.SkillCurationCategory{RootTag: "drama", Name: "Story", SortOrder: 2, Enabled: true}})
c := state.Categories[0]
for _, s := range []model.Skill{{ID: "public", Name: "Story", Tag: "drama", Status: 1}, {ID: "other", Name: "Other", Tag: "social", Status: 1}, {ID: "private", Name: "Secret", Tag: "drama", Status: 1, IsPrivate: true, OwnerID: actor.ID}} {
if err := db.Create(&s).Error; err != nil {
t.Fatal(err)
}
}
state = update(SkillCurationUpdate{Assignment: &SkillCurationAssignmentInput{SkillID: "public", CategoryIDs: []string{c.ID}}})
list, err := svc.Skills(actor.ID, SkillListRequest{PlatformCategoryID: c.ID})
if err != nil || list.TotalCount != 1 {
t.Fatalf("filtered: %#v %v", list, err)
}
list, err = svc.Skills(actor.ID, SkillListRequest{PlatformUncategorized: true})
if err != nil || list.TotalCount != 1 {
t.Fatalf("uncategorized: %#v %v", list, err)
}
for _, id := range []string{"other", "private"} {
_, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &state.Revision, Assignment: &SkillCurationAssignmentInput{SkillID: id, CategoryIDs: []string{c.ID}}}, "")
if err == nil {
t.Fatalf("accepted invalid assignment: %s", id)
}
}
stale := int64(0)
if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &stale, Enabled: &on}, ""); appErrorStatus(err) != 409 {
t.Fatalf("stale update: %v", err)
}
c.Name = "Renamed"
c.SortOrder = -1
c.Enabled = false
state = update(SkillCurationUpdate{Category: &c})
public, err := svc.SkillCuration(actor, false)
if err != nil || len(public.Categories) != 0 || len(public.Assignments) != 0 {
t.Fatalf("disabled category: %#v %v", public, err)
}
var assignments int64
db.Model(&model.SkillCurationAssignment{}).Count(&assignments)
if assignments != 1 {
t.Fatal("disable deleted assignments")
}
c.Enabled = true
update(SkillCurationUpdate{Category: &c})
off := false
update(SkillCurationUpdate{Enabled: &off})
list, err = svc.Skills(actor.ID, SkillListRequest{PlatformCategoryID: c.ID})
if err != nil || list.TotalCount != 2 {
t.Fatalf("disabled switch must ignore filters: %#v %v", list, err)
}
// A stale in-memory administrator cannot keep writing after revocation.
db.Model(&model.User{}).Where("id = ?", actor.ID).Update("role", model.UserRoleUser)
if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &state.Revision, Enabled: &on}, ""); appErrorStatus(err) != 403 {
t.Fatalf("revoked administrator: %v", err)
}
}
func TestSkillCurationAuditFailureRollsBack(t *testing.T) {
svc, db, actor := newCurationTest(t)
if err := db.Exec(`CREATE TRIGGER reject_curation_audit BEFORE INSERT ON admin_audit_events BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`).Error; err != nil {
t.Fatal(err)
}
revision := int64(0)
on := true
if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &revision, Enabled: &on}, ""); err == nil {
t.Fatal("audit failure accepted")
}
state, err := svc.SkillCuration(actor, true)
if err != nil || state.Revision != 0 || state.Enabled {
t.Fatalf("partial commit: %#v %v", state, err)
}
}
func TestSkillCurationDeletionCleanup(t *testing.T) {
svc, db, _ := newCurationTest(t)
if err := db.AutoMigrate(&model.SkillVersion{}, &model.SkillFile{}, &model.BuiltinSkillTombstone{}); err != nil {
t.Fatal(err)
}
for _, id := range []string{"normal", "builtin"} {
db.Create(&model.Skill{ID: id, Status: 1})
db.Create(&model.SkillCurationAssignment{SkillID: id, CategoryID: "category"})
}
if err := svc.repo.DeleteSkill("normal"); err != nil {
t.Fatal(err)
}
if err := svc.repo.DeleteBuiltinSkill("builtin", "admin"); err != nil {
t.Fatal(err)
}
var count int64
db.Model(&model.SkillCurationAssignment{}).Count(&count)
if count != 0 {
t.Fatal("deleted skills retain assignments")
}
}