Files
kandev/apps/backend/internal/github/controller_write_endpoints_test.go

723 lines
24 KiB
Go

package github
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// doControllerJSON issues a request carrying a JSON body (marshalled from
// payload, or sent verbatim when payload is a string) and returns the recorder.
func doControllerJSON(
t *testing.T, router http.Handler, method, target string, payload any,
) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, target, bytes.NewReader(marshalPayload(t, payload)))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func TestController_ListTaskPRs_RequiresAWorkspaceOrTaskIDs(t *testing.T) {
router, _ := setupControllerStoreTest(t)
request := httptest.NewRequest(http.MethodGet, "/api/v1/github/task-prs", nil)
request.Header.Set("X-Test-Omit-Workspace", "1")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
var body struct {
Error string `json:"error"`
}
decodeControllerBody(t, response, &body)
if body.Error != "workspace_id or task_ids query parameter required" {
t.Errorf("error = %q", body.Error)
}
}
func TestController_ListTaskPRs_WorkspaceScoped(t *testing.T) {
router, store := setupControllerStoreTest(t)
seedControllerTaskPR(t, store, "task-1", 7)
response := doControllerRequest(router, http.MethodGet, "/api/v1/github/task-prs?workspace_id=ws-1")
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body)
}
var body struct {
TaskPRs map[string][]TaskPR `json:"task_prs"`
}
decodeControllerBody(t, response, &body)
if len(body.TaskPRs) != 1 || len(body.TaskPRs["task-1"]) != 1 {
t.Fatalf("task_prs = %#v, want one association keyed by task-1", body.TaskPRs)
}
got := body.TaskPRs["task-1"][0]
if got.TaskID != "task-1" || got.PRNumber != 7 || got.Owner != "acme" || got.Repo != "widget" {
t.Errorf("task PR = %#v", got)
}
if got.WorkspaceID != "ws-1" || got.State != "open" || got.BaseBranch != "main" {
t.Errorf("task PR = %#v", got)
}
}
// The legacy task_ids form filters by explicit IDs rather than by workspace.
func TestController_ListTaskPRs_LegacyTaskIDsFilter(t *testing.T) {
router, store := setupControllerStoreTest(t)
seedControllerTaskPR(t, store, "task-1", 7)
seedControllerTaskPR(t, store, "task-2", 8)
request := httptest.NewRequest(http.MethodGet, "/api/v1/github/task-prs?task_ids=task-2", nil)
request.Header.Set("X-Test-Omit-Workspace", "1")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body)
}
var body struct {
TaskPRs map[string][]TaskPR `json:"task_prs"`
}
decodeControllerBody(t, response, &body)
if len(body.TaskPRs) != 1 {
t.Fatalf("task_prs = %#v, want only the requested task-2", body.TaskPRs)
}
rows := body.TaskPRs["task-2"]
if len(rows) != 1 || rows[0].PRNumber != 8 {
t.Fatalf("task-2 rows = %#v, want PR 8", rows)
}
}
func TestController_GetTaskPR(t *testing.T) {
router, store := setupControllerStoreTest(t)
seedControllerTaskPR(t, store, "task-1", 7)
response := doControllerRequest(router, http.MethodGet, "/api/v1/github/task-prs/task-1")
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body)
}
var got TaskPR
decodeControllerBody(t, response, &got)
if got.TaskID != "task-1" || got.PRNumber != 7 {
t.Errorf("task PR = %#v", got)
}
}
// No association is a 204 with no body, not a 404 — the panel treats an empty
// response as "this task has no PR yet".
func TestController_GetTaskPR_MissingAssociationIsNoContent(t *testing.T) {
router, _ := setupControllerStoreTest(t)
response := doControllerRequest(router, http.MethodGet, "/api/v1/github/task-prs/task-unknown")
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body = %s", response.Code, response.Body)
}
if response.Body.Len() != 0 {
t.Errorf("body = %q, want empty", response.Body.String())
}
}
func TestController_CreateTaskPR_Validation(t *testing.T) {
cases := []struct {
name string
payload any
want string
}{
{"malformed JSON", "{not json", "invalid payload"},
{
"missing workspace_id",
map[string]string{"task_id": "task-1", "pr_url": "https://github.com/acme/widget/pull/7"},
"workspace_id, task_id and pr_url are required",
},
{
"missing task_id",
map[string]string{"workspace_id": "ws-1", "pr_url": "https://github.com/acme/widget/pull/7"},
"workspace_id, task_id and pr_url are required",
},
{
"missing pr_url",
map[string]string{"workspace_id": "ws-1", "task_id": "task-1"},
"workspace_id, task_id and pr_url are required",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
router, _ := setupControllerStoreTest(t)
response := doControllerJSON(t, router, http.MethodPost, "/api/v1/github/task-prs", tc.payload)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
var body struct {
Error string `json:"error"`
}
decodeControllerBody(t, response, &body)
if body.Error != tc.want {
t.Errorf("error = %q, want %q", body.Error, tc.want)
}
})
}
}
// A URL that is not a PR link is the caller's fault, so it maps to 400 rather
// than collapsing into the generic 500.
func TestController_CreateTaskPR_InvalidPRURLIs400(t *testing.T) {
router, _ := setupControllerStoreTest(t)
response := doControllerJSON(t, router, http.MethodPost, "/api/v1/github/task-prs", map[string]string{
"workspace_id": "ws-1",
"task_id": "task-1",
"pr_url": "https://example.com/not-a-pr",
})
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
}
func TestController_ActionPresets(t *testing.T) {
router, _ := setupControllerStoreTest(t)
t.Run("defaults are returned when nothing is stored", func(t *testing.T) {
response := doControllerRequest(
router, http.MethodGet, "/api/v1/github/action-presets?workspace_id=ws-1",
)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body)
}
var presets ActionPresets
decodeControllerBody(t, response, &presets)
if presets.WorkspaceID != "ws-1" {
t.Errorf("workspace_id = %q", presets.WorkspaceID)
}
if len(presets.PR) != len(DefaultPRActionPresets()) {
t.Errorf("pr presets = %d, want the %d built-ins",
len(presets.PR), len(DefaultPRActionPresets()))
}
if len(presets.Issue) == 0 {
t.Error("issue presets are empty, want the built-ins")
}
})
t.Run("update then read back", func(t *testing.T) {
custom := []ActionPreset{{
ID: "triage", Label: "Triage", Hint: "sort it", Icon: "list",
PromptTemplate: "Triage {{url}}",
}}
response := doControllerJSON(t, router, http.MethodPut, "/api/v1/github/action-presets",
UpdateActionPresetsRequest{WorkspaceID: "ws-1", PR: &custom})
if response.Code != http.StatusOK {
t.Fatalf("update status = %d, want 200; body = %s", response.Code, response.Body)
}
var updated ActionPresets
decodeControllerBody(t, response, &updated)
if len(updated.PR) != 1 || updated.PR[0].ID != "triage" || updated.PR[0].Label != "Triage" {
t.Fatalf("pr presets = %#v", updated.PR)
}
// Issue presets were not supplied, so they must be left untouched.
if len(updated.Issue) == 0 {
t.Error("issue presets were cleared, want them unchanged")
}
readBack := doControllerRequest(
router, http.MethodGet, "/api/v1/github/action-presets?workspace_id=ws-1",
)
var stored ActionPresets
decodeControllerBody(t, readBack, &stored)
if len(stored.PR) != 1 || stored.PR[0].ID != "triage" {
t.Fatalf("stored pr presets = %#v, want the update persisted", stored.PR)
}
})
t.Run("reset restores the built-ins", func(t *testing.T) {
response := doControllerJSON(t, router, http.MethodPost,
"/api/v1/github/action-presets/reset?workspace_id=ws-1", nil)
if response.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200; body = %s", response.Code, response.Body)
}
var presets ActionPresets
decodeControllerBody(t, response, &presets)
if len(presets.PR) != len(DefaultPRActionPresets()) {
t.Fatalf("pr presets = %d, want the built-ins back", len(presets.PR))
}
})
}
func TestController_ActionPresets_Validation(t *testing.T) {
router, _ := setupControllerStoreTest(t)
cases := []struct {
name string
method string
target string
payload any
want string
}{
{
"get without a workspace", http.MethodGet, "/api/v1/github/action-presets", nil,
"workspace_id query parameter required",
},
{
"reset without a workspace", http.MethodPost, "/api/v1/github/action-presets/reset", nil,
"workspace_id query parameter required",
},
{
"update with malformed JSON", http.MethodPut, "/api/v1/github/action-presets", "{nope",
"invalid payload",
},
{
"update without a workspace", http.MethodPut, "/api/v1/github/action-presets",
map[string]any{}, "workspace_id is required",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(tc.method, tc.target, bytes.NewReader(marshalPayload(t, tc.payload)))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Test-Omit-Workspace", "1")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
var body struct {
Error string `json:"error"`
}
decodeControllerBody(t, response, &body)
if body.Error != tc.want {
t.Errorf("error = %q, want %q", body.Error, tc.want)
}
})
}
}
func TestController_IssueWatchCRUD(t *testing.T) {
router, _ := setupControllerStoreTest(t)
created := doControllerJSON(t, router, http.MethodPost, "/api/v1/github/watches/issue",
CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Repos: []RepoFilter{{Owner: "acme", Name: "widget"}},
Prompt: "fix it",
Labels: []string{"bug"},
CustomQuery: "is:open",
PollIntervalSeconds: 300,
})
if created.Code != http.StatusCreated {
t.Fatalf("create status = %d, want 201; body = %s", created.Code, created.Body)
}
var watch IssueWatch
decodeControllerBody(t, created, &watch)
if watch.ID == "" {
t.Fatal("created watch has no ID")
}
if watch.WorkspaceID != "ws-1" || watch.Prompt != "fix it" || watch.CustomQuery != "is:open" {
t.Errorf("created watch = %#v", watch)
}
if len(watch.Repos) != 1 || watch.Repos[0].Owner != "acme" || watch.Repos[0].Name != "widget" {
t.Errorf("created repos = %#v", watch.Repos)
}
if watch.PollIntervalSeconds != 300 {
t.Errorf("poll interval = %d, want 300", watch.PollIntervalSeconds)
}
t.Run("list returns the stored watch", func(t *testing.T) {
response := doControllerRequest(
router, http.MethodGet, "/api/v1/github/watches/issue?workspace_id=ws-1",
)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body)
}
var body struct {
Watches []IssueWatch `json:"watches"`
}
decodeControllerBody(t, response, &body)
if len(body.Watches) != 1 || body.Watches[0].ID != watch.ID {
t.Fatalf("watches = %#v, want the seeded watch", body.Watches)
}
got := body.Watches[0]
if got.Prompt != "fix it" || got.CustomQuery != "is:open" || !got.Enabled {
t.Errorf("watch = %#v", got)
}
if len(got.Repos) != 1 || got.Repos[0].Owner != "acme" || got.Repos[0].Name != "widget" {
t.Errorf("repos = %#v", got.Repos)
}
if got.PollIntervalSeconds != 300 {
t.Errorf("poll interval = %d, want 300", got.PollIntervalSeconds)
}
})
t.Run("update mutates only the supplied fields", func(t *testing.T) {
newPrompt := "handle it"
disabled := false
response := doControllerJSON(t, router, http.MethodPut,
"/api/v1/github/watches/issue/"+watch.ID+"?workspace_id=ws-1",
UpdateIssueWatchRequest{Prompt: &newPrompt, Enabled: &disabled})
if response.Code != http.StatusOK {
t.Fatalf("update status = %d, want 200; body = %s", response.Code, response.Body)
}
var updated IssueWatch
decodeControllerBody(t, response, &updated)
if updated.Prompt != "handle it" {
t.Errorf("prompt = %q, want the update applied", updated.Prompt)
}
if updated.Enabled {
t.Error("enabled = true, want the update applied")
}
if updated.CustomQuery != "is:open" {
t.Errorf("custom_query = %q, want it left untouched", updated.CustomQuery)
}
if len(updated.Repos) != 1 || updated.Repos[0].Owner != "acme" {
t.Errorf("repos = %#v, want them left untouched", updated.Repos)
}
})
t.Run("delete removes the watch", func(t *testing.T) {
response := doControllerJSON(t, router, http.MethodDelete,
"/api/v1/github/watches/issue/"+watch.ID+"?workspace_id=ws-1", nil)
if response.Code != http.StatusOK {
t.Fatalf("delete status = %d, want 200; body = %s", response.Code, response.Body)
}
var body struct {
Deleted bool `json:"deleted"`
}
decodeControllerBody(t, response, &body)
if !body.Deleted {
t.Error("deleted = false, want true")
}
list := doControllerRequest(router, http.MethodGet, "/api/v1/github/watches/issue?workspace_id=ws-1")
if strings.Contains(list.Body.String(), watch.ID) {
t.Errorf("watch %s still listed after delete: %s", watch.ID, list.Body)
}
})
}
func TestController_IssueWatch_MalformedPayloadIs400(t *testing.T) {
router, _ := setupControllerStoreTest(t)
response := doControllerJSON(t, router, http.MethodPost, "/api/v1/github/watches/issue", "{nope")
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
var body struct {
Error string `json:"error"`
}
decodeControllerBody(t, response, &body)
if body.Error != "invalid payload" {
t.Errorf("error = %q", body.Error)
}
}
func TestController_CreateReviewWatch(t *testing.T) {
router, _ := setupControllerStoreTest(t)
response := doControllerJSON(t, router, http.MethodPost, "/api/v1/github/watches/review",
CreateReviewWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
WorkflowStepID: "step-1",
Repos: []RepoFilter{{Owner: "acme", Name: "widget"}},
})
if response.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", response.Code, response.Body)
}
var watch ReviewWatch
decodeControllerBody(t, response, &watch)
if watch.ID == "" {
t.Fatal("created watch has no ID")
}
if watch.WorkspaceID != "ws-1" || watch.WorkflowID != "wf-1" {
t.Errorf("created watch = %#v", watch)
}
if len(watch.Repos) != 1 || watch.Repos[0].Owner != "acme" || watch.Repos[0].Name != "widget" {
t.Errorf("created repos = %#v", watch.Repos)
}
}
func TestController_CreateReviewWatch_MalformedPayloadIs400(t *testing.T) {
router, _ := setupControllerStoreTest(t)
response := doControllerJSON(t, router, http.MethodPost, "/api/v1/github/watches/review", "{nope")
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
}
// The trigger-all endpoints are workspace-scoped sweeps; without a workspace
// they would fan out across every workspace, so they refuse.
func TestController_TriggerAllChecks_RequireAWorkspace(t *testing.T) {
router, _ := setupControllerStoreTest(t)
for _, target := range []string{
"/api/v1/github/watches/review/trigger-all",
"/api/v1/github/watches/issue/trigger-all",
} {
t.Run(target, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, target, nil)
request.Header.Set("X-Test-Omit-Workspace", "1")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
var body struct {
Error string `json:"error"`
}
decodeControllerBody(t, response, &body)
if body.Error != "workspace_id query parameter required" {
t.Errorf("error = %q", body.Error)
}
})
}
}
func TestController_TriggerAllChecks_ReportNothingFoundOnAnEmptyWorkspace(t *testing.T) {
router, _ := setupControllerStoreTest(t)
cases := []struct {
target string
countAt string
}{
{"/api/v1/github/watches/review/trigger-all?workspace_id=ws-1", "new_prs_found"},
{"/api/v1/github/watches/issue/trigger-all?workspace_id=ws-1", "new_issues_found"},
}
for _, tc := range cases {
t.Run(tc.countAt, func(t *testing.T) {
response := doControllerJSON(t, router, http.MethodPost, tc.target, nil)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body)
}
var body map[string]int
decodeControllerBody(t, response, &body)
count, ok := body[tc.countAt]
if !ok {
t.Fatalf("body = %s, want a %q field", response.Body, tc.countAt)
}
if count != 0 {
t.Errorf("%s = %d, want 0 with no watches configured", tc.countAt, count)
}
})
}
}
func TestController_CleanupEndpoints(t *testing.T) {
router, _ := setupControllerStoreTest(t)
for _, target := range []string{
"/api/v1/github/cleanup/review-tasks?workspace_id=ws-1",
"/api/v1/github/cleanup/issue-tasks?workspace_id=ws-1",
// No workspace_id takes the cleanup-everything branch.
"/api/v1/github/cleanup/review-tasks",
"/api/v1/github/cleanup/issue-tasks",
} {
t.Run(target, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, target, nil)
request.Header.Set("X-Test-Omit-Workspace", "1")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body)
}
var body struct {
Deleted int `json:"deleted"`
}
decodeControllerBody(t, response, &body)
if body.Deleted != 0 {
t.Errorf("deleted = %d, want 0 with nothing to clean up", body.Deleted)
}
})
}
}
func TestController_WorkspaceSettings_UpdateAndRead(t *testing.T) {
router, _ := setupControllerStoreTest(t)
mode := RepoScopeModeOrgs
orgs := []string{"acme"}
updated := doControllerJSON(t, router, http.MethodPut, "/api/v1/github/workspace-settings",
UpdateWorkspaceSettingsRequest{WorkspaceID: "ws-1", RepoScopeMode: &mode, RepoScopeOrgs: &orgs})
if updated.Code != http.StatusOK {
t.Fatalf("update status = %d, want 200; body = %s", updated.Code, updated.Body)
}
var settings WorkspaceSettings
decodeControllerBody(t, updated, &settings)
if settings.RepoScopeMode != RepoScopeModeOrgs {
t.Errorf("repo_scope_mode = %q, want orgs", settings.RepoScopeMode)
}
if len(settings.RepoScopeOrgs) != 1 || settings.RepoScopeOrgs[0] != "acme" {
t.Errorf("repo_scope_orgs = %#v", settings.RepoScopeOrgs)
}
readBack := doControllerRequest(
router, http.MethodGet, "/api/v1/github/workspace-settings?workspace_id=ws-1",
)
if readBack.Code != http.StatusOK {
t.Fatalf("read status = %d, want 200; body = %s", readBack.Code, readBack.Body)
}
var stored WorkspaceSettings
decodeControllerBody(t, readBack, &stored)
if stored.RepoScopeMode != RepoScopeModeOrgs || len(stored.RepoScopeOrgs) != 1 {
t.Fatalf("stored settings = %#v, want the update persisted", stored)
}
}
func TestController_WorkspaceSettings_RejectsAnInvalidScopeMode(t *testing.T) {
router, _ := setupControllerStoreTest(t)
mode := "not-a-mode"
response := doControllerJSON(t, router, http.MethodPut, "/api/v1/github/workspace-settings",
UpdateWorkspaceSettingsRequest{WorkspaceID: "ws-1", RepoScopeMode: &mode})
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
}
func TestController_WorkspaceSettings_MalformedPayloadIs400(t *testing.T) {
router, _ := setupControllerStoreTest(t)
response := doControllerJSON(t, router, http.MethodPut, "/api/v1/github/workspace-settings", "{nope")
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
var body struct {
Error string `json:"error"`
}
decodeControllerBody(t, response, &body)
if body.Error != "invalid payload" {
t.Errorf("error = %q", body.Error)
}
}
func TestController_CopyWorkspaceSettings_Validation(t *testing.T) {
router, _ := setupControllerStoreTest(t)
cases := []struct {
name string
target string
payload any
omitWS bool
wantErrMsg string
}{
{
"missing source workspace", "/api/v1/github/workspace-settings/copy",
map[string]string{"targetWorkspaceId": "ws-2"}, true,
"workspace_id query parameter required",
},
{
"malformed payload", "/api/v1/github/workspace-settings/copy?workspace_id=ws-1",
"{nope", false, "invalid payload",
},
{
"missing target", "/api/v1/github/workspace-settings/copy?workspace_id=ws-1",
map[string]string{}, false, "targetWorkspaceId required",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(
http.MethodPost, tc.target, bytes.NewReader(marshalPayload(t, tc.payload)),
)
request.Header.Set("Content-Type", "application/json")
if tc.omitWS {
request.Header.Set("X-Test-Omit-Workspace", "1")
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
var body struct {
Error string `json:"error"`
}
decodeControllerBody(t, response, &body)
if body.Error != tc.wantErrMsg {
t.Errorf("error = %q, want %q", body.Error, tc.wantErrMsg)
}
})
}
}
// Copying a workspace onto itself is rejected rather than silently no-oping.
func TestController_CopyWorkspaceSettings_RejectsTheSameWorkspace(t *testing.T) {
router, _ := setupControllerStoreTest(t)
response := doControllerJSON(t, router, http.MethodPost,
"/api/v1/github/workspace-settings/copy?workspace_id=ws-1",
map[string]string{"targetWorkspaceId": "ws-1"})
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
}
func TestController_ConfigureToken_Validation(t *testing.T) {
router, _ := setupControllerStoreTest(t)
t.Run("missing workspace", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/github/token",
strings.NewReader(`{"token":"ghp_x"}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Test-Omit-Workspace", "1")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code == http.StatusOK {
t.Fatalf("status = %d, want a failure; body = %s", response.Code, response.Body)
}
})
t.Run("malformed payload", func(t *testing.T) {
response := doControllerJSON(t, router, http.MethodPost,
"/api/v1/github/token?workspace_id=ws-1", "{nope")
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", response.Code, response.Body)
}
var body struct {
Error string `json:"error"`
}
decodeControllerBody(t, response, &body)
if body.Error != "invalid payload: token is required" {
t.Errorf("error = %q", body.Error)
}
})
}
// marshalPayload encodes a test payload, passing strings through verbatim so a
// case can send deliberately malformed JSON.
func marshalPayload(t *testing.T, payload any) []byte {
t.Helper()
switch typed := payload.(type) {
case nil:
return nil
case string:
return []byte(typed)
default:
encoded, err := json.Marshal(typed)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
return encoded
}
}
// seedControllerTaskPR persists one task→PR association in the ws-1 workspace,
// along with the task row the workspace-scoped listing joins against.
func seedControllerTaskPR(t *testing.T, store *Store, taskID string, prNumber int) *TaskPR {
t.Helper()
if _, err := store.db.Exec(
`INSERT INTO tasks (id, workspace_id, title) VALUES (?, 'ws-1', ?)`, taskID, taskID,
); err != nil {
t.Fatalf("seed task row: %v", err)
}
tp := &TaskPR{
WorkspaceID: "ws-1",
TaskID: taskID,
Owner: "acme",
Repo: "widget",
PRNumber: prNumber,
PRURL: fmt.Sprintf("https://github.com/acme/widget/pull/%d", prNumber),
PRTitle: fmt.Sprintf("PR %d", prNumber),
HeadBranch: fmt.Sprintf("feature-%d", prNumber),
BaseBranch: "main",
AuthorLogin: "octocat",
State: "open",
}
if err := store.CreateTaskPR(context.Background(), tp); err != nil {
t.Fatalf("seed task PR: %v", err)
}
return tp
}