Files
kandev/apps/backend/internal/azuredevops/controller_test.go
T
2026-07-21 08:40:26 +01:00

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
}