package folder import ( "context" "fmt" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func requireTestPool(t *testing.T) (*pgxpool.Pool, string) { t.Helper() dsn := os.Getenv("TEST_TENANT_DSN") if dsn == "" { t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(func() { pool.Close() }) if _, err := pool.Exec(ctx, ` CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS folders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), parent_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE, name TEXT NOT NULL, path TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id), trashed_at TIMESTAMPTZ, trashed_via_folder_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_folders_path ON folders (path text_pattern_ops); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE folders, users CASCADE`) }) var userID string if err := pool.QueryRow(ctx, ` INSERT INTO users (email, name) VALUES ($1, 'Test-Benutzer') RETURNING id `, fmt.Sprintf("folder-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil { t.Fatalf("testbenutzer anlegen: %v", err) } return pool, userID } // Akzeptanzkriterium 1: beliebig tiefe Hierarchie abbildbar. func TestCreate_ArbitraryDepthHierarchy(t *testing.T) { pool, userID := requireTestPool(t) ctx := context.Background() var parentID *string var last Folder for i := 0; i < 20; i++ { f, err := Create(ctx, pool, fmt.Sprintf("ebene-%d", i), parentID, userID) if err != nil { t.Fatalf("Create ebene %d: %v", i, err) } parentID = &f.ID last = f } // Pfad der tiefsten Ebene enthaelt alle 20 IDs. subtree, err := Subtree(ctx, pool, last.ID) if err != nil { t.Fatalf("Subtree: %v", err) } if len(subtree) != 1 { t.Fatalf("erwartet 1 (blatt hat keine kinder), habe %d", len(subtree)) } root, err := Get(ctx, pool, *findRootID(t, ctx, pool, last.ID)) if err != nil { t.Fatalf("Get root: %v", err) } rootSubtree, err := Subtree(ctx, pool, root.ID) if err != nil { t.Fatalf("Subtree(root): %v", err) } if len(rootSubtree) != 20 { t.Fatalf("erwartet 20 ordner im gesamten teilbaum, habe %d", len(rootSubtree)) } } func findRootID(t *testing.T, ctx context.Context, pool *pgxpool.Pool, folderID string) *string { t.Helper() f, err := Get(ctx, pool, folderID) if err != nil { t.Fatalf("Get: %v", err) } if f.ParentFolderID == nil { return &f.ID } return findRootID(t, ctx, pool, *f.ParentFolderID) } // Akzeptanzkriterium 2 + Pruefung: Verschieben aktualisiert den gesamten // Teilbaum. func TestMove_UpdatesEntireSubtree(t *testing.T) { pool, userID := requireTestPool(t) ctx := context.Background() root1, err := Create(ctx, pool, "wurzel-1", nil, userID) if err != nil { t.Fatalf("root1: %v", err) } root2, err := Create(ctx, pool, "wurzel-2", nil, userID) if err != nil { t.Fatalf("root2: %v", err) } child, err := Create(ctx, pool, "kind", &root1.ID, userID) if err != nil { t.Fatalf("child: %v", err) } grandchild, err := Create(ctx, pool, "enkel", &child.ID, userID) if err != nil { t.Fatalf("grandchild: %v", err) } if err := Move(ctx, pool, child.ID, &root2.ID); err != nil { t.Fatalf("Move: %v", err) } movedChild, err := Get(ctx, pool, child.ID) if err != nil { t.Fatalf("Get child: %v", err) } if *movedChild.ParentFolderID != root2.ID { t.Fatalf("kind haette unter root2 haengen sollen") } movedGrandchild, err := Get(ctx, pool, grandchild.ID) if err != nil { t.Fatalf("Get grandchild: %v", err) } if movedGrandchild.Path[:len(movedChild.Path)] != movedChild.Path { t.Fatalf("enkel-pfad %q sollte mit kind-pfad %q beginnen (teilbaum mitgewandert)", movedGrandchild.Path, movedChild.Path) } subtreeUnderRoot2, err := Subtree(ctx, pool, root2.ID) if err != nil { t.Fatalf("Subtree(root2): %v", err) } if len(subtreeUnderRoot2) != 3 { // root2, kind, enkel t.Fatalf("erwartet 3 ordner unter root2 nach dem verschieben, habe %d", len(subtreeUnderRoot2)) } } // Akzeptanzkriterium 1 + Pflichtpruefung 1: Zyklus beim Verschieben wird // verhindert. func TestMove_PreventsCycle(t *testing.T) { pool, userID := requireTestPool(t) ctx := context.Background() root, err := Create(ctx, pool, "wurzel", nil, userID) if err != nil { t.Fatalf("root: %v", err) } child, err := Create(ctx, pool, "kind", &root.ID, userID) if err != nil { t.Fatalf("child: %v", err) } grandchild, err := Create(ctx, pool, "enkel", &child.ID, userID) if err != nil { t.Fatalf("grandchild: %v", err) } // Wurzel unter ihr eigenes Kind verschieben -> Zyklus. if err := Move(ctx, pool, root.ID, &child.ID); err != ErrCycle { t.Fatalf("erwartet ErrCycle, habe: %v", err) } // Wurzel unter ihren eigenen Enkel verschieben -> ebenfalls Zyklus. if err := Move(ctx, pool, root.ID, &grandchild.ID); err != ErrCycle { t.Fatalf("erwartet ErrCycle (enkel), habe: %v", err) } // Ordner in sich selbst verschieben -> Zyklus. if err := Move(ctx, pool, child.ID, &child.ID); err != ErrCycle { t.Fatalf("erwartet ErrCycle (selbst), habe: %v", err) } // Zustand nach den abgelehnten Versuchen unveraendert. unchangedChild, err := Get(ctx, pool, child.ID) if err != nil { t.Fatalf("Get: %v", err) } if *unchangedChild.ParentFolderID != root.ID { t.Fatal("kind haette nach abgelehntem zyklus-versuch unveraendert unter root bleiben sollen") } } // Akzeptanzkriterium 3 + Pflichtpruefung 3: Umbenennen bricht keine // Verweise - der Pfad (auf IDs basierend) bleibt unveraendert, auch fuer // Kinder. func TestRename_DoesNotBreakPathReferences(t *testing.T) { pool, userID := requireTestPool(t) ctx := context.Background() root, err := Create(ctx, pool, "alter-name", nil, userID) if err != nil { t.Fatalf("root: %v", err) } child, err := Create(ctx, pool, "kind", &root.ID, userID) if err != nil { t.Fatalf("child: %v", err) } pathBefore := root.Path childPathBefore := child.Path if err := Rename(ctx, pool, root.ID, "neuer-name"); err != nil { t.Fatalf("Rename: %v", err) } renamed, err := Get(ctx, pool, root.ID) if err != nil { t.Fatalf("Get: %v", err) } if renamed.Name != "neuer-name" { t.Fatalf("name = %q, want neuer-name", renamed.Name) } if renamed.Path != pathBefore { t.Fatalf("pfad haette sich durch umbenennen NICHT aendern duerfen: %q != %q", renamed.Path, pathBefore) } unchangedChild, err := Get(ctx, pool, child.ID) if err != nil { t.Fatalf("Get child: %v", err) } if unchangedChild.Path != childPathBefore { t.Fatalf("kind-pfad haette sich durch umbenennen des elternteils NICHT aendern duerfen: %q != %q", unchangedChild.Path, childPathBefore) } // Teilbaum-Abfrage ueber den (unveraenderten) Pfad findet das kind // weiterhin - der "Verweis" (Pfad) ist nicht gebrochen. subtree, err := Subtree(ctx, pool, root.ID) if err != nil { t.Fatalf("Subtree: %v", err) } if len(subtree) != 2 { t.Fatalf("erwartet 2 ordner (wurzel+kind) nach umbenennen, habe %d", len(subtree)) } } // Akzeptanzkriterium 3 + Pflichtpruefung 2: Abfrage bei 10.000 Ordnern im // Zeitbudget. func TestSubtree_RemainsFastWith10000Folders(t *testing.T) { if testing.Short() { t.Skip("uebersprungen (-short)") } pool, userID := requireTestPool(t) ctx := context.Background() root, err := Create(ctx, pool, "grosse-wurzel", nil, userID) if err != nil { t.Fatalf("root: %v", err) } // Breiter Baum: 100 Kinder der Wurzel, je 99 Enkel -> 9900 + 100 + 1 = 10001. const branches = 100 const perBranch = 99 for i := 0; i < branches; i++ { branch, err := Create(ctx, pool, fmt.Sprintf("zweig-%d", i), &root.ID, userID) if err != nil { t.Fatalf("branch %d: %v", i, err) } for j := 0; j < perBranch; j++ { if _, err := Create(ctx, pool, fmt.Sprintf("blatt-%d-%d", i, j), &branch.ID, userID); err != nil { t.Fatalf("leaf %d/%d: %v", i, j, err) } } } start := time.Now() subtree, err := Subtree(ctx, pool, root.ID) elapsed := time.Since(start) if err != nil { t.Fatalf("Subtree: %v", err) } t.Logf("teilbaum-abfrage ueber %d ordner: %v", len(subtree), elapsed) if len(subtree) != branches*perBranch+branches+1 { t.Fatalf("erwartet %d ordner, habe %d", branches*perBranch+branches+1, len(subtree)) } if elapsed > 500*time.Millisecond { t.Fatalf("teilbaum-abfrage dauerte %v, erwartet < 500ms", elapsed) } }