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 <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-21 16:07:43 +00:00
parent de76399608
commit 9e3d15da68
8 changed files with 745 additions and 6 deletions

77
actions.go Normal file
View file

@ -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)
}

262
actions_test.go Normal file
View file

@ -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)
}
}

View file

@ -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{}

50
notifications.go Normal file
View file

@ -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)
}

164
notifications_test.go Normal file
View file

@ -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)
}
}

46
packages.go Normal file
View file

@ -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)
}

143
packages_test.go Normal file
View file

@ -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)
}
}

View file

@ -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{}