feat(admin): 分类管理(分类树增删改移 + 后台页面)
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package adminstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// newTestStore connects to the test database, skipping when it is unreachable
|
||||
// or migrations have not been applied.
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Skipf("no database: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
t.Skipf("database not reachable: %v", err)
|
||||
}
|
||||
var hasTable bool
|
||||
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.category') IS NOT NULL").Scan(&hasTable); err != nil || !hasTable {
|
||||
pool.Close()
|
||||
t.Skip("migrations not applied (category missing)")
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return New(pool)
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
func TestCategoryLifecycle(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
root, err := s.CreateCategory(ctx, "tester", CategoryInput{
|
||||
NameZH: "测试根", Slug: ptr("test_root_" + randomHex(6)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM category WHERE path <@ $1::ltree", root.Path) })
|
||||
|
||||
if root.Level != 0 || root.ParentID != nil {
|
||||
t.Fatalf("root level/parent wrong: level=%d parent=%v", root.Level, root.ParentID)
|
||||
}
|
||||
|
||||
child, err := s.CreateCategory(ctx, "tester", CategoryInput{
|
||||
NameZH: "测试子", NameEN: ptr("Test Child"), ParentID: &root.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
if child.Level != 1 || child.ParentID == nil || *child.ParentID != root.ID {
|
||||
t.Fatalf("child hierarchy wrong: %+v", child)
|
||||
}
|
||||
|
||||
// Deleting a node with children must fail.
|
||||
if err := s.DeleteCategory(ctx, root.ID, "tester"); !errors.Is(err, ErrCategoryHasChildren) {
|
||||
t.Fatalf("expected ErrCategoryHasChildren, got %v", err)
|
||||
}
|
||||
|
||||
// Rename child.
|
||||
renamed, err := s.UpdateCategory(ctx, child.ID, "tester", CategoryInput{NameZH: "测试子-改名"})
|
||||
if err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
if renamed.NameZH != "测试子-改名" {
|
||||
t.Fatalf("rename not applied: %q", renamed.NameZH)
|
||||
}
|
||||
|
||||
// Move child to a second root, descendants' path/level should follow.
|
||||
root2, err := s.CreateCategory(ctx, "tester", CategoryInput{
|
||||
NameZH: "测试根2", Slug: ptr("test_root2_" + randomHex(6)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create root2: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM category WHERE path <@ $1::ltree", root2.Path) })
|
||||
|
||||
moved, err := s.UpdateCategory(ctx, child.ID, "tester", CategoryInput{NameZH: "测试子-改名", ParentID: &root2.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("move: %v", err)
|
||||
}
|
||||
if moved.ParentID == nil || *moved.ParentID != root2.ID {
|
||||
t.Fatalf("move parent wrong: %+v", moved)
|
||||
}
|
||||
if moved.Level != 1 {
|
||||
t.Fatalf("moved level wrong: %d", moved.Level)
|
||||
}
|
||||
|
||||
// Moving a node under itself must be rejected.
|
||||
if _, err := s.UpdateCategory(ctx, root2.ID, "tester", CategoryInput{NameZH: "测试根2", ParentID: &child.ID}); !errors.Is(err, ErrInvalidParent) {
|
||||
t.Fatalf("expected ErrInvalidParent for self-move, got %v", err)
|
||||
}
|
||||
|
||||
// Duplicate path on create must be rejected.
|
||||
if _, err := s.CreateCategory(ctx, "tester", CategoryInput{NameZH: "dup", Slug: ptr(root.Path)}); !errors.Is(err, ErrDuplicatePath) {
|
||||
t.Fatalf("expected ErrDuplicatePath, got %v", err)
|
||||
}
|
||||
|
||||
// Now the leaf can be deleted.
|
||||
if err := s.DeleteCategory(ctx, child.ID, "tester"); err != nil {
|
||||
t.Fatalf("delete leaf: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Cooking Oil": "cooking_oil",
|
||||
" Hello--Wld": "hello_wld",
|
||||
"食品": "",
|
||||
"a__b": "a_b",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := slugify(in); got != want {
|
||||
t.Errorf("slugify(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user