241 lines
9.6 KiB
Go
241 lines
9.6 KiB
Go
package azuredevops
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
type fakeReadClient struct {
|
|
invalidClient
|
|
projects []Project
|
|
work *WorkItemSearchResult
|
|
pr *PullRequest
|
|
reviewers []Reviewer
|
|
threads []Thread
|
|
refs []WorkItemRef
|
|
policies []PolicyEvaluation
|
|
lastFilter PullRequestFilter
|
|
}
|
|
|
|
func (f *fakeReadClient) ListPullRequests(_ context.Context, filter PullRequestFilter) (*PullRequestPage, error) {
|
|
f.lastFilter = filter
|
|
return &PullRequestPage{}, nil
|
|
}
|
|
|
|
func (f *fakeReadClient) TestAuth(context.Context) (*TestConnectionResult, error) {
|
|
return &TestConnectionResult{OK: true, ID: "me", DisplayName: "Ada"}, nil
|
|
}
|
|
func (f *fakeReadClient) ListProjects(context.Context) ([]Project, error) { return f.projects, nil }
|
|
func (f *fakeReadClient) QueryWIQL(context.Context, string, string, int) (*WorkItemSearchResult, error) {
|
|
return f.work, nil
|
|
}
|
|
func (f *fakeReadClient) GetPullRequest(context.Context, string, string, int) (*PullRequest, error) {
|
|
return f.pr, nil
|
|
}
|
|
func (f *fakeReadClient) ListReviewers(context.Context, string, string, int) ([]Reviewer, error) {
|
|
return f.reviewers, nil
|
|
}
|
|
func (f *fakeReadClient) ListThreads(context.Context, string, string, int) ([]Thread, error) {
|
|
return f.threads, nil
|
|
}
|
|
func (f *fakeReadClient) ListLinkedWorkItems(context.Context, string, string, int) ([]WorkItemRef, error) {
|
|
return f.refs, nil
|
|
}
|
|
func (f *fakeReadClient) ListPolicyEvaluations(context.Context, string, int) ([]PolicyEvaluation, error) {
|
|
return f.policies, nil
|
|
}
|
|
|
|
func newControllerFixture(t *testing.T) (*gin.Engine, *Service) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
db := newTestDB(t)
|
|
store, err := NewStore(db, db)
|
|
if err != nil {
|
|
t.Fatalf("new store: %v", err)
|
|
}
|
|
client := &fakeReadClient{
|
|
projects: []Project{{ID: "project-1", Name: "Platform"}},
|
|
work: &WorkItemSearchResult{Items: []WorkItem{{ID: 101, Title: "Fix build"}}, Count: 1},
|
|
pr: &PullRequest{ID: 42, Title: "Ship it", ProjectID: "project-1", RepositoryID: "repo-1"},
|
|
reviewers: []Reviewer{{Identity: Identity{ID: "u2", DisplayName: "Grace"}, Vote: 10, IsRequired: true}},
|
|
threads: []Thread{{ID: 7, Status: "active"}},
|
|
refs: []WorkItemRef{{ID: 101}},
|
|
policies: []PolicyEvaluation{{ID: "p1", Status: "approved", IsBlocking: true}},
|
|
}
|
|
service := NewService(store, newFakeSecretStore(), func(*Config, string) Client { return client }, logger.Default())
|
|
if _, err := service.SetConfigForWorkspace(context.Background(), "ws-1", &SetConfigRequest{
|
|
OrganizationURL: "https://dev.azure.com/acme", PAT: "pat",
|
|
}); err != nil {
|
|
t.Fatalf("set config: %v", err)
|
|
}
|
|
router := gin.New()
|
|
RegisterRoutes(router, service, logger.Default())
|
|
return router, service
|
|
}
|
|
|
|
func TestControllerConfigAndWorkspaceScopedReads(t *testing.T) {
|
|
router, _ := newControllerFixture(t)
|
|
|
|
config := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/config?workspace_id=ws-1", nil)
|
|
if config.Code != http.StatusOK || bytes.Contains(config.Body.Bytes(), []byte("\"pat\":")) {
|
|
t.Fatalf("config response %d: %s", config.Code, config.Body.String())
|
|
}
|
|
projects := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/projects?workspace_id=ws-1", nil)
|
|
if projects.Code != http.StatusOK || !bytes.Contains(projects.Body.Bytes(), []byte("Platform")) {
|
|
t.Fatalf("projects response %d: %s", projects.Code, projects.Body.String())
|
|
}
|
|
search := performRequest(t, router, http.MethodPost, "/api/v1/azure-devops/work-items/search?workspace_id=ws-1", map[string]any{
|
|
"project": "project-1", "wiql": "SELECT [System.Id] FROM WorkItems", "top": 20,
|
|
})
|
|
if search.Code != http.StatusOK || !bytes.Contains(search.Body.Bytes(), []byte("Fix build")) {
|
|
t.Fatalf("search response %d: %s", search.Code, search.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestControllerPersistsWorkspaceSavedViews(t *testing.T) {
|
|
router, _ := newControllerFixture(t)
|
|
path := "/api/v1/azure-devops/views?workspace_id=ws-1"
|
|
created := performRequest(t, router, http.MethodPut, path, map[string]any{
|
|
"views": []map[string]any{{
|
|
"id": "mine", "kind": "work_item", "label": "Assigned to me",
|
|
"projectId": "project-1", "wiql": "SELECT [System.Id] FROM WorkItems", "top": 50,
|
|
}},
|
|
})
|
|
if created.Code != http.StatusOK || !bytes.Contains(created.Body.Bytes(), []byte("Assigned to me")) {
|
|
t.Fatalf("save views response %d: %s", created.Code, created.Body.String())
|
|
}
|
|
loaded := performRequest(t, router, http.MethodGet, path, nil)
|
|
if loaded.Code != http.StatusOK || !bytes.Contains(loaded.Body.Bytes(), []byte("Assigned to me")) {
|
|
t.Fatalf("get views response %d: %s", loaded.Code, loaded.Body.String())
|
|
}
|
|
invalid := performRequest(t, router, http.MethodPut, path, map[string]any{
|
|
"views": []map[string]any{{
|
|
"id": "bad", "kind": "pull_request", "label": "Bad", "projectId": "project-1",
|
|
}},
|
|
})
|
|
if invalid.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid views response %d: %s", invalid.Code, invalid.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestControllerPullRequestFeedbackAggregation(t *testing.T) {
|
|
router, _ := newControllerFixture(t)
|
|
path := "/api/v1/azure-devops/pull-requests/project-1/repo-1/42/feedback?workspace_id=ws-1"
|
|
response := performRequest(t, router, http.MethodGet, path, nil)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("feedback response %d: %s", response.Code, response.Body.String())
|
|
}
|
|
var feedback PullRequestFeedback
|
|
if err := json.Unmarshal(response.Body.Bytes(), &feedback); err != nil {
|
|
t.Fatalf("decode feedback: %v", err)
|
|
}
|
|
if feedback.PullRequest.ID != 42 || feedback.ReviewState != "approved" || feedback.PolicyState != "success" || len(feedback.Threads) != 1 {
|
|
t.Fatalf("feedback = %+v", feedback)
|
|
}
|
|
}
|
|
|
|
func TestPullRequestSearchResolvesCurrentUserSentinel(t *testing.T) {
|
|
router, service := newControllerFixture(t)
|
|
client := service.clientFn(nil, "").(*fakeReadClient)
|
|
response := performRequest(
|
|
t, router, http.MethodGet,
|
|
"/api/v1/azure-devops/pull-requests?workspace_id=ws-1&project=project-1&repository=repo-1&reviewer=%40me", nil,
|
|
)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("pull request response %d: %s", response.Code, response.Body.String())
|
|
}
|
|
if client.lastFilter.ReviewerID != "me" {
|
|
t.Fatalf("reviewer filter = %q, want authenticated identity", client.lastFilter.ReviewerID)
|
|
}
|
|
}
|
|
|
|
func TestControllerRejectsMissingAndUnconfiguredWorkspace(t *testing.T) {
|
|
router, _ := newControllerFixture(t)
|
|
missing := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/projects", nil)
|
|
if missing.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing workspace status = %d: %s", missing.Code, missing.Body.String())
|
|
}
|
|
unconfigured := performRequest(t, router, http.MethodGet, "/api/v1/azure-devops/projects?workspace_id=ws-2", nil)
|
|
if unconfigured.Code != http.StatusServiceUnavailable || !bytes.Contains(unconfigured.Body.Bytes(), []byte("azure_devops_not_configured")) {
|
|
t.Fatalf("unconfigured status = %d: %s", unconfigured.Code, unconfigured.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestControllerTaskPullRequestAssociationRoutes(t *testing.T) {
|
|
router, service := newControllerFixture(t)
|
|
service.SetRepositoryLookup(fakeAzureRepositoryLookup{binding: &RepositoryBinding{
|
|
WorkspaceID: "ws-1", Provider: RepositoryProvider,
|
|
ProviderOwner: "project-1", ProviderRepoID: "repo-1",
|
|
}})
|
|
if _, err := service.Store().db.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL)`); err != nil {
|
|
t.Fatalf("create tasks table: %v", err)
|
|
}
|
|
if _, err := service.Store().db.Exec(`INSERT INTO tasks (id, workspace_id) VALUES ('task-1', 'ws-1')`); err != nil {
|
|
t.Fatalf("seed task: %v", err)
|
|
}
|
|
body := map[string]any{"repositoryId": "repository-1", "pullRequestId": 42}
|
|
associated := performRequest(
|
|
t, router, http.MethodPost,
|
|
"/api/v1/azure-devops/tasks/task-1/pull-requests?workspace_id=ws-1", body,
|
|
)
|
|
if associated.Code != http.StatusOK || !bytes.Contains(associated.Body.Bytes(), []byte("Ship it")) {
|
|
t.Fatalf("associate response %d: %s", associated.Code, associated.Body.String())
|
|
}
|
|
synced := performRequest(
|
|
t, router, http.MethodPost,
|
|
"/api/v1/azure-devops/tasks/task-1/pull-requests/sync?workspace_id=ws-1", body,
|
|
)
|
|
if synced.Code != http.StatusOK {
|
|
t.Fatalf("sync response %d: %s", synced.Code, synced.Body.String())
|
|
}
|
|
listed := performRequest(
|
|
t, router, http.MethodGet,
|
|
"/api/v1/azure-devops/workspaces/ws-1/task-prs", nil,
|
|
)
|
|
if listed.Code != http.StatusOK || !bytes.Contains(listed.Body.Bytes(), []byte(`"taskPrs":{"task-1"`)) {
|
|
t.Fatalf("list response %d: %s", listed.Code, listed.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestControllerTaskPullRequestAssociationRejectsInvalidInput(t *testing.T) {
|
|
router, service := newControllerFixture(t)
|
|
service.SetRepositoryLookup(fakeAzureRepositoryLookup{binding: &RepositoryBinding{
|
|
WorkspaceID: "ws-1", Provider: "github",
|
|
ProviderOwner: "project-1", ProviderRepoID: "repo-1",
|
|
}})
|
|
response := performRequest(
|
|
t, router, http.MethodPost,
|
|
"/api/v1/azure-devops/tasks/task-1/pull-requests?workspace_id=ws-1",
|
|
map[string]any{"repositoryId": "repository-1", "pullRequestId": 42},
|
|
)
|
|
if response.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid association response %d: %s", response.Code, response.Body.String())
|
|
}
|
|
}
|
|
|
|
func performRequest(t *testing.T, router http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var encoded []byte
|
|
if body != nil {
|
|
var err error
|
|
encoded, err = json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal request: %v", err)
|
|
}
|
|
}
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(encoded))
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, req)
|
|
return response
|
|
}
|