diff --git a/licensecheck/copyright_test.go b/licensecheck/copyright_test.go index f862490..13955bf 100644 --- a/licensecheck/copyright_test.go +++ b/licensecheck/copyright_test.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHasMatchingCopyright(t *testing.T) { @@ -273,3 +274,91 @@ func TestHasCopyright(t *testing.T) { }) } } + +func TestHasMatchingCopyright_ErrorHandling(t *testing.T) { + t.Run("error on non-existent file", func(t *testing.T) { + hasCopyright, err := HasMatchingCopyright("/nonexistent/file.txt", "Copyright", false) + assert.NotNil(t, err) + assert.False(t, hasCopyright) + }) +} + +func TestHasMatchingCopyright_EdgeCases(t *testing.T) { + AppFs := afero.NewOsFs() + tempDir := t.TempDir() + + t.Run("file exactly 300 bytes with copyright at end", func(t *testing.T) { + // Create a file exactly 300 bytes where "Copyright" appears at byte 290 + padding := make([]byte, 290) + for i := range padding { + padding[i] = 'A' + } + content := string(padding) + "Copyright!" + + f, err := afero.TempFile(AppFs, tempDir, "") + require.NoError(t, err) + err = afero.WriteFile(AppFs, f.Name(), []byte(content), 0644) + require.NoError(t, err) + + hasCopyright, err := HasMatchingCopyright(f.Name(), "Copyright", false) + assert.Nil(t, err) + assert.True(t, hasCopyright) + }) + + t.Run("file less than 300 bytes", func(t *testing.T) { + content := "Short file with Copyright notice" + + f, err := afero.TempFile(AppFs, tempDir, "") + require.NoError(t, err) + err = afero.WriteFile(AppFs, f.Name(), []byte(content), 0644) + require.NoError(t, err) + + hasCopyright, err := HasMatchingCopyright(f.Name(), "Copyright", false) + assert.Nil(t, err) + assert.True(t, hasCopyright) + }) + + t.Run("file larger than 300 bytes with copyright after header", func(t *testing.T) { + // Create content > 300 bytes with copyright appearing after byte 300 + header := make([]byte, 350) + for i := range header { + header[i] = 'X' + } + content := string(header) + "\nCopyright notice here" + + f, _ := afero.TempFile(AppFs, tempDir, "") + _ = afero.WriteFile(AppFs, f.Name(), []byte(content), 0644) + + // Should not find copyright since it's after the 300-byte header check + hasCopyright, err := HasMatchingCopyright(f.Name(), "Copyright", false) + assert.Nil(t, err) + assert.False(t, hasCopyright) + }) + + t.Run("empty search string", func(t *testing.T) { + f, _ := afero.TempFile(AppFs, tempDir, "") + _ = afero.WriteFile(AppFs, f.Name(), []byte("Some content"), 0644) + + // Empty string should always be found + hasCopyright, err := HasMatchingCopyright(f.Name(), "", false) + assert.Nil(t, err) + assert.True(t, hasCopyright) + }) + + t.Run("search string longer than file", func(t *testing.T) { + f, _ := afero.TempFile(AppFs, tempDir, "") + _ = afero.WriteFile(AppFs, f.Name(), []byte("Short"), 0644) + + hasCopyright, err := HasMatchingCopyright(f.Name(), "This is a very long copyright statement that is longer than the file content", false) + assert.Nil(t, err) + assert.False(t, hasCopyright) + }) +} + +func TestHasCopyright_ErrorHandling(t *testing.T) { + t.Run("error on non-existent file", func(t *testing.T) { + hasCopyright, err := HasCopyright("/nonexistent/file.txt") + assert.NotNil(t, err) + assert.False(t, hasCopyright) + }) +} diff --git a/licensecheck/licensecheck_test.go b/licensecheck/licensecheck_test.go index 486698e..5974397 100644 --- a/licensecheck/licensecheck_test.go +++ b/licensecheck/licensecheck_test.go @@ -6,11 +6,13 @@ package licensecheck import ( "path/filepath" "sort" + "strings" "testing" "github.com/samber/lo" "github.com/spf13/afero" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func createTempFiles(t *testing.T, fileNames []string) (dirPath string, filePaths []string) { @@ -85,14 +87,138 @@ func TestEnsureCorrectName(t *testing.T) { } } +func TestEnsureCorrectName_ErrorHandling(t *testing.T) { + t.Run("error when file does not exist", func(t *testing.T) { + _, err := EnsureCorrectName("/nonexistent/path/license.txt") + assert.NotNil(t, err) + }) +} + func TestAddHeader(t *testing.T) { - // stub - t.Skip() + AppFs := afero.NewOsFs() + + t.Run("add header to empty file", func(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "test.txt") + _ = afero.WriteFile(AppFs, filePath, []byte(""), 0644) + + header := "Copyright (c) 2023 Test Corp" + err := AddHeader(filePath, header) + assert.Nil(t, err) + + // Read file and verify header was added + content, _ := afero.ReadFile(AppFs, filePath) + assert.Contains(t, string(content), header) + // Should have double newline after header + assert.Contains(t, string(content), header+"\n\n") + }) + + t.Run("add header to file with existing content", func(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "test.txt") + originalContent := "This is the original file content" + err := afero.WriteFile(AppFs, filePath, []byte(originalContent), 0644) + require.NoError(t, err) + + header := "Copyright (c) 2023 Test Corp" + err = AddHeader(filePath, header) + assert.Nil(t, err) + + // Read file and verify header was prepended + content, _ := afero.ReadFile(AppFs, filePath) + assert.Contains(t, string(content), header) + assert.Contains(t, string(content), originalContent) + // Header should come before original content + headerIdx := strings.Index(string(content), header) + contentIdx := strings.Index(string(content), originalContent) + assert.Less(t, headerIdx, contentIdx) + }) + + t.Run("add multi-line header", func(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "test.txt") + _ = afero.WriteFile(AppFs, filePath, []byte("Original content"), 0644) + + header := "Copyright (c) 2023 Test Corp\nSPDX-License-Identifier: MPL-2.0" + err := AddHeader(filePath, header) + assert.Nil(t, err) + + content, _ := afero.ReadFile(AppFs, filePath) + assert.Contains(t, string(content), "Copyright (c) 2023 Test Corp") + assert.Contains(t, string(content), "SPDX-License-Identifier: MPL-2.0") + assert.Contains(t, string(content), "Original content") + }) + + t.Run("error on non-existent file", func(t *testing.T) { + header := "Copyright (c) 2023 Test Corp" + err := AddHeader("/nonexistent/path/file.txt", header) + assert.NotNil(t, err) + }) } func TestAddLicenseFile(t *testing.T) { - // stub - t.Skip() + AppFs := afero.NewOsFs() + + t.Run("create LICENSE file with MPL-2.0", func(t *testing.T) { + tempDir := t.TempDir() + licensePath, err := AddLicenseFile(tempDir, "MPL-2.0") + assert.Nil(t, err) + assert.NotEmpty(t, licensePath) + + // Verify file exists + fileExists, _ := afero.Exists(AppFs, licensePath) + assert.True(t, fileExists) + + // Verify content contains MPL-2.0 license text + content, _ := afero.ReadFile(AppFs, licensePath) + assert.Contains(t, string(content), "Mozilla Public License") + }) + + t.Run("create LICENSE file with Apache-2.0", func(t *testing.T) { + tempDir := t.TempDir() + licensePath, err := AddLicenseFile(tempDir, "Apache-2.0") + assert.Nil(t, err) + + content, _ := afero.ReadFile(AppFs, licensePath) + assert.Contains(t, string(content), "Apache License") + }) + + t.Run("create LICENSE file with MIT", func(t *testing.T) { + tempDir := t.TempDir() + licensePath, err := AddLicenseFile(tempDir, "MIT") + assert.Nil(t, err) + + content, _ := afero.ReadFile(AppFs, licensePath) + assert.Contains(t, string(content), "Permission is hereby granted") + }) + + t.Run("error on unknown SPDX ID", func(t *testing.T) { + tempDir := t.TempDir() + licensePath, err := AddLicenseFile(tempDir, "UNKNOWN-LICENSE-99") + assert.NotNil(t, err) + assert.Empty(t, licensePath) + assert.Contains(t, err.Error(), "unknown SPDX license ID") + }) + + t.Run("error on invalid directory path", func(t *testing.T) { + licensePath, err := AddLicenseFile("/nonexistent/invalid/path", "MPL-2.0") + assert.NotNil(t, err) + assert.Empty(t, licensePath) + }) + + t.Run("returned path is absolute", func(t *testing.T) { + tempDir := t.TempDir() + licensePath, err := AddLicenseFile(tempDir, "MPL-2.0") + assert.Nil(t, err) + assert.True(t, filepath.IsAbs(licensePath)) + }) + + t.Run("file is named LICENSE", func(t *testing.T) { + tempDir := t.TempDir() + licensePath, err := AddLicenseFile(tempDir, "Apache-2.0") + assert.Nil(t, err) + assert.Equal(t, "LICENSE", filepath.Base(licensePath)) + }) } func sortSlice(input *[]string) { @@ -179,3 +305,27 @@ func TestFindLicenseFiles(t *testing.T) { }) } } + +func TestFindLicenseFiles_ErrorHandling(t *testing.T) { + t.Run("returns empty slice when directory doesn't exist", func(t *testing.T) { + // When the directory doesn't exist, glob returns empty without error + result, err := FindLicenseFiles("/nonexistent/directory/path") + assert.Nil(t, err) + assert.Equal(t, []string{}, result) + }) + + t.Run("handles subdirectories correctly", func(t *testing.T) { + AppFs := afero.NewOsFs() + tempDir := t.TempDir() + + // Create a subdirectory with a LICENSE file + subDir := filepath.Join(tempDir, "subdir") + _ = AppFs.MkdirAll(subDir, 0755) + _ = afero.WriteFile(AppFs, filepath.Join(subDir, "LICENSE"), []byte("sublicense"), 0644) + + // FindLicenseFiles should only find files in the top-level directory, not subdirs + result, err := FindLicenseFiles(tempDir) + assert.Nil(t, err) + assert.Equal(t, []string{}, result) + }) +} diff --git a/licensecheck/update_test.go b/licensecheck/update_test.go index 0d353c4..dd1eb14 100644 --- a/licensecheck/update_test.go +++ b/licensecheck/update_test.go @@ -6,8 +6,10 @@ package licensecheck import ( "fmt" "os" + "os/exec" "path/filepath" "strconv" + "sync" "testing" "time" @@ -925,7 +927,6 @@ func TestUpdateCopyrightHeader_HandlebarsFiles(t *testing.T) { assert.False(t, needsUpdate2, "Should not need another update after being updated to current year") } - func TestUpdateCopyrightHeader_IgnoreYear1(t *testing.T) { tempDir := t.TempDir() testFile := filepath.Join(tempDir, "test.go") @@ -994,3 +995,259 @@ package main expected := fmt.Sprintf("// Copyright IBM Corp. 2021, %d\npackage main\n", currentYear) assert.Equal(t, expected, string(content)) } + +func TestGitOperations(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git executable not found in PATH; skipping git operations test") + } + + tempDir := t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init", "--initial-branch=main") + cmd.Dir = tempDir + require.NoError(t, cmd.Run()) + + require.NoError(t, exec.Command("git", "-C", tempDir, "config", "user.email", "test@example.com").Run()) + require.NoError(t, exec.Command("git", "-C", tempDir, "config", "user.name", "Test User").Run()) + + // Create and commit a file with a specific date + testFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFile, []byte("test content"), 0644) + require.NoError(t, err) + + require.NoError(t, exec.Command("git", "-C", tempDir, "add", "test.txt").Run()) + + commitCmd := exec.Command("git", "-C", tempDir, "-c", "commit.gpgsign=false", "commit", "-m", "first commit") + commitCmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2020-01-01T12:00:00Z", "GIT_COMMITTER_DATE=2020-01-01T12:00:00Z") + require.NoError(t, commitCmd.Run()) + + // Create and commit a second file to test latest commit year functionality + testFile2 := filepath.Join(tempDir, "test2.txt") + err = os.WriteFile(testFile2, []byte("test content 2"), 0644) + require.NoError(t, err) + require.NoError(t, exec.Command("git", "-C", tempDir, "add", "test2.txt").Run()) + commitCmd2 := exec.Command("git", "-C", tempDir, "-c", "commit.gpgsign=false", "commit", "-m", "second commit") + commitCmd2.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2023-01-01T12:00:00Z", "GIT_COMMITTER_DATE=2023-01-01T12:00:00Z") + require.NoError(t, commitCmd2.Run()) + + t.Run("GetRepoRoot", func(t *testing.T) { + root, err := GetRepoRoot(tempDir) + require.NoError(t, err) + + // Resolve symlinks since macOS TempDir paths are symlinks (/var -> /private/var) + evalRoot, err := filepath.EvalSymlinks(tempDir) + require.NoError(t, err) + assert.Equal(t, evalRoot, root) + }) + + t.Run("buildRepositoryCache", func(t *testing.T) { + evalRoot, err := filepath.EvalSymlinks(tempDir) + require.NoError(t, err) + cache, firstYear, err := buildRepositoryCache(evalRoot) + require.NoError(t, err) + assert.Equal(t, 2020, firstYear) + assert.Contains(t, cache, "test.txt") + assert.Equal(t, 2020, cache["test.txt"]) + }) + + t.Run("executeGitCommand", func(t *testing.T) { + out, err := executeGitCommand(tempDir, "log", "-1", "--format=%s") + require.NoError(t, err) + assert.Equal(t, "second commit\n", string(out)) + }) + + t.Run("InitializeGitCache", func(t *testing.T) { + evalRoot, _ := filepath.EvalSymlinks(tempDir) + once = sync.Once{} + err := InitializeGitCache(evalRoot) + require.NoError(t, err) + assert.Equal(t, 2020, firstCommitYearCached) + assert.Contains(t, lastCommitYearsCache, "test.txt") + assert.Contains(t, lastCommitYearsCache, "test2.txt") + assert.Equal(t, 2020, lastCommitYearsCache["test.txt"]) + assert.Equal(t, 2023, lastCommitYearsCache["test2.txt"]) + }) + + t.Run("InitializeGitCache - Failure", func(t *testing.T) { + once = sync.Once{} + err := InitializeGitCache("/non/existent/path/for/git/cache") + require.NoError(t, err) + assert.NotNil(t, lastCommitYearsCache) + assert.Empty(t, lastCommitYearsCache) + }) + + t.Run("getCachedFileLastCommitYear", func(t *testing.T) { + evalRoot, err := filepath.EvalSymlinks(tempDir) + require.NoError(t, err) + once = sync.Once{} + err = InitializeGitCache(evalRoot) + require.NoError(t, err) + + year, err := getCachedFileLastCommitYear("test2.txt", evalRoot) + require.NoError(t, err) + assert.Equal(t, 2023, year) + + year, err = getCachedFileLastCommitYear("nonexistent.txt", evalRoot) + assert.Error(t, err) + assert.Equal(t, 0, year) + }) + + t.Run("getFileLastCommitYear", func(t *testing.T) { + evalRoot, _ := filepath.EvalSymlinks(tempDir) + once = sync.Once{} + err = InitializeGitCache(evalRoot) + require.NoError(t, err) + + year, err := getFileLastCommitYear(filepath.Join(evalRoot, "test.txt"), evalRoot) + require.NoError(t, err) + assert.Equal(t, 2020, year) + + // Not in cache + year, err = getFileLastCommitYear(filepath.Join(evalRoot, "nonexistent.txt"), evalRoot) + require.NoError(t, err) + assert.Equal(t, 0, year) + }) + + t.Run("GetRepoFirstCommitYear", func(t *testing.T) { + evalRoot, _ := filepath.EvalSymlinks(tempDir) + once = sync.Once{} + year, err := GetRepoFirstCommitYear(evalRoot) + require.NoError(t, err) + assert.Equal(t, 2020, year) + }) + + t.Run("GetRepoFirstCommitYear - Invalid Repo", func(t *testing.T) { + year, err := GetRepoFirstCommitYear(t.TempDir()) + assert.Error(t, err) + assert.Equal(t, 0, year) + }) + + t.Run("GetRepoLastCommitYear - Invalid Repo", func(t *testing.T) { + year, err := GetRepoLastCommitYear(t.TempDir()) + assert.Error(t, err) + assert.Equal(t, 0, year) + }) +} + +func TestUpdateCopyrightHeaderWithCache(t *testing.T) { + currentYear := time.Now().Year() + + tests := []struct { + name string + initialContent string + targetHolder string + configYear int + forceCurrentYear bool + ignoreYear1 bool + repoFirstYear int + repoRoot string + expectModified bool + expectedContent string + }{ + { + name: "Update start year using repoFirstYear when configYear is 0", + initialContent: `// Copyright IBM Corp. 2023 +package main +`, + targetHolder: "IBM Corp.", + configYear: 0, + forceCurrentYear: false, + ignoreYear1: false, + repoFirstYear: 2019, + repoRoot: "", // empty to avoid real git lookups + expectModified: true, + expectedContent: `// Copyright IBM Corp. 2019, 2023 +package main +`, + }, + { + name: "No update when ignoring year 1 and configYear differs", + initialContent: `// Copyright IBM Corp. 2023, 2023 +package main +`, + targetHolder: "IBM Corp.", + configYear: 2020, + forceCurrentYear: false, + ignoreYear1: true, + repoFirstYear: 2018, + repoRoot: "", + expectModified: false, + }, + { + name: "Update end year with forceCurrentYear", + initialContent: `// Copyright IBM Corp. 2020, 2022 +package main +`, + targetHolder: "IBM Corp.", + configYear: 2020, + forceCurrentYear: true, + ignoreYear1: false, + repoFirstYear: 2020, + repoRoot: "", + expectModified: true, + expectedContent: `// Copyright IBM Corp. 2020, ` + strconv.Itoa(currentYear) + ` +package main +`, + }, + { + name: "Skip .copywrite.hcl file", + initialContent: `// Copyright IBM Corp. 2020 +schema_version = 1 +`, + targetHolder: "IBM Corp.", + configYear: 2022, + forceCurrentYear: true, + ignoreYear1: false, + repoFirstYear: 2020, + repoRoot: "", + expectModified: false, + }, + { + name: "Wrong holder (Google Inc.) - no update", + initialContent: `// Copyright (c) Google Inc. +package main +`, + targetHolder: "IBM Corp.", + configYear: 2022, + forceCurrentYear: true, + ignoreYear1: false, + repoFirstYear: 2020, + repoRoot: "", + expectModified: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + + fileName := "test.go" + if tt.name == "Skip .copywrite.hcl file" { + fileName = ".copywrite.hcl" + } + testFile := filepath.Join(tempDir, fileName) + + err := os.WriteFile(testFile, []byte(tt.initialContent), 0644) + require.NoError(t, err) + + modified, err := UpdateCopyrightHeaderWithCache( + testFile, + tt.targetHolder, + tt.configYear, + tt.forceCurrentYear, + tt.ignoreYear1, + tt.repoFirstYear, + tt.repoRoot, + ) + require.NoError(t, err) + assert.Equal(t, tt.expectModified, modified) + + if tt.expectModified && tt.expectedContent != "" { + content, err := os.ReadFile(testFile) + require.NoError(t, err) + assert.Equal(t, tt.expectedContent, string(content)) + } + }) + } +} diff --git a/repodata/repodata_test.go b/repodata/repodata_test.go index 1d9a606..652d86f 100644 --- a/repodata/repodata_test.go +++ b/repodata/repodata_test.go @@ -5,9 +5,11 @@ package repodata import ( "testing" + "time" "github.com/google/go-github/v45/github" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // to help with making archived and non archived repos for testing @@ -107,3 +109,180 @@ func TestValidateInputFields(t *testing.T) { }) } } + +func TestTransform(t *testing.T) { + t.Run("empty array returns empty result", func(t *testing.T) { + result, err := Transform([]*github.Repository{}) + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("transform repo with string fields", func(t *testing.T) { + name := "test-repo" + url := "https://github.com/test/repo" + repo := &github.Repository{ + Name: &name, + HTMLURL: &url, + } + + result, err := Transform([]*github.Repository{repo}) + require.NoError(t, err) + require.Len(t, result, 1) + + assert.Equal(t, "test-repo", result[0]["Name"]) + assert.Equal(t, "https://github.com/test/repo", result[0]["HTMLURL"]) + }) + + t.Run("transform repo with nil string fields", func(t *testing.T) { + repo := &github.Repository{ + Name: nil, + HTMLURL: nil, + } + + result, err := Transform([]*github.Repository{repo}) + require.NoError(t, err) + require.Len(t, result, 1) + + // Nil pointers should be transformed to empty strings + assert.Equal(t, "", result[0]["Name"]) + assert.Equal(t, "", result[0]["HTMLURL"]) + }) + + t.Run("transform repo with license", func(t *testing.T) { + licenseKey := "mit" + license := &github.License{ + Key: &licenseKey, + } + name := "licensed-repo" + repo := &github.Repository{ + Name: &name, + License: license, + } + + result, err := Transform([]*github.Repository{repo}) + require.NoError(t, err) + require.Len(t, result, 1) + + assert.Equal(t, "licensed-repo", result[0]["Name"]) + assert.Equal(t, "mit", result[0]["License"]) + }) + + t.Run("transform repo with nil license", func(t *testing.T) { + name := "unlicensed-repo" + repo := &github.Repository{ + Name: &name, + License: nil, + } + + result, err := Transform([]*github.Repository{repo}) + require.NoError(t, err) + require.Len(t, result, 1) + + assert.Equal(t, "unlicensed-repo", result[0]["Name"]) + assert.Equal(t, "", result[0]["License"]) + }) + + t.Run("transform repo with timestamp", func(t *testing.T) { + name := "timestamped-repo" + testTime := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) + timestamp := &github.Timestamp{Time: testTime} + repo := &github.Repository{ + Name: &name, + CreatedAt: timestamp, + } + + result, err := Transform([]*github.Repository{repo}) + require.NoError(t, err) + require.Len(t, result, 1) + + assert.Equal(t, "timestamped-repo", result[0]["Name"]) + // Timestamp should be converted to string representation + assert.Equal(t, testTime.String(), result[0]["CreatedAt"]) + }) + + t.Run("transform repo with nil timestamp", func(t *testing.T) { + name := "no-timestamp-repo" + repo := &github.Repository{ + Name: &name, + CreatedAt: nil, + } + + result, err := Transform([]*github.Repository{repo}) + require.NoError(t, err) + require.Len(t, result, 1) + + assert.Equal(t, "no-timestamp-repo", result[0]["Name"]) + assert.Equal(t, "", result[0]["CreatedAt"]) + }) + + t.Run("transform multiple repos", func(t *testing.T) { + name1 := "repo-one" + name2 := "repo-two" + url1 := "https://github.com/test/one" + url2 := "https://github.com/test/two" + + repos := []*github.Repository{ + { + Name: &name1, + HTMLURL: &url1, + }, + { + Name: &name2, + HTMLURL: &url2, + }, + } + + result, err := Transform(repos) + require.NoError(t, err) + require.Len(t, result, 2) + + assert.Equal(t, "repo-one", result[0]["Name"]) + assert.Equal(t, "https://github.com/test/one", result[0]["HTMLURL"]) + assert.Equal(t, "repo-two", result[1]["Name"]) + assert.Equal(t, "https://github.com/test/two", result[1]["HTMLURL"]) + }) + + t.Run("transform repo with mixed field types", func(t *testing.T) { + name := "complex-repo" + url := "https://github.com/test/complex" + licenseKey := "apache-2.0" + license := &github.License{Key: &licenseKey} + testTime := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + timestamp := &github.Timestamp{Time: testTime} + + repo := &github.Repository{ + Name: &name, + HTMLURL: &url, + License: license, + CreatedAt: timestamp, + } + + result, err := Transform([]*github.Repository{repo}) + require.NoError(t, err) + require.Len(t, result, 1) + + assert.Equal(t, "complex-repo", result[0]["Name"]) + assert.Equal(t, "https://github.com/test/complex", result[0]["HTMLURL"]) + assert.Equal(t, "apache-2.0", result[0]["License"]) + assert.Equal(t, testTime.String(), result[0]["CreatedAt"]) + }) + + t.Run("transform repo with all nil fields", func(t *testing.T) { + repo := &github.Repository{ + Name: nil, + HTMLURL: nil, + License: nil, + CreatedAt: nil, + } + + result, err := Transform([]*github.Repository{repo}) + require.NoError(t, err) + require.Len(t, result, 1) + + // All nil fields should become empty strings + assert.Equal(t, "", result[0]["Name"]) + assert.Equal(t, "", result[0]["HTMLURL"]) + assert.Equal(t, "", result[0]["License"]) + assert.Equal(t, "", result[0]["CreatedAt"]) + }) +}