Files
kandev/apps/backend/internal/github/service_task_issue_test.go
T
2026-06-23 17:28:52 +01:00

288 lines
9.7 KiB
Go

package github
import (
"context"
"errors"
"testing"
taskmodels "github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type fakeTaskIssueStore struct {
task *taskmodels.Task
repos []*taskmodels.TaskRepository
entities map[string]*taskmodels.Repository
repositoryErrs map[string]error
failOnCanceledUpdate bool
// taskErr takes priority over the task-not-set fallback. Set it to an
// error wrapping ErrTaskNotFound when a test needs the controller's 404 path.
taskErr error
updateErr error
updated map[string]interface{}
}
type countingIssueClient struct {
*MockClient
getIssueCalls int
}
func (c *countingIssueClient) GetIssue(ctx context.Context, owner, repo string, number int) (*Issue, error) {
c.getIssueCalls++
return c.MockClient.GetIssue(ctx, owner, repo, number)
}
func (f *fakeTaskIssueStore) GetTask(_ context.Context, taskID string) (*taskmodels.Task, error) {
if f.taskErr != nil {
return nil, f.taskErr
}
if f.task == nil || f.task.ID != taskID {
return nil, errors.New("task not found")
}
return f.task, nil
}
func (f *fakeTaskIssueStore) ListTaskRepositories(_ context.Context, taskID string) ([]*taskmodels.TaskRepository, error) {
if f.taskErr != nil {
return nil, f.taskErr
}
if f.task == nil || f.task.ID != taskID {
return nil, errors.New("task not found")
}
return f.repos, nil
}
func (f *fakeTaskIssueStore) GetRepository(_ context.Context, repositoryID string) (*taskmodels.Repository, error) {
if err := f.repositoryErrs[repositoryID]; err != nil {
return nil, err
}
if repo := f.entities[repositoryID]; repo != nil {
return repo, nil
}
return nil, errors.New("repository not found")
}
func (f *fakeTaskIssueStore) UpdateTaskMetadata(ctx context.Context, taskID string, metadata map[string]interface{}) (*taskmodels.Task, error) {
if f.taskErr != nil {
return nil, f.taskErr
}
if f.task == nil || f.task.ID != taskID {
return nil, errors.New("task not found")
}
if f.failOnCanceledUpdate && ctx.Err() != nil {
return nil, ctx.Err()
}
if f.updateErr != nil {
return nil, f.updateErr
}
f.updated = metadata
f.task.Metadata = metadata
return f.task, nil
}
func TestLinkTaskIssue_MergesIssueMetadataAndPreservesState(t *testing.T) {
client := NewMockClient()
client.AddIssue(&Issue{
Number: 1470,
Title: "Link existing task",
HTMLURL: "https://github.com/kdlbs/kandev/issues/1470",
RepoOwner: "kdlbs",
RepoName: "kandev",
State: "open",
})
store := &fakeTaskIssueStore{
failOnCanceledUpdate: true,
task: &taskmodels.Task{
ID: "task-1",
State: v1.TaskStateInProgress,
Metadata: map[string]interface{}{"keep": "me"},
},
repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}},
entities: map[string]*taskmodels.Repository{
"repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"},
},
}
svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
ctx, cancel := context.WithCancel(context.Background())
cancel()
resp, err := svc.LinkTaskIssue(ctx, "task-1", LinkTaskIssueRequest{
Issue: "https://github.com/kdlbs/kandev/issues/1470",
})
if err != nil {
t.Fatalf("LinkTaskIssue: %v", err)
}
if resp.IssueNumber != 1470 || resp.IssueURL == "" {
t.Fatalf("unexpected response: %+v", resp)
}
if store.task.State != v1.TaskStateInProgress {
t.Fatalf("state changed: %s", store.task.State)
}
if store.updated["keep"] != "me" {
t.Fatalf("existing metadata was not preserved: %+v", store.updated)
}
if store.updated[taskMetaIssueURL] != "https://github.com/kdlbs/kandev/issues/1470" {
t.Fatalf("issue url not written: %+v", store.updated)
}
if store.updated[taskMetaIssueNumber] != 1470 {
t.Fatalf("issue number not written: %+v", store.updated)
}
}
func TestLinkTaskIssue_PropagatesRepositoryLookupError(t *testing.T) {
client := NewMockClient()
client.AddIssue(&Issue{
Number: 1470,
Title: "Link existing task",
HTMLURL: "https://github.com/kdlbs/kandev/issues/1470",
RepoOwner: "kdlbs",
RepoName: "kandev",
})
repoErr := errors.New("repository store unavailable")
store := &fakeTaskIssueStore{
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}},
repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}},
repositoryErrs: map[string]error{"repo-1": repoErr},
}
svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
_, err := svc.LinkTaskIssue(context.Background(), "task-1", LinkTaskIssueRequest{
Issue: "https://github.com/kdlbs/kandev/issues/1470",
})
if !errors.Is(err, repoErr) {
t.Fatalf("err = %v, want repository lookup error", err)
}
if errors.Is(err, ErrIssueRepositoryMismatch) {
t.Fatalf("repository lookup error should not be masked as mismatch: %v", err)
}
if store.updated != nil {
t.Fatalf("metadata should not be updated: %+v", store.updated)
}
}
func TestLinkTaskIssue_ChecksTaskBeforeFetchingIssue(t *testing.T) {
client := &countingIssueClient{MockClient: NewMockClient()}
taskErr := errors.New("task lookup failed")
store := &fakeTaskIssueStore{taskErr: taskErr}
svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
_, err := svc.LinkTaskIssue(context.Background(), "missing-task", LinkTaskIssueRequest{
Issue: "https://github.com/kdlbs/kandev/issues/1470",
})
if !errors.Is(err, taskErr) {
t.Fatalf("expected task lookup error before GitHub fetch, got %v", err)
}
if client.getIssueCalls != 0 {
t.Fatalf("GetIssue called %d times before task lookup succeeded", client.getIssueCalls)
}
}
func TestLinkTaskIssue_RejectsIssueFromDifferentTaskRepository(t *testing.T) {
client := NewMockClient()
client.AddIssue(&Issue{
Number: 1,
Title: "Wrong repo",
HTMLURL: "https://github.com/other/repo/issues/1",
RepoOwner: "other",
RepoName: "repo",
})
store := &fakeTaskIssueStore{
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{}},
repos: []*taskmodels.TaskRepository{{RepositoryID: "repo-1"}},
entities: map[string]*taskmodels.Repository{
"repo-1": {ID: "repo-1", Provider: "github", ProviderOwner: "kdlbs", ProviderName: "kandev"},
},
}
svc := NewService(client, AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
_, err := svc.LinkTaskIssue(context.Background(), "task-1", LinkTaskIssueRequest{
Issue: "https://github.com/other/repo/issues/1",
})
if !errors.Is(err, ErrIssueRepositoryMismatch) {
t.Fatalf("err = %v, want ErrIssueRepositoryMismatch", err)
}
if store.updated != nil {
t.Fatalf("metadata should not be updated: %+v", store.updated)
}
}
func TestUnlinkTaskIssue_RemovesIssueMetadataOnly(t *testing.T) {
store := &fakeTaskIssueStore{
failOnCanceledUpdate: true,
task: &taskmodels.Task{ID: "task-1", Metadata: map[string]interface{}{
taskMetaIssueURL: "https://github.com/kdlbs/kandev/issues/1470",
taskMetaIssueNumber: 1470,
taskMetaIssueOwner: "kdlbs",
taskMetaIssueRepo: "kandev",
taskMetaIssueLinked: true,
"issue_watch_id": "watch-1",
"keep": "me",
}},
}
svc := NewService(NewMockClient(), AuthMethodPAT, nil, nil, nil, testLogger(t))
svc.SetTaskIssueStore(store)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := svc.UnlinkTaskIssue(ctx, "task-1"); err != nil {
t.Fatalf("UnlinkTaskIssue: %v", err)
}
if _, ok := store.updated[taskMetaIssueURL]; ok {
t.Fatalf("issue url should be removed: %+v", store.updated)
}
if store.updated["issue_watch_id"] != "watch-1" {
t.Fatalf("watch metadata should be preserved: %+v", store.updated)
}
if store.updated["keep"] != "me" {
t.Fatalf("unrelated metadata should be preserved: %+v", store.updated)
}
}
func TestParseIssueReference(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
defaultOwner string
defaultRepo string
wantOwner string
wantRepo string
wantNumber int
wantErr bool
}{
{name: "empty", input: " ", wantErr: true},
{name: "hash number with default repo", input: "#1470", defaultOwner: "kdlbs", defaultRepo: "kandev", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470},
{name: "bare number with default repo", input: "1470", defaultOwner: "kdlbs", defaultRepo: "kandev", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470},
{name: "number without default repo", input: "#1470", defaultOwner: "kdlbs", wantErr: true},
{name: "valid github url", input: "https://github.com/kdlbs/kandev/issues/1470", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470},
{name: "www github url", input: "https://www.github.com/kdlbs/kandev/issues/1470?foo=bar", wantOwner: "kdlbs", wantRepo: "kandev", wantNumber: 1470},
{name: "non github host", input: "https://gitlab.com/kdlbs/kandev/issues/1470", wantErr: true},
{name: "pull request url", input: "https://github.com/kdlbs/kandev/pull/1470", wantErr: true},
{name: "missing issue number", input: "https://github.com/kdlbs/kandev/issues", wantErr: true},
{name: "invalid issue number", input: "https://github.com/kdlbs/kandev/issues/nope", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
owner, repo, number, err := parseIssueReference(tt.input, tt.defaultOwner, tt.defaultRepo)
if tt.wantErr {
if !errors.Is(err, ErrInvalidIssueReference) {
t.Fatalf("err = %v, want ErrInvalidIssueReference", err)
}
return
}
if err != nil {
t.Fatalf("parseIssueReference: %v", err)
}
if owner != tt.wantOwner || repo != tt.wantRepo || number != tt.wantNumber {
t.Fatalf("got %s/%s#%d, want %s/%s#%d", owner, repo, number, tt.wantOwner, tt.wantRepo, tt.wantNumber)
}
})
}
}