From 428efe24b7875d3a1e7840e8d774d3716526b6b3 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 08:22:24 +0200 Subject: [PATCH 01/17] test(phase1): add unit tests for config and gitlab check --- config/assignment_test.go | 126 ++++++++++++++++++++++++++++++ config/repo_test.go | 104 +++++++++++++++++++++++++ config/students_test.go | 133 ++++++++++++++++++++++++++++++++ config/testhelpers_test.go | 13 ++++ gitlab/check_test.go | 154 +++++++++++++++++++++++++++++++++++++ 5 files changed, 530 insertions(+) create mode 100644 config/assignment_test.go create mode 100644 config/repo_test.go create mode 100644 config/students_test.go create mode 100644 config/testhelpers_test.go create mode 100644 gitlab/check_test.go diff --git a/config/assignment_test.go b/config/assignment_test.go new file mode 100644 index 0000000..b08d350 --- /dev/null +++ b/config/assignment_test.go @@ -0,0 +1,126 @@ +package config + +import ( + "testing" + + "github.com/spf13/viper" +) + +func TestAssignmentPath(t *testing.T) { + resetViper(t) + viper.Set("course.coursepath", "mpd") + viper.Set("course.semesterpath", "ss26") + viper.Set("course.a1.assignmentpath", "blatt-01") + + got := assignmentPath("course", "a1") + want := "mpd/ss26/blatt-01" + + if got != want { + t.Fatalf("assignmentPath() = %q, want %q", got, want) + } +} + +func TestPerAndDescriptionDefaults(t *testing.T) { + resetViper(t) + + if got := per("course.a1"); got != PerStudent { + t.Fatalf("per() default = %q, want %q", got, PerStudent) + } + + viper.Set("course.a1.per", "group") + if got := per("course.a1"); got != PerGroup { + t.Fatalf("per() for group = %q, want %q", got, PerGroup) + } + + if got := description("course.a1"); got != "generated by glabs" { + t.Fatalf("description() default = %q", got) + } + + viper.Set("course.a1.description", "custom") + if got := description("course.a1"); got != "custom" { + t.Fatalf("description() override = %q", got) + } +} + +func TestRepoNamingHelpers(t *testing.T) { + id := 123 + mail := "a@b.example" + user := "alice" + + cfg := &AssignmentConfig{Course: "mpd", Name: "blatt01", UseCoursenameAsPrefix: true} + + if got := cfg.RepoSuffix(&Student{Email: &mail}); got != "a_at_b.example" { + t.Fatalf("RepoSuffix(email) = %q", got) + } + if got := cfg.RepoSuffix(&Student{Id: &id}); got != "123" { + t.Fatalf("RepoSuffix(id) = %q", got) + } + if got := cfg.RepoSuffix(&Student{Username: &user}); got != "alice" { + t.Fatalf("RepoSuffix(username) = %q", got) + } + if got := cfg.RepoSuffix(&Student{}); got != "" { + t.Fatalf("RepoSuffix(empty) = %q", got) + } + + if got := cfg.RepoBaseName(); got != "mpd-blatt01" { + t.Fatalf("RepoBaseName() = %q", got) + } + if got := cfg.RepoNameWithSuffix("g1"); got != "mpd-blatt01-g1" { + t.Fatalf("RepoNameWithSuffix() = %q", got) + } + if got := cfg.RepoNameForStudent(&Student{Username: &user}); got != "mpd-blatt01-alice" { + t.Fatalf("RepoNameForStudent() = %q", got) + } + if got := cfg.RepoNameForGroup(&Group{Name: "team1"}); got != "mpd-blatt01-team1" { + t.Fatalf("RepoNameForGroup() = %q", got) + } + + cfg.UseCoursenameAsPrefix = false + if got := cfg.RepoBaseName(); got != "blatt01" { + t.Fatalf("RepoBaseName() without course prefix = %q", got) + } +} + +func TestGetAssignmentConfig(t *testing.T) { + resetViper(t) + + viper.Set("gitlab.host", "https://gitlab.example.org") + viper.Set("course", true) + viper.Set("course.coursepath", "mpd") + viper.Set("course.semesterpath", "ss26") + viper.Set("course.useCoursenameAsPrefix", true) + viper.Set("course.students", []string{"alice", "1001"}) + viper.Set("course.a1", true) + viper.Set("course.a1.assignmentpath", "blatt-01") + viper.Set("course.a1.per", "student") + viper.Set("course.a1.description", "blatt 1") + viper.Set("course.a1.release", map[string]any{"dockerImages": []string{"registry/app"}}) + viper.Set("course.a1.release.dockerImages", []string{"registry/app"}) + + cfg := GetAssignmentConfig("course", "a1") + + if cfg.Path != "mpd/ss26/blatt-01" { + t.Fatalf("Path = %q", cfg.Path) + } + if cfg.URL != "https://gitlab.example.org/mpd/ss26/blatt-01" { + t.Fatalf("URL = %q", cfg.URL) + } + if cfg.Per != PerStudent { + t.Fatalf("Per = %q", cfg.Per) + } + if !cfg.ContainerRegistry { + t.Fatal("ContainerRegistry should be true when release docker images are configured") + } + if cfg.Release == nil || len(cfg.Release.DockerImages) != 1 { + t.Fatalf("Release DockerImages = %#v", cfg.Release) + } + if cfg.Clone == nil { + t.Fatal("Clone should be initialized with defaults") + } + if len(cfg.Students) != 2 { + t.Fatalf("Students len = %d, want 2", len(cfg.Students)) + } + if cfg.AccessLevel != Developer { + t.Fatalf("AccessLevel = %v, want %v", cfg.AccessLevel, Developer) + } +} diff --git a/config/repo_test.go b/config/repo_test.go new file mode 100644 index 0000000..58ef855 --- /dev/null +++ b/config/repo_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "reflect" + "testing" + + "github.com/spf13/viper" +) + +func TestStartercodeDefaultsAndReplication(t *testing.T) { + resetViper(t) + viper.Set("course.a1.startercode", map[string]string{"url": "git@example.org:starter.git"}) + viper.Set("course.a1.startercode.url", "git@example.org:starter.git") + viper.Set("course.a1.startercode.replicateIssue", true) + + s := startercode("course.a1") + if s == nil { + t.Fatal("startercode should not be nil") + } + if s.FromBranch != "main" || s.ToBranch != "main" || s.DevBranch != "main" { + t.Fatalf("unexpected startercode defaults: %#v", s) + } + if !s.ReplicateIssue { + t.Fatal("ReplicateIssue should be true") + } + if !reflect.DeepEqual(s.IssueNumbers, []int{1}) { + t.Fatalf("IssueNumbers = %#v, want [1]", s.IssueNumbers) + } +} + +func TestStartercodeOverrides(t *testing.T) { + resetViper(t) + viper.Set("course.a1.startercode", map[string]string{"url": "git@example.org:starter.git"}) + viper.Set("course.a1.startercode.url", "git@example.org:starter.git") + viper.Set("course.a1.startercode.fromBranch", "template") + viper.Set("course.a1.startercode.toBranch", "submission") + viper.Set("course.a1.startercode.devBranch", "develop") + viper.Set("course.a1.startercode.additionalBranches", []string{"release", "demo"}) + viper.Set("course.a1.startercode.replicateIssue", true) + viper.Set("course.a1.startercode.issueNumbers", []int{4, 7}) + viper.Set("course.a1.startercode.protectToBranch", true) + viper.Set("course.a1.startercode.protectDevBranchMergeOnly", true) + + s := startercode("course.a1") + if s.FromBranch != "template" || s.ToBranch != "submission" || s.DevBranch != "develop" { + t.Fatalf("startercode branches = %#v", s) + } + if !reflect.DeepEqual(s.AdditionalBranches, []string{"release", "demo"}) { + t.Fatalf("additional branches = %#v", s.AdditionalBranches) + } + if !reflect.DeepEqual(s.IssueNumbers, []int{4, 7}) { + t.Fatalf("IssueNumbers = %#v", s.IssueNumbers) + } + if !s.ProtectToBranch || !s.ProtectDevBranchMergeOnly { + t.Fatalf("protect flags = %#v", s) + } +} + +func TestCloneDefaultsAndOverrides(t *testing.T) { + resetViper(t) + + c := clone("course.a1") + if c.LocalPath != "." || c.Branch != "main" || c.Force { + t.Fatalf("clone defaults = %#v", c) + } + + viper.Set("course.a1.clone", map[string]string{"localpath": "/tmp/repos", "branch": "dev"}) + viper.Set("course.a1.clone.localpath", "/tmp/repos") + viper.Set("course.a1.clone.branch", "dev") + viper.Set("course.a1.clone.force", true) + + c = clone("course.a1") + if c.LocalPath != "/tmp/repos" || c.Branch != "dev" || !c.Force { + t.Fatalf("clone overrides = %#v", c) + } +} + +func TestReleaseDefaultsAndOverrides(t *testing.T) { + resetViper(t) + + viper.Set("course.a1.release", map[string]any{"mergeRequest": true}) + viper.Set("course.a1.release.mergeRequest", map[string]string{"enabled": "true"}) + + r := release("course.a1") + if r == nil || r.MergeRequest == nil { + t.Fatalf("release = %#v", r) + } + if r.MergeRequest.SourceBranch != "develop" || r.MergeRequest.TargetBranch != "main" { + t.Fatalf("merge request defaults = %#v", r.MergeRequest) + } + + viper.Set("course.a1.release.mergeRequest.source", "feature/release") + viper.Set("course.a1.release.mergeRequest.target", "stable") + viper.Set("course.a1.release.mergeRequest.pipeline", true) + viper.Set("course.a1.release.dockerImages", []string{"img/app", "img/web"}) + + r = release("course.a1") + if r.MergeRequest.SourceBranch != "feature/release" || r.MergeRequest.TargetBranch != "stable" || !r.MergeRequest.HasPipeline { + t.Fatalf("merge request overrides = %#v", r.MergeRequest) + } + if !reflect.DeepEqual(r.DockerImages, []string{"img/app", "img/web"}) { + t.Fatalf("docker images = %#v", r.DockerImages) + } +} diff --git a/config/students_test.go b/config/students_test.go new file mode 100644 index 0000000..af7e9fa --- /dev/null +++ b/config/students_test.go @@ -0,0 +1,133 @@ +package config + +import ( + "reflect" + "testing" + + "github.com/spf13/viper" +) + +func TestSetAccessLevel(t *testing.T) { + cfg := &AssignmentConfig{AccessLevel: Developer} + + cfg.SetAccessLevel("guest") + if cfg.AccessLevel != Guest { + t.Fatalf("SetAccessLevel(guest) = %v", cfg.AccessLevel) + } + + cfg.SetAccessLevel("reporter") + if cfg.AccessLevel != Reporter { + t.Fatalf("SetAccessLevel(reporter) = %v", cfg.AccessLevel) + } + + cfg.SetAccessLevel("maintainer") + if cfg.AccessLevel != Maintainer { + t.Fatalf("SetAccessLevel(maintainer) = %v", cfg.AccessLevel) + } + + cfg.SetAccessLevel("unknown") + if cfg.AccessLevel != Developer { + t.Fatalf("SetAccessLevel(default) = %v", cfg.AccessLevel) + } +} + +func TestAccessLevel(t *testing.T) { + resetViper(t) + + if got := accessLevel("course.a1"); got != Developer { + t.Fatalf("default accessLevel = %v", got) + } + + viper.Set("course.a1.accesslevel", "guest") + if got := accessLevel("course.a1"); got != Guest { + t.Fatalf("guest accessLevel = %v", got) + } + + viper.Set("course.a1.accesslevel", "reporter") + if got := accessLevel("course.a1"); got != Reporter { + t.Fatalf("reporter accessLevel = %v", got) + } + + viper.Set("course.a1.accesslevel", "maintainer") + if got := accessLevel("course.a1"); got != Maintainer { + t.Fatalf("maintainer accessLevel = %v", got) + } +} + +func TestMkStudentsClassifiesIdentifiers(t *testing.T) { + students := mkStudents([]string{"1001", "alice", "alice@example.org", "0123"}) + + if students[0].Id == nil || *students[0].Id != 1001 { + t.Fatalf("expected first student to be user id, got %#v", students[0]) + } + if students[1].Username == nil || *students[1].Username != "alice" { + t.Fatalf("expected second student username alice, got %#v", students[1]) + } + if students[2].Email == nil || *students[2].Email != "alice@example.org" { + t.Fatalf("expected third student email, got %#v", students[2]) + } + if students[3].Username == nil || *students[3].Username != "0123" { + t.Fatalf("leading zero ids must be treated as username, got %#v", students[3]) + } +} + +func TestStudentsMergeFilterAndSort(t *testing.T) { + resetViper(t) + + viper.Set("course.students", []string{"carol", "1002"}) + viper.Set("course.a1.students", []string{"alice", "bob"}) + + studs := students(PerStudent, "course", "a1", "^a", "^100") + if len(studs) != 2 { + t.Fatalf("students len = %d, want 2", len(studs)) + } + + got := []string{studs[0].Raw, studs[1].Raw} + want := []string{"1002", "alice"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("students order/filter = %#v, want %#v", got, want) + } +} + +func TestStudentsReturnsNilForGroupMode(t *testing.T) { + resetViper(t) + viper.Set("course.students", []string{"alice"}) + + if studs := students(PerGroup, "course", "a1"); studs != nil { + t.Fatalf("students for group mode = %#v, want nil", studs) + } +} + +func TestGroupsMergeFilterAndSort(t *testing.T) { + resetViper(t) + + viper.Set("course.groups", map[string][]string{ + "g2": {"bob", "alice"}, + "g1": {"1001"}, + }) + viper.Set("course.a1.groups", map[string][]string{ + "g2": {"carol"}, + "g3": {"dave"}, + }) + + grps := groups(PerGroup, "course", "a1", "^g[23]$") + if len(grps) != 2 { + t.Fatalf("groups len = %d, want 2", len(grps)) + } + + if grps[0].Name != "g2" || len(grps[0].Members) != 1 || grps[0].Members[0].Raw != "carol" { + t.Fatalf("g2 members = %#v", grps[0]) + } + if grps[1].Name != "g3" || len(grps[1].Members) != 1 || grps[1].Members[0].Raw != "dave" { + t.Fatalf("g3 members = %#v", grps[1]) + } +} + +func TestGroupsReturnsNilForStudentMode(t *testing.T) { + resetViper(t) + viper.Set("course.groups", map[string][]string{"g1": {"alice"}}) + + if grps := groups(PerStudent, "course", "a1"); grps != nil { + t.Fatalf("groups for student mode = %#v, want nil", grps) + } +} diff --git a/config/testhelpers_test.go b/config/testhelpers_test.go new file mode 100644 index 0000000..43cf5a8 --- /dev/null +++ b/config/testhelpers_test.go @@ -0,0 +1,13 @@ +package config + +import ( + "testing" + + "github.com/spf13/viper" +) + +func resetViper(t *testing.T) { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) +} diff --git a/gitlab/check_test.go b/gitlab/check_test.go new file mode 100644 index 0000000..9407a7b --- /dev/null +++ b/gitlab/check_test.go @@ -0,0 +1,154 @@ +package gitlab + +import ( + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/obcode/glabs/config" + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +func newTestClient(t *testing.T, idUsers map[int]string, searchUsers map[string][]string) *Client { + t.Helper() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/users": + search := r.URL.Query().Get("search") + users, ok := searchUsers[search] + if !ok { + _, _ = w.Write([]byte("[]")) + return + } + resp := "[" + for i, username := range users { + if i > 0 { + resp += "," + } + resp += fmt.Sprintf(`{"id":%d,"name":"%s","username":"%s"}`, i+100, username, username) + } + resp += "]" + _, _ = w.Write([]byte(resp)) + return + + case r.Method == http.MethodGet && len(r.URL.Path) > len("/api/v4/users/") && r.URL.Path[:14] == "/api/v4/users/": + var id int + _, _ = fmt.Sscanf(r.URL.Path, "/api/v4/users/%d", &id) + username, ok := idUsers[id] + if !ok { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 User Not Found"}`)) + return + } + _, _ = w.Write([]byte(fmt.Sprintf(`{"id":%d,"name":"%s","username":"%s"}`, id, username, username))) + return + } + + w.WriteHeader(http.StatusNotFound) + }) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + apiClient, err := gitlabapi.NewClient("token", gitlabapi.WithBaseURL(server.URL+"/api/v4")) + if err != nil { + t.Fatalf("creating gitlab test client failed: %v", err) + } + + return &Client{apiClient} +} + +func TestCheckDupsInGroups_NoDuplicates(t *testing.T) { + groups := []*config.Group{ + {Name: "g1", Members: []*config.Student{{Raw: "alice"}, {Raw: "bob"}}}, + {Name: "g2", Members: []*config.Student{{Raw: "carol"}}}, + } + + dups := checkDupsInGroups(groups) + if len(dups) != 0 { + t.Fatalf("expected no duplicates, got %#v", dups) + } +} + +func TestCheckDupsInGroups_WithDuplicates(t *testing.T) { + groups := []*config.Group{ + {Name: "g1", Members: []*config.Student{{Raw: "alice"}, {Raw: "bob"}}}, + {Name: "g2", Members: []*config.Student{{Raw: "bob"}, {Raw: "dave"}}}, + {Name: "g3", Members: []*config.Student{{Raw: "alice"}}}, + } + + dups := checkDupsInGroups(groups) + + if len(dups) != 2 { + t.Fatalf("expected two duplicate entries, got %#v", dups) + } + + if !reflect.DeepEqual(dups["alice"], []string{"g1", "g3"}) { + t.Fatalf("alice groups = %#v", dups["alice"]) + } + if !reflect.DeepEqual(dups["bob"], []string{"g1", "g2"}) { + t.Fatalf("bob groups = %#v", dups["bob"]) + } +} + +func TestCheckCourseReturnsTrueForResolvableStudents(t *testing.T) { + id := 1001 + username := "alice" + email := "new.user@example.org" + + client := newTestClient(t, + map[int]string{1001: "id-user"}, + map[string][]string{ + "alice": {"alice"}, + "new.user@example.org": {}, + }, + ) + + cfg := &config.CourseConfig{ + Course: "course", + Students: []*config.Student{ + {Id: &id, Raw: "1001"}, + {Username: &username, Raw: "alice"}, + {Email: &email, Raw: email}, + }, + } + + if ok := client.CheckCourse(cfg); !ok { + t.Fatal("CheckCourse() = false, want true") + } +} + +func TestCheckCourseReturnsFalseOnMissingUserAndDuplicate(t *testing.T) { + missing := "missinguser" + + client := newTestClient(t, + map[int]string{}, + map[string][]string{}, + ) + + cfg := &config.CourseConfig{ + Course: "course", + Groups: []*config.Group{ + { + Name: "g1", + Members: []*config.Student{ + {Username: &missing, Raw: "missinguser"}, + {Raw: "dup"}, + }, + }, + { + Name: "g2", + Members: []*config.Student{ + {Raw: "dup"}, + }, + }, + }, + } + + if ok := client.CheckCourse(cfg); ok { + t.Fatal("CheckCourse() = true, want false") + } +} From b1cd0674b5a0adcaa8495a812e8a5011b59f3a12 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 08:28:51 +0200 Subject: [PATCH 02/17] test(phase2): add gitlab API contract tests with httptest --- gitlab/contract_test_helpers_test.go | 23 +++++ gitlab/groups_contract_test.go | 88 +++++++++++++++++ gitlab/projects_contract_test.go | 137 ++++++++++++++++++++++++++ gitlab/report_helper_contract_test.go | 136 +++++++++++++++++++++++++ 4 files changed, 384 insertions(+) create mode 100644 gitlab/contract_test_helpers_test.go create mode 100644 gitlab/groups_contract_test.go create mode 100644 gitlab/projects_contract_test.go create mode 100644 gitlab/report_helper_contract_test.go diff --git a/gitlab/contract_test_helpers_test.go b/gitlab/contract_test_helpers_test.go new file mode 100644 index 0000000..024e041 --- /dev/null +++ b/gitlab/contract_test_helpers_test.go @@ -0,0 +1,23 @@ +package gitlab + +import ( + "net/http" + "net/http/httptest" + "testing" + + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +func newContractClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + apiClient, err := gitlabapi.NewClient("token", gitlabapi.WithBaseURL(server.URL+"/api/v4")) + if err != nil { + t.Fatalf("creating gitlab test client failed: %v", err) + } + + return &Client{apiClient} +} diff --git a/gitlab/groups_contract_test.go b/gitlab/groups_contract_test.go new file mode 100644 index 0000000..0760b18 --- /dev/null +++ b/gitlab/groups_contract_test.go @@ -0,0 +1,88 @@ +package gitlab + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +func TestGetGroupIDByFullPath_FindsGroup(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[ + {"id":1,"full_path":"other/path"}, + {"id":42,"full_path":"mpd/ss26/blatt-01"} + ]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + id, err := client.getGroupIDByFullPath("mpd/ss26/blatt-01") + if err != nil { + t.Fatalf("getGroupIDByFullPath() returned error: %v", err) + } + if id != 42 { + t.Fatalf("group id = %d, want 42", id) + } +} + +func TestGetGroupIDByFullPath_ReturnsErrorWhenNotFound(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"other/path"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + _, err := client.getGroupIDByFullPath("mpd/ss26/blatt-01") + if err == nil { + t.Fatal("getGroupIDByFullPath() expected error, got nil") + } +} + +func TestCreateGroup_WithParentGroup(t *testing.T) { + var createBody string + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + _, _ = w.Write([]byte(`[{"id":41,"full_path":"mpd/ss26"}]`)) + return + + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/groups": + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + createBody = string(body) + _, _ = w.Write([]byte(`{"id":99,"full_path":"mpd/ss26/blatt-01"}`)) + return + } + + w.WriteHeader(http.StatusNotFound) + }) + + assignmentCfg := &config.AssignmentConfig{Course: "mpd", Path: "mpd/ss26/blatt-01"} + id, err := client.createGroup(assignmentCfg) + if err != nil { + t.Fatalf("createGroup() returned error: %v", err) + } + if id != 99 { + t.Fatalf("group id = %d, want 99", id) + } + + if !strings.Contains(createBody, `"name":"blatt-01"`) && !strings.Contains(createBody, "name=blatt-01") { + t.Fatalf("create group request body missing name: %q", createBody) + } + if !strings.Contains(createBody, `"path":"blatt-01"`) && !strings.Contains(createBody, "path=blatt-01") { + t.Fatalf("create group request body missing path: %q", createBody) + } + if !strings.Contains(createBody, `"parent_id":41`) && !strings.Contains(createBody, "parent_id=41") { + t.Fatalf("create group request body missing parent_id: %q", createBody) + } +} diff --git a/gitlab/projects_contract_test.go b/gitlab/projects_contract_test.go new file mode 100644 index 0000000..cfaa061 --- /dev/null +++ b/gitlab/projects_contract_test.go @@ -0,0 +1,137 @@ +package gitlab + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +func TestGetProjectByName_FindsSingleProject(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects" { + _, _ = w.Write([]byte(`[{"id":1,"name":"repo","path_with_namespace":"mpd/ss26/repo"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + project, err := client.getProjectByName("mpd/ss26/repo") + if err != nil { + t.Fatalf("getProjectByName() returned error: %v", err) + } + if project == nil || project.ID != 1 { + t.Fatalf("project = %#v", project) + } +} + +func TestGetProjectByName_SelectsExactMatchOnMultipleResults(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects" { + _, _ = w.Write([]byte(`[ + {"id":1,"name":"repo","path_with_namespace":"other/path/repo"}, + {"id":2,"name":"repo","path_with_namespace":"mpd/ss26/repo"} + ]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + project, err := client.getProjectByName("mpd/ss26/repo") + if err != nil { + t.Fatalf("getProjectByName() returned error: %v", err) + } + if project == nil || project.ID != 2 { + t.Fatalf("project = %#v", project) + } +} + +func TestGetProjectByName_ReturnsErrorWhenNotFound(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects" { + _, _ = w.Write([]byte(`[]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + project, err := client.getProjectByName("mpd/ss26/repo") + if err == nil { + t.Fatal("getProjectByName() expected error, got nil") + } + if project != nil { + t.Fatalf("project = %#v, want nil", project) + } +} + +func TestGenerateProject_CreatesProject(t *testing.T) { + var createBody string + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects" { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + createBody = string(body) + _, _ = w.Write([]byte(`{"id":11,"name":"repo-a","path_with_namespace":"mpd/ss26/repo-a"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + assignmentCfg := &config.AssignmentConfig{ + Description: "desc", + Path: "mpd/ss26", + ContainerRegistry: true, + } + + project, generated, err := client.generateProject(assignmentCfg, "repo-a", 123) + if err != nil { + t.Fatalf("generateProject() returned error: %v", err) + } + if !generated { + t.Fatal("generateProject() generated = false, want true") + } + if project == nil || project.ID != 11 { + t.Fatalf("project = %#v", project) + } + if !strings.Contains(createBody, `"name":"repo-a"`) && !strings.Contains(createBody, "name=repo-a") { + t.Fatalf("create project request body missing name: %q", createBody) + } +} + +func TestGenerateProject_FallsBackToExistingProject(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects": + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":{"name":["has already been taken"]}}`)) + return + + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects": + if got := r.URL.Query().Get("search"); !strings.Contains(got, "repo-a") { + t.Fatalf("search query = %q", got) + } + _, _ = w.Write([]byte(`[{"id":21,"name":"repo-a","path_with_namespace":"mpd/ss26/repo-a"}]`)) + return + } + + w.WriteHeader(http.StatusNotFound) + }) + + assignmentCfg := &config.AssignmentConfig{Description: "desc", Path: "mpd/ss26"} + + project, generated, err := client.generateProject(assignmentCfg, "repo-a", 123) + if err != nil { + t.Fatalf("generateProject() returned error: %v", err) + } + if generated { + t.Fatal("generateProject() generated = true, want false") + } + if project == nil || project.ID != 21 { + t.Fatalf("project = %#v", project) + } +} diff --git a/gitlab/report_helper_contract_test.go b/gitlab/report_helper_contract_test.go new file mode 100644 index 0000000..ea55b4b --- /dev/null +++ b/gitlab/report_helper_contract_test.go @@ -0,0 +1,136 @@ +package gitlab + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/obcode/glabs/config" + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +func TestProjectReport_AggregatesReleaseAndCommitData(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/7/repository/branches": + _, _ = w.Write([]byte(`[{"name":"main"},{"name":"develop"}]`)) + return + + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/7/repository/commits": + ref := r.URL.Query().Get("ref_name") + if ref == "main" { + _, _ = w.Write([]byte(`[ + {"title":"initial","committer_name":"alice","committed_date":"2026-04-20T10:00:00Z","web_url":"https://gitlab.example.org/c1"} + ]`)) + return + } + if ref == "develop" { + _, _ = w.Write([]byte(`[ + {"title":"latest","committer_name":"bob","committed_date":"2026-04-22T12:00:00Z","web_url":"https://gitlab.example.org/c2"} + ]`)) + return + } + _, _ = w.Write([]byte(`[]`)) + return + + case r.Method == http.MethodGet && + (r.URL.Path == "/api/v4/projects/7/members" || r.URL.Path == "/api/v4/projects/7/members/all"): + _, _ = w.Write([]byte(`[{"id":100,"name":"Alice","username":"alice","web_url":"https://gitlab.example.org/alice"}]`)) + return + + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/7/merge_requests": + if r.URL.Query().Get("state") != "opened" { + t.Fatalf("merge request state query = %q", r.URL.Query().Get("state")) + } + _, _ = w.Write([]byte(`[ + {"iid":11,"source_branch":"develop","target_branch":"main","web_url":"https://gitlab.example.org/mr/11"} + ]`)) + return + + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/7/merge_requests/11/pipelines": + _, _ = w.Write([]byte(`[ + {"id":1,"status":"failed","created_at":"2026-04-22T11:00:00Z"}, + {"id":2,"status":"success","created_at":"2026-04-22T12:30:00Z"} + ]`)) + return + + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v4/projects/7/registry/repositories"): + _, _ = w.Write([]byte(`[ + {"name":"registry/app","location":"registry.example.org/mpd/ss26/repo/app"} + ]`)) + return + } + + w.WriteHeader(http.StatusNotFound) + }) + + createdAt := time.Date(2026, 4, 20, 9, 0, 0, 0, time.UTC) + lastActivity := time.Date(2026, 4, 20, 9, 0, 0, 0, time.UTC) + project := &gitlabapi.Project{ + ID: 7, + Name: "mpd-a1-team1", + CreatedAt: &createdAt, + LastActivityAt: &lastActivity, + OpenIssuesCount: 3, + WebURL: "https://gitlab.example.org/mpd-a1-team1", + } + + assignmentCfg := &config.AssignmentConfig{ + Release: &config.Release{ + MergeRequest: &config.MergeRequest{ + SourceBranch: "develop", + TargetBranch: "main", + HasPipeline: true, + }, + DockerImages: []string{"registry/app", "registry/missing"}, + }, + } + + projectName, report := client.projectReport(assignmentCfg, project) + + if projectName != "mpd-a1-team1" { + t.Fatalf("projectName = %q", projectName) + } + if report == nil { + t.Fatal("report is nil") + } + if report.Commits != 2 { + t.Fatalf("commits = %d, want 2", report.Commits) + } + if report.LastCommit == nil || report.LastCommit.Title != "latest" { + t.Fatalf("last commit = %#v", report.LastCommit) + } + if report.OpenMergeRequestsCount != 1 { + t.Fatalf("open merge requests = %d", report.OpenMergeRequestsCount) + } + if !report.IsActive { + t.Fatal("project should be marked active") + } + if len(report.Members) != 1 { + t.Fatalf("members len = %d, want 1", len(report.Members)) + } + + if report.Release == nil || report.Release.MergeRequest == nil { + t.Fatalf("release merge request = %#v", report.Release) + } + if !report.Release.MergeRequest.Found { + t.Fatal("release merge request should be found") + } + if report.Release.MergeRequest.PipelineStatus != "success" { + t.Fatalf("pipeline status = %q, want success", report.Release.MergeRequest.PipelineStatus) + } + + if report.Release.DockerImages == nil { + t.Fatal("docker images report should be set") + } + if report.Release.DockerImages.Status != "1 of 2 available" { + t.Fatalf("docker images status = %q", report.Release.DockerImages.Status) + } + if len(report.Release.DockerImages.Images) != 1 { + t.Fatalf("docker images len = %d, want 1", len(report.Release.DockerImages.Images)) + } + if report.Release.DockerImages.Images[0].Wanted != "registry/app" { + t.Fatalf("docker image wanted = %q", report.Release.DockerImages.Images[0].Wanted) + } +} From 139d94357f8c5c050e1b05abd1ee9fd3b2ae4bb1 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 08:36:27 +0200 Subject: [PATCH 03/17] refactor(phase3): add injectable exit/panic seams for gitlab flows --- gitlab/archive.go | 5 +- gitlab/delete.go | 5 +- gitlab/generate.go | 7 +-- gitlab/gitlab.go | 2 +- gitlab/protect.go | 5 +- gitlab/report.go | 20 +++---- gitlab/runtime.go | 9 +++ gitlab/runtime_test.go | 129 +++++++++++++++++++++++++++++++++++++++++ gitlab/setaccess.go | 5 +- gitlab/update.go | 7 +-- 10 files changed, 163 insertions(+), 31 deletions(-) create mode 100644 gitlab/runtime.go create mode 100644 gitlab/runtime_test.go diff --git a/gitlab/archive.go b/gitlab/archive.go index b903253..02ece50 100644 --- a/gitlab/archive.go +++ b/gitlab/archive.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "time" "github.com/logrusorgru/aurora" @@ -16,7 +15,7 @@ func (c *Client) Archive(assignmentCfg *config.AssignmentConfig, unarchive bool) _, err := c.getGroupID(assignmentCfg) if err != nil { fmt.Printf("error: GitLab group for assignment does not exist, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } switch per := assignmentCfg.Per; per { @@ -26,7 +25,7 @@ func (c *Client) Archive(assignmentCfg *config.AssignmentConfig, unarchive bool) c.archivePerStudent(assignmentCfg, unarchive) default: fmt.Printf("it is only possible to set access levels for students oder groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/delete.go b/gitlab/delete.go index fa1f6b8..6c9b9e3 100644 --- a/gitlab/delete.go +++ b/gitlab/delete.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "github.com/obcode/glabs/config" "github.com/rs/zerolog/log" @@ -13,7 +12,7 @@ func (c *Client) Delete(assignmentCfg *config.AssignmentConfig) { assignmentGitLabGroupID, err := c.getGroupID(assignmentCfg) if err != nil { fmt.Printf("error: GitLab group for assignment does not exist, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } switch per := assignmentCfg.Per; per { @@ -23,7 +22,7 @@ func (c *Client) Delete(assignmentCfg *config.AssignmentConfig) { c.deletePerStudent(assignmentCfg, assignmentGitLabGroupID) default: fmt.Printf("it is only possible to delete projects for students or groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/generate.go b/gitlab/generate.go index 7e771f4..847169d 100644 --- a/gitlab/generate.go +++ b/gitlab/generate.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "time" "github.com/logrusorgru/aurora" @@ -23,7 +22,7 @@ func (c *Client) Generate(assignmentCfg *config.AssignmentConfig) { Str("assignmentpath", assignmentCfg.Path). Msg("error while creating group for assignment") fmt.Printf("error: cannot create GitLab group for assignment, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } } @@ -34,7 +33,7 @@ func (c *Client) Generate(assignmentCfg *config.AssignmentConfig) { if err != nil { fmt.Println(err) - os.Exit(1) + exitFunc(1) } } @@ -45,7 +44,7 @@ func (c *Client) Generate(assignmentCfg *config.AssignmentConfig) { c.generatePerStudent(assignmentCfg, assignmentGitLabGroupID, starterrepo) default: fmt.Printf("it is only possible to generate for students oder groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/gitlab.go b/gitlab/gitlab.go index e44e5c1..f527b0a 100644 --- a/gitlab/gitlab.go +++ b/gitlab/gitlab.go @@ -17,7 +17,7 @@ func NewClient() *Client { gitlab.WithBaseURL(viper.GetString("gitlab.host"))) if err != nil { - panic("cannot create a gitlab client") + panicFunc("cannot create a gitlab client") } return &Client{client} diff --git a/gitlab/protect.go b/gitlab/protect.go index ab72e5c..2a45ae5 100644 --- a/gitlab/protect.go +++ b/gitlab/protect.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "time" "github.com/logrusorgru/aurora" @@ -16,7 +15,7 @@ func (c *Client) ProtectToBranch(assignmentCfg *config.AssignmentConfig) { _, err := c.getGroupID(assignmentCfg) if err != nil { fmt.Printf("error: GitLab group for assignment does not exist, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } switch per := assignmentCfg.Per; per { @@ -26,7 +25,7 @@ func (c *Client) ProtectToBranch(assignmentCfg *config.AssignmentConfig) { c.protectToBranchPerStudent(assignmentCfg) default: fmt.Printf("it is only possible to protect the branch for students oder groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/report.go b/gitlab/report.go index c7ab94a..66f383f 100644 --- a/gitlab/report.go +++ b/gitlab/report.go @@ -25,24 +25,24 @@ func (c *Client) Report(assignmentCfg *config.AssignmentConfig, templateFile *st } if err != nil { - panic(err) + panicFunc(err) } if output != nil { os.Remove(*output) //nolint f, err := os.Create(*output) if err != nil { - panic(err) + panicFunc(err) } defer f.Close() //nolint err = tmpl.Execute(f, report) if err != nil { - panic(err) + panicFunc(err) } } else { err = tmpl.Execute(os.Stdout, report) if err != nil { - panic(err) + panicFunc(err) } } @@ -61,24 +61,24 @@ func (c *Client) ReportHTML(assignmentCfg *config.AssignmentConfig, templateFile tmpl, err = htmlTemplate.New("html").Parse(r.HTMLTemplate) } if err != nil { - panic(err) + panicFunc(err) } if output != nil { os.Remove(*output) //nolint f, err := os.Create(*output) if err != nil { - panic(err) + panicFunc(err) } defer f.Close() //nolint err = tmpl.Execute(f, report) if err != nil { - panic(err) + panicFunc(err) } } else { err = tmpl.Execute(os.Stdout, report) if err != nil { - panic(err) + panicFunc(err) } } } @@ -88,13 +88,13 @@ func (c *Client) ReportJSON(assignmentCfg *config.AssignmentConfig, output *stri json, err := json.MarshalIndent(report, "", " ") if err != nil { - panic(err) + panicFunc(err) } if output != nil { err := os.WriteFile(*output, json, 0644) if err != nil { - panic(err) + panicFunc(err) } } else { fmt.Println(string(json)) diff --git a/gitlab/runtime.go b/gitlab/runtime.go new file mode 100644 index 0000000..60c755b --- /dev/null +++ b/gitlab/runtime.go @@ -0,0 +1,9 @@ +package gitlab + +import "os" + +var exitFunc = os.Exit + +var panicFunc = func(v interface{}) { + panic(v) +} diff --git a/gitlab/runtime_test.go b/gitlab/runtime_test.go new file mode 100644 index 0000000..27af9a4 --- /dev/null +++ b/gitlab/runtime_test.go @@ -0,0 +1,129 @@ +package gitlab + +import ( + "fmt" + "net/http" + "testing" + + "github.com/obcode/glabs/config" + "github.com/spf13/viper" +) + +type exitTriggered struct { + code int +} + +func (e exitTriggered) Error() string { + return fmt.Sprintf("exit called with code %d", e.code) +} + +func withExitCapture(t *testing.T) func() { + t.Helper() + origExit := exitFunc + exitFunc = func(code int) { + panic(exitTriggered{code: code}) + } + return func() { + exitFunc = origExit + } +} + +func withPanicCapture(t *testing.T, fn func(v interface{})) func() { + t.Helper() + origPanic := panicFunc + panicFunc = fn + return func() { + panicFunc = origPanic + } +} + +func assertExitCode(t *testing.T, expected int, fn func()) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("expected exit with code %d, got no panic", expected) + } + e, ok := r.(exitTriggered) + if !ok { + t.Fatalf("expected exitTriggered panic, got %T", r) + } + if e.code != expected { + t.Fatalf("exit code = %d, want %d", e.code, expected) + } + }() + fn() +} + +func TestGenerate_UsesExitSeamForInvalidPer(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerFailed, + } + + assertExitCode(t, 1, func() { + client.Generate(cfg) + }) +} + +func TestProtectToBranch_UsesExitSeamForInvalidPer(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerFailed, + Startercode: &config.Startercode{ + ToBranch: "main", + }, + } + + assertExitCode(t, 1, func() { + client.ProtectToBranch(cfg) + }) +} + +func TestNewClient_UsesPanicSeamOnInvalidBaseURL(t *testing.T) { + triggered := false + defer withPanicCapture(t, func(v interface{}) { + triggered = true + panic(v) + })() + viper.Reset() + defer viper.Reset() + viper.Set("gitlab.host", "://invalid-base-url") + viper.Set("gitlab.token", "token") + + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic on invalid GitLab base URL") + } + if !triggered { + t.Fatal("expected panic to pass through panicFunc seam") + } + }() + + _ = NewClient() +} diff --git a/gitlab/setaccess.go b/gitlab/setaccess.go index 3105a34..812fd49 100644 --- a/gitlab/setaccess.go +++ b/gitlab/setaccess.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "time" "github.com/logrusorgru/aurora" @@ -16,7 +15,7 @@ func (c *Client) Setaccess(assignmentCfg *config.AssignmentConfig) { _, err := c.getGroupID(assignmentCfg) if err != nil { fmt.Printf("error: GitLab group for assignment does not exist, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } switch per := assignmentCfg.Per; per { @@ -26,7 +25,7 @@ func (c *Client) Setaccess(assignmentCfg *config.AssignmentConfig) { c.setaccessPerStudent(assignmentCfg) default: fmt.Printf("it is only possible to set access levels for students oder groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/update.go b/gitlab/update.go index d84294d..8d6d610 100644 --- a/gitlab/update.go +++ b/gitlab/update.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "time" "github.com/logrusorgru/aurora" @@ -17,7 +16,7 @@ func (c *Client) Update(assignmentCfg *config.AssignmentConfig) { _, err := c.getGroupID(assignmentCfg) if err != nil { fmt.Printf("error: GitLab group for assignment does not exist, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } var starterrepo *git.Starterrepo @@ -27,7 +26,7 @@ func (c *Client) Update(assignmentCfg *config.AssignmentConfig) { if err != nil { fmt.Println(err) - os.Exit(1) + exitFunc(1) } } @@ -38,7 +37,7 @@ func (c *Client) Update(assignmentCfg *config.AssignmentConfig) { c.updatePerStudent(assignmentCfg, starterrepo) default: fmt.Printf("it is only possible to update for students oder groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } From b66bb31486e75bb8de2a45ae72700ee1825d96cb Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 08:40:37 +0200 Subject: [PATCH 04/17] test(phase4): add opt-in GitLab testcontainer integration test --- README.md | 19 +++ gitlab/integration_gitlab_test.go | 191 ++++++++++++++++++++++++++++++ go.mod | 53 ++++++++- go.sum | 98 +++++++++++++++ 4 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 gitlab/integration_gitlab_test.go diff --git a/README.md b/README.md index 599e961..b4e5c67 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,25 @@ glabs report [--html|--json] Issues and pull requests are welcome. +## Testing + +Default unit and contract tests: + +```sh +go test ./... +``` + +Integration tests with GitLab Testcontainers (opt-in): + +```sh +GLABS_RUN_GITLAB_TC=1 go test -tags=integration ./gitlab -run TestIntegration_GitLab_GroupAndProjectLifecycle -count=1 +``` + +Notes: + +- Integration tests are intentionally opt-in because starting GitLab in a container is resource intensive. +- In CI, run integration tests in a dedicated job. + ## License MIT, see [LICENSE](LICENSE). diff --git a/gitlab/integration_gitlab_test.go b/gitlab/integration_gitlab_test.go new file mode 100644 index 0000000..e412cab --- /dev/null +++ b/gitlab/integration_gitlab_test.go @@ -0,0 +1,191 @@ +//go:build integration + +package gitlab + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/obcode/glabs/config" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +const ( + gitLabImage = "gitlab/gitlab-ce:17.6.1-ce.0" + gitLabRootToken = "glabs-integration-root-token" + runIntegrationEnv = "GLABS_RUN_GITLAB_TC" +) + +func requireIntegrationEnabled(t *testing.T) { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + if os.Getenv(runIntegrationEnv) != "1" { + t.Skipf("set %s=1 to run GitLab testcontainer integration tests", runIntegrationEnv) + } +} + +func createRootToken(ctx context.Context, t *testing.T, c testcontainers.Container) string { + t.Helper() + + script := strings.Join([]string{ + "user = User.find_by_username('root')", + "token = user.personal_access_tokens.find_by(name: 'glabs-integration-token')", + "token&.revoke!", + "token = user.personal_access_tokens.create!(name: 'glabs-integration-token', scopes: [:api], expires_at: 365.days.from_now)", + fmt.Sprintf("token.set_token('%s')", gitLabRootToken), + "token.save!", + "puts token.token", + }, "; ") + + cmd := []string{"gitlab-rails", "runner", script} + exitCode, reader, err := c.Exec(ctx, cmd) + if err != nil { + t.Fatalf("creating root token via gitlab-rails failed: %v", err) + } + outputBytes, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("reading gitlab-rails output failed: %v", err) + } + if exitCode != 0 { + t.Fatalf("gitlab-rails runner exit code %d, output:\n%s", exitCode, string(outputBytes)) + } + + scanner := bufio.NewScanner(strings.NewReader(string(outputBytes))) + lastLine := "" + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + lastLine = line + } + } + if lastLine == "" { + t.Fatalf("could not parse token from gitlab-rails output: %q", string(outputBytes)) + } + + return lastLine +} + +func startGitLabContainer(t *testing.T) (*Client, string) { + t.Helper() + + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: gitLabImage, + ExposedPorts: []string{"80/tcp"}, + Env: map[string]string{ + "GITLAB_ROOT_PASSWORD": "glabs-root-password", + "GITLAB_OMNIBUS_CONFIG": strings.Join([]string{ + "external_url 'http://localhost'", + "nginx['listen_port'] = 80", + "prometheus_monitoring['enable'] = false", + "puma['worker_processes'] = 0", + "sidekiq['max_concurrency'] = 5", + }, "; "), + }, + WaitingFor: wait.ForHTTP("/users/sign_in").WithPort("80/tcp").WithStartupTimeout(25 * time.Minute), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("starting gitlab testcontainer failed: %v", err) + } + t.Cleanup(func() { + _ = container.Terminate(ctx) + }) + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("getting container host failed: %v", err) + } + port, err := container.MappedPort(ctx, "80/tcp") + if err != nil { + t.Fatalf("getting mapped port failed: %v", err) + } + + baseURL := fmt.Sprintf("http://%s:%s", host, port.Port()) + rootToken := createRootToken(ctx, t, container) + + apiClient, err := gitlabapi.NewClient(rootToken, gitlabapi.WithBaseURL(baseURL+"/api/v4")) + if err != nil { + t.Fatalf("creating gitlab api client failed: %v", err) + } + + return &Client{apiClient}, baseURL +} + +func TestIntegration_GitLab_GroupAndProjectLifecycle(t *testing.T) { + requireIntegrationEnabled(t) + + client, baseURL := startGitLabContainer(t) + + parentName := "mpd-it-parent" + parentPath := "mpd-it-parent" + visibility := gitlabapi.PublicVisibility + parent, _, err := client.Groups.CreateGroup(&gitlabapi.CreateGroupOptions{ + Name: &parentName, + Path: &parentPath, + Visibility: &visibility, + }) + if err != nil { + t.Fatalf("creating parent group failed: %v", err) + } + + assignmentCfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "a1", + Path: parent.FullPath + "/blatt-01", + URL: baseURL + "/" + parent.FullPath + "/blatt-01", + Per: config.PerStudent, + Description: "integration test assignment", + ContainerRegistry: false, + } + + groupID, err := client.createGroup(assignmentCfg) + if err != nil { + t.Fatalf("createGroup failed: %v", err) + } + if groupID == 0 { + t.Fatal("createGroup returned zero group id") + } + + resolvedGroupID, err := client.getGroupIDByFullPath(assignmentCfg.Path) + if err != nil { + t.Fatalf("getGroupIDByFullPath failed: %v", err) + } + if resolvedGroupID != groupID { + t.Fatalf("resolved group id = %d, want %d", resolvedGroupID, groupID) + } + + project, generated, err := client.generateProject(assignmentCfg, "a1-team1", groupID) + if err != nil { + t.Fatalf("generateProject failed: %v", err) + } + if !generated { + t.Fatal("expected generateProject to create a new project") + } + if project == nil || project.PathWithNamespace == "" { + t.Fatalf("invalid project response: %#v", project) + } + + foundProject, err := client.getProjectByName(project.PathWithNamespace) + if err != nil { + t.Fatalf("getProjectByName failed: %v", err) + } + if foundProject.ID != project.ID { + t.Fatalf("found project id = %d, want %d", foundProject.ID, project.ID) + } +} diff --git a/go.mod b/go.mod index 5843ed0..d483cf0 100644 --- a/go.mod +++ b/go.mod @@ -18,45 +18,92 @@ require ( ) require ( - dario.cat/mergo v1.0.0 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.16.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-querystring v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 98de615..6da9da0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -9,20 +13,44 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -37,15 +65,25 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= @@ -64,6 +102,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -75,6 +115,10 @@ github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczG github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA= github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -83,8 +127,30 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= @@ -93,6 +159,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -104,7 +172,11 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -127,31 +199,56 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= gitlab.com/gitlab-org/api/client-go/v2 v2.20.1 h1:srcxre2lb0+SyYE1p/gtp7hOonFYWzdDfwwMOxnI1Eo= gitlab.com/gitlab-org/api/client-go/v2 v2.20.1/go.mod h1:HpSOwHPuHwHjbpPCsdP5z34/S2LI3GFFj+p4twZOmwg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= @@ -165,6 +262,7 @@ golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= From 06fa0b1757cf2a24f5a3b2566355f6d14e7ee27b Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 08:42:25 +0200 Subject: [PATCH 05/17] ci(phase5): split fast and integration test jobs --- .github/workflows/ci.yml | 47 ++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5508c06..5958786 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,14 @@ on: push: branches: - "**" + pull_request: + workflow_dispatch: + inputs: + run_integration: + description: "Run integration tests with GitLab Testcontainers" + required: false + default: false + type: boolean permissions: contents: write @@ -24,8 +32,8 @@ jobs: - name: Run golangci-lint run: golangci-lint run --timeout=10m - test: - name: test + test-fast: + name: test (fast) runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory @@ -35,22 +43,39 @@ jobs: with: go-version-file: go.mod id: go - - name: Get dependencies - run: | - go get -v -t -d ./... - if [ -f Gopkg.toml ]; then - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh - dep ensure - fi - - name: Test + - name: Download dependencies + run: go mod download + - name: Run fast tests run: | go test -v ./... + test-integration: + name: test (integration) + runs-on: ubuntu-latest + timeout-minutes: 90 + if: >- + github.ref == 'refs/heads/main' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_integration == 'true') + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Run integration tests + env: + GLABS_RUN_GITLAB_TC: "1" + run: | + go test -tags=integration ./gitlab -run TestIntegration_GitLab_GroupAndProjectLifecycle -count=1 -v + release: runs-on: ubuntu-latest needs: - golangci - - test + - test-fast steps: - uses: actions/checkout@v4 with: From add0dfaf2a462c39c4b3d3d3ea6032b18daa942f Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 09:04:04 +0200 Subject: [PATCH 06/17] ci: fix workflow syntax and add coverage job --- .github/workflows/ci.yml | 124 ++++++++++++++++++++++++--------------- 1 file changed, 76 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5958786..e3b955b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ name: ci + on: push: branches: @@ -22,68 +23,95 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod - name: Install golangci-lint - run: | - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + run: | + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run golangci-lint - run: golangci-lint run --timeout=10m + run: golangci-lint run --timeout=10m + + test-fast: + name: test (fast) + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Run fast tests + run: go test -v ./... - test-fast: - name: test (fast) + coverage: + name: coverage runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v4 + uses: actions/checkout@v4 - name: Set up Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - id: go - - name: Download dependencies - run: go mod download - - name: Run fast tests - run: | - go test -v ./... + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Generate coverage + run: | + go test ./... -coverprofile=coverage.out + go tool cover -func=coverage.out | tee coverage.txt + echo "## Coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```text' >> "$GITHUB_STEP_SUMMARY" + cat coverage.txt >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: go-coverage + path: | + coverage.out + coverage.txt - test-integration: - name: test (integration) - runs-on: ubuntu-latest - timeout-minutes: 90 - if: >- - github.ref == 'refs/heads/main' || - (github.event_name == 'workflow_dispatch' && github.event.inputs.run_integration == 'true') - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v4 - - name: Set up Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Download dependencies - run: go mod download - - name: Run integration tests - env: - GLABS_RUN_GITLAB_TC: "1" - run: | - go test -tags=integration ./gitlab -run TestIntegration_GitLab_GroupAndProjectLifecycle -count=1 -v + test-integration: + name: test (integration) + runs-on: ubuntu-latest + timeout-minutes: 90 + if: >- + github.ref == 'refs/heads/main' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_integration == 'true') + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Run integration tests + env: + GLABS_RUN_GITLAB_TC: "1" + run: | + go test -tags=integration ./gitlab -run TestIntegration_GitLab_GroupAndProjectLifecycle -count=1 -v release: runs-on: ubuntu-latest needs: - golangci - test-fast + - coverage steps: - uses: actions/checkout@v4 - with: - persist-credentials: false + with: + persist-credentials: false - uses: go-semantic-release/action@v1.0.0 - with: - changelog-generator-opt: "emojis=true" - allow-initial-development-versions: true - hooks: goreleaser - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + changelog-generator-opt: "emojis=true" + allow-initial-development-versions: true + hooks: goreleaser + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 7edf6d86b085a78a202a62358aa29ddaa5dd717b Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 09:19:47 +0200 Subject: [PATCH 07/17] ci: add PR coverage comment and fix workflow indentation --- .github/workflows/ci.yml | 230 +++++++++++++++++++++------------------ 1 file changed, 124 insertions(+), 106 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3b955b..014d510 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,117 +1,135 @@ name: ci on: - push: - branches: - - "**" - pull_request: - workflow_dispatch: - inputs: - run_integration: - description: "Run integration tests with GitLab Testcontainers" - required: false - default: false - type: boolean + push: + branches: + - "**" + pull_request: + workflow_dispatch: + inputs: + run_integration: + description: "Run integration tests with GitLab Testcontainers" + required: false + default: false + type: boolean permissions: - contents: write + contents: write + pull-requests: write jobs: - golangci: - name: lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Install golangci-lint - run: | - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - - name: Run golangci-lint - run: golangci-lint run --timeout=10m + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Install golangci-lint + run: | + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + - name: Run golangci-lint + run: golangci-lint run --timeout=10m - test-fast: - name: test (fast) - runs-on: ubuntu-latest - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v4 - - name: Set up Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Download dependencies - run: go mod download - - name: Run fast tests - run: go test -v ./... + test-fast: + name: test (fast) + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Run fast tests + run: go test -v ./... - coverage: - name: coverage - runs-on: ubuntu-latest - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v4 - - name: Set up Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Download dependencies - run: go mod download - - name: Generate coverage - run: | - go test ./... -coverprofile=coverage.out - go tool cover -func=coverage.out | tee coverage.txt - echo "## Coverage" >> "$GITHUB_STEP_SUMMARY" - echo '```text' >> "$GITHUB_STEP_SUMMARY" - cat coverage.txt >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - - name: Upload coverage artifact - uses: actions/upload-artifact@v4 - with: - name: go-coverage - path: | - coverage.out - coverage.txt + coverage: + name: coverage + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Generate coverage + id: coverage-output + run: | + go test ./... -coverprofile=coverage.out + go tool cover -func=coverage.out | tee coverage.txt + { + echo 'coverage_text<> "$GITHUB_OUTPUT" + echo "## Coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```text' >> "$GITHUB_STEP_SUMMARY" + cat coverage.txt >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: go-coverage + path: | + coverage.out + coverage.txt + - name: Comment coverage on PR + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: coverage + message: | + ## Coverage - test-integration: - name: test (integration) - runs-on: ubuntu-latest - timeout-minutes: 90 - if: >- - github.ref == 'refs/heads/main' || - (github.event_name == 'workflow_dispatch' && github.event.inputs.run_integration == 'true') - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v4 - - name: Set up Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Download dependencies - run: go mod download - - name: Run integration tests - env: - GLABS_RUN_GITLAB_TC: "1" - run: | - go test -tags=integration ./gitlab -run TestIntegration_GitLab_GroupAndProjectLifecycle -count=1 -v + ```text + ${{ steps.coverage-output.outputs.coverage_text }} + ``` - release: - runs-on: ubuntu-latest - needs: - - golangci - - test-fast - - coverage - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: go-semantic-release/action@v1.0.0 - with: - changelog-generator-opt: "emojis=true" - allow-initial-development-versions: true - hooks: goreleaser - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + test-integration: + name: test (integration) + runs-on: ubuntu-latest + timeout-minutes: 90 + if: >- + github.ref == 'refs/heads/main' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_integration == 'true') + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Run integration tests + env: + GLABS_RUN_GITLAB_TC: "1" + run: | + go test -tags=integration ./gitlab -run TestIntegration_GitLab_GroupAndProjectLifecycle -count=1 -v + + release: + runs-on: ubuntu-latest + needs: + - golangci + - test-fast + - coverage + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: go-semantic-release/action@v1.0.0 + with: + changelog-generator-opt: "emojis=true" + allow-initial-development-versions: true + hooks: goreleaser + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 706072c3af08d5810759b84c838f293da41be8e6 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 13:55:51 +0200 Subject: [PATCH 08/17] test: add broad happy-path edge and error coverage --- config/accesslevel_test.go | 23 ++ config/course_test.go | 51 +++++ config/seeder_test.go | 73 ++++++ config/setters_test.go | 74 ++++++ config/urls_show_test.go | 227 +++++++++++++++++++ git/auth_test.go | 69 ++++++ git/clone_helpers_test.go | 104 +++++++++ gitlab/archive_contract_test.go | 282 +++++++++++++++++++++++ gitlab/delete_contract_test.go | 213 ++++++++++++++++++ gitlab/protect_contract_test.go | 359 ++++++++++++++++++++++++++++++ gitlab/report_contract_test.go | 314 ++++++++++++++++++++++++++ gitlab/report_helper.go | 2 + gitlab/setaccess_contract_test.go | 271 ++++++++++++++++++++++ gitlab/update_contract_test.go | 169 ++++++++++++++ gitlab/users_contract_test.go | 270 ++++++++++++++++++++++ 15 files changed, 2501 insertions(+) create mode 100644 config/accesslevel_test.go create mode 100644 config/course_test.go create mode 100644 config/seeder_test.go create mode 100644 config/setters_test.go create mode 100644 config/urls_show_test.go create mode 100644 git/auth_test.go create mode 100644 git/clone_helpers_test.go create mode 100644 gitlab/archive_contract_test.go create mode 100644 gitlab/delete_contract_test.go create mode 100644 gitlab/protect_contract_test.go create mode 100644 gitlab/report_contract_test.go create mode 100644 gitlab/setaccess_contract_test.go create mode 100644 gitlab/update_contract_test.go create mode 100644 gitlab/users_contract_test.go diff --git a/config/accesslevel_test.go b/config/accesslevel_test.go new file mode 100644 index 0000000..cd9f9de --- /dev/null +++ b/config/accesslevel_test.go @@ -0,0 +1,23 @@ +package config + +import "testing" + +func TestAccessLevelString(t *testing.T) { + tests := []struct { + level AccessLevel + want string + }{ + {Guest, "guest"}, + {Reporter, "reporter"}, + {Developer, "developer"}, + {Maintainer, "maintainer"}, + {AccessLevel(99), "maintainer"}, // default: anything != 10/20/30 returns maintainer + } + + for _, tc := range tests { + got := tc.level.String() + if got != tc.want { + t.Errorf("AccessLevel(%d).String() = %q, want %q", tc.level, got, tc.want) + } + } +} diff --git a/config/course_test.go b/config/course_test.go new file mode 100644 index 0000000..3da6d73 --- /dev/null +++ b/config/course_test.go @@ -0,0 +1,51 @@ +package config + +import ( + "testing" + + "github.com/spf13/viper" +) + +func TestGetCourseConfig_HappyPath_Students(t *testing.T) { + resetViper(t) + viper.Set("mpd.students", []string{"alice", "bob"}) + + cc := GetCourseConfig("mpd") + if cc == nil { + t.Fatal("GetCourseConfig() returned nil") + } + if cc.Course != "mpd" { + t.Fatalf("Course = %q, want %q", cc.Course, "mpd") + } + if len(cc.Students) != 2 { + t.Fatalf("Students = %d, want 2", len(cc.Students)) + } +} + +func TestGetCourseConfig_HappyPath_Groups(t *testing.T) { + resetViper(t) + viper.Set("mpd.groups.team1.members", []string{"alice"}) + viper.Set("mpd.groups.team2.members", []string{"bob", "carol"}) + + cc := GetCourseConfig("mpd") + if cc == nil { + t.Fatal("GetCourseConfig() returned nil") + } + if len(cc.Groups) != 2 { + t.Fatalf("Groups = %d, want 2", len(cc.Groups)) + } +} + +func TestGetCourseConfig_NoStudentsNoGroups(t *testing.T) { + resetViper(t) + // Just set the top-level key so IsSet("mpd") returns true + viper.Set("mpd.semesterpath", "ss26") + + cc := GetCourseConfig("mpd") + if cc == nil { + t.Fatal("GetCourseConfig() returned nil") + } + if cc.Course != "mpd" { + t.Fatalf("Course = %q, want %q", cc.Course, "mpd") + } +} diff --git a/config/seeder_test.go b/config/seeder_test.go new file mode 100644 index 0000000..fec699d --- /dev/null +++ b/config/seeder_test.go @@ -0,0 +1,73 @@ +package config + +import ( + "testing" + + "github.com/spf13/viper" +) + +func TestSeeder_NoSeeder_ReturnsNil(t *testing.T) { + resetViper(t) + if got := seeder("course.a1"); got != nil { + t.Fatalf("seeder() = %#v, want nil", got) + } +} + +func TestSeeder_HappyPath_Defaults(t *testing.T) { + resetViper(t) + viper.Set("course.a1.seeder.cmd", "make") + viper.Set("course.a1.seeder.args", []string{"build", "test"}) + viper.Set("course.a1.seeder.name", "Seed Bot") + viper.Set("course.a1.seeder.email", "bot@example.com") + + got := seeder("course.a1") + if got == nil { + t.Fatal("seeder() returned nil, want non-nil") + } + if got.Command != "make" { + t.Fatalf("Command = %q, want %q", got.Command, "make") + } + if got.ToBranch != "main" { + t.Fatalf("ToBranch = %q, want default %q", got.ToBranch, "main") + } + if got.Name != "Seed Bot" { + t.Fatalf("Name = %q, want %q", got.Name, "Seed Bot") + } + if got.EMail != "bot@example.com" { + t.Fatalf("EMail = %q, want %q", got.EMail, "bot@example.com") + } + if got.SignKey != nil { + t.Fatal("SignKey should be nil when not set") + } +} + +func TestSeeder_ToBranchOverride(t *testing.T) { + resetViper(t) + viper.Set("course.a1.seeder.cmd", "make") + viper.Set("course.a1.seeder.toBranch", "develop") + + got := seeder("course.a1") + if got == nil { + t.Fatal("seeder() returned nil") + } + if got.ToBranch != "develop" { + t.Fatalf("ToBranch = %q, want %q", got.ToBranch, "develop") + } +} + +func TestSeeder_WithArgs(t *testing.T) { + resetViper(t) + viper.Set("course.a1.seeder.cmd", "python") + viper.Set("course.a1.seeder.args", []string{"seed.py", "--verbose"}) + + got := seeder("course.a1") + if got == nil { + t.Fatal("seeder() returned nil") + } + if len(got.Args) != 2 { + t.Fatalf("Args = %v, want 2 elements", got.Args) + } + if got.Args[0] != "seed.py" { + t.Fatalf("Args[0] = %q, want seed.py", got.Args[0]) + } +} diff --git a/config/setters_test.go b/config/setters_test.go new file mode 100644 index 0000000..969ee00 --- /dev/null +++ b/config/setters_test.go @@ -0,0 +1,74 @@ +package config + +import "testing" + +func TestSetBranch(t *testing.T) { + cfg := &AssignmentConfig{Clone: &Clone{Branch: "main"}} + cfg.SetBranch("develop") + if cfg.Clone.Branch != "develop" { + t.Fatalf("SetBranch() = %q, want %q", cfg.Clone.Branch, "develop") + } +} + +func TestSetBranch_Empty(t *testing.T) { + cfg := &AssignmentConfig{Clone: &Clone{Branch: "main"}} + cfg.SetBranch("") + if cfg.Clone.Branch != "" { + t.Fatalf("SetBranch(\"\") = %q, want empty", cfg.Clone.Branch) + } +} + +func TestSetProtectToBranch_WithBranch(t *testing.T) { + cfg := &AssignmentConfig{Startercode: &Startercode{ToBranch: "main"}} + cfg.SetProtectToBranch("feature") + if cfg.Startercode.ToBranch != "feature" { + t.Fatalf("ToBranch = %q, want %q", cfg.Startercode.ToBranch, "feature") + } + if !cfg.Startercode.ProtectToBranch { + t.Fatal("ProtectToBranch should be true") + } +} + +func TestSetProtectToBranch_EmptyBranch(t *testing.T) { + cfg := &AssignmentConfig{Startercode: &Startercode{ToBranch: "main"}} + cfg.SetProtectToBranch("") + // empty: ToBranch stays unchanged, ProtectToBranch is set to true + if cfg.Startercode.ToBranch != "main" { + t.Fatalf("ToBranch = %q, want %q", cfg.Startercode.ToBranch, "main") + } + if !cfg.Startercode.ProtectToBranch { + t.Fatal("ProtectToBranch should be true even with empty branch string") + } +} + +func TestSetLocalpath(t *testing.T) { + cfg := &AssignmentConfig{Clone: &Clone{LocalPath: ""}} + cfg.SetLocalpath("/tmp/repos") + if cfg.Clone.LocalPath != "/tmp/repos" { + t.Fatalf("LocalPath = %q, want /tmp/repos", cfg.Clone.LocalPath) + } +} + +func TestSetLocalpath_Override(t *testing.T) { + cfg := &AssignmentConfig{Clone: &Clone{LocalPath: "/old"}} + cfg.SetLocalpath("/new") + if cfg.Clone.LocalPath != "/new" { + t.Fatalf("LocalPath = %q, want /new", cfg.Clone.LocalPath) + } +} + +func TestSetForce(t *testing.T) { + cfg := &AssignmentConfig{Clone: &Clone{Force: false}} + cfg.SetForce() + if !cfg.Clone.Force { + t.Fatal("Force should be true after SetForce()") + } +} + +func TestSetForce_Idempotent(t *testing.T) { + cfg := &AssignmentConfig{Clone: &Clone{Force: true}} + cfg.SetForce() + if !cfg.Clone.Force { + t.Fatal("Force should remain true after second SetForce()") + } +} diff --git a/config/urls_show_test.go b/config/urls_show_test.go new file mode 100644 index 0000000..a91a521 --- /dev/null +++ b/config/urls_show_test.go @@ -0,0 +1,227 @@ +package config + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" +) + +// captureStdout redirects os.Stdout to a pipe and returns what was written. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe(): %v", err) + } + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func TestUrls_AssignmentURL(t *testing.T) { + cfg := &AssignmentConfig{URL: "https://gitlab.example.org/mpd/ss26/blatt-01"} + out := captureStdout(t, func() { cfg.Urls(true) }) + if out != "https://gitlab.example.org/mpd/ss26/blatt-01\n" { + t.Fatalf("Urls(true) = %q", out) + } +} + +func TestUrls_PerStudent(t *testing.T) { + alice := "alice" + bob := "bob" + cfg := &AssignmentConfig{ + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Name: "blatt01", + Course: "mpd", + UseCoursenameAsPrefix: true, + Per: PerStudent, + Students: []*Student{ + {Username: &alice, Raw: "alice"}, + {Username: &bob, Raw: "bob"}, + }, + } + out := captureStdout(t, func() { cfg.Urls(false) }) + want := fmt.Sprintf("%s/%s\n%s/%s\n", + cfg.URL, cfg.RepoNameForStudent(cfg.Students[0]), + cfg.URL, cfg.RepoNameForStudent(cfg.Students[1]), + ) + if out != want { + t.Fatalf("Urls(PerStudent) = %q, want %q", out, want) + } +} + +func TestUrls_PerGroup(t *testing.T) { + cfg := &AssignmentConfig{ + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Name: "blatt01", + Course: "mpd", + UseCoursenameAsPrefix: true, + Per: PerGroup, + Groups: []*Group{ + {Name: "team1"}, + {Name: "team2"}, + }, + } + out := captureStdout(t, func() { cfg.Urls(false) }) + want := fmt.Sprintf("%s/%s\n%s/%s\n", + cfg.URL, cfg.RepoNameForGroup(cfg.Groups[0]), + cfg.URL, cfg.RepoNameForGroup(cfg.Groups[1]), + ) + if out != want { + t.Fatalf("Urls(PerGroup) = %q, want %q", out, want) + } +} + +func TestShow_Minimal(t *testing.T) { + cfg := &AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + // Show() should not panic with minimal config + cfg.Show() +} + +func TestShow_ContainerRegistryEnabled(t *testing.T) { + cfg := &AssignmentConfig{ContainerRegistry: true} + cfg.Show() +} + +func TestShow_WithStartercode_NoIssues(t *testing.T) { + cfg := &AssignmentConfig{ + Startercode: &Startercode{ + URL: "https://gitlab.example.org/starter", + FromBranch: "main", + ToBranch: "main", + ReplicateIssue: false, + }, + } + cfg.Show() +} + +func TestShow_WithStartercode_WithIssues(t *testing.T) { + cfg := &AssignmentConfig{ + Startercode: &Startercode{ + URL: "https://gitlab.example.org/starter", + FromBranch: "main", + ToBranch: "main", + ReplicateIssue: true, + IssueNumbers: []int{1, 2, 3}, + }, + } + cfg.Show() +} + +func TestShow_WithSeeder(t *testing.T) { + cfg := &AssignmentConfig{ + Seeder: &Seeder{ + Command: "make", + Args: []string{"build"}, + Name: "Bot", + EMail: "bot@example.com", + ToBranch: "main", + ProtectToBranch: true, + }, + } + cfg.Show() +} + +func TestShow_WithClone(t *testing.T) { + cfg := &AssignmentConfig{ + Clone: &Clone{LocalPath: "/tmp/repos", Branch: "main", Force: true}, + } + cfg.Show() +} + +func TestShow_WithRelease_MergeRequestAndDockerImages(t *testing.T) { + cfg := &AssignmentConfig{ + Release: &Release{ + MergeRequest: &MergeRequest{ + SourceBranch: "develop", + TargetBranch: "main", + HasPipeline: true, + }, + DockerImages: []string{"myimage:latest", "myimage:1.0"}, + }, + } + cfg.Show() +} + +func TestShow_WithRelease_MergeRequestOnly(t *testing.T) { + cfg := &AssignmentConfig{ + Release: &Release{ + MergeRequest: &MergeRequest{ + SourceBranch: "develop", + TargetBranch: "main", + }, + }, + } + cfg.Show() +} + +func TestShow_WithRelease_DockerImagesOnly(t *testing.T) { + cfg := &AssignmentConfig{ + Release: &Release{ + DockerImages: []string{"myimage:latest"}, + }, + } + cfg.Show() +} + +func TestShow_OutputContainsCourseName(t *testing.T) { + cfg := &AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + } + out := captureStdout(t, func() { cfg.Show() }) + if !strings.Contains(out, "mpd") { + t.Fatalf("Show() output does not contain course name %q: %q", "mpd", out) + } +} + +func TestShow_PerStudent_ListsStudents(t *testing.T) { + alice := "alice" + bob := "bob" + cfg := &AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Per: PerStudent, + Students: []*Student{ + {Username: &alice, Raw: "alice"}, + {Username: &bob, Raw: "bob"}, + }, + } + out := captureStdout(t, func() { cfg.Show() }) + if !strings.Contains(out, "alice") { + t.Fatalf("Show(PerStudent) output does not contain student alice: %q", out) + } + if !strings.Contains(out, "bob") { + t.Fatalf("Show(PerStudent) output does not contain student bob: %q", out) + } +} + +func TestShow_PerGroup_ListsGroups(t *testing.T) { + alice := "alice" + bob := "bob" + cfg := &AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Per: PerGroup, + Groups: []*Group{ + {Name: "team1", Members: []*Student{{Username: &alice, Raw: "alice"}, {Username: &bob, Raw: "bob"}}}, + {Name: "team2", Members: []*Student{{Username: &alice, Raw: "carol"}}}, + }, + } + out := captureStdout(t, func() { cfg.Show() }) + if !strings.Contains(out, "team1") { + t.Fatalf("Show(PerGroup) output does not contain team1: %q", out) + } +} diff --git a/git/auth_test.go b/git/auth_test.go new file mode 100644 index 0000000..7bf6696 --- /dev/null +++ b/git/auth_test.go @@ -0,0 +1,69 @@ +package git + +import ( + "os" + "testing" + + "github.com/spf13/viper" +) + +func resetViper(t *testing.T) { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) +} + +func TestGetAuth_NoKeyConfigured(t *testing.T) { + resetViper(t) + // sshprivatekey not set → returns nil, nil + + auth, err := GetAuth() + if err != nil { + t.Fatalf("GetAuth() error = %v, want nil", err) + } + if auth != nil { + t.Fatalf("GetAuth() = %v, want nil", auth) + } +} + +func TestGetAuth_ExplicitlyEmpty(t *testing.T) { + resetViper(t) + viper.Set("sshprivatekey", "") + + auth, err := GetAuth() + if err != nil { + t.Fatalf("GetAuth() error = %v, want nil", err) + } + if auth != nil { + t.Fatalf("GetAuth() = %v, want nil", auth) + } +} + +func TestGetAuth_MissingFile(t *testing.T) { + resetViper(t) + viper.Set("sshprivatekey", "/nonexistent/totally/missing/key") + + _, err := GetAuth() + if err == nil { + t.Fatal("GetAuth() expected error for missing file, got nil") + } +} + +func TestGetAuth_InvalidKeyContent(t *testing.T) { + resetViper(t) + + f, err := os.CreateTemp(t.TempDir(), "sshkey-*") + if err != nil { + t.Fatalf("creating temp file: %v", err) + } + _, _ = f.WriteString("this is not a valid SSH private key") + f.Close() + + viper.Set("sshprivatekey", f.Name()) + + // File exists but content is not a valid key → error from ssh.NewPublicKeysFromFile + _, err = GetAuth() + if err == nil { + t.Fatal("GetAuth() expected error for invalid key content, got nil") + } +} diff --git a/git/clone_helpers_test.go b/git/clone_helpers_test.go new file mode 100644 index 0000000..be4dfb8 --- /dev/null +++ b/git/clone_helpers_test.go @@ -0,0 +1,104 @@ +package git + +import ( + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +func TestCloneurl_HTTPS(t *testing.T) { + cfg := &config.AssignmentConfig{ + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Name: "blatt01", + Course: "mpd", + UseCoursenameAsPrefix: true, + } + + got := cloneurl(cfg, "alice") + want := "git@gitlab.example.org:mpd/ss26/blatt-01/mpd-blatt01-alice" + if got != want { + t.Fatalf("cloneurl() = %q, want %q", got, want) + } +} + +func TestCloneurl_ContainsExpectedParts(t *testing.T) { + cfg := &config.AssignmentConfig{ + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Name: "blatt01", + Course: "mpd", + UseCoursenameAsPrefix: true, + } + + got := cloneurl(cfg, "bob") + + if strings.Contains(got, "https://") { + t.Fatalf("cloneurl() should not contain https://, got %q", got) + } + if !strings.HasPrefix(got, "git@") { + t.Fatalf("cloneurl() should start with git@, got %q", got) + } + if !strings.HasSuffix(got, "bob") { + t.Fatalf("cloneurl() should end with suffix, got %q", got) + } +} + +func TestCloneurl_WithoutCoursePrefix(t *testing.T) { + cfg := &config.AssignmentConfig{ + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Name: "blatt01", + Course: "mpd", + UseCoursenameAsPrefix: false, + } + + got := cloneurl(cfg, "team1") + want := "git@gitlab.example.org:mpd/ss26/blatt-01/blatt01-team1" + if got != want { + t.Fatalf("cloneurl() = %q, want %q", got, want) + } +} + +func TestLocalpath(t *testing.T) { + cfg := &config.AssignmentConfig{ + Clone: &config.Clone{LocalPath: "/home/user/repos"}, + Name: "blatt01", + Course: "mpd", + UseCoursenameAsPrefix: true, + } + + got := localpath(cfg, "alice") + want := "/home/user/repos/mpd-blatt01-alice" + if got != want { + t.Fatalf("localpath() = %q, want %q", got, want) + } +} + +func TestLocalpath_TrailingSlash(t *testing.T) { + cfg := &config.AssignmentConfig{ + Clone: &config.Clone{LocalPath: "/repos"}, + Name: "hw01", + Course: "fun", + UseCoursenameAsPrefix: true, + } + + got := localpath(cfg, "team2") + want := "/repos/fun-hw01-team2" + if got != want { + t.Fatalf("localpath() = %q, want %q", got, want) + } +} + +func TestLocalpath_WithoutCoursePrefix(t *testing.T) { + cfg := &config.AssignmentConfig{ + Clone: &config.Clone{LocalPath: "/repos"}, + Name: "hw01", + Course: "fun", + UseCoursenameAsPrefix: false, + } + + got := localpath(cfg, "teamA") + want := "/repos/hw01-teamA" + if got != want { + t.Fatalf("localpath() = %q, want %q", got, want) + } +} diff --git a/gitlab/archive_contract_test.go b/gitlab/archive_contract_test.go new file mode 100644 index 0000000..7010c56 --- /dev/null +++ b/gitlab/archive_contract_test.go @@ -0,0 +1,282 @@ +package gitlab + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +// projectJSONStr returns a minimal project JSON body. +func projectJSONStr(id int64, name, pathNS string) string { + return fmt.Sprintf(`{"id":%d,"name":%q,"path_with_namespace":%q,"ssh_url_to_repo":"git@gitlab.example.org:%s.git"}`, + id, name, pathNS, pathNS) +} + +// -- Archive ------------------------------------------------------------------ + +func TestArchive_GroupNotFound_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Group Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerStudent, + Startercode: &config.Startercode{ToBranch: "main"}, + } + assertExitCode(t, 1, func() { client.Archive(cfg, false) }) +} + +func TestArchive_InvalidPer_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerFailed, + Startercode: &config.Startercode{ToBranch: "main"}, + } + assertExitCode(t, 1, func() { client.Archive(cfg, false) }) +} + +// -- archivePerStudent -------------------------------------------------------- + +func TestArchivePerStudent_NoStudents(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Students: []*config.Student{}, + Startercode: &config.Startercode{ToBranch: "main"}, + } + // No students → returns immediately without calling API + client.archivePerStudent(cfg, false) +} + +func TestArchivePerStudent_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + Startercode: &config.Startercode{ToBranch: "main"}, + } + // GetProject fails → prints error, returns + client.archivePerStudent(cfg, false) +} + +func TestArchivePerStudent_Success_Archive(t *testing.T) { + pj := `{"id":1,"name":"mpd-blatt01-alice","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-alice","ssh_url_to_repo":"git@gitlab.example.org:mpd/ss26/blatt-01/mpd-blatt01-alice.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-alice"): + _, _ = w.Write([]byte(pj)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/archive"): + _, _ = w.Write([]byte(pj)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + Startercode: &config.Startercode{ToBranch: "main"}, + } + client.archivePerStudent(cfg, false) +} + +func TestArchivePerStudent_Success_Unarchive(t *testing.T) { + pj := `{"id":1,"name":"mpd-blatt01-alice","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-alice","ssh_url_to_repo":"git@gitlab.example.org:mpd/ss26/blatt-01/mpd-blatt01-alice.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-alice"): + _, _ = w.Write([]byte(pj)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/unarchive"): + _, _ = w.Write([]byte(pj)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + Startercode: &config.Startercode{ToBranch: "main"}, + } + client.archivePerStudent(cfg, true) // unarchive=true +} + +// -- archivePerGroup ---------------------------------------------------------- + +func TestArchivePerGroup_NoGroups(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + Groups: []*config.Group{}, + Startercode: &config.Startercode{ToBranch: "main"}, + } + client.archivePerGroup(cfg, false) +} + +func TestArchivePerGroup_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{{Name: "team1"}}, + Startercode: &config.Startercode{ToBranch: "main"}, + } + client.archivePerGroup(cfg, false) +} + +func TestArchivePerGroup_Success(t *testing.T) { + pj := `{"id":2,"name":"mpd-blatt01-team1","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-team1","ssh_url_to_repo":"git@gitlab.example.org:mpd/ss26/blatt-01/mpd-blatt01-team1.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-team1"): + _, _ = w.Write([]byte(pj)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/archive"): + _, _ = w.Write([]byte(pj)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + alice := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + Startercode: &config.Startercode{ToBranch: "main"}, + } + client.archivePerGroup(cfg, false) +} + +// -- archive (low-level) ------------------------------------------------------ + +func TestArchive_ArchiveFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/archive") { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"500 Internal Server Error"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Startercode: &config.Startercode{ToBranch: "main"}, + } + err := client.archive(cfg, &gitlabapi.Project{ID: 3, Name: "myrepo"}, false, false) + if err == nil { + t.Fatal("archive() expected error on 500, got nil") + } +} + +func TestArchive_UnarchiveFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/unarchive") { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"403 Forbidden"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Startercode: &config.Startercode{ToBranch: "main"}, + } + err := client.archive(cfg, &gitlabapi.Project{ID: 4, Name: "myrepo"}, false, true) + if err == nil { + t.Fatal("archive() unarchive expected error on 403, got nil") + } +} + +func TestArchive_Success(t *testing.T) { + pj := projectJSONStr(5, "myrepo", "mpd/ss26/myrepo") + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/archive") { + _, _ = w.Write([]byte(pj)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Startercode: &config.Startercode{ToBranch: "main"}, + } + err := client.archive(cfg, &gitlabapi.Project{ID: 5, Name: "myrepo"}, false, false) + if err != nil { + t.Fatalf("archive() error = %v", err) + } +} diff --git a/gitlab/delete_contract_test.go b/gitlab/delete_contract_test.go new file mode 100644 index 0000000..e2ed53a --- /dev/null +++ b/gitlab/delete_contract_test.go @@ -0,0 +1,213 @@ +package gitlab + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +// groupSearchHandler returns a handler that mocks getGroupID + Search.ProjectsByGroup + DeleteProject. +// groupID: the group to return for path "mpd/ss26/blatt-01" +// projectID: the project to return from group search, 0 → empty results +// deletePath: the DELETE path that should succeed +func makeDeleteHandler(groupID, projectID int64, projectName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + // getGroupIDByFullPath → SearchGroup + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + fmt.Fprintf(w, `[{"id":%d,"full_path":"mpd/ss26/blatt-01"}]`, groupID) + + // Search.ProjectsByGroup + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, fmt.Sprintf("/api/v4/groups/%d/search", groupID)): + if projectID == 0 { + _, _ = w.Write([]byte(`[]`)) + } else { + fmt.Fprintf(w, `[{"id":%d,"name":%q}]`, projectID, projectName) + } + + // DeleteProject + case r.Method == http.MethodDelete && r.URL.Path == fmt.Sprintf("/api/v4/projects/%d", projectID): + w.WriteHeader(http.StatusAccepted) + + default: + w.WriteHeader(http.StatusNotFound) + } + } +} + +// ---- Delete (top-level) ----------------------------------------------------- + +func TestDelete_GroupNotFound_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + } + assertExitCode(t, 1, func() { client.Delete(cfg) }) +} + +func TestDelete_InvalidPer_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerFailed, + } + assertExitCode(t, 1, func() { client.Delete(cfg) }) +} + +// ---- deletePerStudent ------------------------------------------------------- + +func TestDeletePerStudent_NoStudents(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Students: []*config.Student{}, + } + client.deletePerStudent(cfg, 1) +} + +func TestDeletePerStudent_ProjectFound_Deleted(t *testing.T) { + username := "alice" + client := newContractClient(t, makeDeleteHandler(1, 42, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + } + client.deletePerStudent(cfg, 1) +} + +func TestDeletePerStudent_ProjectNotFound(t *testing.T) { + username := "alice" + client := newContractClient(t, makeDeleteHandler(1, 0, "")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + } + // Project not found → delete is a no-op + client.deletePerStudent(cfg, 1) +} + +// ---- deletePerGroup --------------------------------------------------------- + +func TestDeletePerGroup_NoGroups(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + Groups: []*config.Group{}, + } + client.deletePerGroup(cfg, 1) +} + +func TestDeletePerGroup_ProjectFound_Deleted(t *testing.T) { + alice := "alice" + client := newContractClient(t, makeDeleteHandler(1, 43, "mpd-blatt01-team1")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + } + client.deletePerGroup(cfg, 1) +} + +// ---- delete (low-level) ----------------------------------------------------- + +func TestDelete_LowLevel_SearchError(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"500 Internal Server Error"}`)) + }) + + // Should log error and return without panicking + client.delete(1, "myrepo") +} + +func TestDelete_LowLevel_EmptyResults(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/search") { + _, _ = w.Write([]byte(`[]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + // No results → nothing to delete + client.delete(1, "myrepo") +} + +func TestDelete_LowLevel_NameMismatch(t *testing.T) { + // Search returns a project but with a different name → no deletion + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/search") { + _, _ = w.Write([]byte(`[{"id":99,"name":"different-name"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + client.delete(1, "myrepo") +} + +func TestDelete_LowLevel_DeleteFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/search"): + _, _ = w.Write([]byte(`[{"id":99,"name":"myrepo"}]`)) + case r.Method == http.MethodDelete && r.URL.Path == "/api/v4/projects/99": + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"403 Forbidden"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + // delete fails → logs error, no panic + client.delete(1, "myrepo") +} diff --git a/gitlab/protect_contract_test.go b/gitlab/protect_contract_test.go new file mode 100644 index 0000000..b3e7a5e --- /dev/null +++ b/gitlab/protect_contract_test.go @@ -0,0 +1,359 @@ +package gitlab + +import ( + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +// ---- ProtectToBranch (top-level) -------------------------------------------- + +func TestProtectToBranch_GroupNotFound_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + assertExitCode(t, 1, func() { client.ProtectToBranch(cfg) }) +} + +func TestProtectToBranch_InvalidPer_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerFailed, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + assertExitCode(t, 1, func() { client.ProtectToBranch(cfg) }) +} + +// ---- protectToBranchPerStudent ---------------------------------------------- + +func TestProtectToBranchPerStudent_NoStudents(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Students: []*config.Student{}, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + client.protectToBranchPerStudent(cfg) +} + +func TestProtectToBranchPerStudent_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + // GetProject fails → prints error and returns + client.protectToBranchPerStudent(cfg) +} + +func TestProtectToBranchPerStudent_Success(t *testing.T) { + pj := `{"id":1,"name":"mpd-blatt01-alice","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-alice","ssh_url_to_repo":"git@example.com:mpd/ss26/mpd-blatt01-alice.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-alice"): + _, _ = w.Write([]byte(pj)) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + client.protectToBranchPerStudent(cfg) +} + +// ---- protectToBranchPerGroup ------------------------------------------------ + +func TestProtectToBranchPerGroup_NoGroups(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + Groups: []*config.Group{}, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + client.protectToBranchPerGroup(cfg) +} + +func TestProtectToBranchPerGroup_Success(t *testing.T) { + pj := `{"id":2,"name":"mpd-blatt01-team1","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-team1","ssh_url_to_repo":"git@example.com:mpd/ss26/mpd-blatt01-team1.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-team1"): + _, _ = w.Write([]byte(pj)) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + alice := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + client.protectToBranchPerGroup(cfg) +} + +// ---- protectBranch ---------------------------------------------------------- + +func TestProtectBranch_NoFlags_IsNoOp(t *testing.T) { + // Neither ProtectToBranch nor ProtectDevBranchMergeOnly → nothing happens + called := false + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: false, + ProtectDevBranchMergeOnly: false, + }, + } + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectBranch(cfg, project, false) + if err != nil { + t.Fatalf("protectBranch() (no-op) error = %v", err) + } + if called { + t.Fatal("protectBranch() made HTTP calls when neither flag is set") + } +} + +func TestProtectBranch_ProtectToBranch_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{ + ToBranch: "main", + DevBranch: "develop", + ProtectToBranch: true, + }, + } + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectBranch(cfg, project, false) + if err != nil { + t.Fatalf("protectBranch(ProtectToBranch) error = %v", err) + } +} + +func TestProtectBranch_ProtectDevBranchMergeOnly_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"develop"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{ + ToBranch: "main", + DevBranch: "develop", + ProtectDevBranchMergeOnly: true, + }, + } + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectBranch(cfg, project, false) + if err != nil { + t.Fatalf("protectBranch(ProtectDevBranchMergeOnly) error = %v", err) + } +} + +func TestProtectBranch_BothSameBranch_Success(t *testing.T) { + // ProtectDevBranchMergeOnly=true AND DevBranch==ToBranch → single protectSingleBranch call + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{ + ToBranch: "main", + DevBranch: "main", // same as ToBranch + ProtectToBranch: true, + ProtectDevBranchMergeOnly: true, + }, + } + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectBranch(cfg, project, false) + if err != nil { + t.Fatalf("protectBranch(both, same branch) error = %v", err) + } +} + +// ---- protectSingleBranch ---------------------------------------------------- + +func TestProtectSingleBranch_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectSingleBranch(project, "main", gitlabapi.MaintainerPermissions, gitlabapi.MaintainerPermissions) + if err != nil { + t.Fatalf("protectSingleBranch() error = %v", err) + } +} + +func TestProtectSingleBranch_UnprotectFails_ProtectStillCalled(t *testing.T) { + // Unprotect returns 404 (branch not yet protected) → protectSingleBranch continues + protectCalled := false + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNotFound) // not protected yet + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + protectCalled = true + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectSingleBranch(project, "main", gitlabapi.NoPermissions, gitlabapi.DeveloperPermissions) + if err != nil { + t.Fatalf("protectSingleBranch() error = %v", err) + } + if !protectCalled { + t.Fatal("protectSingleBranch() did not call ProtectRepositoryBranches") + } +} + +func TestProtectSingleBranch_ProtectFails_ReturnsError(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"403 Forbidden"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectSingleBranch(project, "main", gitlabapi.MaintainerPermissions, gitlabapi.MaintainerPermissions) + if err == nil { + t.Fatal("protectSingleBranch() expected error on 403, got nil") + } +} diff --git a/gitlab/report_contract_test.go b/gitlab/report_contract_test.go new file mode 100644 index 0000000..a041f7a --- /dev/null +++ b/gitlab/report_contract_test.go @@ -0,0 +1,314 @@ +package gitlab + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +// makeFullReportHandler returns a handler that mocks all calls needed by report(). +// It serves: getGroupID, ListGroupProjects, and full projectReport for one project. +func makeFullReportHandler(groupID, projectID int64, projectName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + // getGroupIDByFullPath + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + json.NewEncoder(w).Encode([]map[string]interface{}{ //nolint + {"id": groupID, "full_path": "mpd/ss26/blatt-01"}, + }) + + // ListGroupProjects - no next page + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/projects") && + strings.Contains(r.URL.Path, "/groups/"): + json.NewEncoder(w).Encode([]map[string]interface{}{ //nolint + {"id": projectID, "name": projectName, + "created_at": "2026-04-01T09:00:00Z", + "last_activity_at": "2026-04-02T10:00:00Z", + "path_with_namespace": "mpd/ss26/blatt-01/" + projectName, + "ssh_url_to_repo": "git@example.com:mpd/ss26/blatt-01/" + projectName + ".git"}, + }) + + // ListBranches + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/repository/branches"): + _, _ = w.Write([]byte(`[{"name":"main"}]`)) + + // ListCommits + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/repository/commits"): + _, _ = w.Write([]byte(`[{"title":"first commit","committer_name":"alice","committed_date":"2026-04-01T10:00:00Z","web_url":"https://gitlab.example.org/c1"}]`)) + + // ListProjectMembers + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/members"): + _, _ = w.Write([]byte(`[{"id":1,"name":"Alice","username":"alice","web_url":"https://gitlab.example.org/alice"}]`)) + + // ListProjectMergeRequests + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/merge_requests"): + _, _ = w.Write([]byte(`[]`)) + + // Registry (docker images) - return empty + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/registry/repositories"): + _, _ = w.Write([]byte(`[]`)) + + default: + w.WriteHeader(http.StatusNotFound) + } + } +} + +// ---- report (internal) ------------------------------------------------------ + +func TestReport_GroupNotFound_ReturnsNil(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + result := client.report(cfg) + if result != nil { + t.Fatalf("report() = %#v, want nil", result) + } +} + +func TestReport_ListGroupProjectsFails_ReturnsNil(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + default: + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"500 Internal Server Error"}`)) + } + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + result := client.report(cfg) + if result != nil { + t.Fatalf("report() = %#v, want nil after ListGroupProjects failure", result) + } +} + +func TestReport_HappyPath_NoProjects(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/groups/1/projects"): + _, _ = w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + result := client.report(cfg) + if result == nil { + t.Fatal("report() returned nil, want non-nil") + } + if result.Course != "mpd" { + t.Fatalf("Course = %q, want mpd", result.Course) + } + if len(result.Projects) != 0 { + t.Fatalf("Projects = %d, want 0", len(result.Projects)) + } +} + +func TestReport_HappyPath_WithProject(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 10, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + result := client.report(cfg) + if result == nil { + t.Fatal("report() returned nil, want non-nil") + } + if len(result.Projects) != 1 { + t.Fatalf("Projects = %d, want 1", len(result.Projects)) + } +} + +func TestReport_HappyPath_WithRelease(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 10, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Release: &config.Release{ + MergeRequest: &config.MergeRequest{SourceBranch: "develop", TargetBranch: "main"}, + DockerImages: []string{"myimage:latest"}, + }, + } + result := client.report(cfg) + if result == nil { + t.Fatal("report() returned nil, want non-nil") + } + if !result.HasReleaseMergeRequest { + t.Fatal("HasReleaseMergeRequest should be true") + } + if !result.HasReleaseDockerImages { + t.Fatal("HasReleaseDockerImages should be true") + } +} + +// ---- Report (text output) --------------------------------------------------- + +func TestReport_TextTemplate_ToStdout(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 11, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + // nil template → uses default text template; nil output → uses stdout + client.Report(cfg, nil, nil) +} + +func TestReport_TextTemplate_ToFile(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 12, "mpd-blatt01-alice")) + + outFile := filepath.Join(t.TempDir(), "report.txt") + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.Report(cfg, nil, &outFile) + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + if len(data) == 0 { + t.Fatal("Report() wrote empty file") + } +} + +func TestReport_CustomTemplate_ToFile(t *testing.T) { + // Write a custom template file + tmplContent := `Course: {{.Course}}, Assignment: {{.Assignment}}` + tmplFile := filepath.Join(t.TempDir(), "tmpl.txt") + if err := os.WriteFile(tmplFile, []byte(tmplContent), 0644); err != nil { + t.Fatalf("writing template: %v", err) + } + + client := newContractClient(t, makeFullReportHandler(1, 13, "mpd-blatt01-alice")) + + outFile := filepath.Join(t.TempDir(), "out.txt") + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.Report(cfg, &tmplFile, &outFile) + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + if !strings.Contains(string(data), "mpd") { + t.Fatalf("Report() output missing course name: %q", data) + } +} + +// ---- ReportHTML ------------------------------------------------------------- + +func TestReportHTML_DefaultTemplate_ToStdout(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 14, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.ReportHTML(cfg, nil, nil) +} + +func TestReportHTML_DefaultTemplate_ToFile(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 15, "mpd-blatt01-alice")) + + outFile := filepath.Join(t.TempDir(), "report.html") + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.ReportHTML(cfg, nil, &outFile) + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + if len(data) == 0 { + t.Fatal("ReportHTML() wrote empty file") + } +} + +// ---- ReportJSON ------------------------------------------------------------- + +func TestReportJSON_ToStdout(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 16, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.ReportJSON(cfg, nil) +} + +func TestReportJSON_ToFile(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 17, "mpd-blatt01-alice")) + + outFile := filepath.Join(t.TempDir(), "report.json") + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.ReportJSON(cfg, &outFile) + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("ReportJSON() wrote invalid JSON: %v", err) + } + if parsed["course"] != "mpd" { + t.Fatalf("JSON course = %q, want mpd", parsed["course"]) + } +} diff --git a/gitlab/report_helper.go b/gitlab/report_helper.go index 0820bf8..89b09ab 100644 --- a/gitlab/report_helper.go +++ b/gitlab/report_helper.go @@ -80,6 +80,8 @@ func (c *Client) report(assignmentCfg *config.AssignmentConfig) *report.Reports PerPage: 0, }, } + } else { + break } } diff --git a/gitlab/setaccess_contract_test.go b/gitlab/setaccess_contract_test.go new file mode 100644 index 0000000..0432e4d --- /dev/null +++ b/gitlab/setaccess_contract_test.go @@ -0,0 +1,271 @@ +package gitlab + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +// ---- Setaccess (top-level) -------------------------------------------------- + +func TestSetaccess_GroupNotFound_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + } + assertExitCode(t, 1, func() { client.Setaccess(cfg) }) +} + +func TestSetaccess_InvalidPer_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerFailed, + } + assertExitCode(t, 1, func() { client.Setaccess(cfg) }) +} + +// ---- setaccessPerStudent ---------------------------------------------------- + +func TestSetaccessPerStudent_NoStudents(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Students: []*config.Student{}, + AccessLevel: config.Developer, + } + client.setaccessPerStudent(cfg) +} + +func TestSetaccessPerStudent_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + AccessLevel: config.Developer, + } + client.setaccessPerStudent(cfg) +} + +func TestSetaccessPerStudent_Success_NewMember(t *testing.T) { + pj := `{"id":10,"name":"mpd-blatt01-alice","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-alice","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + // GetProject + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-alice"): + _, _ = w.Write([]byte(pj)) + // ListUsers + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/users": + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 5, "username": "alice"}, + }) + // GetInheritedProjectMember + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + // AddProjectMember + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/members": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 30, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + AccessLevel: config.Developer, + } + client.setaccessPerStudent(cfg) +} + +// ---- setaccessPerGroup ------------------------------------------------------ + +func TestSetaccessPerGroup_NoGroups(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + Groups: []*config.Group{}, + AccessLevel: config.Developer, + } + client.setaccessPerGroup(cfg) +} + +func TestSetaccessPerGroup_Success(t *testing.T) { + pj := `{"id":11,"name":"mpd-blatt01-team1","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-team1","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-team1"): + _, _ = w.Write([]byte(pj)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/users": + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 6, "username": "alice"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/11/members/all/6": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/11/members": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 6, "username": "alice", "access_level": 30, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + alice := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + AccessLevel: config.Developer, + } + client.setaccessPerGroup(cfg) +} + +// ---- inviteByEmail ---------------------------------------------------------- + +func TestInviteByEmail_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/invitations" { + _ = json.NewEncoder(w).Encode(map[string]string{"status": "success"}) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.inviteByEmail(cfg, 10, "newuser@example.com") + if err != nil { + t.Fatalf("inviteByEmail() error = %v", err) + } + if info == "" { + t.Fatal("inviteByEmail() returned empty info") + } +} + +func TestInviteByEmail_APIError(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/invitations" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"403 Forbidden"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + _, err := client.inviteByEmail(cfg, 10, "user@example.com") + if err == nil { + t.Fatal("inviteByEmail() expected error on 403, got nil") + } +} + +func TestInviteByEmail_StatusNotSuccess(t *testing.T) { + email := "user@example.com" + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/invitations" { + resp := fmt.Sprintf(`{"status":"error","message":{%q:"User already exists"}}`, email) + _, _ = w.Write([]byte(resp)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + _, err := client.inviteByEmail(cfg, 10, email) + if err == nil { + t.Fatal("inviteByEmail() expected error when status != success, got nil") + } +} + +// ---- setaccess (inviteByEmail path) ----------------------------------------- + +func TestSetaccessPerStudent_UserNotFound_InviteByEmail(t *testing.T) { + // When getUserID fails but student has email → try invite + pj := `{"id":12,"name":"mpd-blatt01-bob","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-bob","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-bob"): + _, _ = w.Write([]byte(pj)) + // ListUsers returns empty (user not found) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/users": + _ = json.NewEncoder(w).Encode([]map[string]interface{}{}) + // Invite by email succeeds + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/12/invitations": + _ = json.NewEncoder(w).Encode(map[string]string{"status": "success"}) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + email := "bob@example.com" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Email: &email, Raw: "bob@example.com"}}, + AccessLevel: config.Developer, + } + client.setaccessPerStudent(cfg) +} diff --git a/gitlab/update_contract_test.go b/gitlab/update_contract_test.go new file mode 100644 index 0000000..92705f8 --- /dev/null +++ b/gitlab/update_contract_test.go @@ -0,0 +1,169 @@ +package gitlab + +import ( + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +// ---- Update (top-level) ----------------------------------------------------- + +func TestUpdate_GroupNotFound_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + } + assertExitCode(t, 1, func() { client.Update(cfg) }) +} + +func TestUpdate_InvalidPer_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerFailed, + } + assertExitCode(t, 1, func() { client.Update(cfg) }) +} + +// ---- updatePerStudent ------------------------------------------------------- + +func TestUpdatePerStudent_NoStudents(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Students: []*config.Student{}, + } + client.updatePerStudent(cfg, nil) +} + +func TestUpdatePerStudent_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + } + // GetProject fails → prints error, returns + client.updatePerStudent(cfg, nil) +} + +func TestUpdatePerStudent_NoStartercode_Success(t *testing.T) { + // No starterrepo → update function just starts/stops spinner, no actual API calls + pj := `{"id":20,"name":"mpd-blatt01-alice","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-alice","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-alice") { + _, _ = w.Write([]byte(pj)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + } + // starterrepo=nil → update() just logs, no push + client.updatePerStudent(cfg, nil) +} + +// ---- updatePerGroup --------------------------------------------------------- + +func TestUpdatePerGroup_NoGroups(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + Groups: []*config.Group{}, + } + client.updatePerGroup(cfg, nil) +} + +func TestUpdatePerGroup_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + alice := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + } + client.updatePerGroup(cfg, nil) +} + +func TestUpdatePerGroup_NoStartercode_Success(t *testing.T) { + pj := `{"id":21,"name":"mpd-blatt01-team1","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-team1","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-team1") { + _, _ = w.Write([]byte(pj)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + alice := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + } + client.updatePerGroup(cfg, nil) +} diff --git a/gitlab/users_contract_test.go b/gitlab/users_contract_test.go new file mode 100644 index 0000000..fe89bfd --- /dev/null +++ b/gitlab/users_contract_test.go @@ -0,0 +1,270 @@ +package gitlab + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/obcode/glabs/config" +) + +// ---- getUser ---------------------------------------------------------------- + +func TestGetUser_ByID_Found(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users/42" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 42, "name": "Alice", "username": "alice", + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + id := 42 + user, err := client.getUser(&config.Student{Id: &id}) + if err != nil { + t.Fatalf("getUser(byID) error = %v", err) + } + if user == nil || user.ID != 42 { + t.Fatalf("user = %#v, want id 42", user) + } +} + +func TestGetUser_ByUsername_Found(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 1, "name": "Alice", "username": "alice"}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "alice" + user, err := client.getUser(&config.Student{Username: &username}) + if err != nil { + t.Fatalf("getUser(byUsername) error = %v", err) + } + if user == nil || user.ID != 1 { + t.Fatalf("user = %#v, want id 1", user) + } +} + +func TestGetUser_ByEmail_Found(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 2, "name": "Bob", "username": "bob", "email": "bob@example.com"}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + email := "bob@example.com" + user, err := client.getUser(&config.Student{Email: &email}) + if err != nil { + t.Fatalf("getUser(byEmail) error = %v", err) + } + if user == nil || user.ID != 2 { + t.Fatalf("user = %#v, want id 2", user) + } +} + +func TestGetUser_NotFound(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{}) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "ghost" + user, err := client.getUser(&config.Student{Username: &username}) + if err == nil { + t.Fatal("getUser() expected error for not found, got nil") + } + if user != nil { + t.Fatalf("user = %#v, want nil", user) + } +} + +func TestGetUser_MultipleFound(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 1, "username": "alice"}, + {"id": 2, "username": "alice2"}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "alice" + user, err := client.getUser(&config.Student{Username: &username}) + if err == nil { + t.Fatal("getUser() expected error for multiple users, got nil") + } + if user != nil { + t.Fatalf("user = %#v, want nil", user) + } +} + +// ---- getUserID -------------------------------------------------------------- + +func TestGetUserID_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 5, "username": "alice"}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "alice" + id, err := client.getUserID(&config.Student{Username: &username}) + if err != nil { + t.Fatalf("getUserID() error = %v", err) + } + if id != 5 { + t.Fatalf("getUserID() = %d, want 5", id) + } +} + +func TestGetUserID_Error(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{}) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "ghost" + _, err := client.getUserID(&config.Student{Username: &username}) + if err == nil { + t.Fatal("getUserID() expected error, got nil") + } +} + +// ---- addMember -------------------------------------------------------------- + +func TestAddMember_NewMember_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "404 Not Found"}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/members": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 30, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.addMember(cfg, 10, 5) + if err != nil { + t.Fatalf("addMember() error = %v", err) + } + if info == "" { + t.Fatal("addMember() returned empty info") + } +} + +func TestAddMember_AlreadyOwner(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 50, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.addMember(cfg, 10, 5) + if err != nil { + t.Fatalf("addMember() error = %v", err) + } + if info != "already owner" { + t.Fatalf("addMember() = %q, want \"already owner\"", info) + } +} + +func TestAddMember_AlreadyMember_SameLevel(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 30, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.addMember(cfg, 10, 5) + if err != nil { + t.Fatalf("addMember() error = %v", err) + } + if info == "" { + t.Fatal("addMember() returned empty info for already-member case") + } +} + +func TestAddMember_AlreadyMember_DifferentLevel_Updated(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 20, // reporter + }) + case r.Method == http.MethodPut && r.URL.Path == "/api/v4/projects/10/members/5": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 30, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.addMember(cfg, 10, 5) + if err != nil { + t.Fatalf("addMember() error = %v", err) + } + if info == "" { + t.Fatal("addMember() returned empty info for level-change case") + } +} + +func TestAddMember_AddFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "404 Not Found"}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/members": + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "403 Forbidden"}) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + _, err := client.addMember(cfg, 10, 5) + if err == nil { + t.Fatal("addMember() expected error on forbidden, got nil") + } +} From 07def9b1f77707b140b0db1d2db493bfc4c6c6d0 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 14:27:21 +0200 Subject: [PATCH 09/17] test: raise coverage for cmd, git clone, and gitlab issues --- cmd/smoke_test.go | 85 ++++++++++++++++++++ git/clone_runtime_test.go | 70 ++++++++++++++++ gitlab/issues_contract_test.go | 142 +++++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 cmd/smoke_test.go create mode 100644 git/clone_runtime_test.go create mode 100644 gitlab/issues_contract_test.go diff --git a/cmd/smoke_test.go b/cmd/smoke_test.go new file mode 100644 index 0000000..4ccd1af --- /dev/null +++ b/cmd/smoke_test.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func resetReportGlobals(t *testing.T) { + t.Helper() + oldHTML := Html + oldJSON := Json + oldTemplate := Template + oldExportTemplate := ExportTemplate + oldOutput := OutPut + t.Cleanup(func() { + Html = oldHTML + Json = oldJSON + Template = oldTemplate + ExportTemplate = oldExportTemplate + OutPut = oldOutput + }) +} + +func TestRootCommand_HasSubcommands(t *testing.T) { + if len(rootCmd.Commands()) == 0 { + t.Fatal("expected root command to have subcommands") + } +} + +func TestReportCmd_ArgsRequireTwoArgs(t *testing.T) { + resetReportGlobals(t) + ExportTemplate = false + + err := reportCmd.Args(reportCmd, []string{"course-only"}) + if err == nil { + t.Fatal("expected args validation error") + } +} + +func TestReportCmd_ArgsAllowNoArgsWhenExportTemplate(t *testing.T) { + resetReportGlobals(t) + ExportTemplate = true + + if err := reportCmd.Args(reportCmd, nil); err != nil { + t.Fatalf("Args() unexpected error: %v", err) + } +} + +func TestReportCmd_RunPanicsForHtmlAndJSONTogether(t *testing.T) { + resetReportGlobals(t) + Html = true + Json = true + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic when --html and --json are both set") + } + }() + + reportCmd.Run(reportCmd, []string{"mpd", "blatt01"}) +} + +func TestReportCmd_ExportDefaultTemplateToFile(t *testing.T) { + resetReportGlobals(t) + ExportTemplate = true + Html = true + + out := filepath.Join(t.TempDir(), "default-report-template.html") + OutPut = out + + reportCmd.Run(reportCmd, nil) + + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("reading template output file failed: %v", err) + } + if len(data) == 0 { + t.Fatal("expected template output file to be non-empty") + } + if !strings.Contains(string(data), " Date: Thu, 23 Apr 2026 14:50:51 +0200 Subject: [PATCH 10/17] test: add integration tests for Archive, Delete, ProtectToBranch, Setaccess --- gitlab/integration_gitlab_test.go | 190 ++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/gitlab/integration_gitlab_test.go b/gitlab/integration_gitlab_test.go index e412cab..24d9bba 100644 --- a/gitlab/integration_gitlab_test.go +++ b/gitlab/integration_gitlab_test.go @@ -127,6 +127,196 @@ func startGitLabContainer(t *testing.T) (*Client, string) { return &Client{apiClient}, baseURL } +// TestIntegration_GitLab_Operations starts one container and exercises Archive, +// Delete, ProtectToBranch and Setaccess end-to-end in sub-tests so that the +// expensive container start-up happens only once. +func TestIntegration_GitLab_Operations(t *testing.T) { + requireIntegrationEnabled(t) + + client, baseURL := startGitLabContainer(t) + + // ── Shared parent group ────────────────────────────────────────────────── + visibility := gitlabapi.PublicVisibility + parentName := "ops-it-parent" + parentPath := "ops-it-parent" + parent, _, err := client.Groups.CreateGroup(&gitlabapi.CreateGroupOptions{ + Name: &parentName, + Path: &parentPath, + Visibility: &visibility, + }) + if err != nil { + t.Fatalf("creating parent group failed: %v", err) + } + + // ── Shared test user (used by Setaccess sub-test) ──────────────────────── + itUsername := "it-testuser" + itName := "IT Testuser" + itEmail := "it-testuser@example.com" + itPassword := "Pa$$w0rd-test-99" + skipConfirmation := true + _, _, err = client.Users.CreateUser(&gitlabapi.CreateUserOptions{ + Username: &itUsername, + Name: &itName, + Email: &itEmail, + Password: &itPassword, + SkipConfirmation: &skipConfirmation, + }) + if err != nil { + t.Fatalf("creating test user failed: %v", err) + } + + // ── Helper: build an AssignmentConfig for a sub-group of the parent ────── + makeAssignmentCfg := func(subPath, studentUsername string) *config.AssignmentConfig { + path := parent.FullPath + "/" + subPath + un := studentUsername + return &config.AssignmentConfig{ + Course: "it", + Name: "a1", + Path: path, + URL: baseURL + "/" + path, + Per: config.PerStudent, + Description: "integration test", + ContainerRegistry: false, + Students: []*config.Student{{Username: &un, Raw: un}}, + } + } + + // ── Helper: create assignment group + student project ──────────────────── + createGroupAndProject := func(t *testing.T, subPath, studentUsername string, withReadme bool) *config.AssignmentConfig { + t.Helper() + cfg := makeAssignmentCfg(subPath, studentUsername) + groupID, err := client.createGroup(cfg) + if err != nil { + t.Fatalf("createGroup(%q) failed: %v", subPath, err) + } + repoName := cfg.RepoNameWithSuffix(studentUsername) + initReadme := withReadme + _, _, err = client.Projects.CreateProject(&gitlabapi.CreateProjectOptions{ + Name: &repoName, + NamespaceID: &groupID, + InitializeWithReadme: &initReadme, + }) + if err != nil { + t.Fatalf("createProject(%q) failed: %v", repoName, err) + } + return cfg + } + + // ── Sub-test: Archive / Unarchive ───────────────────────────────────────── + t.Run("Archive", func(t *testing.T) { + cfg := createGroupAndProject(t, "archive-a1", "student1", false) + projectPath := cfg.Path + "/" + cfg.RepoNameWithSuffix("student1") + + client.Archive(cfg, false) + + proj, _, err := client.Projects.GetProject(projectPath, &gitlabapi.GetProjectOptions{}) + if err != nil { + t.Fatalf("GetProject after archive failed: %v", err) + } + if !proj.Archived { + t.Fatal("expected project to be archived") + } + + client.Archive(cfg, true) // unarchive + + proj, _, err = client.Projects.GetProject(projectPath, &gitlabapi.GetProjectOptions{}) + if err != nil { + t.Fatalf("GetProject after unarchive failed: %v", err) + } + if proj.Archived { + t.Fatal("expected project to be unarchived after Archive(unarchive=true)") + } + }) + + // ── Sub-test: Delete ────────────────────────────────────────────────────── + t.Run("Delete", func(t *testing.T) { + cfg := createGroupAndProject(t, "delete-a1", "student1", false) + repoName := cfg.RepoNameWithSuffix("student1") + + groupID, err := client.getGroupIDByFullPath(cfg.Path) + if err != nil { + t.Fatalf("getGroupIDByFullPath before delete failed: %v", err) + } + + client.Delete(cfg) + + projects, _, err := client.Search.ProjectsByGroup(groupID, repoName, &gitlabapi.SearchOptions{}) + if err != nil { + t.Fatalf("search after Delete failed: %v", err) + } + for _, p := range projects { + if p.Name == repoName { + t.Fatalf("project %q still exists after Delete()", repoName) + } + } + }) + + // ── Sub-test: ProtectToBranch ───────────────────────────────────────────── + t.Run("ProtectToBranch", func(t *testing.T) { + // withReadme=true so the project has a 'main' branch immediately + cfg := createGroupAndProject(t, "protect-a1", "student1", true) + cfg.Startercode = &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + } + + client.ProtectToBranch(cfg) + + projectPath := cfg.Path + "/" + cfg.RepoNameWithSuffix("student1") + proj, _, err := client.Projects.GetProject(projectPath, &gitlabapi.GetProjectOptions{}) + if err != nil { + t.Fatalf("GetProject after ProtectToBranch failed: %v", err) + } + branches, _, err := client.ProtectedBranches.ListProtectedBranches( + proj.ID, &gitlabapi.ListProtectedBranchesOptions{}) + if err != nil { + t.Fatalf("ListProtectedBranches failed: %v", err) + } + found := false + for _, b := range branches { + if b.Name == "main" { + found = true + break + } + } + if !found { + t.Fatal("expected branch 'main' to be listed as protected") + } + }) + + // ── Sub-test: Setaccess ─────────────────────────────────────────────────── + t.Run("Setaccess", func(t *testing.T) { + cfg := createGroupAndProject(t, "setaccess-a1", itUsername, false) + cfg.AccessLevel = config.AccessLevel(gitlabapi.DeveloperPermissions) // 30 + + client.Setaccess(cfg) + + projectPath := cfg.Path + "/" + cfg.RepoNameWithSuffix(itUsername) + proj, _, err := client.Projects.GetProject(projectPath, &gitlabapi.GetProjectOptions{}) + if err != nil { + t.Fatalf("GetProject after Setaccess failed: %v", err) + } + members, _, err := client.ProjectMembers.ListProjectMembers( + proj.ID, &gitlabapi.ListProjectMembersOptions{}) + if err != nil { + t.Fatalf("ListProjectMembers failed: %v", err) + } + found := false + for _, m := range members { + if m.Username == itUsername { + found = true + if gitlabapi.AccessLevelValue(m.AccessLevel) != gitlabapi.DeveloperPermissions { + t.Fatalf("member access level = %v, want DeveloperPermissions (30)", m.AccessLevel) + } + break + } + } + if !found { + t.Fatalf("expected user %q to be a project member after Setaccess()", itUsername) + } + }) +} + func TestIntegration_GitLab_GroupAndProjectLifecycle(t *testing.T) { requireIntegrationEnabled(t) From 296bb4e8959ce61576767406ee30b13961442c3f Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 14:54:23 +0200 Subject: [PATCH 11/17] docs: document integration test commands and update CI to run all integration tests --- .github/workflows/ci.yml | 2 +- README.md | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 014d510..2c4251e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,7 @@ jobs: env: GLABS_RUN_GITLAB_TC: "1" run: | - go test -tags=integration ./gitlab -run TestIntegration_GitLab_GroupAndProjectLifecycle -count=1 -v + go test -tags=integration ./gitlab/... -count=1 -v release: runs-on: ubuntu-latest diff --git a/README.md b/README.md index b4e5c67..56f2b63 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,21 @@ go test ./... Integration tests with GitLab Testcontainers (opt-in): ```sh -GLABS_RUN_GITLAB_TC=1 go test -tags=integration ./gitlab -run TestIntegration_GitLab_GroupAndProjectLifecycle -count=1 +# Group/project lifecycle (createGroup, generateProject, …) +GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... -run TestIntegration_GitLab_GroupAndProjectLifecycle + +# Archive, Delete, ProtectToBranch, Setaccess end-to-end +GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... -run TestIntegration_GitLab_Operations + +# Run all integration tests at once +GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... ``` Notes: -- Integration tests are intentionally opt-in because starting GitLab in a container is resource intensive. -- In CI, run integration tests in a dedicated job. +- Integration tests are intentionally opt-in because starting GitLab CE in a container takes 5–25 minutes. +- Set `GLABS_RUN_GITLAB_TC=1` to enable them; without it the tests are skipped automatically. +- In CI, trigger them via the `run_integration` workflow dispatch input (dedicated `test-integration` job). ## License From 27fa2c694bd60d3a9e7977b0c994c209e4b4c294 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 15:13:04 +0200 Subject: [PATCH 12/17] docs: run only TestIntegration_* in integration commands --- .github/workflows/ci.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c4251e..4470d77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,7 @@ jobs: env: GLABS_RUN_GITLAB_TC: "1" run: | - go test -tags=integration ./gitlab/... -count=1 -v + go test -tags=integration ./gitlab/... -count=1 -v -run '^TestIntegration_' release: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 56f2b63..af4144f 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... -run Te GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... -run TestIntegration_GitLab_Operations # Run all integration tests at once -GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... +GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... -run '^TestIntegration_' ``` Notes: From 53db32de61169251c11d9021c8c73b93e7dee2f3 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 15:24:00 +0200 Subject: [PATCH 13/17] test: use strong root password for GitLab integration container --- gitlab/integration_gitlab_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/gitlab/integration_gitlab_test.go b/gitlab/integration_gitlab_test.go index 24d9bba..a22f284 100644 --- a/gitlab/integration_gitlab_test.go +++ b/gitlab/integration_gitlab_test.go @@ -19,9 +19,10 @@ import ( ) const ( - gitLabImage = "gitlab/gitlab-ce:17.6.1-ce.0" - gitLabRootToken = "glabs-integration-root-token" - runIntegrationEnv = "GLABS_RUN_GITLAB_TC" + gitLabImage = "gitlab/gitlab-ce:17.6.1-ce.0" + gitLabRootToken = "glabs-integration-root-token" + runIntegrationEnv = "GLABS_RUN_GITLAB_TC" + gitLabRootPassword = "G!abs-Root-P4ssword-2026" ) func requireIntegrationEnabled(t *testing.T) { @@ -84,7 +85,7 @@ func startGitLabContainer(t *testing.T) (*Client, string) { Image: gitLabImage, ExposedPorts: []string{"80/tcp"}, Env: map[string]string{ - "GITLAB_ROOT_PASSWORD": "glabs-root-password", + "GITLAB_ROOT_PASSWORD": gitLabRootPassword, "GITLAB_OMNIBUS_CONFIG": strings.Join([]string{ "external_url 'http://localhost'", "nginx['listen_port'] = 80", From 865aa386acfe8f5c1be27576c07097c08630d91c Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 16:36:39 +0200 Subject: [PATCH 14/17] test: use random non-dictionary root password for GitLab container --- gitlab/integration_gitlab_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitlab/integration_gitlab_test.go b/gitlab/integration_gitlab_test.go index a22f284..0b7c53d 100644 --- a/gitlab/integration_gitlab_test.go +++ b/gitlab/integration_gitlab_test.go @@ -22,7 +22,7 @@ const ( gitLabImage = "gitlab/gitlab-ce:17.6.1-ce.0" gitLabRootToken = "glabs-integration-root-token" runIntegrationEnv = "GLABS_RUN_GITLAB_TC" - gitLabRootPassword = "G!abs-Root-P4ssword-2026" + gitLabRootPassword = "zXq7!Rp3@Wk9#Tm2vL" ) func requireIntegrationEnabled(t *testing.T) { From 8ae8319febbcdcf5b5ece8e2fc4cfd6538b11b44 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 16:58:48 +0200 Subject: [PATCH 15/17] test: strip Docker multiplexed stream headers from exec output in createRootToken --- gitlab/integration_gitlab_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gitlab/integration_gitlab_test.go b/gitlab/integration_gitlab_test.go index 0b7c53d..77bb14a 100644 --- a/gitlab/integration_gitlab_test.go +++ b/gitlab/integration_gitlab_test.go @@ -73,6 +73,14 @@ func createRootToken(ctx context.Context, t *testing.T, c testcontainers.Contain t.Fatalf("could not parse token from gitlab-rails output: %q", string(outputBytes)) } + // Docker exec returns a multiplexed stream with binary headers; strip any non-printable bytes. + lastLine = strings.Map(func(r rune) rune { + if r >= 32 && r < 127 { + return r + } + return -1 + }, lastLine) + return lastLine } From e27063c054c69e15326d3fbf59c8dbd522d77eb7 Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 17:16:31 +0200 Subject: [PATCH 16/17] fix: avoid nil startercode dereference in archive logging --- gitlab/archive.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gitlab/archive.go b/gitlab/archive.go index 02ece50..34727d0 100644 --- a/gitlab/archive.go +++ b/gitlab/archive.go @@ -108,9 +108,14 @@ func (c *Client) archive(assignmentCfg *config.AssignmentConfig, project *gitlab } log.Debug(). + Str("branch", func() string { + if assignmentCfg.Startercode != nil { + return assignmentCfg.Startercode.ToBranch + } + return "" + }()). Str("name", project.Name). Str("toURL", project.SSHURLToRepo). - Str("branch", assignmentCfg.Startercode.ToBranch). Msg("protecting branch") var err error From 8b9629c6e707f8edc0ec232df27cc7dde2cb68fb Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 17:27:16 +0200 Subject: [PATCH 17/17] docs: clarify integration test opt-in process and environment variable usage Co-authored-by: Copilot --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index af4144f..464d5ce 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,9 @@ GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... -run '^ Notes: - Integration tests are intentionally opt-in because starting GitLab CE in a container takes 5–25 minutes. +- `GLABS_RUN_GITLAB_TC` means: run GitLab Testcontainer tests. - Set `GLABS_RUN_GITLAB_TC=1` to enable them; without it the tests are skipped automatically. +- Example: `GLABS_RUN_GITLAB_TC=0` (or variable unset) keeps integration tests disabled. - In CI, trigger them via the `run_integration` workflow dispatch input (dedicated `test-integration` job). ## License