397 lines
12 KiB
Go
397 lines
12 KiB
Go
package tree
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func TestInitializeCreatesProtectedTree(t *testing.T) {
|
|
store := newTestStore(t)
|
|
root, err := store.Tree(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if root.ID != RootID || root.ParentID != nil || root.Name != "Root" {
|
|
t.Fatalf("unexpected root: %#v", root.Node)
|
|
}
|
|
if len(root.Children) != 1 || root.Children[0].ID != UnsortedID {
|
|
t.Fatalf("expected Unsorted beneath Root, got %#v", root.Children)
|
|
}
|
|
if root.LookupCode == "" || root.Children[0].LookupCode == "" || root.LookupCode == root.Children[0].LookupCode {
|
|
t.Fatalf("expected stable unique lookup codes: %#v", root)
|
|
}
|
|
}
|
|
|
|
func TestInitializeBackfillsLookupCodesInExistingDatabase(t *testing.T) {
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if _, err := db.Exec(`CREATE TABLE nodes (
|
|
id TEXT PRIMARY KEY,
|
|
parent_id TEXT REFERENCES nodes(id),
|
|
name TEXT NOT NULL,
|
|
quantity INTEGER,
|
|
system INTEGER NOT NULL DEFAULT 0
|
|
)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO nodes (id, name, system) VALUES ('root', 'Root', 1)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := NewStore(db)
|
|
if err := store.Initialize(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root, err := store.Get(context.Background(), RootID)
|
|
if err != nil || root.LookupCode == "" {
|
|
t.Fatalf("existing node was not backfilled: %#v, %v", root, err)
|
|
}
|
|
}
|
|
|
|
func TestLookupCodeFindsNodeAndSurvivesMoveAndRename(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
node := createTestNode(t, store, RootID, "Box")
|
|
code := node.LookupCode
|
|
newName := "Renamed box"
|
|
if _, err := store.Update(ctx, node.ID, &newName, StringUpdate{}, QuantityUpdate{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.Move(ctx, node.ID, UnsortedID, WithSubtree); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found, err := store.GetByLookupCode(ctx, strings.ToLower(code))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if found.ID != node.ID || found.LookupCode != code || found.Name != newName {
|
|
t.Fatalf("lookup identity changed: %#v", found)
|
|
}
|
|
}
|
|
|
|
func TestMoveWithSubtree(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
a := createTestNode(t, store, RootID, "A")
|
|
b := createTestNode(t, store, RootID, "B")
|
|
child := createTestNode(t, store, a.ID, "child")
|
|
|
|
moved, err := store.Move(ctx, a.ID, b.ID, WithSubtree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertParent(t, moved, b.ID)
|
|
assertParent(t, mustGet(t, store, child.ID), a.ID)
|
|
}
|
|
|
|
func TestMovePromotesChildren(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
a := createTestNode(t, store, RootID, "A")
|
|
b := createTestNode(t, store, RootID, "B")
|
|
child := createTestNode(t, store, a.ID, "child")
|
|
|
|
if _, err := store.Move(ctx, a.ID, b.ID, PromoteChildren); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertParent(t, mustGet(t, store, child.ID), RootID)
|
|
}
|
|
|
|
func TestMoveSendsChildrenToUnsorted(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
a := createTestNode(t, store, RootID, "A")
|
|
b := createTestNode(t, store, RootID, "B")
|
|
child := createTestNode(t, store, a.ID, "child")
|
|
|
|
if _, err := store.Move(ctx, a.ID, b.ID, ChildrenToUnsorted); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertParent(t, mustGet(t, store, child.ID), UnsortedID)
|
|
}
|
|
|
|
func TestMoveRejectsCycle(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
a := createTestNode(t, store, RootID, "A")
|
|
child := createTestNode(t, store, a.ID, "child")
|
|
|
|
if _, err := store.Move(ctx, a.ID, child.ID, WithSubtree); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected conflict, got %v", err)
|
|
}
|
|
assertParent(t, mustGet(t, store, a.ID), RootID)
|
|
assertParent(t, mustGet(t, store, child.ID), a.ID)
|
|
}
|
|
|
|
func TestDeleteOnlyAllowsEmptyNodes(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
parent := createTestNode(t, store, RootID, "parent")
|
|
child := createTestNode(t, store, parent.ID, "child")
|
|
|
|
if err := store.Delete(ctx, parent.ID); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected conflict deleting non-empty node, got %v", err)
|
|
}
|
|
if err := store.Delete(ctx, child.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Delete(ctx, parent.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.Get(ctx, parent.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected deleted node to be missing, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProtectedNodesCannotBeMovedDeletedOrRenamed(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
newName := "Elsewhere"
|
|
|
|
for _, id := range []string{RootID, UnsortedID} {
|
|
if err := store.Delete(ctx, id); !errors.Is(err, ErrConflict) {
|
|
t.Errorf("delete %s: expected conflict, got %v", id, err)
|
|
}
|
|
if _, err := store.Move(ctx, id, RootID, WithSubtree); !errors.Is(err, ErrConflict) {
|
|
t.Errorf("move %s: expected conflict, got %v", id, err)
|
|
}
|
|
if _, err := store.Update(ctx, id, &newName, StringUpdate{}, QuantityUpdate{}); !errors.Is(err, ErrConflict) {
|
|
t.Errorf("rename %s: expected conflict, got %v", id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateDefaultsToUnsortedAndAcceptsDuplicateNames(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
quantity := int64(0)
|
|
first, err := store.Create(ctx, nil, "Pens", nil, &quantity)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := store.Create(ctx, nil, "Pens", nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertParent(t, first, UnsortedID)
|
|
assertParent(t, second, UnsortedID)
|
|
if first.Quantity == nil || *first.Quantity != 0 || second.Quantity != nil {
|
|
t.Fatalf("quantity did not preserve zero versus missing: %#v %#v", first, second)
|
|
}
|
|
}
|
|
|
|
func TestCreateManyIsOrderedAndAtomic(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
parent := createTestNode(t, store, RootID, "parent")
|
|
|
|
created, err := store.CreateMany(ctx, parent.ID, []string{" Hammer ", "Hammer", "Screws"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(created) != 3 || created[0].Name != "Hammer" || created[1].Name != "Hammer" || created[2].Name != "Screws" {
|
|
t.Fatalf("unexpected created nodes: %#v", created)
|
|
}
|
|
|
|
if _, err := store.CreateMany(ctx, parent.ID, []string{"Valid", " "}, nil); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("expected invalid bulk request, got %v", err)
|
|
}
|
|
tree, err := store.Tree(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parentAfter := tree.findForTest(parent.ID)
|
|
if parentAfter == nil || len(parentAfter.Children) != 3 {
|
|
t.Fatalf("invalid request inserted a partial batch: %#v", parentAfter)
|
|
}
|
|
}
|
|
|
|
func TestMoveManyMovesSiblingSubtreesAtomically(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
source := createTestNode(t, store, RootID, "source")
|
|
target := createTestNode(t, store, RootID, "target")
|
|
first := createTestNode(t, store, source.ID, "first")
|
|
second := createTestNode(t, store, source.ID, "second")
|
|
child := createTestNode(t, store, first.ID, "child")
|
|
|
|
moved, err := store.MoveMany(ctx, []string{first.ID, second.ID}, target.ID, WithSubtree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(moved) != 2 {
|
|
t.Fatalf("expected two moved nodes, got %d", len(moved))
|
|
}
|
|
assertParent(t, mustGet(t, store, first.ID), target.ID)
|
|
assertParent(t, mustGet(t, store, second.ID), target.ID)
|
|
assertParent(t, mustGet(t, store, child.ID), first.ID)
|
|
}
|
|
|
|
func TestMoveManyRejectsNodesFromDifferentParentsWithoutChanges(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
firstParent := createTestNode(t, store, RootID, "first parent")
|
|
secondParent := createTestNode(t, store, RootID, "second parent")
|
|
target := createTestNode(t, store, RootID, "target")
|
|
first := createTestNode(t, store, firstParent.ID, "first")
|
|
second := createTestNode(t, store, secondParent.ID, "second")
|
|
|
|
if _, err := store.MoveMany(ctx, []string{first.ID, second.ID}, target.ID, WithSubtree); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected conflict, got %v", err)
|
|
}
|
|
assertParent(t, mustGet(t, store, first.ID), firstParent.ID)
|
|
assertParent(t, mustGet(t, store, second.ID), secondParent.ID)
|
|
}
|
|
|
|
func TestDeleteManyIsAtomicWhenAnyNodeIsNotEmpty(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
parent := createTestNode(t, store, RootID, "parent")
|
|
empty := createTestNode(t, store, parent.ID, "empty")
|
|
nonEmpty := createTestNode(t, store, parent.ID, "non-empty")
|
|
createTestNode(t, store, nonEmpty.ID, "child")
|
|
|
|
if err := store.DeleteMany(ctx, []string{empty.ID, nonEmpty.ID}); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected conflict, got %v", err)
|
|
}
|
|
if _, err := store.Get(ctx, empty.ID); err != nil {
|
|
t.Fatalf("empty sibling was partially deleted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIndividualizeRetainsFirstIdentityAndCreatesUniqueUnits(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
quantity := int64(3)
|
|
original, err := store.Create(ctx, nil, "Bin", nil, &quantity)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
units, err := store.Individualize(ctx, original.ID, []string{"Bin 01", "Bin 02", "Bin 03"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(units) != 3 || units[0].ID != original.ID || units[0].LookupCode != original.LookupCode {
|
|
t.Fatalf("original identity was not retained: %#v", units)
|
|
}
|
|
codes := map[string]bool{}
|
|
for index, unit := range units {
|
|
if unit.Quantity != nil || unit.Name != fmt.Sprintf("Bin %02d", index+1) || codes[unit.LookupCode] {
|
|
t.Fatalf("unexpected individualized unit: %#v", unit)
|
|
}
|
|
codes[unit.LookupCode] = true
|
|
}
|
|
}
|
|
|
|
func TestIndividualizeRejectsNonEmptyNodeWithoutChanges(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
quantity := int64(2)
|
|
original, err := store.Create(ctx, nil, "Bin", nil, &quantity)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
createTestNode(t, store, original.ID, "contents")
|
|
if _, err := store.Individualize(ctx, original.ID, []string{"Bin 01", "Bin 02"}); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected conflict, got %v", err)
|
|
}
|
|
unchanged := mustGet(t, store, original.ID)
|
|
if unchanged.Quantity == nil || *unchanged.Quantity != 2 || unchanged.Name != "Bin" {
|
|
t.Fatalf("node was partially changed: %#v", unchanged)
|
|
}
|
|
}
|
|
|
|
func TestCombineEmptySiblingsSumsQuantityAndRetainsIdentity(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
first := createTestNode(t, store, UnsortedID, "Bin 01")
|
|
quantity := int64(3)
|
|
second, err := store.Create(ctx, nil, "Bins", nil, &quantity)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
combined, err := store.Combine(ctx, []string{first.ID, second.ID}, first.ID, "Bins")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if combined.ID != first.ID || combined.LookupCode != first.LookupCode || combined.Quantity == nil || *combined.Quantity != 4 || combined.Name != "Bins" {
|
|
t.Fatalf("unexpected combined node: %#v", combined)
|
|
}
|
|
if _, err := store.Get(ctx, second.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("combined identity was not removed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCombineRejectsDifferentParentsWithoutChanges(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
parent := createTestNode(t, store, RootID, "parent")
|
|
first := createTestNode(t, store, UnsortedID, "first")
|
|
second := createTestNode(t, store, parent.ID, "second")
|
|
if _, err := store.Combine(ctx, []string{first.ID, second.ID}, first.ID, "combined"); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected conflict, got %v", err)
|
|
}
|
|
if _, err := store.Get(ctx, second.ID); err != nil {
|
|
t.Fatalf("node was partially deleted: %v", err)
|
|
}
|
|
}
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
store := NewStore(db)
|
|
if err := store.Initialize(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func createTestNode(t *testing.T, store *Store, parentID, name string) Node {
|
|
t.Helper()
|
|
node, err := store.Create(context.Background(), &parentID, name, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return node
|
|
}
|
|
|
|
func mustGet(t *testing.T, store *Store, id string) Node {
|
|
t.Helper()
|
|
node, err := store.Get(context.Background(), id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return node
|
|
}
|
|
|
|
func assertParent(t *testing.T, node Node, parentID string) {
|
|
t.Helper()
|
|
if node.ParentID == nil || *node.ParentID != parentID {
|
|
t.Fatalf("node %s: expected parent %s, got %v", node.ID, parentID, node.ParentID)
|
|
}
|
|
}
|
|
|
|
func (node *TreeNode) findForTest(id string) *TreeNode {
|
|
if node.ID == id {
|
|
return node
|
|
}
|
|
for _, child := range node.Children {
|
|
if found := child.findForTest(id); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|