Files
kandev/apps/backend/internal/gitlab/service_mentions_test.go
T
Henry ffec4aade9 feat(gitlab): filter issues by milestone (#3278)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-02 16:55:31 +00:00

390 lines
15 KiB
Go

package gitlab
import (
"context"
"errors"
"net/url"
"strconv"
"testing"
"github.com/kandev/kandev/internal/common/logger"
)
type mentionRecordingClient struct {
*MockClient
issues func(context.Context, string, string, int, int) (*IssueSearchPage, error)
mrs func(context.Context, string, string, int, int) (*MRSearchPage, error)
}
func (c *mentionRecordingClient) ListIssuesPaged(
ctx context.Context,
filter, customQuery, _ string,
page, perPage int,
) (*IssueSearchPage, error) {
return c.issues(ctx, filter, customQuery, page, perPage)
}
func (c *mentionRecordingClient) SearchMRsPaged(
ctx context.Context,
filter, customQuery string,
page, perPage int,
) (*MRSearchPage, error) {
return c.mrs(ctx, filter, customQuery, page, perPage)
}
func newMentionTestService(t *testing.T, host string, client Client) (*Service, *Store) {
t.Helper()
service := NewService(host, client, "mock", nil, logger.Default())
store := newTestStore(t)
service.SetStore(store)
return service, store
}
func assertLiteralMentionQuery(t *testing.T, filter, customQuery, wantSearch string, page, perPage int) {
t.Helper()
if filter != "" {
t.Fatalf("raw filter = %q, want empty", filter)
}
if page != 1 || perPage != 10 {
t.Fatalf("pagination = page %d perPage %d, want 1/10", page, perPage)
}
values, err := url.ParseQuery(customQuery)
if err != nil {
t.Fatalf("parse generated query: %v", err)
}
if values.Get("search") != wantSearch || values.Get("in") != "title" ||
values.Get("scope") != "all" || values.Get("state") != "opened" ||
values.Get("order_by") != "updated_at" || values.Get("sort") != "desc" {
t.Fatalf("generated query = %q (%v)", customQuery, values)
}
}
func TestServiceMentionSearch_BuildsLiteralQueryAndFiltersWorkspaceProjects(t *testing.T) {
const host = "https://gitlab.example.test/base"
const query = `auth&scope=created_by_me#fragment`
client := &mentionRecordingClient{MockClient: NewMockClient(host)}
client.issues = func(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
assertLiteralMentionQuery(t, filter, customQuery, query, page, perPage)
return &IssueSearchPage{Issues: []*Issue{
{ID: 1001, IID: 42, ProjectID: 101, ProjectPath: "group/api", Title: "Fix auth", WebURL: host + "/group/api/-/issues/42"},
{ID: 9001, IID: 9, ProjectID: 999, ProjectPath: "other/secret", Title: "Leak", WebURL: host + "/other/secret/-/issues/9"},
nil,
}}, nil
}
client.mrs = func(ctx context.Context, filter, customQuery string, page, perPage int) (*MRSearchPage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
assertLiteralMentionQuery(t, filter, customQuery, query, page, perPage)
return &MRSearchPage{MRs: []*MR{
{ID: 2001, IID: 7, ProjectID: 202, ProjectPath: "group/web", Title: "Auth MR", WebURL: host + "/group/web/-/merge_requests/7"},
{ID: 9002, IID: 8, ProjectID: 999, ProjectPath: "other/secret", Title: "Leak", WebURL: host + "/other/secret/-/merge_requests/8"},
nil,
}}, nil
}
service, _ := newMentionTestService(t, host, client)
if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host+"/", []MentionProjectScope{
{ID: 101, Path: "group/api"},
{ID: 202, Path: "group/web"},
}); err != nil {
t.Fatalf("configure mention scope: %v", err)
}
issues, err := service.SearchMentionIssuesForWorkspace(context.Background(), "workspace-1", query, 99)
if err != nil {
t.Fatalf("search issues: %v", err)
}
if len(issues) != 1 || issues[0].ID != 1001 || issues[0].IID != 42 ||
issues[0].ProjectID != 101 || issues[0].ProjectPath != "group/api" ||
issues[0].Host != host || issues[0].URL != host+"/group/api/-/issues/42" {
t.Fatalf("issues = %#v", issues)
}
mrs, err := service.SearchMentionMRsForWorkspace(context.Background(), "workspace-1", query, 99)
if err != nil {
t.Fatalf("search MRs: %v", err)
}
if len(mrs) != 1 || mrs[0].ID != 2001 || mrs[0].IID != 7 ||
mrs[0].ProjectID != 202 || mrs[0].ProjectPath != "group/web" ||
mrs[0].Host != host || mrs[0].URL != host+"/group/web/-/merge_requests/7" {
t.Fatalf("MRs = %#v", mrs)
}
}
func TestServiceMentionIssueSearch_ScansPastForeignFirstPage(t *testing.T) {
const host = "https://gitlab.example.test"
issueCalls := 0
client := &mentionRecordingClient{MockClient: NewMockClient(host)}
client.issues = func(_ context.Context, _, _ string, page, perPage int) (*IssueSearchPage, error) {
issueCalls++
if perPage != 10 {
t.Fatalf("perPage = %d, want 10", perPage)
}
switch page {
case 1:
foreign := make([]*Issue, 10)
for index := range foreign {
foreign[index] = &Issue{
ID: int64(9001 + index), IID: 9 + index, ProjectID: 999, ProjectPath: "other/secret",
Title: "Foreign auth", WebURL: host + "/other/secret/-/issues/" + strconv.Itoa(9+index),
}
}
return &IssueSearchPage{
Issues: foreign, TotalCount: 11, Page: 1, PerPage: 10,
}, nil
case 2:
return &IssueSearchPage{
Issues: []*Issue{{
ID: 1001, IID: 42, ProjectID: 101, ProjectPath: "group/api",
Title: "Allowed auth", WebURL: host + "/group/api/-/issues/42",
}},
TotalCount: 11, Page: 2, PerPage: 10,
}, nil
default:
t.Fatalf("unexpected page %d", page)
return nil, nil
}
}
service, _ := newMentionTestService(t, host, client)
if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{
{ID: 101, Path: "group/api"},
}); err != nil {
t.Fatalf("configure mention scope: %v", err)
}
issues, err := service.SearchMentionIssuesForWorkspace(context.Background(), "workspace-1", "auth", 1)
if err != nil {
t.Fatalf("search issues: %v", err)
}
if len(issues) != 1 || issues[0].ID != 1001 {
t.Fatalf("issues = %#v, want allowed second-page issue", issues)
}
if issueCalls != 2 {
t.Fatalf("issue calls = %d, want 2", issueCalls)
}
}
func TestServiceMentionIssueSearch_StopsPaginationWhenContextCanceled(t *testing.T) {
const host = "https://gitlab.example.test"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
issueCalls := 0
client := &mentionRecordingClient{MockClient: NewMockClient(host)}
client.issues = func(_ context.Context, _, _ string, page, perPage int) (*IssueSearchPage, error) {
issueCalls++
if page == 1 {
cancel()
}
foreign := make([]*Issue, perPage)
for index := range foreign {
foreign[index] = &Issue{
ID: int64(9001 + index), IID: 9 + index, ProjectID: 999, ProjectPath: "other/secret",
Title: "Foreign auth", WebURL: host + "/other/secret/-/issues/" + strconv.Itoa(9+index),
}
}
return &IssueSearchPage{
Issues: foreign, TotalCount: 1000, Page: page, PerPage: perPage,
}, nil
}
service, _ := newMentionTestService(t, host, client)
if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{
{ID: 101, Path: "group/api"},
}); err != nil {
t.Fatalf("configure mention scope: %v", err)
}
_, err := service.SearchMentionIssuesForWorkspace(ctx, "workspace-1", "auth", 1)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context canceled", err)
}
if issueCalls != 1 {
t.Fatalf("issue calls = %d, want 1", issueCalls)
}
}
func TestServiceMentionMRSearch_ScansPastForeignFirstPage(t *testing.T) {
const host = "https://gitlab.example.test"
mrCalls := 0
client := &mentionRecordingClient{MockClient: NewMockClient(host)}
client.mrs = func(_ context.Context, _, _ string, page, perPage int) (*MRSearchPage, error) {
mrCalls++
if perPage != 10 {
t.Fatalf("perPage = %d, want 10", perPage)
}
switch page {
case 1:
foreign := make([]*MR, 10)
for index := range foreign {
foreign[index] = &MR{
ID: int64(9001 + index), IID: 9 + index, ProjectID: 999, ProjectPath: "other/secret",
Title: "Foreign auth", WebURL: host + "/other/secret/-/merge_requests/" + strconv.Itoa(9+index),
}
}
return &MRSearchPage{
MRs: foreign, TotalCount: 11, Page: 1, PerPage: 10,
}, nil
case 2:
return &MRSearchPage{
MRs: []*MR{{
ID: 2001, IID: 42, ProjectID: 101, ProjectPath: "group/api",
Title: "Allowed auth", WebURL: host + "/group/api/-/merge_requests/42",
}},
TotalCount: 11, Page: 2, PerPage: 10,
}, nil
default:
t.Fatalf("unexpected page %d", page)
return nil, nil
}
}
service, _ := newMentionTestService(t, host, client)
if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{
{ID: 101, Path: "group/api"},
}); err != nil {
t.Fatalf("configure mention scope: %v", err)
}
mrs, err := service.SearchMentionMRsForWorkspace(context.Background(), "workspace-1", "auth", 1)
if err != nil {
t.Fatalf("search MRs: %v", err)
}
if len(mrs) != 1 || mrs[0].ID != 2001 {
t.Fatalf("MRs = %#v, want allowed second-page MR", mrs)
}
if mrCalls != 2 {
t.Fatalf("MR calls = %d, want 2", mrCalls)
}
}
func TestServiceMentionMRSearch_StopsPaginationWhenContextCanceled(t *testing.T) {
const host = "https://gitlab.example.test"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mrCalls := 0
client := &mentionRecordingClient{MockClient: NewMockClient(host)}
client.mrs = func(_ context.Context, _, _ string, page, perPage int) (*MRSearchPage, error) {
mrCalls++
if page == 1 {
cancel()
}
foreign := make([]*MR, perPage)
for index := range foreign {
foreign[index] = &MR{
ID: int64(9001 + index), IID: 9 + index, ProjectID: 999, ProjectPath: "other/secret",
Title: "Foreign auth", WebURL: host + "/other/secret/-/merge_requests/" + strconv.Itoa(9+index),
}
}
return &MRSearchPage{
MRs: foreign, TotalCount: 1000, Page: page, PerPage: perPage,
}, nil
}
service, _ := newMentionTestService(t, host, client)
if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{
{ID: 101, Path: "group/api"},
}); err != nil {
t.Fatalf("configure mention scope: %v", err)
}
_, err := service.SearchMentionMRsForWorkspace(ctx, "workspace-1", "auth", 1)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context canceled", err)
}
if mrCalls != 1 {
t.Fatalf("MR calls = %d, want 1", mrCalls)
}
}
func TestServiceMentionSearch_RequiresExplicitMatchingWorkspaceBinding(t *testing.T) {
called := false
host := "https://gitlab.example.test"
client := &mentionRecordingClient{MockClient: NewMockClient(host)}
client.issues = func(context.Context, string, string, int, int) (*IssueSearchPage, error) {
called = true
return &IssueSearchPage{}, nil
}
client.mrs = func(context.Context, string, string, int, int) (*MRSearchPage, error) {
called = true
return &MRSearchPage{}, nil
}
service, store := newMentionTestService(t, host, client)
if _, err := service.SearchMentionIssuesForWorkspace(context.Background(), "", "auth", 5); !errors.Is(err, ErrMentionWorkspaceRequired) {
t.Fatalf("blank workspace error = %v", err)
}
if _, err := service.SearchMentionMRsForWorkspace(context.Background(), "workspace-1", "auth", 5); !errors.Is(err, ErrMentionUnsupportedScope) {
t.Fatalf("unbound workspace error = %v", err)
}
if err := store.UpsertMentionScope(context.Background(), &MentionScope{
WorkspaceID: "workspace-1",
Host: "https://other-gitlab.example.test",
Projects: []MentionProjectScope{{ID: 101, Path: "group/api"}},
}); err != nil {
t.Fatalf("seed mismatched scope: %v", err)
}
if _, err := service.SearchMentionIssuesForWorkspace(context.Background(), "workspace-1", "auth", 5); !errors.Is(err, ErrMentionUnsupportedScope) {
t.Fatalf("host-mismatched workspace error = %v", err)
}
if called {
t.Fatal("global GitLab client searched without exact workspace binding")
}
}
func TestServiceConfigureMentionScope_RejectsUnsafeOrEmptyScope(t *testing.T) {
host := "https://gitlab.example.test"
client := &mentionRecordingClient{MockClient: NewMockClient(host)}
client.issues = func(context.Context, string, string, int, int) (*IssueSearchPage, error) {
return &IssueSearchPage{}, nil
}
client.mrs = func(context.Context, string, string, int, int) (*MRSearchPage, error) { return &MRSearchPage{}, nil }
service, _ := newMentionTestService(t, host, client)
tests := []struct {
name string
workspace string
host string
projects []MentionProjectScope
wantTarget error
}{
{name: "blank workspace", host: host, projects: []MentionProjectScope{{ID: 1, Path: "group/api"}}, wantTarget: ErrMentionWorkspaceRequired},
{name: "global default forbidden", workspace: "workspace-1", host: "", projects: []MentionProjectScope{{ID: 1, Path: "group/api"}}, wantTarget: ErrMentionUnsupportedScope},
{name: "foreign host", workspace: "workspace-1", host: "https://other.example.test", projects: []MentionProjectScope{{ID: 1, Path: "group/api"}}, wantTarget: ErrMentionUnsupportedScope},
{name: "userinfo", workspace: "workspace-1", host: "https://user:secret@gitlab.example.test", projects: []MentionProjectScope{{ID: 1, Path: "group/api"}}, wantTarget: ErrMentionInvalidScope},
{name: "empty projects", workspace: "workspace-1", host: host, wantTarget: ErrMentionInvalidScope},
{name: "invalid project", workspace: "workspace-1", host: host, projects: []MentionProjectScope{{ID: 0, Path: "../secret"}}, wantTarget: ErrMentionInvalidScope},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := service.ConfigureMentionScopeForWorkspace(
context.Background(), test.workspace, test.host, test.projects,
)
if !errors.Is(err, test.wantTarget) {
t.Fatalf("error = %v, want %v", err, test.wantTarget)
}
})
}
}
func TestServiceMentionSearch_PropagatesCancellation(t *testing.T) {
host := "https://gitlab.example.test"
client := &mentionRecordingClient{MockClient: NewMockClient(host)}
client.issues = func(ctx context.Context, _ string, _ string, _ int, _ int) (*IssueSearchPage, error) {
return nil, ctx.Err()
}
client.mrs = func(ctx context.Context, _ string, _ string, _ int, _ int) (*MRSearchPage, error) {
return nil, ctx.Err()
}
service, _ := newMentionTestService(t, host, client)
if err := service.ConfigureMentionScopeForWorkspace(context.Background(), "workspace-1", host, []MentionProjectScope{{ID: 101, Path: "group/api"}}); err != nil {
t.Fatalf("configure scope: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := service.SearchMentionIssuesForWorkspace(ctx, "workspace-1", "auth", 5); !errors.Is(err, context.Canceled) {
t.Fatalf("issue cancellation = %v", err)
}
if _, err := service.SearchMentionMRsForWorkspace(ctx, "workspace-1", "auth", 5); !errors.Is(err, context.Canceled) {
t.Fatalf("MR cancellation = %v", err)
}
}