package git import ( "context" "errors" "testing" "github.com/stretchr/testify/require" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" ) type mockRepoManager struct { downloadErr error commitID string commitIDErr error refs []string refsErr error files []string filesErr error downloadCalled int commitIDCalled int listRefsCalled int listFilesCalled int lastCloneOptions *git.CloneOptions } func (m *mockRepoManager) Download(_ context.Context, _ string, opts *git.CloneOptions) error { m.downloadCalled++ m.lastCloneOptions = opts return m.downloadErr } func (m *mockRepoManager) LatestCommitID(_ context.Context, _, _ string, _ *git.ListOptions) (string, error) { m.commitIDCalled++ return m.commitID, m.commitIDErr } func (m *mockRepoManager) ListRefs(_ context.Context, _ string, _ *git.ListOptions) ([]string, error) { m.listRefsCalled++ return m.refs, m.refsErr } func (m *mockRepoManager) ListFiles(_ context.Context, _ bool, _ *git.CloneOptions) ([]string, error) { m.listFilesCalled++ return m.files, m.filesErr } func newTestService(ctx context.Context, cacheSize int, gitMgr, azureMgr RepoManager) *Service { s := newService(ctx, cacheSize, 0) s.git = gitMgr s.azure = azureMgr return s } func TestCloneRepository(t *testing.T) { t.Parallel() downloadErr := errors.New("clone failed") testCases := []struct { name string url string referenceName string gitManagerDownloadCalled int azureManagerDownloadCalled int managerErr bool expectedError error expectedReferenceName string }{ { name: "non-azure URL routes to git manager", url: "https://github.com/example/repo.git", gitManagerDownloadCalled: 1, azureManagerDownloadCalled: 0, }, { name: "azure URL routes to azure manager", url: "https://dev.azure.com/org/project/_git/repo", gitManagerDownloadCalled: 0, azureManagerDownloadCalled: 1, }, { name: "error from manager propagated", url: "https://github.com/example/repo.git", managerErr: true, gitManagerDownloadCalled: 1, expectedError: downloadErr, }, { name: "ReferenceName is passed to clone options", url: "https://github.com/example/repo.git", referenceName: "refs/heads/feature-branch", gitManagerDownloadCalled: 1, expectedReferenceName: "refs/heads/feature-branch", }, { name: "empty ReferenceName leaves clone options unset", url: "https://github.com/example/repo.git", referenceName: "", gitManagerDownloadCalled: 1, expectedReferenceName: "", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { gitMgr := &mockRepoManager{} azureMgr := &mockRepoManager{} if tc.managerErr { gitMgr.downloadErr = downloadErr azureMgr.downloadErr = downloadErr } s := newTestService(t.Context(), 4, gitMgr, azureMgr) err := s.CloneRepository(t.Context(), "/tmp", tc.url, tc.referenceName, "", "", false) require.Equal(t, tc.expectedError, err) require.Equal(t, tc.gitManagerDownloadCalled, gitMgr.downloadCalled) require.Equal(t, tc.azureManagerDownloadCalled, azureMgr.downloadCalled) activeMgr := gitMgr if tc.azureManagerDownloadCalled > 0 { activeMgr = azureMgr } if activeMgr.lastCloneOptions != nil { require.Equal(t, plumbing.ReferenceName(tc.expectedReferenceName), activeMgr.lastCloneOptions.ReferenceName) } }) } } func TestLatestCommitID(t *testing.T) { t.Parallel() commitLookupErr := errors.New("commit lookup failed") testCases := []struct { name string url string gitCommitID string azureCommitID string commitIDErr error expectedID string expectedError error gitCalled int azureCalled int }{ { name: "non-azure URL routes to git manager", url: "https://github.com/example/repo.git", gitCommitID: "abc123", expectedID: "abc123", gitCalled: 1, }, { name: "azure URL routes to azure manager", url: "https://dev.azure.com/org/project/_git/repo", azureCommitID: "def456", expectedID: "def456", azureCalled: 1, }, { name: "error propagated", url: "https://github.com/example/repo.git", commitIDErr: commitLookupErr, expectedError: commitLookupErr, gitCalled: 1, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { gitMgr := &mockRepoManager{commitID: tc.gitCommitID, commitIDErr: tc.commitIDErr} azureMgr := &mockRepoManager{commitID: tc.azureCommitID} s := newTestService(t.Context(), 4, gitMgr, azureMgr) id, err := s.LatestCommitID(t.Context(), tc.url, "", "", "", false) require.Equal(t, tc.expectedError, err) require.Equal(t, tc.expectedID, id) require.Equal(t, tc.gitCalled, gitMgr.commitIDCalled) require.Equal(t, tc.azureCalled, azureMgr.commitIDCalled) }) } } func TestListRefs(t *testing.T) { t.Parallel() t.Run("cache hit on second call", func(t *testing.T) { gitMgr := &mockRepoManager{refs: []string{"refs/heads/main", "refs/heads/develop"}} s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) refs1, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) require.NoError(t, err) refs2, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) require.NoError(t, err) require.Equal(t, 1, gitMgr.listRefsCalled, "expected manager to be called once") require.Equal(t, refs1, refs2) }) t.Run("hard refresh clears cache and calls manager again", func(t *testing.T) { gitMgr := &mockRepoManager{refs: []string{"refs/heads/main"}} s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) _, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) require.NoError(t, err) _, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", true, false) require.NoError(t, err) require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice with hard refresh") }) t.Run("error propagated and not cached", func(t *testing.T) { wantErr := errors.New("refs failed") gitMgr := &mockRepoManager{refsErr: wantErr} s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) _, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) require.Equal(t, wantErr, err) _, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", true, false) require.Equal(t, wantErr, err) require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice after error") }) t.Run("azure URL routes to azure manager", func(t *testing.T) { gitMgr := &mockRepoManager{} azureMgr := &mockRepoManager{refs: []string{"refs/heads/main"}} s := newTestService(t.Context(), 4, gitMgr, azureMgr) _, err := s.ListRefs(t.Context(), "https://dev.azure.com/org/project/_git/repo", "", "", false, false) require.NoError(t, err) require.Equal(t, 1, azureMgr.listRefsCalled, "expected azure.ListRefs to be called once") require.Equal(t, 0, gitMgr.listRefsCalled, "expected git.ListRefs to not be called") }) t.Run("cache disabled: manager always called", func(t *testing.T) { gitMgr := &mockRepoManager{refs: []string{"refs/heads/main"}} s := newTestService(t.Context(), 0, gitMgr, &mockRepoManager{}) _, err := s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) require.NoError(t, err) _, err = s.ListRefs(t.Context(), "https://github.com/example/repo.git", "", "", false, false) require.NoError(t, err) require.Equal(t, 2, gitMgr.listRefsCalled, "expected manager to be called twice with cache disabled") }) } func TestListFiles(t *testing.T) { t.Parallel() t.Run("cache hit on second call", func(t *testing.T) { gitMgr := &mockRepoManager{files: []string{"docker-compose.yml", "README.md"}} s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) files1, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false) require.NoError(t, err) files2, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false) require.NoError(t, err) require.Equal(t, 1, gitMgr.listFilesCalled, "expected manager to be called once") require.Equal(t, files1, files2) }) t.Run("hard refresh clears file cache", func(t *testing.T) { gitMgr := &mockRepoManager{files: []string{"docker-compose.yml"}} s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) _, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false) require.NoError(t, err) _, err = s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, true, nil, false) require.NoError(t, err) require.Equal(t, 2, gitMgr.listFilesCalled, "expected manager to be called twice with hard refresh") }) t.Run("azure URL routes to azure manager", func(t *testing.T) { gitMgr := &mockRepoManager{} azureMgr := &mockRepoManager{files: []string{"docker-compose.yml"}} s := newTestService(t.Context(), 4, gitMgr, azureMgr) _, err := s.ListFiles(t.Context(), "https://dev.azure.com/org/project/_git/repo", "", "", "", false, false, nil, false) require.NoError(t, err) require.Equal(t, 1, azureMgr.listFilesCalled, "expected azure.ListFiles to be called once") require.Equal(t, 0, gitMgr.listFilesCalled, "expected git.ListFiles to not be called") }) t.Run("extension filter applied", func(t *testing.T) { gitMgr := &mockRepoManager{files: []string{"docker-compose.yml", "README.md", "stack.yml", "config.json"}} s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) files, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "", "", "", false, false, []string{".yml"}, false) require.NoError(t, err) require.Equal(t, []string{"docker-compose.yml", "stack.yml"}, files) }) t.Run("error is returned and not cached", func(t *testing.T) { wantErr := errors.New("list files failed") gitMgr := &mockRepoManager{filesErr: wantErr} s := newTestService(t.Context(), 4, gitMgr, &mockRepoManager{}) files, err := s.ListFiles(t.Context(), "https://github.com/example/repo.git", "refs/heads/main", "", "", false, false, nil, false) require.ErrorIs(t, err, wantErr) require.Nil(t, files) }) } func TestFilterFiles(t *testing.T) { t.Parallel() testCases := []struct { name string files []string exts []string expectedFiles []string }{ { name: "empty ext list returns all files", files: []string{"a.yml", "b.json", "c.txt"}, exts: nil, expectedFiles: []string{"a.yml", "b.json", "c.txt"}, }, { name: "non-matching exts returns empty", files: []string{"a.yml", "b.json"}, exts: []string{".txt"}, expectedFiles: nil, }, { name: "partial match returns only matching files", files: []string{"a.yml", "b.json", "c.yml"}, exts: []string{".yml"}, expectedFiles: []string{"a.yml", "c.yml"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.expectedFiles, filterFiles(tc.files, tc.exts)) }) } }