diff --git a/internal/virtual/merger_test.go b/internal/virtual/merger_test.go
new file mode 100644
index 0000000..5b610fc
--- /dev/null
+++ b/internal/virtual/merger_test.go
@@ -0,0 +1,138 @@
+package virtual
+
+import (
+ "strings"
+ "testing"
+
+ "git.unkin.net/unkin/artifactapi/pkg/models"
+)
+
+func TestRegisterGetMerger(t *testing.T) {
+ if _, err := GetMerger(models.PackageHelm); err != nil {
+ t.Errorf("helm merger should be registered: %v", err)
+ }
+ if _, err := GetMerger(models.PackagePyPI); err != nil {
+ t.Errorf("pypi merger should be registered: %v", err)
+ }
+ if _, err := GetMerger(models.PackageType("nope")); err == nil {
+ t.Error("expected error for unknown merger")
+ }
+}
+
+func TestPyPIMerge(t *testing.T) {
+ m := &PyPIMerger{}
+ members := []MemberIndex{
+ {RemoteName: "a", RepoType: models.RepoTypeRemote, Body: []byte(`foo-1.0.whl`)},
+ {RemoteName: "b", RepoType: models.RepoTypeLocal, Body: []byte(`bar-2.0.whl`)},
+ }
+ out, err := m.MergeIndexes(members, "http://proxy")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(out)
+ if !strings.Contains(s, "foo-1.0.whl") || !strings.Contains(s, "bar-2.0.whl") {
+ t.Errorf("merged index missing entries: %s", s)
+ }
+ if !strings.Contains(s, "http://proxy/api/v1/remote/a/pkg/foo-1.0.whl") {
+ t.Errorf("remote href not rewritten: %s", s)
+ }
+ if !strings.Contains(s, "http://proxy/api/v1/local/b/bar-2.0.whl") {
+ t.Errorf("local href not rewritten: %s", s)
+ }
+
+ // Sorted output: foo before... entries sorted by link text.
+ if strings.Index(s, "bar-2.0.whl") > strings.Index(s, "foo-1.0.whl") {
+ t.Error("entries should be sorted by text")
+ }
+
+ // Duplicate link texts across members are de-duplicated.
+ dup := []MemberIndex{
+ {RemoteName: "a", Body: []byte(`dup`)},
+ {RemoteName: "b", Body: []byte(`dup`)},
+ }
+ out, _ = m.MergeIndexes(dup, "")
+ if strings.Count(string(out), ">dup") != 1 {
+ t.Errorf("duplicate not de-duplicated: %s", out)
+ }
+}
+
+func TestHelmMerge(t *testing.T) {
+ m := &HelmMerger{}
+ memberA := `apiVersion: v1
+entries:
+ alpha:
+ - name: alpha
+ version: 1.0.0
+ urls:
+ - charts/alpha-1.0.0.tgz
+`
+ memberB := `apiVersion: v1
+entries:
+ beta:
+ - name: beta
+ version: 2.0.0
+ urls:
+ - https://charts.example.com/beta-2.0.0.tgz
+ gamma:
+ - name: gamma
+ version: 3.0.0
+ urls:
+ - https://other-host.example.net/gamma-3.0.0.tgz
+`
+ members := []MemberIndex{
+ {RemoteName: "a", RepoType: models.RepoTypeLocal, BaseURL: "https://charts.example.com", Body: []byte(memberA)},
+ {RemoteName: "b", RepoType: models.RepoTypeRemote, BaseURL: "https://charts.example.com", Body: []byte(memberB)},
+ }
+ out, err := m.MergeIndexes(members, "http://proxy")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(out)
+ for _, chart := range []string{"alpha", "beta", "gamma"} {
+ if !strings.Contains(s, chart) {
+ t.Errorf("merged index missing chart %q: %s", chart, s)
+ }
+ }
+ // Relative URL from a local member is rewritten under /local/.
+ if !strings.Contains(s, "http://proxy/api/v1/local/a/charts/alpha-1.0.0.tgz") {
+ t.Errorf("relative local url not rewritten: %s", s)
+ }
+ // Same-host absolute URL from a remote member is rewritten under /remote/.
+ if !strings.Contains(s, "http://proxy/api/v1/remote/b/beta-2.0.0.tgz") {
+ t.Errorf("same-host absolute url not rewritten: %s", s)
+ }
+ // Cross-host absolute URL is left untouched.
+ if !strings.Contains(s, "https://other-host.example.net/gamma-3.0.0.tgz") {
+ t.Errorf("cross-host url should be preserved: %s", s)
+ }
+}
+
+func TestHelmMergeDedup(t *testing.T) {
+ m := &HelmMerger{}
+ body := `apiVersion: v1
+entries:
+ alpha:
+ - name: alpha
+ version: 1.0.0
+ urls: [charts/alpha-1.0.0.tgz]
+`
+ members := []MemberIndex{
+ {RemoteName: "a", BaseURL: "https://x", Body: []byte(body)},
+ {RemoteName: "b", BaseURL: "https://x", Body: []byte(body)},
+ }
+ out, _ := m.MergeIndexes(members, "")
+ if strings.Count(string(out), "version: 1.0.0") != 1 {
+ t.Errorf("duplicate chart version not de-duplicated: %s", out)
+ }
+}
+
+func TestHelmMergeInvalidYAML(t *testing.T) {
+ m := &HelmMerger{}
+ out, err := m.MergeIndexes([]MemberIndex{{RemoteName: "a", Body: []byte("::: not yaml :::")}}, "")
+ if err != nil {
+ t.Fatalf("invalid member yaml should be skipped, not error: %v", err)
+ }
+ if !strings.Contains(string(out), "apiVersion") {
+ t.Errorf("expected a valid empty merged index: %s", out)
+ }
+}
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
new file mode 100644
index 0000000..62f9fd5
--- /dev/null
+++ b/pkg/client/client_test.go
@@ -0,0 +1,145 @@
+package client
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "git.unkin.net/unkin/artifactapi/pkg/models"
+)
+
+func testServer(t *testing.T, h http.HandlerFunc) *Client {
+ t.Helper()
+ srv := httptest.NewServer(h)
+ t.Cleanup(srv.Close)
+ return New(srv.URL)
+}
+
+func TestRemotesRoundTrip(t *testing.T) {
+ var gotMethod, gotPath string
+ c := testServer(t, func(w http.ResponseWriter, r *http.Request) {
+ gotMethod, gotPath = r.Method, r.URL.Path
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/api/v2/remotes":
+ w.Write([]byte(`[{"name":"a"},{"name":"b"}]`))
+ case r.Method == http.MethodGet && r.URL.Path == "/api/v2/remotes/a":
+ w.Write([]byte(`{"name":"a"}`))
+ case r.Method == http.MethodDelete:
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"name":"a"}`))
+ }
+ })
+ ctx := context.Background()
+
+ remotes, err := c.ListRemotes(ctx)
+ if err != nil || len(remotes) != 2 {
+ t.Fatalf("ListRemotes: %v %v", remotes, err)
+ }
+ if r, err := c.GetRemote(ctx, "a"); err != nil || r.Name != "a" {
+ t.Fatalf("GetRemote: %v %v", r, err)
+ }
+ if err := c.CreateRemote(ctx, &models.Remote{Name: "a", PackageType: models.PackageGeneric}); err != nil {
+ t.Fatalf("CreateRemote: %v", err)
+ }
+ if err := c.UpdateRemote(ctx, &models.Remote{Name: "a"}); err != nil {
+ t.Fatalf("UpdateRemote: %v", err)
+ }
+ if err := c.DeleteRemote(ctx, "a"); err != nil {
+ t.Fatalf("DeleteRemote: %v", err)
+ }
+ if gotMethod != http.MethodDelete || gotPath != "/api/v2/remotes/a" {
+ t.Errorf("last call = %s %s", gotMethod, gotPath)
+ }
+}
+
+func TestVirtualsRoundTrip(t *testing.T) {
+ c := testServer(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/virtuals"):
+ w.Write([]byte(`[{"name":"v"}]`))
+ case r.Method == http.MethodGet:
+ w.Write([]byte(`{"name":"v"}`))
+ case r.Method == http.MethodDelete:
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"name":"v"}`))
+ }
+ })
+ ctx := context.Background()
+ if vs, err := c.ListVirtuals(ctx); err != nil || len(vs) != 1 {
+ t.Fatalf("ListVirtuals: %v %v", vs, err)
+ }
+ if v, err := c.GetVirtual(ctx, "v"); err != nil || v.Name != "v" {
+ t.Fatalf("GetVirtual: %v %v", v, err)
+ }
+ if err := c.CreateVirtual(ctx, &models.Virtual{Name: "v"}); err != nil {
+ t.Fatalf("CreateVirtual: %v", err)
+ }
+ if err := c.UpdateVirtual(ctx, &models.Virtual{Name: "v"}); err != nil {
+ t.Fatalf("UpdateVirtual: %v", err)
+ }
+ if err := c.DeleteVirtual(ctx, "v"); err != nil {
+ t.Fatalf("DeleteVirtual: %v", err)
+ }
+}
+
+func TestStatsHealthObjects(t *testing.T) {
+ c := testServer(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/stats"):
+ w.Write([]byte(`{"total_remotes":3}`))
+ case strings.HasSuffix(r.URL.Path, "/health"):
+ w.Write([]byte(`{"status":"ok"}`))
+ case r.Method == http.MethodDelete:
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ w.Write([]byte(`[{"path":"p"}]`))
+ }
+ })
+ ctx := context.Background()
+ if _, err := c.Stats(ctx); err != nil {
+ t.Fatalf("Stats: %v", err)
+ }
+ if _, err := c.Health(ctx); err != nil {
+ t.Fatalf("Health: %v", err)
+ }
+ if objs, err := c.ListObjects(ctx, "r", 1, 50); err != nil || len(objs) != 1 {
+ t.Fatalf("ListObjects: %v %v", objs, err)
+ }
+ if err := c.EvictObject(ctx, "r", "some/path"); err != nil {
+ t.Fatalf("EvictObject: %v", err)
+ }
+}
+
+func TestErrorResponses(t *testing.T) {
+ c := testServer(t, func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "boom", http.StatusInternalServerError)
+ })
+ ctx := context.Background()
+ _, err := c.GetRemote(ctx, "x")
+ if err == nil || !strings.Contains(err.Error(), "api error 500") {
+ t.Errorf("expected api error, got %v", err)
+ }
+}
+
+func TestDecodeError(t *testing.T) {
+ c := testServer(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(`not json`))
+ })
+ if _, err := c.ListRemotes(context.Background()); err == nil || !strings.Contains(err.Error(), "decode") {
+ t.Errorf("expected decode error, got %v", err)
+ }
+}
+
+func TestRequestError(t *testing.T) {
+ // Invalid base URL triggers request construction failure.
+ c := New("http://[::1]:namedport")
+ if err := c.DeleteRemote(context.Background(), "x"); err == nil {
+ t.Error("expected request error for invalid URL")
+ }
+}