/* Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "fmt" "io/fs" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( pathToDocs = "%s/../../../../docs/sources" fileName = "index.md" ) func TestIndexMdExists(t *testing.T) { testPath, _ := os.Getwd() fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) st, err := fs.Stat(fsys, fileName) assert.NoError(t, err, "expected file %s to exist", fileName) assert.Equal(t, fileName, st.Name()) } func TestIndexMdUpToDate(t *testing.T) { testPath, _ := os.Getwd() fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) expected, err := fs.ReadFile(fsys, fileName) assert.NoError(t, err, "expected file %s to exist", fileName) sourceDir := fmt.Sprintf("%s/../../../../source", testPath) sources, err := discoverSources(sourceDir) require.NoError(t, err, "expected to find sources") actual, err := sources.generateMarkdown() assert.NoError(t, err) assert.Contains(t, string(expected), actual, "expected file 'docs/sources/index.md' to be up to date. execute 'make generate-sources-documentation'") } func TestDiscoverSources(t *testing.T) { testPath, _ := os.Getwd() sourceDir := fmt.Sprintf("%s/../../../../source", testPath) sources, err := discoverSources(sourceDir) require.NoError(t, err) assert.GreaterOrEqual(t, len(sources), 5, "Expected at least 5 sources with annotations") // Verify sources are sorted by name for i := range len(sources) - 1 { prev, curr := sources[i], sources[i+1] if prev.Name > curr.Name { t.Errorf("Sources not sorted correctly: %s should come before %s", curr.Name, prev.Name) } } } func TestGenerateMarkdown(t *testing.T) { sources := Sources{ { Name: "test", Type: "testSource", File: "source/test.go", Category: "Test", Description: "Test source", Resources: "TestResource", Filters: "annotation,label", Namespace: "all,single", FQDNTemplate: "true", }, } content, err := sources.generateMarkdown() require.NoError(t, err) assert.NotEmpty(t, content) assert.Contains(t, content, "# Supported Sources") assert.Contains(t, content, "## Available Sources") } func TestParseSourceAnnotations(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test_source.go") content := `package main // testSource is a test source implementation. // // +externaldns:source:name=test-source // +externaldns:source:category=Testing // +externaldns:source:description=A test source for unit testing // +externaldns:source:resources=TestResource // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true type testSource struct { client string } ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseSourceAnnotations(tmpDir) require.NoError(t, err) assert.Len(t, sources, 1) source := sources[0] assert.Equal(t, "test-source", source.Name) assert.Equal(t, "Testing", source.Category) assert.Equal(t, "TestResource", source.Resources) assert.Equal(t, "annotation,label", source.Filters) assert.Equal(t, "all,single", source.Namespace) assert.Equal(t, "true", source.FQDNTemplate) assert.Equal(t, "false", source.Events) assert.Equal(t, "true", source.ProviderSpecific) } func TestParseSourceAnnotations_SkipsTestFiles(t *testing.T) { tmpDir := t.TempDir() // Create a test file that should be skipped testFile := filepath.Join(tmpDir, "test_source_test.go") content := `package main // +externaldns:source:name=should-be-skipped // +externaldns:source:category=Test // +externaldns:source:description=Should be skipped type testSource struct {} ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseSourceAnnotations(tmpDir) require.NoError(t, err) assert.Empty(t, sources) } func TestParseFile_MultipleSourcesInOneFile(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "multi.go") content := `package main // firstSource is the first source. // // +externaldns:source:name=first // +externaldns:source:category=Testing // +externaldns:source:description=First source type firstSource struct {} // secondSource is the second source. // // +externaldns:source:name=second // +externaldns:source:category=Testing // +externaldns:source:description=Second source // +externaldns:source:events=true type secondSource struct {} ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseFile(testFile, tmpDir) require.NoError(t, err) assert.Len(t, sources, 2) assert.Equal(t, "first", sources[0].Name) assert.Equal(t, "false", sources[0].Events) assert.Equal(t, "false", sources[0].ProviderSpecific) assert.Equal(t, "second", sources[1].Name) assert.Equal(t, "true", sources[1].Events) assert.Equal(t, "false", sources[1].ProviderSpecific) } func TestParseFile_IgnoresNonSourceTypes(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "nonsource.go") content := `package main // regularStruct is not a source (doesn't end with "Source"). // // +externaldns:source:name=should-not-parse // +externaldns:source:category=Test // +externaldns:source:description=Should not be parsed type regularStruct struct {} ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseFile(testFile, tmpDir) require.NoError(t, err) assert.Empty(t, sources) } func TestParseSourceAnnotations_ErrorOnInvalidFile(t *testing.T) { tmpDir := t.TempDir() // Create a file with invalid Go syntax testFile := filepath.Join(tmpDir, "invalid.go") content := `package main this is not valid go syntax ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) _, err = parseSourceAnnotations(tmpDir) require.Error(t, err) } func TestParseFile_InvalidGoFile(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "invalid.go") content := `this is not valid go code` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) _, err = parseFile(testFile, tmpDir) require.Error(t, err) } func TestParseSourceAnnotations_WithSubdirectories(t *testing.T) { tmpDir := t.TempDir() subDir := filepath.Join(tmpDir, "subdir") if err := os.Mkdir(subDir, 0755); err != nil { t.Fatalf("Failed to create subdirectory: %v", err) } // Create a test file in subdirectory testFile := filepath.Join(subDir, "nested_source.go") content := `package main // nestedSource is in a subdirectory. // // +externaldns:source:name=nested // +externaldns:source:category=Testing // +externaldns:source:description=Nested source type nestedSource struct {} ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseSourceAnnotations(tmpDir) require.NoError(t, err) assert.Len(t, sources, 1) assert.Equal(t, "nested", sources[0].Name) assert.Contains(t, sources[0].File, "subdir/nested_source.go") } func TestGenerateMarkdown_WithMultipleCategories(t *testing.T) { sources := Sources{ { Name: "service", Category: "Kubernetes Core", Description: "Service source", Resources: "Service", Filters: "annotation,label", Namespace: "all,single", FQDNTemplate: "true", }, { Name: "ingress", Category: "Kubernetes Core", Description: "Ingress source", Resources: "Ingress", Filters: "annotation,label", Namespace: "all,single", FQDNTemplate: "true", }, { Name: "gateway-httproute", Category: "Gateway API", Description: "HTTP route source", Resources: "HTTPRoute.gateway.networking.k8s.io", Filters: "annotation,label", Namespace: "all,single", FQDNTemplate: "false", }, } content, err := sources.generateMarkdown() require.NoError(t, err) assert.Contains(t, content, "service") assert.Contains(t, content, "ingress") assert.Contains(t, content, "gateway-httproute") } func TestExtractSourcesFromComments(t *testing.T) { tests := []struct { name string comments string typeName string filePath string wantSources int wantErr bool validate func(*testing.T, Source) }{ { name: "valid single source", comments: `testSource is a test implementation. +externaldns:source:name=test +externaldns:source:category=Testing +externaldns:source:description=A test source +externaldns:source:resources=TestResource +externaldns:source:filters=annotation +externaldns:source:namespace=all +externaldns:source:fqdn-template=false `, typeName: "testSource", filePath: "test.go", wantSources: 1, validate: func(t *testing.T, s Source) { assert.Equal(t, "test", s.Name) assert.Equal(t, "Testing", s.Category) assert.Equal(t, "A test source", s.Description) }, }, { name: "multiple sources in same comment block", comments: `gatewaySource handles multiple gateway types. +externaldns:source:name=http-route +externaldns:source:category=Gateway +externaldns:source:description=Handles HTTP routes +externaldns:source:resources=HTTPRoute +externaldns:source:name=tcp-route +externaldns:source:category=Gateway +externaldns:source:description=Handles TCP routes +externaldns:source:resources=TCPRoute `, typeName: "gatewaySource", filePath: "gateway.go", wantSources: 2, validate: func(t *testing.T, s Source) { assert.Contains(t, []string{"http-route", "tcp-route"}, s.Name) }, }, { name: "missing required name annotation", comments: `testSource without name. +externaldns:source:category=Testing +externaldns:source:description=Missing name `, typeName: "testSource", filePath: "test.go", wantErr: true, }, { name: "optional annotations can be missing", comments: `testSource with minimal annotations. +externaldns:source:name=minimal +externaldns:source:category=Testing +externaldns:source:description=Minimal source `, typeName: "testSource", filePath: "test.go", wantSources: 1, validate: func(t *testing.T, s Source) { assert.Equal(t, "minimal", s.Name) assert.Empty(t, s.Resources) assert.Empty(t, s.Filters) }, }, { name: "empty name annotation", wantSources: 0, comments: `testSource with minimal annotations. +externaldns:source:name= +externaldns:source:category=Testing +externaldns:source:description=Minimal source `, typeName: "testSource", filePath: "test.go", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sources, err := extractSourcesFromComments(tt.comments, tt.typeName, tt.filePath) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Len(t, sources, tt.wantSources) if tt.validate != nil { for _, source := range sources { tt.validate(t, source) } } // Verify all sources have required fields for _, source := range sources { assert.Equal(t, tt.typeName, source.Type) assert.Equal(t, tt.filePath, source.File) } }) } }