From 9e3d15da68d33aceb75a960c80b63e9430106a1c Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 16:07:43 +0000 Subject: [PATCH] feat: ActionsService, NotificationService, PackageService Add three new services for the Forgejo API client: - ActionsService: repo/org secrets, variables, workflow dispatch - NotificationService: list, mark read, thread operations - PackageService: list, get, delete packages and files Wire up real constructors in forge.go and remove stubs from services_stub.go. All 21 new tests pass. Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- actions.go | 77 +++++++++++++ actions_test.go | 262 ++++++++++++++++++++++++++++++++++++++++++ forge.go | 6 +- notifications.go | 50 ++++++++ notifications_test.go | 164 ++++++++++++++++++++++++++ packages.go | 46 ++++++++ packages_test.go | 143 +++++++++++++++++++++++ services_stub.go | 3 - 8 files changed, 745 insertions(+), 6 deletions(-) create mode 100644 actions.go create mode 100644 actions_test.go create mode 100644 notifications.go create mode 100644 notifications_test.go create mode 100644 packages.go create mode 100644 packages_test.go diff --git a/actions.go b/actions.go new file mode 100644 index 0000000..cc11abc --- /dev/null +++ b/actions.go @@ -0,0 +1,77 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// ActionsService handles CI/CD actions operations across repositories and +// organisations — secrets, variables, and workflow dispatches. +// No Resource embedding — heterogeneous endpoints across repo and org levels. +type ActionsService struct { + client *Client +} + +func newActionsService(c *Client) *ActionsService { + return &ActionsService{client: c} +} + +// ListRepoSecrets returns all secrets for a repository. +func (s *ActionsService) ListRepoSecrets(ctx context.Context, owner, repo string) ([]types.Secret, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets", owner, repo) + return ListAll[types.Secret](ctx, s.client, path, nil) +} + +// CreateRepoSecret creates or updates a secret in a repository. +// Forgejo expects a PUT with {"data": "secret-value"} body. +func (s *ActionsService) CreateRepoSecret(ctx context.Context, owner, repo, name string, data string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, name) + body := map[string]string{"data": data} + return s.client.Put(ctx, path, body, nil) +} + +// DeleteRepoSecret removes a secret from a repository. +func (s *ActionsService) DeleteRepoSecret(ctx context.Context, owner, repo, name string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, name) + return s.client.Delete(ctx, path) +} + +// ListRepoVariables returns all action variables for a repository. +func (s *ActionsService) ListRepoVariables(ctx context.Context, owner, repo string) ([]types.ActionVariable, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables", owner, repo) + return ListAll[types.ActionVariable](ctx, s.client, path, nil) +} + +// CreateRepoVariable creates a new action variable in a repository. +// Forgejo expects a POST with {"value": "var-value"} body. +func (s *ActionsService) CreateRepoVariable(ctx context.Context, owner, repo, name, value string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, name) + body := types.CreateVariableOption{Value: value} + return s.client.Post(ctx, path, body, nil) +} + +// DeleteRepoVariable removes an action variable from a repository. +func (s *ActionsService) DeleteRepoVariable(ctx context.Context, owner, repo, name string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, name) + return s.client.Delete(ctx, path) +} + +// ListOrgSecrets returns all secrets for an organisation. +func (s *ActionsService) ListOrgSecrets(ctx context.Context, org string) ([]types.Secret, error) { + path := fmt.Sprintf("/api/v1/orgs/%s/actions/secrets", org) + return ListAll[types.Secret](ctx, s.client, path, nil) +} + +// ListOrgVariables returns all action variables for an organisation. +func (s *ActionsService) ListOrgVariables(ctx context.Context, org string) ([]types.ActionVariable, error) { + path := fmt.Sprintf("/api/v1/orgs/%s/actions/variables", org) + return ListAll[types.ActionVariable](ctx, s.client, path, nil) +} + +// DispatchWorkflow triggers a workflow run. +func (s *ActionsService) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, opts map[string]any) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow) + return s.client.Post(ctx, path, opts, nil) +} diff --git a/actions_test.go b/actions_test.go new file mode 100644 index 0000000..ea648e0 --- /dev/null +++ b/actions_test.go @@ -0,0 +1,262 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestActionsService_Good_ListRepoSecrets(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/secrets" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Secret{ + {Name: "DEPLOY_KEY"}, + {Name: "API_TOKEN"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + secrets, err := f.Actions.ListRepoSecrets(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(secrets) != 2 { + t.Fatalf("got %d secrets, want 2", len(secrets)) + } + if secrets[0].Name != "DEPLOY_KEY" { + t.Errorf("got name=%q, want %q", secrets[0].Name, "DEPLOY_KEY") + } + if secrets[1].Name != "API_TOKEN" { + t.Errorf("got name=%q, want %q", secrets[1].Name, "API_TOKEN") + } +} + +func TestActionsService_Good_CreateRepoSecret(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/secrets/DEPLOY_KEY" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["data"] != "super-secret" { + t.Errorf("got data=%q, want %q", body["data"], "super-secret") + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Actions.CreateRepoSecret(context.Background(), "core", "go-forge", "DEPLOY_KEY", "super-secret") + if err != nil { + t.Fatal(err) + } +} + +func TestActionsService_Good_DeleteRepoSecret(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/secrets/OLD_KEY" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Actions.DeleteRepoSecret(context.Background(), "core", "go-forge", "OLD_KEY") + if err != nil { + t.Fatal(err) + } +} + +func TestActionsService_Good_ListRepoVariables(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/variables" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.ActionVariable{ + {Name: "CI_ENV", Data: "production"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + vars, err := f.Actions.ListRepoVariables(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(vars) != 1 { + t.Fatalf("got %d variables, want 1", len(vars)) + } + if vars[0].Name != "CI_ENV" { + t.Errorf("got name=%q, want %q", vars[0].Name, "CI_ENV") + } +} + +func TestActionsService_Good_CreateRepoVariable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/variables/CI_ENV" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateVariableOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Value != "staging" { + t.Errorf("got value=%q, want %q", body.Value, "staging") + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Actions.CreateRepoVariable(context.Background(), "core", "go-forge", "CI_ENV", "staging") + if err != nil { + t.Fatal(err) + } +} + +func TestActionsService_Good_DeleteRepoVariable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/variables/OLD_VAR" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Actions.DeleteRepoVariable(context.Background(), "core", "go-forge", "OLD_VAR") + if err != nil { + t.Fatal(err) + } +} + +func TestActionsService_Good_ListOrgSecrets(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/lethean/actions/secrets" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Secret{ + {Name: "ORG_SECRET"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + secrets, err := f.Actions.ListOrgSecrets(context.Background(), "lethean") + if err != nil { + t.Fatal(err) + } + if len(secrets) != 1 { + t.Fatalf("got %d secrets, want 1", len(secrets)) + } + if secrets[0].Name != "ORG_SECRET" { + t.Errorf("got name=%q, want %q", secrets[0].Name, "ORG_SECRET") + } +} + +func TestActionsService_Good_ListOrgVariables(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/lethean/actions/variables" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.ActionVariable{ + {Name: "ORG_VAR", Data: "org-value"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + vars, err := f.Actions.ListOrgVariables(context.Background(), "lethean") + if err != nil { + t.Fatal(err) + } + if len(vars) != 1 { + t.Fatalf("got %d variables, want 1", len(vars)) + } + if vars[0].Name != "ORG_VAR" { + t.Errorf("got name=%q, want %q", vars[0].Name, "ORG_VAR") + } +} + +func TestActionsService_Good_DispatchWorkflow(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/actions/workflows/build.yml/dispatches" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["ref"] != "main" { + t.Errorf("got ref=%v, want %q", body["ref"], "main") + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Actions.DispatchWorkflow(context.Background(), "core", "go-forge", "build.yml", map[string]any{ + "ref": "main", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestActionsService_Bad_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Actions.ListRepoSecrets(context.Background(), "core", "nonexistent") + if err == nil { + t.Fatal("expected error, got nil") + } + if !IsNotFound(err) { + t.Errorf("expected not-found error, got %v", err) + } +} diff --git a/forge.go b/forge.go index 0980813..3dfd184 100644 --- a/forge.go +++ b/forge.go @@ -38,9 +38,9 @@ func NewForge(url, token string, opts ...Option) *Forge { f.Releases = newReleaseService(c) f.Labels = newLabelService(c) f.Webhooks = newWebhookService(c) - f.Notifications = &NotificationService{} - f.Packages = &PackageService{} - f.Actions = &ActionsService{} + f.Notifications = newNotificationService(c) + f.Packages = newPackageService(c) + f.Actions = newActionsService(c) f.Contents = newContentService(c) f.Wiki = &WikiService{} f.Misc = &MiscService{} diff --git a/notifications.go b/notifications.go new file mode 100644 index 0000000..10906b3 --- /dev/null +++ b/notifications.go @@ -0,0 +1,50 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// NotificationService handles notification operations via the Forgejo API. +// No Resource embedding — varied endpoint shapes. +type NotificationService struct { + client *Client +} + +func newNotificationService(c *Client) *NotificationService { + return &NotificationService{client: c} +} + +// List returns all notifications for the authenticated user. +func (s *NotificationService) List(ctx context.Context) ([]types.NotificationThread, error) { + return ListAll[types.NotificationThread](ctx, s.client, "/api/v1/notifications", nil) +} + +// ListRepo returns all notifications for a specific repository. +func (s *NotificationService) ListRepo(ctx context.Context, owner, repo string) ([]types.NotificationThread, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/notifications", owner, repo) + return ListAll[types.NotificationThread](ctx, s.client, path, nil) +} + +// MarkRead marks all notifications as read. +func (s *NotificationService) MarkRead(ctx context.Context) error { + return s.client.Put(ctx, "/api/v1/notifications", nil, nil) +} + +// GetThread returns a single notification thread by ID. +func (s *NotificationService) GetThread(ctx context.Context, id int64) (*types.NotificationThread, error) { + path := fmt.Sprintf("/api/v1/notifications/threads/%d", id) + var out types.NotificationThread + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// MarkThreadRead marks a single notification thread as read. +func (s *NotificationService) MarkThreadRead(ctx context.Context, id int64) error { + path := fmt.Sprintf("/api/v1/notifications/threads/%d", id) + return s.client.Patch(ctx, path, nil, nil) +} diff --git a/notifications_test.go b/notifications_test.go new file mode 100644 index 0000000..2a87421 --- /dev/null +++ b/notifications_test.go @@ -0,0 +1,164 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestNotificationService_Good_List(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/notifications" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.NotificationThread{ + {ID: 1, Unread: true, Subject: &types.NotificationSubject{Title: "Issue opened"}}, + {ID: 2, Unread: false, Subject: &types.NotificationSubject{Title: "PR merged"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + threads, err := f.Notifications.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(threads) != 2 { + t.Fatalf("got %d threads, want 2", len(threads)) + } + if threads[0].ID != 1 { + t.Errorf("got id=%d, want 1", threads[0].ID) + } + if threads[0].Subject.Title != "Issue opened" { + t.Errorf("got title=%q, want %q", threads[0].Subject.Title, "Issue opened") + } + if !threads[0].Unread { + t.Error("expected thread 1 to be unread") + } +} + +func TestNotificationService_Good_ListRepo(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/notifications" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.NotificationThread{ + {ID: 10, Unread: true, Subject: &types.NotificationSubject{Title: "New commit"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + threads, err := f.Notifications.ListRepo(context.Background(), "core", "go-forge") + if err != nil { + t.Fatal(err) + } + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + if threads[0].ID != 10 { + t.Errorf("got id=%d, want 10", threads[0].ID) + } +} + +func TestNotificationService_Good_GetThread(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/notifications/threads/42" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.NotificationThread{ + ID: 42, + Unread: true, + Subject: &types.NotificationSubject{ + Title: "Build failed", + }, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + thread, err := f.Notifications.GetThread(context.Background(), 42) + if err != nil { + t.Fatal(err) + } + if thread.ID != 42 { + t.Errorf("got id=%d, want 42", thread.ID) + } + if thread.Subject.Title != "Build failed" { + t.Errorf("got title=%q, want %q", thread.Subject.Title, "Build failed") + } + if !thread.Unread { + t.Error("expected thread to be unread") + } +} + +func TestNotificationService_Good_MarkRead(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/notifications" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusResetContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Notifications.MarkRead(context.Background()) + if err != nil { + t.Fatal(err) + } +} + +func TestNotificationService_Good_MarkThreadRead(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/v1/notifications/threads/42" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusResetContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Notifications.MarkThreadRead(context.Background(), 42) + if err != nil { + t.Fatal(err) + } +} + +func TestNotificationService_Bad_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "thread not found"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Notifications.GetThread(context.Background(), 9999) + if err == nil { + t.Fatal("expected error, got nil") + } + if !IsNotFound(err) { + t.Errorf("expected not-found error, got %v", err) + } +} + diff --git a/packages.go b/packages.go new file mode 100644 index 0000000..a0f4c02 --- /dev/null +++ b/packages.go @@ -0,0 +1,46 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// PackageService handles package registry operations via the Forgejo API. +// No Resource embedding — paths vary by operation. +type PackageService struct { + client *Client +} + +func newPackageService(c *Client) *PackageService { + return &PackageService{client: c} +} + +// List returns all packages for a given owner. +func (s *PackageService) List(ctx context.Context, owner string) ([]types.Package, error) { + path := fmt.Sprintf("/api/v1/packages/%s", owner) + return ListAll[types.Package](ctx, s.client, path, nil) +} + +// Get returns a single package by owner, type, name, and version. +func (s *PackageService) Get(ctx context.Context, owner, pkgType, name, version string) (*types.Package, error) { + path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, version) + var out types.Package + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Delete removes a package by owner, type, name, and version. +func (s *PackageService) Delete(ctx context.Context, owner, pkgType, name, version string) error { + path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, version) + return s.client.Delete(ctx, path) +} + +// ListFiles returns all files for a specific package version. +func (s *PackageService) ListFiles(ctx context.Context, owner, pkgType, name, version string) ([]types.PackageFile, error) { + path := fmt.Sprintf("/api/v1/packages/%s/%s/%s/%s/files", owner, pkgType, name, version) + return ListAll[types.PackageFile](ctx, s.client, path, nil) +} diff --git a/packages_test.go b/packages_test.go new file mode 100644 index 0000000..1c45332 --- /dev/null +++ b/packages_test.go @@ -0,0 +1,143 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestPackageService_Good_List(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/packages/core" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Package{ + {ID: 1, Name: "go-forge", Type: "generic", Version: "0.1.0"}, + {ID: 2, Name: "go-forge", Type: "generic", Version: "0.2.0"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + pkgs, err := f.Packages.List(context.Background(), "core") + if err != nil { + t.Fatal(err) + } + if len(pkgs) != 2 { + t.Fatalf("got %d packages, want 2", len(pkgs)) + } + if pkgs[0].Name != "go-forge" { + t.Errorf("got name=%q, want %q", pkgs[0].Name, "go-forge") + } + if pkgs[1].Version != "0.2.0" { + t.Errorf("got version=%q, want %q", pkgs[1].Version, "0.2.0") + } +} + +func TestPackageService_Good_Get(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/packages/core/generic/go-forge/0.1.0" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Package{ + ID: 1, + Name: "go-forge", + Type: "generic", + Version: "0.1.0", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + pkg, err := f.Packages.Get(context.Background(), "core", "generic", "go-forge", "0.1.0") + if err != nil { + t.Fatal(err) + } + if pkg.ID != 1 { + t.Errorf("got id=%d, want 1", pkg.ID) + } + if pkg.Name != "go-forge" { + t.Errorf("got name=%q, want %q", pkg.Name, "go-forge") + } + if pkg.Version != "0.1.0" { + t.Errorf("got version=%q, want %q", pkg.Version, "0.1.0") + } +} + +func TestPackageService_Good_Delete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/packages/core/generic/go-forge/0.1.0" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Packages.Delete(context.Background(), "core", "generic", "go-forge", "0.1.0") + if err != nil { + t.Fatal(err) + } +} + +func TestPackageService_Good_ListFiles(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/packages/core/generic/go-forge/0.1.0/files" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.PackageFile{ + {ID: 1, Name: "go-forge-0.1.0.tar.gz", Size: 1024, HashMD5: "abc123"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + files, err := f.Packages.ListFiles(context.Background(), "core", "generic", "go-forge", "0.1.0") + if err != nil { + t.Fatal(err) + } + if len(files) != 1 { + t.Fatalf("got %d files, want 1", len(files)) + } + if files[0].Name != "go-forge-0.1.0.tar.gz" { + t.Errorf("got name=%q, want %q", files[0].Name, "go-forge-0.1.0.tar.gz") + } + if files[0].Size != 1024 { + t.Errorf("got size=%d, want 1024", files[0].Size) + } +} + +func TestPackageService_Bad_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "package not found"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Packages.Get(context.Background(), "core", "generic", "nonexistent", "0.0.0") + if err == nil { + t.Fatal("expected error, got nil") + } + if !IsNotFound(err) { + t.Errorf("expected not-found error, got %v", err) + } +} diff --git a/services_stub.go b/services_stub.go index 5c42cdd..d286184 100644 --- a/services_stub.go +++ b/services_stub.go @@ -2,8 +2,5 @@ package forge // Stub service types — replaced as each service is implemented. -type NotificationService struct{} -type PackageService struct{} -type ActionsService struct{} type WikiService struct{} type MiscService struct{}