diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..862e74e --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,506 @@ +package database + +import ( + "context" + "testing" + "time" +) + +// runStoreTests runs the test suite against a Store implementation. +func runStoreTests(t *testing.T, newStore func(t *testing.T) Store) { + tests := []struct { + name string + test func(t *testing.T, store Store) + }{ + {"Initialize", testInitialize}, + {"Revisions", testRevisions}, + {"Options", testOptions}, + {"OptionsSearch", testOptionsSearch}, + {"OptionChildren", testOptionChildren}, + {"Declarations", testDeclarations}, + {"Files", testFiles}, + {"SchemaVersion", testSchemaVersion}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := newStore(t) + defer store.Close() + tt.test(t, store) + }) + } +} + +func testInitialize(t *testing.T, store Store) { + ctx := context.Background() + + // Initialize should work + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Initialize again should be idempotent + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Second Initialize failed: %v", err) + } +} + +func testRevisions(t *testing.T, store Store) { + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Create a revision + rev := &Revision{ + GitHash: "abc123def456", + ChannelName: "nixos-unstable", + CommitDate: time.Now().UTC().Truncate(time.Second), + OptionCount: 0, + } + + if err := store.CreateRevision(ctx, rev); err != nil { + t.Fatalf("CreateRevision failed: %v", err) + } + + if rev.ID == 0 { + t.Error("Expected revision ID to be set") + } + + // Get by git hash + got, err := store.GetRevision(ctx, "abc123def456") + if err != nil { + t.Fatalf("GetRevision failed: %v", err) + } + if got == nil { + t.Fatal("Expected revision, got nil") + } + if got.GitHash != rev.GitHash { + t.Errorf("GitHash = %q, want %q", got.GitHash, rev.GitHash) + } + if got.ChannelName != rev.ChannelName { + t.Errorf("ChannelName = %q, want %q", got.ChannelName, rev.ChannelName) + } + + // Get by channel name + got, err = store.GetRevisionByChannel(ctx, "nixos-unstable") + if err != nil { + t.Fatalf("GetRevisionByChannel failed: %v", err) + } + if got == nil { + t.Fatal("Expected revision, got nil") + } + if got.ID != rev.ID { + t.Errorf("ID = %d, want %d", got.ID, rev.ID) + } + + // Get non-existent + got, err = store.GetRevision(ctx, "nonexistent") + if err != nil { + t.Fatalf("GetRevision for nonexistent failed: %v", err) + } + if got != nil { + t.Error("Expected nil for nonexistent revision") + } + + // List revisions + revs, err := store.ListRevisions(ctx) + if err != nil { + t.Fatalf("ListRevisions failed: %v", err) + } + if len(revs) != 1 { + t.Errorf("Expected 1 revision, got %d", len(revs)) + } + + // Update option count + if err := store.UpdateRevisionOptionCount(ctx, rev.ID, 100); err != nil { + t.Fatalf("UpdateRevisionOptionCount failed: %v", err) + } + got, _ = store.GetRevision(ctx, "abc123def456") + if got.OptionCount != 100 { + t.Errorf("OptionCount = %d, want 100", got.OptionCount) + } + + // Delete revision + if err := store.DeleteRevision(ctx, rev.ID); err != nil { + t.Fatalf("DeleteRevision failed: %v", err) + } + + revs, _ = store.ListRevisions(ctx) + if len(revs) != 0 { + t.Errorf("Expected 0 revisions after delete, got %d", len(revs)) + } +} + +func testOptions(t *testing.T, store Store) { + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Create a revision first + rev := &Revision{GitHash: "test123", ChannelName: "test"} + if err := store.CreateRevision(ctx, rev); err != nil { + t.Fatalf("CreateRevision failed: %v", err) + } + + // Create an option + opt := &Option{ + RevisionID: rev.ID, + Name: "services.nginx.enable", + ParentPath: "services.nginx", + Type: "boolean", + DefaultValue: "false", + Description: "Whether to enable nginx.", + ReadOnly: false, + } + + if err := store.CreateOption(ctx, opt); err != nil { + t.Fatalf("CreateOption failed: %v", err) + } + + if opt.ID == 0 { + t.Error("Expected option ID to be set") + } + + // Get option + got, err := store.GetOption(ctx, rev.ID, "services.nginx.enable") + if err != nil { + t.Fatalf("GetOption failed: %v", err) + } + if got == nil { + t.Fatal("Expected option, got nil") + } + if got.Name != opt.Name { + t.Errorf("Name = %q, want %q", got.Name, opt.Name) + } + if got.Type != opt.Type { + t.Errorf("Type = %q, want %q", got.Type, opt.Type) + } + if got.Description != opt.Description { + t.Errorf("Description = %q, want %q", got.Description, opt.Description) + } + + // Get non-existent option + got, err = store.GetOption(ctx, rev.ID, "nonexistent") + if err != nil { + t.Fatalf("GetOption for nonexistent failed: %v", err) + } + if got != nil { + t.Error("Expected nil for nonexistent option") + } + + // Batch create options + opts := []*Option{ + {RevisionID: rev.ID, Name: "services.caddy.enable", ParentPath: "services.caddy", Type: "boolean"}, + {RevisionID: rev.ID, Name: "services.caddy.config", ParentPath: "services.caddy", Type: "string"}, + } + if err := store.CreateOptionsBatch(ctx, opts); err != nil { + t.Fatalf("CreateOptionsBatch failed: %v", err) + } + + for _, o := range opts { + if o.ID == 0 { + t.Errorf("Expected option %q ID to be set", o.Name) + } + } +} + +func testOptionsSearch(t *testing.T, store Store) { + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + rev := &Revision{GitHash: "search123", ChannelName: "test"} + if err := store.CreateRevision(ctx, rev); err != nil { + t.Fatalf("CreateRevision failed: %v", err) + } + + opts := []*Option{ + {RevisionID: rev.ID, Name: "services.nginx.enable", ParentPath: "services.nginx", Type: "boolean", Description: "Enable the nginx web server"}, + {RevisionID: rev.ID, Name: "services.nginx.package", ParentPath: "services.nginx", Type: "package", Description: "Nginx package to use"}, + {RevisionID: rev.ID, Name: "services.caddy.enable", ParentPath: "services.caddy", Type: "boolean", Description: "Enable the caddy web server"}, + {RevisionID: rev.ID, Name: "programs.git.enable", ParentPath: "programs.git", Type: "boolean", Description: "Enable git version control"}, + } + if err := store.CreateOptionsBatch(ctx, opts); err != nil { + t.Fatalf("CreateOptionsBatch failed: %v", err) + } + + // Search for "nginx" + results, err := store.SearchOptions(ctx, rev.ID, "nginx", SearchFilters{}) + if err != nil { + t.Fatalf("SearchOptions failed: %v", err) + } + if len(results) < 1 { + t.Errorf("Expected at least 1 result for 'nginx', got %d", len(results)) + } + + // Search with namespace filter + results, err = store.SearchOptions(ctx, rev.ID, "enable", SearchFilters{Namespace: "services"}) + if err != nil { + t.Fatalf("SearchOptions with namespace failed: %v", err) + } + for _, r := range results { + if r.Name[:8] != "services" { + t.Errorf("Result %q doesn't match namespace filter", r.Name) + } + } + + // Search with type filter + results, err = store.SearchOptions(ctx, rev.ID, "nginx", SearchFilters{Type: "boolean"}) + if err != nil { + t.Fatalf("SearchOptions with type failed: %v", err) + } + for _, r := range results { + if r.Type != "boolean" { + t.Errorf("Result %q has type %q, expected boolean", r.Name, r.Type) + } + } + + // Search with limit + results, err = store.SearchOptions(ctx, rev.ID, "enable", SearchFilters{Limit: 2}) + if err != nil { + t.Fatalf("SearchOptions with limit failed: %v", err) + } + if len(results) > 2 { + t.Errorf("Expected at most 2 results, got %d", len(results)) + } +} + +func testOptionChildren(t *testing.T, store Store) { + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + rev := &Revision{GitHash: "children123", ChannelName: "test"} + if err := store.CreateRevision(ctx, rev); err != nil { + t.Fatalf("CreateRevision failed: %v", err) + } + + // Create a hierarchy of options + opts := []*Option{ + {RevisionID: rev.ID, Name: "services", ParentPath: "", Type: "attrsOf"}, + {RevisionID: rev.ID, Name: "services.nginx", ParentPath: "services", Type: "submodule"}, + {RevisionID: rev.ID, Name: "services.nginx.enable", ParentPath: "services.nginx", Type: "boolean"}, + {RevisionID: rev.ID, Name: "services.nginx.package", ParentPath: "services.nginx", Type: "package"}, + {RevisionID: rev.ID, Name: "services.caddy", ParentPath: "services", Type: "submodule"}, + } + if err := store.CreateOptionsBatch(ctx, opts); err != nil { + t.Fatalf("CreateOptionsBatch failed: %v", err) + } + + // Get children of root + children, err := store.GetChildren(ctx, rev.ID, "") + if err != nil { + t.Fatalf("GetChildren root failed: %v", err) + } + if len(children) != 1 { + t.Errorf("Expected 1 root child, got %d", len(children)) + } + if len(children) > 0 && children[0].Name != "services" { + t.Errorf("Expected root child to be 'services', got %q", children[0].Name) + } + + // Get children of services + children, err = store.GetChildren(ctx, rev.ID, "services") + if err != nil { + t.Fatalf("GetChildren services failed: %v", err) + } + if len(children) != 2 { + t.Errorf("Expected 2 children of services, got %d", len(children)) + } + + // Get children of services.nginx + children, err = store.GetChildren(ctx, rev.ID, "services.nginx") + if err != nil { + t.Fatalf("GetChildren services.nginx failed: %v", err) + } + if len(children) != 2 { + t.Errorf("Expected 2 children of services.nginx, got %d", len(children)) + } +} + +func testDeclarations(t *testing.T, store Store) { + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + rev := &Revision{GitHash: "decl123", ChannelName: "test"} + if err := store.CreateRevision(ctx, rev); err != nil { + t.Fatalf("CreateRevision failed: %v", err) + } + + opt := &Option{ + RevisionID: rev.ID, + Name: "services.nginx.enable", + ParentPath: "services.nginx", + Type: "boolean", + } + if err := store.CreateOption(ctx, opt); err != nil { + t.Fatalf("CreateOption failed: %v", err) + } + + // Create declarations + decl := &Declaration{ + OptionID: opt.ID, + FilePath: "nixos/modules/services/web-servers/nginx/default.nix", + Line: 42, + } + if err := store.CreateDeclaration(ctx, decl); err != nil { + t.Fatalf("CreateDeclaration failed: %v", err) + } + + if decl.ID == 0 { + t.Error("Expected declaration ID to be set") + } + + // Get declarations + decls, err := store.GetDeclarations(ctx, opt.ID) + if err != nil { + t.Fatalf("GetDeclarations failed: %v", err) + } + if len(decls) != 1 { + t.Fatalf("Expected 1 declaration, got %d", len(decls)) + } + if decls[0].FilePath != decl.FilePath { + t.Errorf("FilePath = %q, want %q", decls[0].FilePath, decl.FilePath) + } + if decls[0].Line != 42 { + t.Errorf("Line = %d, want 42", decls[0].Line) + } + + // Batch create + batch := []*Declaration{ + {OptionID: opt.ID, FilePath: "file1.nix", Line: 10}, + {OptionID: opt.ID, FilePath: "file2.nix", Line: 20}, + } + if err := store.CreateDeclarationsBatch(ctx, batch); err != nil { + t.Fatalf("CreateDeclarationsBatch failed: %v", err) + } + + decls, _ = store.GetDeclarations(ctx, opt.ID) + if len(decls) != 3 { + t.Errorf("Expected 3 declarations after batch, got %d", len(decls)) + } +} + +func testFiles(t *testing.T, store Store) { + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + rev := &Revision{GitHash: "files123", ChannelName: "test"} + if err := store.CreateRevision(ctx, rev); err != nil { + t.Fatalf("CreateRevision failed: %v", err) + } + + // Create a file + file := &File{ + RevisionID: rev.ID, + FilePath: "nixos/modules/services/web-servers/nginx/default.nix", + Extension: ".nix", + Content: "{ config, lib, pkgs, ... }:\n\n# nginx module\n", + } + if err := store.CreateFile(ctx, file); err != nil { + t.Fatalf("CreateFile failed: %v", err) + } + + if file.ID == 0 { + t.Error("Expected file ID to be set") + } + + // Get file + got, err := store.GetFile(ctx, rev.ID, "nixos/modules/services/web-servers/nginx/default.nix") + if err != nil { + t.Fatalf("GetFile failed: %v", err) + } + if got == nil { + t.Fatal("Expected file, got nil") + } + if got.Content != file.Content { + t.Errorf("Content mismatch") + } + if got.Extension != ".nix" { + t.Errorf("Extension = %q, want .nix", got.Extension) + } + + // Get non-existent file + got, err = store.GetFile(ctx, rev.ID, "nonexistent.nix") + if err != nil { + t.Fatalf("GetFile for nonexistent failed: %v", err) + } + if got != nil { + t.Error("Expected nil for nonexistent file") + } + + // Batch create + files := []*File{ + {RevisionID: rev.ID, FilePath: "file1.nix", Extension: ".nix", Content: "content1"}, + {RevisionID: rev.ID, FilePath: "file2.nix", Extension: ".nix", Content: "content2"}, + } + if err := store.CreateFilesBatch(ctx, files); err != nil { + t.Fatalf("CreateFilesBatch failed: %v", err) + } + + for _, f := range files { + if f.ID == 0 { + t.Errorf("Expected file %q ID to be set", f.FilePath) + } + } +} + +func testSchemaVersion(t *testing.T, store Store) { + ctx := context.Background() + + // First initialization + if err := store.Initialize(ctx); err != nil { + t.Fatalf("First Initialize failed: %v", err) + } + + // Create some data + rev := &Revision{GitHash: "version123", ChannelName: "test"} + if err := store.CreateRevision(ctx, rev); err != nil { + t.Fatalf("CreateRevision failed: %v", err) + } + + // Second initialization should preserve data (same version) + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Second Initialize failed: %v", err) + } + + got, err := store.GetRevision(ctx, "version123") + if err != nil { + t.Fatalf("GetRevision after second init failed: %v", err) + } + if got == nil { + t.Error("Data should be preserved after second initialization") + } +} + +// TestParentPath tests the ParentPath helper function. +func TestParentPath(t *testing.T) { + tests := []struct { + name string + input string + expect string + }{ + {"top level", "services", ""}, + {"one level", "services.nginx", "services"}, + {"two levels", "services.nginx.enable", "services.nginx"}, + {"three levels", "services.nginx.virtualHosts.default", "services.nginx.virtualHosts"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParentPath(tt.input) + if got != tt.expect { + t.Errorf("ParentPath(%q) = %q, want %q", tt.input, got, tt.expect) + } + }) + } +} diff --git a/internal/database/postgres_test.go b/internal/database/postgres_test.go new file mode 100644 index 0000000..da7b8f4 --- /dev/null +++ b/internal/database/postgres_test.go @@ -0,0 +1,21 @@ +package database + +import ( + "os" + "testing" +) + +func TestPostgresStore(t *testing.T) { + connStr := os.Getenv("TEST_POSTGRES_CONN") + if connStr == "" { + t.Skip("TEST_POSTGRES_CONN not set, skipping PostgreSQL tests") + } + + runStoreTests(t, func(t *testing.T) Store { + store, err := NewPostgresStore(connStr) + if err != nil { + t.Fatalf("Failed to create PostgreSQL store: %v", err) + } + return store + }) +} diff --git a/internal/database/sqlite_test.go b/internal/database/sqlite_test.go new file mode 100644 index 0000000..21c03c4 --- /dev/null +++ b/internal/database/sqlite_test.go @@ -0,0 +1,13 @@ +package database + +import "testing" + +func TestSQLiteStore(t *testing.T) { + runStoreTests(t, func(t *testing.T) Store { + store, err := NewSQLiteStore(":memory:") + if err != nil { + t.Fatalf("Failed to create SQLite store: %v", err) + } + return store + }) +}