Files
kandev/apps/backend/internal/github/workspace_settings_test.go
T
2026-07-06 21:43:13 +01:00

493 lines
17 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
)
func TestStore_GitHubWorkspaceSettingsRoundTrip(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
input := &WorkspaceSettings{
WorkspaceID: "ws-1",
RepoScopeMode: RepoScopeModeRepos,
RepoScopeOrgs: []string{"kdlbs"},
RepoScopeRepos: []RepoFilter{
{Owner: "kdlbs", Name: "kandev"},
},
SavedPresets: []byte(`[{"id":"p1","kind":"pr","label":"Mine"}]`),
DefaultQueryPresets: []byte(`{"pr":[],"issue":[]}`),
}
if err := store.UpsertWorkspaceSettings(ctx, input); err != nil {
t.Fatalf("upsert workspace settings: %v", err)
}
got, err := store.GetWorkspaceSettings(ctx, "ws-1")
if err != nil {
t.Fatalf("get workspace settings: %v", err)
}
if got.WorkspaceID != "ws-1" || got.RepoScopeMode != RepoScopeModeRepos {
t.Fatalf("unexpected settings identity/scope: %+v", got)
}
if len(got.RepoScopeRepos) != 1 || got.RepoScopeRepos[0].Owner != "kdlbs" || got.RepoScopeRepos[0].Name != "kandev" {
t.Fatalf("repo scope lost on round trip: %+v", got.RepoScopeRepos)
}
if string(got.SavedPresets) != string(input.SavedPresets) {
t.Fatalf("saved presets = %s, want %s", got.SavedPresets, input.SavedPresets)
}
if string(got.DefaultQueryPresets) != string(input.DefaultQueryPresets) {
t.Fatalf("default query presets = %s, want %s", got.DefaultQueryPresets, input.DefaultQueryPresets)
}
}
func TestService_SearchUserPRsPagedForWorkspace_FiltersToSelectedRepos(t *testing.T) {
client := NewMockClient()
client.AddPR(&PR{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "in scope"})
client.AddPR(&PR{RepoOwner: "other", RepoName: "repo", Number: 2, Title: "out of scope"})
store := newTestStore(t)
svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
ctx := context.Background()
if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
RepoScopeMode: RepoScopeModeRepos,
RepoScopeRepos: []RepoFilter{{Owner: "kdlbs", Name: "kandev"}},
}); err != nil {
t.Fatalf("save workspace settings: %v", err)
}
page, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "", "repo:other/repo is:open", 1, 25)
if err != nil {
t.Fatalf("search scoped prs: %v", err)
}
if page.TotalCount != 2 || len(page.PRs) != 1 {
t.Fatalf("expected one scoped PR, got total=%d prs=%+v", page.TotalCount, page.PRs)
}
if page.PRs[0].RepoOwner != "kdlbs" || page.PRs[0].RepoName != "kandev" {
t.Fatalf("custom query escaped workspace scope: %+v", page.PRs[0])
}
}
func TestService_SearchUserPRsPagedForWorkspace_AppendsScopeToQuery(t *testing.T) {
client := &capturingSearchClient{MockClient: NewMockClient()}
client.AddPR(&PR{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "in scope"})
store := newTestStore(t)
svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
ctx := context.Background()
if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
RepoScopeMode: RepoScopeModeRepos,
RepoScopeRepos: []RepoFilter{
{Owner: "kdlbs", Name: "kandev"},
{Owner: "kdlbs", Name: "docs"},
},
}); err != nil {
t.Fatalf("save workspace settings: %v", err)
}
page, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "", "is:open", 1, 25)
if err != nil {
t.Fatalf("search scoped prs: %v", err)
}
if page.TotalCount != 1 || len(page.PRs) != 1 {
t.Fatalf("expected one scoped PR, got total=%d prs=%+v", page.TotalCount, page.PRs)
}
if !strings.Contains(client.customQuery, "is:open") ||
!strings.Contains(client.customQuery, "(repo:kdlbs/kandev OR repo:kdlbs/docs)") {
t.Fatalf("custom query was not workspace scoped: %q", client.customQuery)
}
if _, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "review-requested:@me", "", 1, 25); err != nil {
t.Fatalf("search scoped prs with preset filter: %v", err)
}
if !strings.Contains(client.filter, "review-requested:@me") ||
!strings.Contains(client.filter, "(repo:kdlbs/kandev OR repo:kdlbs/docs)") {
t.Fatalf("filter query was not workspace scoped: %q", client.filter)
}
}
func TestService_SearchUserPRsPagedForWorkspace_OrgScopeIncludesPersonalOwner(t *testing.T) {
client := &capturingSearchClient{MockClient: NewMockClient()}
client.AddPR(&PR{RepoOwner: "octo", RepoName: "personal", Number: 1, Title: "in scope"})
store := newTestStore(t)
svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
ctx := context.Background()
if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
RepoScopeMode: RepoScopeModeOrgs,
RepoScopeOrgs: []string{"octo"},
}); err != nil {
t.Fatalf("save workspace settings: %v", err)
}
if _, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "", "is:open", 1, 25); err != nil {
t.Fatalf("search scoped prs: %v", err)
}
if !strings.Contains(client.customQuery, "(org:octo OR user:octo)") {
t.Fatalf("org scope did not include personal-owner qualifier: %q", client.customQuery)
}
}
func TestService_SearchUserPRsPagedForWorkspace_EmptyRepoScopeFailsClosed(t *testing.T) {
client := NewMockClient()
client.AddPR(&PR{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "hidden"})
store := newTestStore(t)
svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
ctx := context.Background()
if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
RepoScopeMode: RepoScopeModeRepos,
RepoScopeRepos: []RepoFilter{},
}); err != nil {
t.Fatalf("save workspace settings: %v", err)
}
page, err := svc.SearchUserPRsPagedForWorkspace(ctx, "ws-1", "", "is:open", 1, 25)
if err != nil {
t.Fatalf("search scoped prs: %v", err)
}
if page.TotalCount != 0 || len(page.PRs) != 0 {
t.Fatalf("empty repo scope should return no PRs, got total=%d prs=%+v", page.TotalCount, page.PRs)
}
}
func TestService_UpdateWorkspaceSettings_PartialUpdateAndNullClear(t *testing.T) {
store := newTestStore(t)
svc := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t))
ctx := context.Background()
defaults := json.RawMessage(`{"pr":[{"value":"mine","label":"Mine","filter":"is:open","group":"inbox"}],"issue":[]}`)
saved := json.RawMessage(`[{"id":"p1","kind":"pr","label":"Mine"}]`)
mode := RepoScopeModeRepos
repos := []RepoFilter{{Owner: "kdlbs", Name: "kandev"}}
if _, err := svc.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{
WorkspaceID: "ws-1",
RepoScopeMode: &mode,
RepoScopeRepos: &repos,
SavedPresets: &saved,
SavedPresetsSet: true,
DefaultQueryPresets: &defaults,
DefaultQueriesSet: true,
}); err != nil {
t.Fatalf("initial update: %v", err)
}
nextSaved := json.RawMessage(`[{"id":"p2","kind":"issue","label":"Issues"}]`)
got, err := svc.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{
WorkspaceID: "ws-1",
SavedPresets: &nextSaved,
SavedPresetsSet: true,
})
if err != nil {
t.Fatalf("partial saved presets update: %v", err)
}
if string(got.SavedPresets) != string(nextSaved) {
t.Fatalf("saved presets = %s, want %s", got.SavedPresets, nextSaved)
}
if string(got.DefaultQueryPresets) != string(defaults) {
t.Fatalf("default presets should be preserved, got %s", got.DefaultQueryPresets)
}
got, err = svc.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{
WorkspaceID: "ws-1",
DefaultQueriesSet: true,
})
if err != nil {
t.Fatalf("clear default query presets: %v", err)
}
if got.DefaultQueryPresets != nil {
t.Fatalf("default presets should be cleared, got %s", got.DefaultQueryPresets)
}
if string(got.SavedPresets) != string(nextSaved) {
t.Fatalf("saved presets should be preserved after clear, got %s", got.SavedPresets)
}
got, err = svc.UpdateWorkspaceSettings(ctx, &UpdateWorkspaceSettingsRequest{
WorkspaceID: "ws-1",
SavedPresetsSet: true,
})
if err != nil {
t.Fatalf("clear saved presets: %v", err)
}
if string(got.SavedPresets) != "[]" {
t.Fatalf("saved presets should be cleared to empty array, got %s", got.SavedPresets)
}
}
func TestUpdateWorkspaceSettingsRequest_UnmarshalTracksExplicitNull(t *testing.T) {
var req UpdateWorkspaceSettingsRequest
if err := json.Unmarshal([]byte(`{"workspace_id":"ws-1","default_query_presets":null}`), &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if !req.DefaultQueriesSet {
t.Fatal("expected default query presets to be marked present")
}
if req.DefaultQueryPresets != nil {
t.Fatalf("expected explicit null to leave raw pointer nil, got %s", *req.DefaultQueryPresets)
}
}
func TestUpdateWorkspaceSettingsRequest_UnmarshalTracksSavedPresetsNull(t *testing.T) {
var req UpdateWorkspaceSettingsRequest
if err := json.Unmarshal([]byte(`{"workspace_id":"ws-1","saved_presets":null}`), &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if !req.SavedPresetsSet {
t.Fatal("expected saved presets to be marked present")
}
if req.SavedPresets != nil {
t.Fatalf("expected explicit null to leave raw pointer nil, got %s", *req.SavedPresets)
}
}
func TestService_UpdateWorkspaceSettings_RejectsUnknownRepoScopeMode(t *testing.T) {
store := newTestStore(t)
svc := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t))
mode := "everything"
_, err := svc.UpdateWorkspaceSettings(context.Background(), &UpdateWorkspaceSettingsRequest{
WorkspaceID: "ws-1",
RepoScopeMode: &mode,
})
if !errors.Is(err, ErrWorkspaceSettingsValidation) {
t.Fatalf("expected validation error, got %v", err)
}
}
func TestService_UpdateWorkspaceSettings_RejectsConflictingScopePatch(t *testing.T) {
store := newTestStore(t)
svc := NewService(NewMockClient(), AuthMethodPAT, nil, store, nil, testLogger(t))
mode := RepoScopeModeAll
orgs := []string{"octo"}
_, err := svc.UpdateWorkspaceSettings(context.Background(), &UpdateWorkspaceSettingsRequest{
WorkspaceID: "ws-1",
RepoScopeMode: &mode,
RepoScopeOrgs: &orgs,
})
if !errors.Is(err, ErrWorkspaceSettingsValidation) {
t.Fatalf("expected validation error, got %v", err)
}
}
func TestService_SearchUserIssuesPagedForWorkspace_AllScopePreservesResults(t *testing.T) {
client := &issueSearchClient{
issues: []*Issue{
{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "one"},
{RepoOwner: "other", RepoName: "repo", Number: 2, Title: "two"},
},
}
store := newTestStore(t)
svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
page, err := svc.SearchUserIssuesPagedForWorkspace(context.Background(), "ws-1", "", "is:open", 1, 25)
if err != nil {
t.Fatalf("search scoped issues: %v", err)
}
if page.TotalCount != 2 || len(page.Issues) != 2 {
t.Fatalf("all scope should preserve results, got total=%d issues=%+v", page.TotalCount, page.Issues)
}
}
func TestService_CheckReviewWatch_AppliesWorkspaceRepoScope(t *testing.T) {
client := NewMockClient()
client.AddPR(&PR{
RepoOwner: "kdlbs",
RepoName: "kandev",
Number: 1,
Title: "in scope",
RequestedReviewers: []RequestedReviewer{{Login: "octo", Type: "user"}},
})
client.AddPR(&PR{
RepoOwner: "other",
RepoName: "repo",
Number: 2,
Title: "out of scope",
RequestedReviewers: []RequestedReviewer{{Login: "octo", Type: "user"}},
})
store := newTestStore(t)
svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
ctx := context.Background()
if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
RepoScopeMode: RepoScopeModeRepos,
RepoScopeRepos: []RepoFilter{{Owner: "kdlbs", Name: "kandev"}},
}); err != nil {
t.Fatalf("save workspace settings: %v", err)
}
prs, err := svc.CheckReviewWatch(ctx, &ReviewWatch{
ID: "watch-1",
WorkspaceID: "ws-1",
Repos: nil,
ReviewScope: ReviewScopeUserAndTeams,
Enabled: true,
PollIntervalSeconds: 300,
})
if err != nil {
t.Fatalf("check review watch: %v", err)
}
if len(prs) != 1 || prs[0].RepoOwner != "kdlbs" || prs[0].RepoName != "kandev" {
t.Fatalf("expected one in-scope PR, got %+v", prs)
}
}
func TestService_CheckReviewWatch_EmptyWorkspaceScopeSkipsProviderFetch(t *testing.T) {
client := &countingReviewClient{MockClient: NewMockClient()}
client.AddPR(&PR{
RepoOwner: "kdlbs",
RepoName: "kandev",
Number: 1,
Title: "hidden",
RequestedReviewers: []RequestedReviewer{{Login: "octo", Type: "user"}},
})
store := newTestStore(t)
svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
ctx := context.Background()
if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
RepoScopeMode: RepoScopeModeRepos,
RepoScopeRepos: []RepoFilter{},
}); err != nil {
t.Fatalf("save workspace settings: %v", err)
}
prs, err := svc.CheckReviewWatch(ctx, &ReviewWatch{
ID: "watch-1",
WorkspaceID: "ws-1",
Repos: nil,
ReviewScope: ReviewScopeUserAndTeams,
Enabled: true,
PollIntervalSeconds: 300,
})
if err != nil {
t.Fatalf("check review watch: %v", err)
}
if len(prs) != 0 {
t.Fatalf("empty workspace scope should return no PRs, got %+v", prs)
}
if client.listReviewRequestedCalls != 0 {
t.Fatalf("expected no broad provider fetch, got %d calls", client.listReviewRequestedCalls)
}
}
func TestService_CheckIssueWatch_AppliesWorkspaceRepoScope(t *testing.T) {
client := &issueSearchClient{
issues: []*Issue{
{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "in scope"},
{RepoOwner: "other", RepoName: "repo", Number: 2, Title: "out of scope"},
},
}
store := newTestStore(t)
svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
ctx := context.Background()
if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
RepoScopeMode: RepoScopeModeOrgs,
RepoScopeOrgs: []string{"kdlbs"},
}); err != nil {
t.Fatalf("save workspace settings: %v", err)
}
issues, err := svc.CheckIssueWatch(ctx, &IssueWatch{
ID: "watch-1",
WorkspaceID: "ws-1",
Repos: nil,
Enabled: true,
PollIntervalSeconds: 300,
})
if err != nil {
t.Fatalf("check issue watch: %v", err)
}
if len(issues) != 1 || issues[0].RepoOwner != "kdlbs" || issues[0].RepoName != "kandev" {
t.Fatalf("expected one in-scope issue, got %+v", issues)
}
}
func TestService_CheckIssueWatch_EmptyWorkspaceScopeSkipsProviderFetch(t *testing.T) {
client := &countingWorkspaceIssueClient{MockClient: NewMockClient()}
store := newTestStore(t)
svc := NewService(client, AuthMethodPAT, nil, store, nil, testLogger(t))
ctx := context.Background()
if err := svc.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
RepoScopeMode: RepoScopeModeOrgs,
RepoScopeOrgs: []string{},
}); err != nil {
t.Fatalf("save workspace settings: %v", err)
}
issues, err := svc.CheckIssueWatch(ctx, &IssueWatch{
ID: "watch-1",
WorkspaceID: "ws-1",
Repos: nil,
Enabled: true,
PollIntervalSeconds: 300,
})
if err != nil {
t.Fatalf("check issue watch: %v", err)
}
if len(issues) != 0 {
t.Fatalf("empty workspace scope should return no issues, got %+v", issues)
}
if client.listIssuesCalls != 0 {
t.Fatalf("expected no broad provider fetch, got %d calls", client.listIssuesCalls)
}
}
type issueSearchClient struct {
*MockClient
issues []*Issue
}
type countingReviewClient struct {
*MockClient
listReviewRequestedCalls int
}
func (c *countingReviewClient) ListReviewRequestedPRs(ctx context.Context, reviewScope, owner, repo string) ([]*PR, error) {
c.listReviewRequestedCalls++
return c.MockClient.ListReviewRequestedPRs(ctx, reviewScope, owner, repo)
}
type countingWorkspaceIssueClient struct {
*MockClient
listIssuesCalls int
}
func (c *countingWorkspaceIssueClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
c.listIssuesCalls++
return []*Issue{{RepoOwner: "kdlbs", RepoName: "kandev", Number: 1, Title: "hidden"}}, nil
}
type capturingSearchClient struct {
*MockClient
filter string
customQuery string
}
func (c *capturingSearchClient) SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) {
c.filter = filter
c.customQuery = customQuery
return c.MockClient.SearchPRsPaged(ctx, filter, customQuery, page, perPage)
}
func (c *issueSearchClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
return c.issues, nil
}
func (c *issueSearchClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) {
return &IssueSearchPage{Issues: c.issues, TotalCount: len(c.issues), Page: 1, PerPage: 25}, nil
}