blob: fb6a9de017432fae405f0259e208a685e8800ae1 [file]
// 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 api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"slices"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/api"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/log"
"golang.org/x/pkgsite/internal/postgres"
"golang.org/x/pkgsite/internal/testing/fakedatasource"
"golang.org/x/pkgsite/internal/testing/sample"
)
type fallbackDataSource struct {
internal.DataSource
fallbackMap map[string]string // requested module -> resolved module
}
func (ds fallbackDataSource) GetUnitMeta(ctx context.Context, path, requestedModulePath, requestedVersion string) (*internal.UnitMeta, error) {
if resolved, ok := ds.fallbackMap[requestedModulePath]; ok {
um, err := ds.DataSource.GetUnitMeta(ctx, path, resolved, requestedVersion)
if err != nil {
return nil, err
}
return um, nil
}
return ds.DataSource.GetUnitMeta(ctx, path, requestedModulePath, requestedVersion)
}
// setupTestDB sets up a DB for testing.
// It also removes the voluminous DB log output,
// which makes it hard to see test information.
// Note: This function modifies global log state and should not
// be used in tests running with t.Parallel().
func setupTestDB(t *testing.T) internal.TestingDataSource {
orig := log.GetLevel()
t.Cleanup(func() { log.SetLevel(orig.String()) })
log.SetLevel("Info")
const testDB = "pkgsite_api"
db, err := postgres.SetupTestDB(testDB)
if err != nil {
if errors.Is(err, derrors.NotFound) && os.Getenv("GO_DISCOVERY_TESTDB") != "true" {
t.Skipf("could not connect to DB (see doc/postgres.md to set up): %v", err)
}
t.Fatalf("setting up DB: %v", err)
}
t.Cleanup(func() {
postgres.ResetTestDB(db, t)
db.Close()
})
return db
}
// modinfo creates a ModuleInfo with the given module path
// and version. It sets LatestVersion to version.
func modinfo(path, version string) internal.ModuleInfo {
return internal.ModuleInfo{
ModulePath: path,
Version: version,
LatestVersion: version,
IsRedistributable: true,
}
}
// Module creates a module with the given ModuleInfo and units.
// The units must not have been previously used.
func module(t *testing.T, mi internal.ModuleInfo, units ...*internal.Unit) *internal.Module {
for _, u := range units {
// If Name is already set, then this unit was used to
// build another module, and that's bad.
if u.Name != "" {
t.Fatal("unit used in two modules")
}
u.ModuleInfo = mi
// Change relative to absolute path.
u.Path = path.Join(mi.ModulePath, u.Path)
// Name is last component of path.
u.Name = u.Path[strings.LastIndexByte(u.Path, '/')+1:]
}
return &internal.Module{
ModuleInfo: mi,
Licenses: sample.Licenses(),
Units: units,
}
}
// unit constructs a Unit with the given relative path and documentation.
// The path is relative to a module path; the full import path
// will be constructed when the Unit is added to a module in
// the [module] function.
func unit(relativePath string, doc ...*internal.Documentation) *internal.Unit {
return &internal.Unit{
UnitMeta: internal.UnitMeta{
Path: relativePath, // expanded in the module function
// ModuleInfo and Name set in the module function.
},
Licenses: sample.LicenseMetadata(),
IsRedistributable: true,
Documentation: doc,
}
}
var diffOptions = []cmp.Option{
cmpopts.IgnoreUnexported(api.Error{}),
cmpopts.IgnoreFields(api.Error{}, "Fixes"),
cmpopts.IgnoreFields(api.Module{}, "CommitTime"),
cmpopts.IgnoreFields(api.ModuleVersion{}, "CommitTime"),
}
func TestAPI(t *testing.T) {
// TODO(jba): test filters for invalid regex, case sensisitivty and multi-field matching
t.Setenv("K_SERVICE", "test")
t.Run("fake", func(t *testing.T) {
testAPI(t, func(t *testing.T) internal.TestingDataSource {
return fakedatasource.New()
})
})
t.Run("db", func(t *testing.T) {
testAPI(t, setupTestDB)
})
}
func testAPI(t *testing.T, newTestingDataSource func(t *testing.T) internal.TestingDataSource) {
t.Run("package", func(t *testing.T) {
testServePackage(t, newTestingDataSource(t))
})
t.Run("module", func(t *testing.T) {
testServeModule(t, newTestingDataSource(t))
})
t.Run("module versions", func(t *testing.T) {
testServeModuleVersions(t, newTestingDataSource(t))
})
t.Run("module packages", func(t *testing.T) {
testServeModulePackages(t, newTestingDataSource(t))
})
t.Run("search", func(t *testing.T) {
testServeSearch(t, newTestingDataSource(t))
})
t.Run("search pagination", func(t *testing.T) {
testServeSearchPagination(t, newTestingDataSource(t))
})
t.Run("package symbols", func(t *testing.T) {
testServePackageSymbols(t, newTestingDataSource(t))
})
t.Run("package imported by", func(t *testing.T) {
testServePackageImportedBy(t, newTestingDataSource(t))
})
}
func testServePackage(t *testing.T, ds internal.TestingDataSource) {
const (
version = "v1.2.3"
latestVersion = "v1.2.4"
)
u := unit("pkg", sample.Documentation("linux", "amd64", sample.DocContents))
u.Imports = []string{"example.com/a/b"}
mi := modinfo("example.com", version)
mi.LatestVersion = latestVersion
ds.MustInsertModule(t, module(t, mi, u))
ds.MustInsertModule(t, module(t, modinfo("example.com/a", version), unit("b", sample.Documentation("linux", "amd64", sample.DocContents))))
ds.MustInsertModule(t, module(t, modinfo("example.com/a/b", version), unit("")))
ds.MustInsertModule(t, module(t, modinfo("example.com", latestVersion),
unit("pkg", sample.DocumentationWithTest("linux", "amd64", `
// Package p is a package.
package p
var V int
`, `
package p
func Example() {
fmt.Println("hello")
// Output: hello
}`),
)))
// Deprecation.
// The fake data source uses ModuleInfo.Deprecated, but the DB
// requires a go.mod file.
modInfo := modinfo("example.com/d/e", version)
modInfo.Deprecated = true
ds.MustInsertModuleGoMod(context.TODO(), t, module(t, modInfo, unit("e")), `module example.com/d/e // Deprecated: bad`)
// The DB needs the above go.mod contents to know that the module
// is deprecated. It doesn't look at ModuleInfo.Deprecated.
ds.MustInsertModule(t, module(t, modinfo("example.com/d", version), unit("e")))
for _, test := range []struct {
name string
url string
wantStatus int
want any // Can be *Package or *Error
overrideDS internal.DataSource
}{
{
name: "missing package path",
url: "/v1beta/package/",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: "missing package path",
},
},
{
name: "basic metadata",
url: "/v1beta/package/example.com/pkg?version=v1.2.3",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Path: "example.com/pkg",
Name: "pkg",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
},
ModulePath: "example.com",
Version: "v1.2.3",
IsLatest: false,
GOOS: "linux",
GOARCH: "amd64",
},
},
{
name: "ambiguous path",
url: "/v1beta/package/example.com/a/b?version=v1.2.3",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: "ambiguous package path",
Candidates: []api.Candidate{
{ModulePath: "example.com/a/b", PackagePath: "example.com/a/b"},
{ModulePath: "example.com/a", PackagePath: "example.com/a/b"},
},
},
},
{
name: "disambiguated path",
url: "/v1beta/package/example.com/a/b?version=v1.2.3&module=example.com/a",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Path: "example.com/a/b",
Name: "b",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
},
ModulePath: "example.com/a",
Version: "v1.2.3",
IsLatest: true,
GOOS: "linux",
GOARCH: "amd64",
},
},
{
name: "default build context",
url: "/v1beta/package/example.com/pkg?version=v1.2.3",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Path: "example.com/pkg",
Name: "pkg",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
},
ModulePath: "example.com",
Version: "v1.2.3",
IsLatest: false,
GOOS: "linux",
GOARCH: "amd64",
},
},
{
name: "latest version",
url: "/v1beta/package/example.com/pkg?version=v1.2.4",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Name: "pkg",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
Path: "example.com/pkg",
},
ModulePath: "example.com",
Version: "v1.2.4",
IsLatest: true,
GOOS: "linux",
GOARCH: "amd64",
},
},
{
name: "doc",
url: "/v1beta/package/example.com/pkg?version=v1.2.3&doc=text",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Name: "pkg",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
Path: "example.com/pkg",
},
ModulePath: "example.com",
Version: "v1.2.3",
GOOS: "linux",
GOARCH: "amd64",
Docs: "package p\n\nPackage p is a package.\n\n# Links\n\n- pkg.go.dev, https://pkg.go.dev\n\nVARIABLES\n\nvar V int\n\n",
},
},
{
name: "doc with examples",
url: "/v1beta/package/example.com/pkg?version=v1.2.4&doc=text&examples=true",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Name: "pkg",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
Path: "example.com/pkg",
},
ModulePath: "example.com",
Version: "v1.2.4",
IsLatest: true,
GOOS: "linux",
GOARCH: "amd64",
Docs: "package p\n\nPackage p is a package.\n\nExample:\n\t{\n\t\tfmt.Println(\"hello\")\n\t}\n\n\tOutput:\n\thello\n\nVARIABLES\n\nvar V int\n\n",
},
},
{
name: "examples without doc",
url: "/v1beta/package/example.com/pkg?version=v1.2.3&examples=true",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: "examples require doc format to be specified",
},
},
{
name: "examples without doc and nonexistent package",
url: "/v1beta/package/nonexistent.com/pkg?version=v1.2.3&examples=true",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: "examples require doc format to be specified",
},
},
{
name: "package not found",
url: "/v1beta/package/nonexistent.com/pkg",
wantStatus: http.StatusNotFound,
want: &api.Error{Code: 404, Message: "not found"},
},
{
name: "doc without examples",
url: "/v1beta/package/example.com/pkg?version=v1.2.4&doc=text&examples=false",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Path: "example.com/pkg",
Name: "pkg",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
},
ModulePath: "example.com",
Version: "v1.2.4",
IsLatest: true,
GOOS: "linux",
GOARCH: "amd64",
Docs: "package p\n\nPackage p is a package.\n\nVARIABLES\n\nvar V int\n\n",
},
},
{
name: "invalid doc format",
url: "/v1beta/package/example.com/pkg?version=v1.2.3&doc=invalid",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: "bad doc format: need one of 'text', 'md', 'markdown' or 'html'",
},
},
{
name: "invalid doc format and nonexistent package",
url: "/v1beta/package/nonexistent.com/pkg?version=v1.2.3&doc=invalid",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: "bad doc format: need one of 'text', 'md', 'markdown' or 'html'",
},
},
{
name: "empty doc format",
url: "/v1beta/package/example.com/pkg?version=v1.2.3&doc=",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Path: "example.com/pkg",
Name: "pkg",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
},
ModulePath: "example.com",
Version: "v1.2.3",
GOOS: "linux",
GOARCH: "amd64",
Docs: "",
},
},
{
name: "licenses",
url: "/v1beta/package/example.com/pkg?version=v1.2.3&licenses=true",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Path: "example.com/pkg",
Name: "pkg",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
},
ModulePath: "example.com",
Version: "v1.2.3",
IsLatest: false,
GOOS: "linux",
GOARCH: "amd64",
Licenses: []api.License{
{
Types: []string{sample.LicenseType},
FilePath: sample.LicenseFilePath,
Contents: "Lorem Ipsum",
},
},
},
},
{
name: "imports",
url: "/v1beta/package/example.com/pkg?version=v1.2.3&imports=true",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Path: "example.com/pkg",
Name: "pkg",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
},
ModulePath: "example.com",
Version: "v1.2.3",
IsLatest: false,
GOOS: "linux",
GOARCH: "amd64",
Imports: []string{"example.com/a/b"},
},
},
{
name: "fallback prevention (false positive candidate)",
url: "/v1beta/package/example.com/a/b?version=v1.2.3",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: "ambiguous package path",
Candidates: []api.Candidate{
{ModulePath: "example.com/a/b", PackagePath: "example.com/a/b"},
{ModulePath: "example.com/a", PackagePath: "example.com/a/b"},
},
},
overrideDS: &fallbackDataSource{
DataSource: ds,
fallbackMap: map[string]string{
"example.com": "example.com/a/b", // simulate fallback
},
},
},
{
name: "deprecation filtering",
url: "/v1beta/package/example.com/d/e?version=v1.2.3",
wantStatus: http.StatusOK,
want: &api.Package{
PackageInfo: api.PackageInfo{
Path: "example.com/d/e",
Name: "e",
IsRedistributable: true,
},
ModulePath: "example.com/d", // picked because example.com/d/e is deprecated
Version: "v1.2.3",
IsLatest: true,
},
},
} {
t.Run(test.name, func(t *testing.T) {
r := httptest.NewRequest("GET", test.url, nil)
w := httptest.NewRecorder()
var currentDS internal.DataSource = ds
if test.overrideDS != nil {
currentDS = test.overrideDS
}
if err := api.ServePackage(w, r, currentDS); err != nil {
api.ServeError(w, r, err)
}
if w.Code != test.wantStatus {
t.Errorf("status = %d, want %d. Body: %s", w.Code, test.wantStatus, w.Body.String())
}
if test.want != nil {
got, err := unmarshalResponse[api.Package](w.Body.Bytes())
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(test.want, got, diffOptions...); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}
})
}
}
func testServeModule(t *testing.T, ds internal.TestingDataSource) {
const (
modulePath = "example.com"
version = "v1.2.3"
)
mi1 := modinfo(modulePath, version)
mi1.LatestVersion = "v1.2.4"
mi1.HasGoMod = true
u := unit("")
u.Readme = &internal.Readme{Filepath: "README.md", Contents: "Hello world"}
ds.MustInsertModule(t, module(t, mi1, u, unit("pkg")))
mi2 := modinfo(modulePath, "v1.2.4")
mi2.HasGoMod = true
ds.MustInsertModule(t, module(t, mi2, unit("")))
for _, test := range []struct {
name string
url string
wantStatus int
want any
}{
{
name: "invalid query parameter",
url: "/v1beta/module/example.com?licenses=invalid",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: `invalid boolean value "invalid" for licenses`,
},
},
{
name: "basic module metadata",
url: "/v1beta/module/example.com?version=v1.2.3",
wantStatus: http.StatusOK,
want: &api.Module{
Path: modulePath,
Version: "v1.2.3",
IsRedistributable: true,
HasGoMod: true,
},
},
{
name: "latest module metadata",
url: "/v1beta/module/example.com?version=v1.2.4",
wantStatus: http.StatusOK,
want: &api.Module{
Path: modulePath,
Version: "v1.2.4",
IsLatest: true,
IsRedistributable: true,
HasGoMod: true,
},
},
{
name: "bad version",
url: "/v1beta/module/example.com?version=nope",
wantStatus: http.StatusNotFound,
want: &api.Error{Code: 404, Message: "not found"},
},
{
name: "module not found",
url: "/v1beta/module/nonexistent.com",
wantStatus: http.StatusNotFound,
want: &api.Error{Code: 404, Message: "not found"},
},
{
name: "package path in module endpoint",
url: "/v1beta/module/example.com/pkg?version=v1.2.3",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: "example.com/pkg is a package, not a module",
},
},
{
name: "missing module path",
url: "/v1beta/module/",
wantStatus: http.StatusBadRequest,
want: &api.Error{Code: 400, Message: "missing module path"},
},
{
name: "module with readme",
url: "/v1beta/module/example.com?version=v1.2.3&readme=true",
wantStatus: http.StatusOK,
want: &api.Module{
Path: modulePath,
Version: "v1.2.3",
IsRedistributable: true,
HasGoMod: true,
Readme: &api.Readme{
Filepath: "README.md",
Contents: "Hello world",
},
},
},
{
name: "module with licenses",
url: "/v1beta/module/example.com?version=v1.2.3&licenses=true",
wantStatus: http.StatusOK,
want: &api.Module{
Path: modulePath,
Version: "v1.2.3",
IsRedistributable: true,
HasGoMod: true,
Licenses: []api.License{
{
Types: []string{"MIT"},
FilePath: "LICENSE",
Contents: "Lorem Ipsum",
},
},
},
},
} {
t.Run(test.name, func(t *testing.T) {
r := httptest.NewRequest("GET", test.url, nil)
w := httptest.NewRecorder()
if err := api.ServeModule(w, r, ds); err != nil {
api.ServeError(w, r, err)
}
if w.Code != test.wantStatus {
t.Errorf("status = %d, want %d", w.Code, test.wantStatus)
}
if test.want != nil {
got, err := unmarshalResponse[api.Module](w.Body.Bytes())
if err != nil {
t.Fatalf("unmarshaling: %v", err)
}
if diff := cmp.Diff(test.want, got, diffOptions...); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}
})
}
}
func testServeModuleVersions(t *testing.T, ds internal.TestingDataSource) {
newMod := func(path, version, latest string) *internal.Module {
mi := modinfo(path, version)
mi.LatestVersion = latest
return module(t, mi, unit(""), unit("pkg"))
}
ds.MustInsertModule(t, newMod("example.com", "v1.0.0", "v1.1.0"))
ds.MustInsertModule(t, newMod("example.com", "v1.1.0", "v1.1.0"))
ds.MustInsertModule(t, newMod("example.com/v2", "v2.0.0", "v2.0.0"))
for _, test := range []struct {
name string
url string
want any
}{
{
name: "all versions (cross-major)",
url: "/v1beta/versions/example.com",
want: &api.PaginatedResponse[api.ModuleVersion]{
Total: 3,
Items: []api.ModuleVersion{
{
ModulePath: "example.com/v2",
Version: "v2.0.0",
LatestVersion: "v2.0.0",
IsRedistributable: true,
},
{
ModulePath: "example.com",
Version: "v1.1.0",
LatestVersion: "v1.1.0",
IsRedistributable: true,
},
{
ModulePath: "example.com",
Version: "v1.0.0",
LatestVersion: "v1.1.0",
IsRedistributable: true,
},
},
},
},
{
name: "module not found",
url: "/v1beta/versions/nonexistent.com",
want: &api.Error{Code: 404, Message: "not found"},
},
{
name: "package path in versions endpoint",
url: "/v1beta/versions/example.com/pkg",
want: &api.Error{
Code: http.StatusBadRequest,
Message: "example.com/pkg is a package, not a module",
},
},
{
name: "missing module path",
url: "/v1beta/versions/",
want: &api.Error{Code: 400, Message: "missing module path"},
},
{
name: "filter",
url: "/v1beta/versions/example.com?filter=" +
url.QueryEscape(`contains(version, "2")`),
want: &api.PaginatedResponse[api.ModuleVersion]{
Total: 1,
Items: []api.ModuleVersion{
{
ModulePath: "example.com/v2",
Version: "v2.0.0",
LatestVersion: "v2.0.0",
IsRedistributable: true,
},
},
},
},
{
name: "invalid filter",
url: "/v1beta/versions/example.com?filter=" + url.QueryEscape(`[`),
want: &api.Error{
Code: 400,
Message: `parsing filter "[": 1:2: expected operand, found 'EOF'`,
},
},
{
name: "case-sensitive filter",
url: "/v1beta/versions/example.com?filter=" +
url.QueryEscape(`hasPrefix(version, "V")`),
want: &api.PaginatedResponse[api.ModuleVersion]{
Total: 0,
Items: nil,
},
},
{
name: "case-insensitive filter",
url: "/v1beta/versions/example.com?filter=" +
url.QueryEscape(`matches(version, "[vV]1")`),
want: &api.PaginatedResponse[api.ModuleVersion]{
Total: 2,
Items: []api.ModuleVersion{
{
ModulePath: "example.com",
Version: "v1.1.0",
LatestVersion: "v1.1.0",
IsRedistributable: true,
},
{
ModulePath: "example.com",
Version: "v1.0.0",
LatestVersion: "v1.1.0",
IsRedistributable: true,
},
},
},
},
} {
t.Run(test.name, func(t *testing.T) {
r := httptest.NewRequest("GET", test.url, nil)
w := httptest.NewRecorder()
if err := api.ServeModuleVersions(w, r, ds); err != nil {
api.ServeError(w, r, err)
}
var wantStatus int
switch w := test.want.(type) {
case *api.Error:
wantStatus = w.Code
default:
wantStatus = http.StatusOK
}
if w.Code != wantStatus {
t.Errorf("status = %d, want %d. Body: %s", w.Code, wantStatus, w.Body.String())
}
if test.want != nil {
got, err := unmarshalResponse[api.PaginatedResponse[api.ModuleVersion]](w.Body.Bytes())
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(test.want, got, diffOptions...); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}
})
}
testPagination[api.PaginatedResponse[api.ModuleVersion]](t, ds, "/v1beta/versions/example.com?limit=1",
api.ServeModuleVersions,
func(r *api.PaginatedResponse[api.ModuleVersion]) (int, int, string) {
return len(r.Items), r.Total, r.NextPageToken
},
[]wantPage{
{wantCount: 1, wantTotal: 3},
{wantCount: 1, wantTotal: 3},
{wantCount: 1, wantTotal: 3},
})
}
func testServeModulePackages(t *testing.T, ds internal.TestingDataSource) {
d := sample.Documentation("linux", "amd64", sample.DocContents)
d.Synopsis = "api.Synopsis for sub"
ds.MustInsertModule(t,
module(t, modinfo("example.com", "v1.2.3"),
unit("", sample.Documentation("linux", "amd64", sample.DocContents)),
unit("sub", d)))
info1 := api.PackageInfo{
Path: "example.com",
Name: "example.com",
IsRedistributable: true,
Synopsis: "This is a package synopsis for GOOS=linux, GOARCH=amd64",
}
info2 := api.PackageInfo{
Path: "example.com/sub",
Name: "sub",
IsRedistributable: true,
Synopsis: "api.Synopsis for sub",
}
response := func(infos ...api.PackageInfo) *api.PackagesResponse {
return &api.PackagesResponse{
ModulePath: "example.com",
Version: "v1.2.3",
Packages: api.PaginatedResponse[api.PackageInfo]{
Total: len(infos),
Items: infos,
},
}
}
for _, test := range []struct {
name string
url string
want any
}{
{
name: "all packages",
url: "/v1beta/packages/example.com?version=v1.2.3",
want: response(info1, info2),
},
{
name: "latest",
url: "/v1beta/packages/example.com",
want: response(info1, info2),
},
{
name: "module not found",
url: "/v1beta/packages/nonexistent.com?version=v1.2.3",
want: &api.Error{Code: 404, Message: "not found"},
},
{
name: "package path in packages endpoint",
url: "/v1beta/packages/example.com/sub?version=v1.2.3",
want: &api.Error{
Code: http.StatusBadRequest,
Message: "example.com/sub is a package, not a module",
},
},
{
name: "missing module path",
url: "/v1beta/packages/",
want: &api.Error{Code: 400, Message: "missing module path"},
},
{
name: "filter on path",
url: "/v1beta/packages/example.com?version=v1.2.3&filter=" +
url.QueryEscape(`matches(path, "s[ux].")`),
want: response(info2),
},
{
name: "filter on synopsis",
url: "/v1beta/packages/example.com?version=v1.2.3&filter=" +
url.QueryEscape(`matches(synopsis, "GO+S")`),
want: response(info1),
},
} {
t.Run(test.name, func(t *testing.T) {
r := httptest.NewRequest("GET", test.url, nil)
w := httptest.NewRecorder()
if err := api.ServeModulePackages(w, r, ds); err != nil {
api.ServeError(w, r, err)
}
got, err := unmarshalResponse[api.PackagesResponse](w.Body.Bytes())
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(test.want, got, diffOptions...); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
testPagination(t, ds, "/v1beta/packages/example.com?limit=1",
api.ServeModulePackages,
func(r *api.PackagesResponse) (int, int, string) {
return len(r.Packages.Items), r.Packages.Total, r.Packages.NextPageToken
},
[]wantPage{
{wantCount: 1, wantTotal: 2},
{wantCount: 1, wantTotal: 2},
},
)
}
func testServeSearch(t *testing.T, ds internal.TestingDataSource) {
ds.MustInsertModule(t, module(t, modinfo("example.com", "v1.0.0"),
unit("pkg", sample.Documentation("linux", "amd64", sample.DocContents))))
ds.MustInsertModule(t, module(t, modinfo("example.com/z", "v1.0.0"),
unit("pkg", sample.Documentation("linux", "amd64", sample.DocContents)),
unit("pkg2", sample.Documentation("linux", "amd64", sample.DocContents))))
for _, test := range []struct {
name string
url string
wantStatus int
wantCount int
}{
{
name: "basic search",
url: "/v1beta/search?q=synopsis",
wantStatus: http.StatusOK,
// All packages match, because sample.Documentation creates a synopsis beginning
// "This is a package synopsis...".
// There is no grouping, so all packages are part of the list.
// See https://go.dev/issues/79697 for more on grouping.
wantCount: 3,
},
{
name: "no results",
url: "/v1beta/search?q=nonexistent",
wantStatus: http.StatusOK,
wantCount: 0,
},
{
name: "missing query",
url: "/v1beta/search",
wantStatus: http.StatusBadRequest,
},
{
name: "search with filter",
url: "/v1beta/search?q=synopsis&filter=" +
url.QueryEscape(`contains(modulePath, "z")`),
wantStatus: http.StatusOK,
wantCount: 2,
},
{
name: "search with non-matching filter",
url: "/v1beta/search?q=great&filter=" + url.QueryEscape(`hasSuffix(packagePath, "moo")`),
wantStatus: http.StatusOK,
wantCount: 0,
},
} {
t.Run(test.name, func(t *testing.T) {
r := httptest.NewRequest("GET", test.url, nil)
w := httptest.NewRecorder()
err := api.ServeSearch(w, r, ds)
if err != nil {
api.ServeError(w, r, err)
}
if w.Code != test.wantStatus {
t.Errorf("%s: status = %d, want %d", test.name, w.Code, test.wantStatus)
}
if test.wantStatus == http.StatusOK {
var got api.PaginatedResponse[api.SearchResult]
unmarshalJSON(t, w.Body.Bytes(), &got)
if len(got.Items) != test.wantCount {
t.Errorf("%s: count = %d, want %d", test.name, len(got.Items), test.wantCount)
}
}
})
}
}
type wantPage struct {
wantCount int
wantTotal int
}
// testPagination is a generic helper for testing paginated API endpoints.
// It performs a sequential crawl of pages starting from baseURL.
// For each page request, it asserts that the response has the expected number
// of items and total results. It verifies the presence or absence of NextPageToken,
// and automatically uses the returned token to fetch the subsequent page.
func testPagination[T any](
t *testing.T,
ds internal.TestingDataSource,
baseURL string,
// serves request
serve func(http.ResponseWriter, *http.Request, internal.DataSource) error,
// extracts pagination info from response
extract func(*T) (count, total int, next string),
pages []wantPage) {
t.Helper()
token := ""
for i, page := range pages {
url := baseURL
if token != "" {
if strings.Contains(url, "?") {
url += "&token=" + token
} else {
url += "?token=" + token
}
}
r := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
if err := serve(w, r, ds); err != nil {
api.ServeError(w, r, err)
}
if w.Code != http.StatusOK {
t.Fatalf("page %d: status = %d, want 200", i+1, w.Code)
}
var got T
unmarshalJSON(t, w.Body.Bytes(), &got)
count, total, nextToken := extract(&got)
if count != page.wantCount {
t.Errorf("page %d: count = %d, want %d", i+1, count, page.wantCount)
}
if total != page.wantTotal {
t.Errorf("page %d: total = %d, want %d", i+1, total, page.wantTotal)
}
if i == len(pages)-1 {
if nextToken != "" {
t.Errorf("page %d: expected empty next page token, got %q", i+1, nextToken)
}
} else {
if nextToken == "" {
t.Errorf("page %d: expected next page token, got empty", i+1)
}
}
token = nextToken
}
}
func testServeSearchPagination(t *testing.T, ds internal.TestingDataSource) {
doc := sample.Documentation("linux", "amd64", sample.DocContents)
for i := range 10 {
modPath := fmt.Sprintf("example.com/m%d", i)
ds.MustInsertModule(t, module(t, modinfo(modPath, "v1.0.0"), unit("pkg", doc)))
}
testPagination[api.PaginatedResponse[api.SearchResult]](t, ds, "/v1beta/search?q=synopsis&limit=3",
api.ServeSearch,
func(r *api.PaginatedResponse[api.SearchResult]) (int, int, string) {
return len(r.Items), r.Total, r.NextPageToken
},
[]wantPage{
{wantCount: 3, wantTotal: 10},
{wantCount: 3, wantTotal: 10},
{wantCount: 3, wantTotal: 10},
{wantCount: 1, wantTotal: 10},
})
}
func testServePackageSymbols(t *testing.T, ds internal.TestingDataSource) {
sym := func(doc *internal.Documentation, name string, kind internal.SymbolKind) *internal.Symbol {
return &internal.Symbol{
SymbolMeta: internal.SymbolMeta{
Name: name,
Kind: kind,
Section: internal.SymbolSectionFunctions,
},
GOOS: doc.GOOS,
GOARCH: doc.GOARCH,
}
}
linuxDoc := sample.Documentation("linux", "amd64", sample.DocContents)
linuxDoc.API = []*internal.Symbol{
sym(linuxDoc, "LinuxSym", internal.SymbolKindFunction),
sym(linuxDoc, "F", internal.SymbolKindFunction),
sym(linuxDoc, "LinuxType", internal.SymbolKindType),
}
winDoc := sample.Documentation("windows", "amd64", sample.DocContents)
winDoc.API = []*internal.Symbol{sym(winDoc, "WindowsSym", internal.SymbolKindFunction)}
wasmDoc := sample.Documentation("js", "wasm", sample.DocContents)
wasmDoc.API = []*internal.Symbol{sym(wasmDoc, "WasmSym", internal.SymbolKindFunction)}
ds.MustInsertModule(t,
module(t, modinfo("example.com", "v1.0.0"),
unit("pkg", linuxDoc, winDoc, wasmDoc)))
for _, test := range []struct {
name string
url string
wantStatus int
wantNames []string // sorted
want any
}{
{
name: "default best match (linux)",
url: "/v1beta/symbols/example.com/pkg?version=v1.0.0",
wantStatus: http.StatusOK,
wantNames: []string{"F", "LinuxSym", "LinuxType"},
},
{
name: "package not found",
url: "/v1beta/symbols/nonexistent.com/pkg?version=v1.0.0",
wantStatus: http.StatusNotFound,
want: &api.Error{Code: 404, Message: "not found"},
},
{
name: "missing package path",
url: "/v1beta/symbols/",
wantStatus: http.StatusBadRequest,
want: &api.Error{Code: 400, Message: "missing package path"},
},
{
name: "explicit linux",
url: "/v1beta/symbols/example.com/pkg?version=v1.0.0&goos=linux&goarch=amd64",
wantStatus: http.StatusOK,
wantNames: []string{"F", "LinuxSym", "LinuxType"},
},
{
name: "version latest",
url: "/v1beta/symbols/example.com/pkg?version=latest",
wantStatus: http.StatusOK,
wantNames: []string{"F", "LinuxSym", "LinuxType"},
},
{
name: "explicit windows",
url: "/v1beta/symbols/example.com/pkg?version=v1.0.0&goos=windows&goarch=amd64",
wantStatus: http.StatusOK,
wantNames: []string{"WindowsSym"},
},
{
name: "explicit wasm",
url: "/v1beta/symbols/example.com/pkg?version=v1.0.0&goos=js&goarch=wasm",
wantStatus: http.StatusOK,
wantNames: []string{"WasmSym"},
},
{
name: "not found build context",
url: "/v1beta/symbols/example.com/pkg?version=v1.0.0&goos=darwin&goarch=amd64",
wantStatus: http.StatusNotFound,
want: &api.Error{
Code: http.StatusNotFound,
Message: "not found",
},
},
} {
t.Run(test.name, func(t *testing.T) {
r := httptest.NewRequest("GET", test.url, nil)
w := httptest.NewRecorder()
if err := api.ServePackageSymbols(w, r, ds); err != nil {
api.ServeError(w, r, err)
}
if w.Code != test.wantStatus {
t.Errorf("status = %d, want %d. Body: %s", w.Code, test.wantStatus, w.Body.String())
}
if test.want != nil {
if want, ok := test.want.(*api.Error); ok {
var got api.Error
unmarshalJSON(t, w.Body.Bytes(), &got)
if diff := cmp.Diff(want, &got, diffOptions...); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}
}
if test.wantStatus == http.StatusOK {
var got api.PackageSymbols
unmarshalJSON(t, w.Body.Bytes(), &got)
var gotNames []string
for _, it := range got.Symbols.Items {
gotNames = append(gotNames, it.Name)
}
slices.Sort(gotNames)
if !slices.Equal(gotNames, test.wantNames) {
t.Errorf("got names %q, want %q", gotNames, test.wantNames)
}
}
})
}
testPagination(t, ds, "/v1beta/symbols/example.com/pkg?version=v1.0.0&limit=1",
api.ServePackageSymbols,
func(ps *api.PackageSymbols) (int, int, string) {
return len(ps.Symbols.Items), ps.Symbols.Total, ps.Symbols.NextPageToken
},
[]wantPage{
{wantCount: 1, wantTotal: 3},
{wantCount: 1, wantTotal: 3},
{wantCount: 1, wantTotal: 3},
})
}
func testServePackageImportedBy(t *testing.T, ds internal.TestingDataSource) {
ds.MustInsertModule(t, module(t, modinfo("example.com", "v1.2.3"), unit("pkg")))
u := unit("pkg")
u.Imports = []string{"example.com/pkg"}
ds.MustInsertModule(t, module(t, modinfo("example.com/mod", "v1.2.3"), u))
u2 := unit("pkg")
u2.Imports = []string{"example.com/pkg"}
ds.MustInsertModule(t, module(t, modinfo("example.com/mod2", "v1.2.3"), u2))
for _, test := range []struct {
name string
url string
wantStatus int
wantCount int
want any
}{
{
name: "missing package path",
url: "/v1beta/imported-by/",
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
Message: "missing package path",
},
},
{
name: "all imported by",
url: "/v1beta/imported-by/example.com/pkg?version=v1.2.3",
wantStatus: http.StatusOK,
wantCount: 2,
},
} {
t.Run(test.name, func(t *testing.T) {
r := httptest.NewRequest("GET", test.url, nil)
w := httptest.NewRecorder()
if err := api.ServePackageImportedBy(w, r, ds); err != nil {
api.ServeError(w, r, err)
}
if w.Code != test.wantStatus {
t.Errorf("status = %d, want %d", w.Code, test.wantStatus)
}
if test.wantStatus == http.StatusOK {
var got api.PackageImportedBy
unmarshalJSON(t, w.Body.Bytes(), &got)
if len(got.ImportedBy.Items) != test.wantCount {
t.Errorf("count = %d, want %d", len(got.ImportedBy.Items), test.wantCount)
}
}
})
}
testPagination[api.PackageImportedBy](t, ds, "/v1beta/imported-by/example.com/pkg?version=v1.2.3&limit=1",
api.ServePackageImportedBy,
func(pib *api.PackageImportedBy) (int, int, string) {
return len(pib.ImportedBy.Items), pib.ImportedBy.Total, pib.ImportedBy.NextPageToken
},
[]wantPage{
{wantCount: 1, wantTotal: 2},
{wantCount: 1, wantTotal: 2},
})
}
// unmarshalJSON is like json.Unmarshal, but checks for unknown
// fields.
func unmarshalJSON(t *testing.T, data []byte, ptr any) {
t.Helper()
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
if err := d.Decode(ptr); err != nil {
t.Fatalf("unmarshalling JSON into %T: %v", ptr, err)
}
}
// unmarshalResponse unmarshals an API response into either
// a *T or an *Error.
func unmarshalResponse[T any](data []byte) (any, error) {
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
var t T
err1 := d.Decode(&t)
if err1 == nil {
return &t, nil
}
d = json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
var e api.Error
err2 := d.Decode(&e)
if err2 == nil {
return &e, nil
}
return nil, errors.Join(err1, err2)
}