package cabana_test import ( "context" "encoding/json" "errors" "strings" "testing" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" ) // Smoke tests of the flat (WinterCMS Sortable) reorder (Phase 18.1 D-17), // Docker-free: the inference rule and the flat flag. The Postgres routes are // TestReorderFlatSmoke in catalog_smoke_test.go; TestReorderFlatNodes is the // unit matrix of the save's node check (plan 18.1-06). No application names // appear in framework tests. // TestReorderFlatCompile pins the flat inference: a config_reorder.yaml // without parentColumn on a record without a parent_id column compiles as // flat and the list schema says so; maxDepth on it and an explicit // parentColumn that is not a column fail boot; the tree fixture still // compiles as a tree whose list schema carries no flat key. func TestReorderFlatCompile(t *testing.T) { const cfg = "controllers/badges/config_reorder.yaml" list, err := cabana.CompiledListSchema([]party.Plugin{catalogFSPlugin{fsys: catalogOverlay(t, nil)}}, "acme.catalog.badges") if err != nil { t.Fatal(err) } if list.Reorder == nil || !list.Reorder.Flat || list.Reorder.Title != "Reorder badges" { t.Fatalf("badges reorder = %+v", list.Reorder) } raw, err := json.Marshal(list.Reorder) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), `"flat":true`) { t.Fatalf("flat list schema JSON: %s", raw) } if err := activateCatalog(t, catalogFSPlugin{fsys: catalogOverlay(t, nil)}); err != nil { t.Fatalf("catalog boot: %v", err) } src := catalogFile(t, cfg) for _, tc := range []struct { name, add string want []string }{ {"maxDepth on a flat reorder", "maxDepth: 2\n", []string{cfg, "maxDepth needs a tree reorder"}}, {"an explicit parentColumn that is not a column", "parentColumn: parent_id\n", []string{cfg, "parentColumn parent_id is not a column of the record"}}, } { t.Run(tc.name, func(t *testing.T) { err := activateCatalog(t, catalogFSPlugin{fsys: catalogOverlay(t, map[string]string{cfg: src + tc.add})}) if err == nil { t.Fatal("boot accepted the reorder config") } for _, part := range tc.want { if !strings.Contains(err.Error(), part) { t.Fatalf("err = %v, want %q", err, part) } } }) } // The localized list schema (GET .../schema/list) carries the flat flag // too, not only the compiled one (deferred defect from plan 18.1-04). t.Run("the localized list schema keeps flat", func(t *testing.T) { app := backpack.New(nil) if err := phrasebook.Activate[party.Plugin](app, nil); err != nil { t.Fatal(err) } tr, ok := app.Lookup[*phrasebook.Translator]() if !ok { t.Fatal("translator missing") } view, err := list.Localize(towel.WithLocale(context.Background(), "en"), tr) if err != nil { t.Fatal(err) } if view.Reorder == nil || !view.Reorder.Flat || view.Reorder.Title != "Reorder badges" { t.Fatalf("localized reorder = %+v", view.Reorder) } raw, err := json.Marshal(view) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), `"reorder":{"title":"Reorder badges","flat":true}`) { t.Fatalf("localized list schema JSON lacks flat: %s", raw) } }) t.Run("the tree fixture stays a tree", func(t *testing.T) { tree, err := cabana.CompiledListSchema([]party.Plugin{companionsFSPlugin{fsys: companionsOverlay(t, nil)}}, "acme.companions.links") if err != nil { t.Fatal(err) } if tree.Reorder == nil || tree.Reorder.Flat { t.Fatalf("links reorder = %+v", tree.Reorder) } raw, err := json.Marshal(tree) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), `"flat"`) { t.Fatalf("tree list schema carries a flat key: %s", raw) } }) } // TestReorderFlatNodes is the unit matrix of the reorder save's node check // on a flat reorder (D-17), Docker-free: any non-null parent is a 422 on // nodes with the flat_parent message, a duplicate id or a zero id is a 422, // a missing or an extra node is the 409 stale set, and a valid set in any // order passes with no depth or cycle evaluation. The same node lists on a // tree reorder show what flat mode changes. TestReorderFlatSmoke drives the // rules over HTTP on Postgres. func TestReorderFlatNodes(t *testing.T) { const flatParent = "A flat reorder has no parents." locked := []cabana.AdminReorderNode{{ID: 1, SortOrder: 1}, {ID: 2, SortOrder: 2}, {ID: 3, SortOrder: 3}} node := func(id uint64, parent *uint64, sort int64) cabana.AdminReorderNodeInput { return cabana.AdminReorderNodeInput{ID: id, ParentID: parent, SortOrder: sort} } ptr := func(v uint64) *uint64 { return &v } nodesError := func(t *testing.T, err error, want string) { t.Helper() var ve *cabana.ValidationError if !errors.As(err, &ve) { t.Fatalf("err = %v, want a 422 validation error", err) } msgs, _ := ve.Details["nodes"].([]string) if len(msgs) != 1 || msgs[0] != want || len(ve.Details) != 1 { t.Fatalf("details = %#v, want nodes: %q", ve.Details, want) } } t.Run("a reversed set passes", func(t *testing.T) { nodes := []cabana.AdminReorderNodeInput{node(3, nil, 1), node(2, nil, 2), node(1, nil, 3)} if err := cabana.CheckReorderNodes(true, 0, locked, nodes, flatParent); err != nil { t.Fatal(err) } }) t.Run("equal and negative sort values pass", func(t *testing.T) { nodes := []cabana.AdminReorderNodeInput{node(1, nil, -5), node(2, nil, 0), node(3, nil, 0)} if err := cabana.CheckReorderNodes(true, 0, locked, nodes, flatParent); err != nil { t.Fatal(err) } }) t.Run("a long flat list has no depth or cycle check", func(t *testing.T) { var many []cabana.AdminReorderNode var nodes []cabana.AdminReorderNodeInput for i := uint(1); i <= 200; i++ { many = append(many, cabana.AdminReorderNode{ID: i}) nodes = append(nodes, node(uint64(201-i), nil, int64(i))) } if err := cabana.CheckReorderNodes(true, 1, many, nodes, flatParent); err != nil { t.Fatal(err) } }) t.Run("an empty set over no records passes", func(t *testing.T) { if err := cabana.CheckReorderNodes(true, 0, nil, nil, flatParent); err != nil { t.Fatal(err) } }) for _, tc := range []struct { name string nodes []cabana.AdminReorderNodeInput want string }{ {"a parent inside the set", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, ptr(1), 2), node(3, nil, 3)}, flatParent}, {"a parent outside the set", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(3, ptr(99), 3)}, flatParent}, {"a parent of zero", []cabana.AdminReorderNodeInput{node(1, ptr(0), 1), node(2, nil, 2), node(3, nil, 3)}, flatParent}, {"its own parent", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(3, ptr(3), 3)}, flatParent}, {"a duplicate id", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(2, nil, 3)}, "The nodes field lists a record twice."}, {"a zero id", []cabana.AdminReorderNodeInput{node(0, nil, 1), node(2, nil, 2), node(3, nil, 3)}, "The nodes field must list record ids."}, } { t.Run(tc.name+" is a 422", func(t *testing.T) { nodesError(t, cabana.CheckReorderNodes(true, 0, locked, tc.nodes, flatParent), tc.want) }) } for _, tc := range []struct { name string nodes []cabana.AdminReorderNodeInput }{ {"a missing node", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2)}}, {"an extra node", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(3, nil, 3), node(4, nil, 4)}}, {"a node swapped for another", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(4, nil, 3)}}, {"no nodes", nil}, } { t.Run(tc.name+" is the 409 stale set", func(t *testing.T) { err := cabana.CheckReorderNodes(true, 0, locked, tc.nodes, flatParent) if !cabana.IsReorderStale(err) { t.Fatalf("err = %v, want the stale set", err) } }) } t.Run("the same parent is accepted on a tree", func(t *testing.T) { nodes := []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, ptr(1), 2), node(3, nil, 3)} if err := cabana.CheckReorderNodes(false, 0, locked, nodes, flatParent); err != nil { t.Fatal(err) } cycle := []cabana.AdminReorderNodeInput{node(1, ptr(2), 1), node(2, ptr(1), 2), node(3, nil, 3)} nodesError(t, cabana.CheckReorderNodes(false, 0, locked, cycle, flatParent), "The tree has a cycle.") deep := []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, ptr(1), 2), node(3, ptr(2), 3)} nodesError(t, cabana.CheckReorderNodes(false, 2, locked, deep, flatParent), "The tree may be at most 2 levels deep.") }) }