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) } } }