| // Copyright 2026 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| package client | |
| import ( | |
| "context" | |
| "encoding/json" | |
| "net/http" | |
| "net/http/httptest" | |
| "slices" | |
| "strings" | |
| "testing" | |
| ) | |
| func TestGetPackage(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| if r.URL.Path != "/v1/package/encoding/json" { | |
| t.Errorf("path = %q, want /v1/package/encoding/json", r.URL.Path) | |
| } | |
| if got := r.Header.Get("User-Agent"); got != "pkgsite-cli/v1" { | |
| t.Errorf("User-Agent = %q, want pkgsite-cli/v1", got) | |
| } | |
| if got := r.URL.Query().Get("version"); got != "go1.26.0" { | |
| t.Errorf("version = %q, want go1.26.0", got) | |
| } | |
| json.NewEncoder(w).Encode(Package{ | |
| PackageInfo: PackageInfo{ | |
| Path: "encoding/json", | |
| Synopsis: "Package json implements encoding and decoding of JSON.", | |
| }, | |
| ModulePath: "std", | |
| Version: "go1.26.0", | |
| IsStandardLibrary: true, | |
| }) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp, err := c.GetPackage(context.Background(), "encoding/json", "go1.26.0", PackageOptions{}) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if resp.Path != "encoding/json" { | |
| t.Errorf("Path = %q, want encoding/json", resp.Path) | |
| } | |
| if !resp.IsStandardLibrary { | |
| t.Error("IsStandardLibrary = false, want true") | |
| } | |
| } | |
| func TestGetPackageWithFlags(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| q := r.URL.Query() | |
| if got := q.Get("doc"); got != "md" { | |
| t.Errorf("doc = %q, want md", got) | |
| } | |
| if got := q.Get("imports"); got != "true" { | |
| t.Errorf("imports = %q, want true", got) | |
| } | |
| if got := q.Get("licenses"); got != "true" { | |
| t.Errorf("licenses = %q, want true", got) | |
| } | |
| if got := q.Get("module"); got != "github.com/foo/bar" { | |
| t.Errorf("module = %q, want github.com/foo/bar", got) | |
| } | |
| json.NewEncoder(w).Encode(Package{ | |
| PackageInfo: PackageInfo{ | |
| Path: "github.com/foo/bar/pkg", | |
| }, | |
| Docs: "# package pkg", | |
| Imports: []string{"fmt", "strings"}, | |
| }) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp, err := c.GetPackage(context.Background(), "github.com/foo/bar/pkg", "", PackageOptions{ | |
| Doc: "md", | |
| Imports: true, | |
| Licenses: true, | |
| Module: "github.com/foo/bar", | |
| }) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if resp.Docs != "# package pkg" { | |
| t.Errorf("Docs = %q, want # package pkg", resp.Docs) | |
| } | |
| if len(resp.Imports) != 2 { | |
| t.Errorf("len(Imports) = %d, want 2", len(resp.Imports)) | |
| } | |
| } | |
| func TestGetModule(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| if r.URL.Path != "/v1/module/golang.org/x/text" { | |
| t.Errorf("path = %q, want /v1/module/golang.org/x/text", r.URL.Path) | |
| } | |
| json.NewEncoder(w).Encode(Module{ | |
| Path: "golang.org/x/text", | |
| Version: "v0.14.0", | |
| RepoURL: "https://github.com/golang/text", | |
| }) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp, err := c.GetModule(context.Background(), "golang.org/x/text", "v0.14.0", ModuleOptions{}) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if resp.Version != "v0.14.0" { | |
| t.Errorf("Version = %q, want v0.14.0", resp.Version) | |
| } | |
| } | |
| func TestGetVersions(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| if r.URL.Path != "/v1/versions/golang.org/x/text" { | |
| t.Errorf("path = %q, want /v1/versions/golang.org/x/text", r.URL.Path) | |
| } | |
| json.NewEncoder(w).Encode(PaginatedResponse[VersionResponse]{ | |
| Items: []VersionResponse{{Version: "v0.14.0"}, {Version: "v0.13.0"}}, | |
| Total: 2, | |
| }) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp, err := c.GetVersions(context.Background(), "golang.org/x/text", PaginationOptions{}) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if len(resp.Items) != 2 { | |
| t.Errorf("len(Items) = %d, want 2", len(resp.Items)) | |
| } | |
| } | |
| func TestGetVulns(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| json.NewEncoder(w).Encode(PaginatedResponse[Vulnerability]{ | |
| Items: []Vulnerability{{ID: "GO-2023-0001", Details: "A vulnerability."}}, | |
| Total: 1, | |
| }) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp, err := c.GetVulns(context.Background(), "golang.org/x/text", "v0.3.0", PaginationOptions{}) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if len(resp.Items) != 1 { | |
| t.Errorf("len(Items) = %d, want 1", len(resp.Items)) | |
| } | |
| if resp.Items[0].ID != "GO-2023-0001" { | |
| t.Errorf("ID = %q, want GO-2023-0001", resp.Items[0].ID) | |
| } | |
| } | |
| func TestSearch(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| if got := r.URL.Query().Get("q"); got != "json parser" { | |
| t.Errorf("q = %q, want %q", got, "json parser") | |
| } | |
| json.NewEncoder(w).Encode(PaginatedResponse[SearchResult]{ | |
| Items: []SearchResult{{ | |
| PackagePath: "encoding/json", | |
| ModulePath: "std", | |
| Version: "go1.26.0", | |
| Synopsis: "Package json implements encoding and decoding of JSON.", | |
| }}, | |
| Total: 1, | |
| }) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp, err := c.Search(context.Background(), "json parser", SearchOptions{}) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if len(resp.Items) != 1 { | |
| t.Errorf("len(Items) = %d, want 1", len(resp.Items)) | |
| } | |
| } | |
| func TestGetSymbols(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| json.NewEncoder(w).Encode(PackageSymbols{ | |
| ModulePath: "std", | |
| Version: "go1.26.0", | |
| Symbols: PaginatedResponse[Symbol]{ | |
| Items: []Symbol{{ | |
| Name: "Marshal", | |
| Kind: "func", | |
| Synopsis: "func Marshal(v any) ([]byte, error)", | |
| }}, | |
| Total: 1, | |
| }, | |
| }) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp, err := c.GetSymbols(context.Background(), "encoding/json", "", SymbolsOptions{}) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if resp.Items[0].Name != "Marshal" { | |
| t.Errorf("Name = %q, want Marshal", resp.Items[0].Name) | |
| } | |
| } | |
| func TestGetImportedBy(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| json.NewEncoder(w).Encode(PackageImportedBy{ | |
| ModulePath: "std", | |
| Version: "go1.26.0", | |
| ImportedBy: PaginatedResponse[string]{ | |
| Items: []string{"github.com/foo/bar", "github.com/baz/qux"}, | |
| Total: 2, | |
| }, | |
| }) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| resp, err := c.GetImportedBy(context.Background(), "encoding/json", "", ImportedByOptions{}) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if len(resp.ImportedBy.Items) != 2 { | |
| t.Errorf("len(ImportedBy.Items) = %d, want 2", len(resp.ImportedBy.Items)) | |
| } | |
| } | |
| func TestAmbiguousPackagePath(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| w.WriteHeader(http.StatusBadRequest) | |
| json.NewEncoder(w).Encode(Error{ | |
| Code: 400, | |
| Message: "ambiguous package path", | |
| Candidates: []Candidate{ | |
| {ModulePath: "github.com/foo/bar", PackagePath: "github.com/foo/bar/pkg"}, | |
| {ModulePath: "github.com/foo/bar/pkg", PackagePath: "github.com/foo/bar/pkg"}, | |
| }, | |
| }) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| _, err = c.GetPackage(context.Background(), "github.com/foo/bar/pkg", "", PackageOptions{}) | |
| if err == nil { | |
| t.Fatal("expected error") | |
| } | |
| msg := err.Error() | |
| if !strings.Contains(msg, "-module=github.com/foo/bar") { | |
| t.Errorf("error missing candidate, got:\n%s", msg) | |
| } | |
| if !strings.Contains(msg, "-module=github.com/foo/bar/pkg") { | |
| t.Errorf("error missing candidate, got:\n%s", msg) | |
| } | |
| } | |
| func TestAPIError(t *testing.T) { | |
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| w.WriteHeader(http.StatusNotFound) | |
| json.NewEncoder(w).Encode(Error{Code: 404, Message: "not found"}) | |
| })) | |
| defer srv.Close() | |
| c, err := New(srv.URL) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| _, err = c.GetPackage(context.Background(), "nonexistent/pkg", "", PackageOptions{}) | |
| if err == nil { | |
| t.Fatal("expected error") | |
| } | |
| aerr, ok := err.(*Error) | |
| if !ok { | |
| t.Fatalf("error type = %T, want *Error", err) | |
| } | |
| if aerr.Code != 404 { | |
| t.Errorf("Code = %d, want 404", aerr.Code) | |
| } | |
| } | |
| func TestAllItemsSinglePage(t *testing.T) { | |
| fetch := func(token string, limit int) (*PaginatedResponse[string], error) { | |
| return &PaginatedResponse[string]{ | |
| Items: []string{"item1"}, | |
| Total: 1, | |
| NextPageToken: "", | |
| }, nil | |
| } | |
| items, total, _, err := AllItems("", 0, fetch) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if len(items) != 1 { | |
| t.Errorf("len(items) = %d, want 1", len(items)) | |
| } | |
| if total != 1 { | |
| t.Errorf("total = %d, want 1", total) | |
| } | |
| } | |
| func TestAllItemsLimit(t *testing.T) { | |
| const totalItems = 5 | |
| pages := map[string]*PaginatedResponse[string]{ | |
| "": { | |
| Items: []string{"a", "b"}, | |
| Total: totalItems, | |
| NextPageToken: "p1", | |
| }, | |
| "p1": { | |
| Items: []string{"c", "d"}, | |
| Total: totalItems, | |
| NextPageToken: "p2", | |
| }, | |
| "p2": { | |
| Items: []string{"e"}, | |
| Total: totalItems, | |
| NextPageToken: "", | |
| }, | |
| } | |
| fetch := func(token string, limit int) (*PaginatedResponse[string], error) { | |
| return pages[token], nil | |
| } | |
| tests := []struct { | |
| name string | |
| limit int | |
| wantItems []string | |
| wantTotal int | |
| }{ | |
| { | |
| name: "limit 3", | |
| limit: 3, | |
| wantItems: []string{"a", "b", "c"}, | |
| wantTotal: totalItems, | |
| }, | |
| { | |
| name: "no limit", | |
| wantItems: []string{"a", "b", "c", "d", "e"}, | |
| wantTotal: totalItems, | |
| }, | |
| { | |
| name: "limit larger than total", | |
| limit: 10, | |
| wantItems: []string{"a", "b", "c", "d", "e"}, | |
| wantTotal: totalItems, | |
| }, | |
| } | |
| for _, tt := range tests { | |
| t.Run(tt.name, func(t *testing.T) { | |
| items, total, _, err := AllItems("", tt.limit, fetch) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if total != tt.wantTotal { | |
| t.Errorf("total = %d, want %d", total, tt.wantTotal) | |
| } | |
| if !slices.Equal(items, tt.wantItems) { | |
| t.Errorf("items = %v, want %v", items, tt.wantItems) | |
| } | |
| }) | |
| } | |
| } | |
| func TestAllItems429(t *testing.T) { | |
| const totalItems = 5 | |
| pages := map[string]*PaginatedResponse[string]{ | |
| "": { | |
| Items: []string{"a", "b"}, | |
| Total: totalItems, | |
| NextPageToken: "p1", | |
| }, | |
| "p1": { | |
| Items: []string{"c", "d"}, | |
| Total: totalItems, | |
| NextPageToken: "p2", | |
| }, | |
| } | |
| fetch := func(token string, limit int) (*PaginatedResponse[string], error) { | |
| if token == "p2" { | |
| return nil, &Error{Code: http.StatusTooManyRequests, Message: "Too Many Requests"} | |
| } | |
| return pages[token], nil | |
| } | |
| items, total, nextToken, err := AllItems("", 0, fetch) | |
| if err == nil { | |
| t.Fatal("expected error, got nil") | |
| } | |
| if !Is429(err) { | |
| t.Errorf("expected 429 error, got %v", err) | |
| } | |
| wantItems := []string{"a", "b", "c", "d"} | |
| if !slices.Equal(items, wantItems) { | |
| t.Errorf("items = %v, want %v", items, wantItems) | |
| } | |
| if total != totalItems { | |
| t.Errorf("total = %d, want %d", total, totalItems) | |
| } | |
| if nextToken != "p2" { | |
| t.Errorf("nextToken = %q, want %q", nextToken, "p2") | |
| } | |
| } | |
| func TestAllItemsPagination(t *testing.T) { | |
| const totalItems = 5 | |
| pages := map[string]*PaginatedResponse[string]{ | |
| "": { | |
| Items: []string{"a", "b"}, | |
| Total: totalItems, | |
| NextPageToken: "p1", | |
| }, | |
| "p1": { | |
| Items: []string{"c", "d"}, | |
| Total: totalItems, | |
| NextPageToken: "p2", | |
| }, | |
| "p2": { | |
| Items: []string{"e"}, | |
| Total: totalItems, | |
| NextPageToken: "", | |
| }, | |
| } | |
| fetch := func(token string, limit int) (*PaginatedResponse[string], error) { | |
| return pages[token], nil | |
| } | |
| t.Run("basic 3 pages successive", func(t *testing.T) { | |
| steps := []struct { | |
| limit int | |
| wantItems []string | |
| wantToken string | |
| }{ | |
| {limit: 2, wantItems: []string{"a", "b"}, wantToken: "p1"}, | |
| {limit: 2, wantItems: []string{"c", "d"}, wantToken: "p2"}, | |
| {limit: 2, wantItems: []string{"e"}, wantToken: ""}, | |
| } | |
| token := "" | |
| for i, step := range steps { | |
| items, total, nextToken, err := AllItems(token, step.limit, fetch) | |
| if err != nil { | |
| t.Fatalf("step %d: %v", i, err) | |
| } | |
| if !slices.Equal(items, step.wantItems) { | |
| t.Errorf("step %d: items = %v, want %v", i, items, step.wantItems) | |
| } | |
| if total != totalItems { | |
| t.Errorf("step %d: total = %d, want %d", i, total, totalItems) | |
| } | |
| if nextToken != step.wantToken { | |
| t.Errorf("step %d: nextToken = %q, want %q", i, nextToken, step.wantToken) | |
| } | |
| token = nextToken | |
| } | |
| }) | |
| for _, test := range []struct { | |
| name string | |
| limit int | |
| want []string | |
| nextToken string | |
| }{ | |
| { | |
| "limit within first page", | |
| 1, | |
| []string{"a"}, | |
| "", | |
| }, | |
| { | |
| "limit within second page", | |
| 3, | |
| []string{"a", "b", "c"}, | |
| "p1", | |
| }, | |
| } { | |
| t.Run(test.name, func(t *testing.T) { | |
| items, total, nextToken, err := AllItems("", test.limit, fetch) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if !slices.Equal(items, test.want) { | |
| t.Errorf("items = %v, want %v", items, test.want) | |
| } | |
| if total != totalItems { | |
| t.Errorf("total = %d, want %d", total, totalItems) | |
| } | |
| if nextToken != test.nextToken { | |
| t.Errorf("nextToken = %q, want %q", nextToken, test.nextToken) | |
| } | |
| }) | |
| } | |
| } |