package inspector import ( "testing" "git.warky.dev/wdevs/relspecgo/pkg/models" ) // Helper function to create test database func createTestDatabase() *models.Database { return &models.Database{ Name: "testdb", Schemas: []*models.Schema{ { Name: "public", Tables: []*models.Table{ { Name: "users", Columns: map[string]*models.Column{ "id": { Name: "id", Type: "bigserial", IsPrimaryKey: true, AutoIncrement: true, }, "username": { Name: "username", Type: "varchar(50)", NotNull: true, IsPrimaryKey: false, }, "rid_organization": { Name: "rid_organization", Type: "bigint", NotNull: true, IsPrimaryKey: false, }, }, Constraints: map[string]*models.Constraint{ "fk_users_organization": { Name: "fk_users_organization", Type: models.ForeignKeyConstraint, Columns: []string{"rid_organization"}, ReferencedTable: "organizations", ReferencedSchema: "public", ReferencedColumns: []string{"id"}, }, }, Indexes: map[string]*models.Index{ "idx_rid_organization": { Name: "idx_rid_organization", Columns: []string{"rid_organization"}, }, }, }, { Name: "organizations", Columns: map[string]*models.Column{ "id": { Name: "id", Type: "bigserial", IsPrimaryKey: true, AutoIncrement: true, }, "name": { Name: "name", Type: "varchar(100)", NotNull: true, IsPrimaryKey: false, }, }, }, }, }, }, } } func TestValidatePrimaryKeyNaming(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int wantPass bool }{ { name: "matching pattern id", rule: Rule{ Pattern: "^id$", Message: "Primary key should be 'id'", }, wantLen: 2, wantPass: true, }, { name: "non-matching pattern id_", rule: Rule{ Pattern: "^id_", Message: "Primary key should start with 'id_'", }, wantLen: 2, wantPass: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validatePrimaryKeyNaming(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validatePrimaryKeyNaming() returned %d results, want %d", len(results), tt.wantLen) } if len(results) > 0 && results[0].Passed != tt.wantPass { t.Errorf("validatePrimaryKeyNaming() passed=%v, want %v", results[0].Passed, tt.wantPass) } }) } } func TestValidatePrimaryKeyDatatype(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int wantPass bool }{ { name: "allowed type bigserial", rule: Rule{ AllowedTypes: []string{"bigserial", "bigint", "int"}, Message: "Primary key should use integer types", }, wantLen: 2, wantPass: true, }, { name: "disallowed type", rule: Rule{ AllowedTypes: []string{"uuid"}, Message: "Primary key should use UUID", }, wantLen: 2, wantPass: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validatePrimaryKeyDatatype(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validatePrimaryKeyDatatype() returned %d results, want %d", len(results), tt.wantLen) } if len(results) > 0 && results[0].Passed != tt.wantPass { t.Errorf("validatePrimaryKeyDatatype() passed=%v, want %v", results[0].Passed, tt.wantPass) } }) } } func TestValidatePrimaryKeyAutoIncrement(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int }{ { name: "require auto increment", rule: Rule{ RequireAutoIncrement: true, Message: "Primary key should have auto-increment", }, wantLen: 0, // No violations - all PKs have auto-increment }, { name: "disallow auto increment", rule: Rule{ RequireAutoIncrement: false, Message: "Primary key should not have auto-increment", }, wantLen: 2, // 2 violations - both PKs have auto-increment }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validatePrimaryKeyAutoIncrement(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validatePrimaryKeyAutoIncrement() returned %d results, want %d", len(results), tt.wantLen) } }) } } func TestValidateForeignKeyColumnNaming(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int wantPass bool }{ { name: "matching pattern rid_", rule: Rule{ Pattern: "^rid_", Message: "Foreign key columns should start with 'rid_'", }, wantLen: 1, wantPass: true, }, { name: "non-matching pattern fk_", rule: Rule{ Pattern: "^fk_", Message: "Foreign key columns should start with 'fk_'", }, wantLen: 1, wantPass: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validateForeignKeyColumnNaming(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validateForeignKeyColumnNaming() returned %d results, want %d", len(results), tt.wantLen) } if len(results) > 0 && results[0].Passed != tt.wantPass { t.Errorf("validateForeignKeyColumnNaming() passed=%v, want %v", results[0].Passed, tt.wantPass) } }) } } func TestValidateForeignKeyConstraintNaming(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int wantPass bool }{ { name: "matching pattern fk_", rule: Rule{ Pattern: "^fk_", Message: "Foreign key constraints should start with 'fk_'", }, wantLen: 1, wantPass: true, }, { name: "non-matching pattern FK_", rule: Rule{ Pattern: "^FK_", Message: "Foreign key constraints should start with 'FK_'", }, wantLen: 1, wantPass: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validateForeignKeyConstraintNaming(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validateForeignKeyConstraintNaming() returned %d results, want %d", len(results), tt.wantLen) } if len(results) > 0 && results[0].Passed != tt.wantPass { t.Errorf("validateForeignKeyConstraintNaming() passed=%v, want %v", results[0].Passed, tt.wantPass) } }) } } func TestValidateForeignKeyIndex(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int wantPass bool }{ { name: "require index with index present", rule: Rule{ RequireIndex: true, Message: "Foreign key columns should have indexes", }, wantLen: 1, wantPass: true, }, { name: "no requirement", rule: Rule{ RequireIndex: false, Message: "Foreign key index check disabled", }, wantLen: 0, wantPass: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validateForeignKeyIndex(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validateForeignKeyIndex() returned %d results, want %d", len(results), tt.wantLen) } if len(results) > 0 && results[0].Passed != tt.wantPass { t.Errorf("validateForeignKeyIndex() passed=%v, want %v", results[0].Passed, tt.wantPass) } }) } } func TestValidateTableNamingCase(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int wantPass bool }{ { name: "lowercase snake_case pattern", rule: Rule{ Pattern: "^[a-z][a-z0-9_]*$", Case: "lowercase", Message: "Table names should be lowercase snake_case", }, wantLen: 2, wantPass: true, }, { name: "uppercase pattern", rule: Rule{ Pattern: "^[A-Z][A-Z0-9_]*$", Case: "uppercase", Message: "Table names should be uppercase", }, wantLen: 2, wantPass: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validateTableNamingCase(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validateTableNamingCase() returned %d results, want %d", len(results), tt.wantLen) } if len(results) > 0 && results[0].Passed != tt.wantPass { t.Errorf("validateTableNamingCase() passed=%v, want %v", results[0].Passed, tt.wantPass) } }) } } func TestValidateColumnNamingCase(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int wantPass bool }{ { name: "lowercase snake_case pattern", rule: Rule{ Pattern: "^[a-z][a-z0-9_]*$", Case: "lowercase", Message: "Column names should be lowercase snake_case", }, wantLen: 5, // 5 total columns across both tables wantPass: true, }, { name: "camelCase pattern", rule: Rule{ Pattern: "^[a-z][a-zA-Z0-9]*$", Case: "camelCase", Message: "Column names should be camelCase", }, wantLen: 5, wantPass: false, // rid_organization has underscore }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validateColumnNamingCase(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validateColumnNamingCase() returned %d results, want %d", len(results), tt.wantLen) } }) } } func TestValidateTableNameLength(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int wantPass bool }{ { name: "max length 64", rule: Rule{ MaxLength: 64, Message: "Table name too long", }, wantLen: 2, wantPass: true, }, { name: "max length 5", rule: Rule{ MaxLength: 5, Message: "Table name too long", }, wantLen: 2, wantPass: false, // "users" is 5 chars (passes), "organizations" is 13 (fails) }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validateTableNameLength(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validateTableNameLength() returned %d results, want %d", len(results), tt.wantLen) } }) } } func TestValidateColumnNameLength(t *testing.T) { db := createTestDatabase() tests := []struct { name string rule Rule wantLen int wantPass bool }{ { name: "max length 64", rule: Rule{ MaxLength: 64, Message: "Column name too long", }, wantLen: 5, wantPass: true, }, { name: "max length 5", rule: Rule{ MaxLength: 5, Message: "Column name too long", }, wantLen: 5, wantPass: false, // Some columns exceed 5 chars }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validateColumnNameLength(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validateColumnNameLength() returned %d results, want %d", len(results), tt.wantLen) } }) } } func TestValidateReservedKeywords(t *testing.T) { // Create a database with reserved keywords db := &models.Database{ Name: "testdb", Schemas: []*models.Schema{ { Name: "public", Tables: []*models.Table{ { Name: "user", // "user" is a reserved keyword Columns: map[string]*models.Column{ "id": { Name: "id", Type: "bigint", IsPrimaryKey: true, }, "select": { // "select" is a reserved keyword Name: "select", Type: "varchar(50)", }, }, }, }, }, }, } tests := []struct { name string rule Rule wantLen int checkPasses bool }{ { name: "check tables only", rule: Rule{ CheckTables: true, CheckColumns: false, Message: "Reserved keyword used", }, wantLen: 1, // "user" table checkPasses: false, }, { name: "check columns only", rule: Rule{ CheckTables: false, CheckColumns: true, Message: "Reserved keyword used", }, wantLen: 2, // "id", "select" columns (id passes, select fails) checkPasses: false, }, { name: "check both", rule: Rule{ CheckTables: true, CheckColumns: true, Message: "Reserved keyword used", }, wantLen: 3, // "user" table + "id", "select" columns checkPasses: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results := validateReservedKeywords(db, tt.rule, "test_rule") if len(results) != tt.wantLen { t.Errorf("validateReservedKeywords() returned %d results, want %d", len(results), tt.wantLen) } }) } } func TestValidateMissingPrimaryKey(t *testing.T) { // Create database with and without primary keys db := &models.Database{ Name: "testdb", Schemas: []*models.Schema{ { Name: "public", Tables: []*models.Table{ { Name: "with_pk", Columns: map[string]*models.Column{ "id": { Name: "id", Type: "bigint", IsPrimaryKey: true, }, }, }, { Name: "without_pk", Columns: map[string]*models.Column{ "name": { Name: "name", Type: "varchar(50)", }, }, }, }, }, }, } rule := Rule{ Message: "Table missing primary key", } results := validateMissingPrimaryKey(db, rule, "test_rule") if len(results) != 2 { t.Errorf("validateMissingPrimaryKey() returned %d results, want 2", len(results)) } // First result should pass (with_pk has PK) if results[0].Passed != true { t.Errorf("validateMissingPrimaryKey() result[0].Passed=%v, want true", results[0].Passed) } // Second result should fail (without_pk missing PK) if results[1].Passed != false { t.Errorf("validateMissingPrimaryKey() result[1].Passed=%v, want false", results[1].Passed) } } func TestValidateOrphanedForeignKey(t *testing.T) { // Create database with orphaned FK db := &models.Database{ Name: "testdb", Schemas: []*models.Schema{ { Name: "public", Tables: []*models.Table{ { Name: "users", Columns: map[string]*models.Column{ "id": { Name: "id", Type: "bigint", IsPrimaryKey: true, }, }, Constraints: map[string]*models.Constraint{ "fk_nonexistent": { Name: "fk_nonexistent", Type: models.ForeignKeyConstraint, Columns: []string{"rid_organization"}, ReferencedTable: "nonexistent_table", ReferencedSchema: "public", }, }, }, }, }, }, } rule := Rule{ Message: "Foreign key references non-existent table", } results := validateOrphanedForeignKey(db, rule, "test_rule") if len(results) != 1 { t.Errorf("validateOrphanedForeignKey() returned %d results, want 1", len(results)) } if results[0].Passed != false { t.Errorf("validateOrphanedForeignKey() passed=%v, want false", results[0].Passed) } } func TestValidateCircularDependency(t *testing.T) { // Create database with circular dependency db := &models.Database{ Name: "testdb", Schemas: []*models.Schema{ { Name: "public", Tables: []*models.Table{ { Name: "table_a", Columns: map[string]*models.Column{ "id": {Name: "id", Type: "bigint", IsPrimaryKey: true}, }, Constraints: map[string]*models.Constraint{ "fk_to_b": { Name: "fk_to_b", Type: models.ForeignKeyConstraint, ReferencedTable: "table_b", ReferencedSchema: "public", }, }, }, { Name: "table_b", Columns: map[string]*models.Column{ "id": {Name: "id", Type: "bigint", IsPrimaryKey: true}, }, Constraints: map[string]*models.Constraint{ "fk_to_a": { Name: "fk_to_a", Type: models.ForeignKeyConstraint, ReferencedTable: "table_a", ReferencedSchema: "public", }, }, }, }, }, }, } rule := Rule{ Message: "Circular dependency detected", } results := validateCircularDependency(db, rule, "test_rule") // Should detect circular dependency in both tables if len(results) == 0 { t.Error("validateCircularDependency() returned 0 results, expected circular dependency detection") } for _, result := range results { if result.Passed { t.Error("validateCircularDependency() passed=true, want false for circular dependency") } } } func TestNormalizeDataType(t *testing.T) { tests := []struct { input string expected string }{ {"varchar(50)", "varchar"}, {"decimal(10,2)", "decimal"}, {"int", "int"}, {"BIGINT", "bigint"}, {"VARCHAR(255)", "varchar"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { result := normalizeDataType(tt.input) if result != tt.expected { t.Errorf("normalizeDataType(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } func TestContains(t *testing.T) { tests := []struct { name string slice []string value string expected bool }{ {"found exact", []string{"foo", "bar", "baz"}, "bar", true}, {"not found", []string{"foo", "bar", "baz"}, "qux", false}, {"case insensitive match", []string{"foo", "Bar", "baz"}, "bar", true}, {"empty slice", []string{}, "foo", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := contains(tt.slice, tt.value) if result != tt.expected { t.Errorf("contains(%v, %q) = %v, want %v", tt.slice, tt.value, result, tt.expected) } }) } } func TestHasCycle(t *testing.T) { tests := []struct { name string graph map[string][]string node string expected bool }{ { name: "simple cycle", graph: map[string][]string{ "A": {"B"}, "B": {"C"}, "C": {"A"}, }, node: "A", expected: true, }, { name: "no cycle", graph: map[string][]string{ "A": {"B"}, "B": {"C"}, "C": {}, }, node: "A", expected: false, }, { name: "self cycle", graph: map[string][]string{ "A": {"A"}, }, node: "A", expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { visited := make(map[string]bool) recStack := make(map[string]bool) result := hasCycle(tt.node, tt.graph, visited, recStack) if result != tt.expected { t.Errorf("hasCycle() = %v, want %v", result, tt.expected) } }) } } func TestFormatLocation(t *testing.T) { tests := []struct { schema string table string column string expected string }{ {"public", "users", "id", "public.users.id"}, {"public", "users", "", "public.users"}, {"public", "", "", "public"}, } for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { result := formatLocation(tt.schema, tt.table, tt.column) if result != tt.expected { t.Errorf("formatLocation(%q, %q, %q) = %q, want %q", tt.schema, tt.table, tt.column, result, tt.expected) } }) } }