From 05726a15e18e4a8223978dff112426141506ea81 Mon Sep 17 00:00:00 2001 From: TenSt Date: Tue, 7 Jul 2026 17:45:28 +0200 Subject: [PATCH 1/3] HMS-10971: add npm packages support Co-authored-by: Cursor --- pkg/tangy/interface.go | 4 + pkg/tangy/npm.go | 544 ++++++++++++++++++++++++++++++++++++++++ pkg/tangy/tangy_mock.go | 314 +++++++++++++++++++++++ 3 files changed, 862 insertions(+) create mode 100644 pkg/tangy/npm.go diff --git a/pkg/tangy/interface.go b/pkg/tangy/interface.go index 5e060e1..33a4ffd 100644 --- a/pkg/tangy/interface.go +++ b/pkg/tangy/interface.go @@ -74,6 +74,10 @@ type Tangy interface { MavenPackageList(ctx context.Context, repositoryHref string, filterOpts MavenPackageListFilters, pageOpts PageOptions) (MavenPackageListResponse, error) MavenBuildList(ctx context.Context, repositoryHref, groupID, artifactID, version string, pageOpts PageOptions) (MavenBuildListResponse, error) MavenRepositoryMetrics(ctx context.Context, repositoryHref string) (MavenRepositoryMetrics, error) + NpmPackageList(ctx context.Context, repositoryHref string, filterOpts NpmPackageListFilters, pageOpts PageOptions) (NpmPackageListResponse, error) + NpmPackageGet(ctx context.Context, repositoryHref, name, version string) (NpmPackageDetail, error) + NpmPackageVersionsGet(ctx context.Context, repositoryHref, name string) ([]NpmPackageDetail, error) + NpmBuildList(ctx context.Context, repositoryHref, name, version string, pageOpts PageOptions) (NpmBuildListResponse, error) Close() } diff --git a/pkg/tangy/npm.go b/pkg/tangy/npm.go new file mode 100644 index 0000000..9d12fb8 --- /dev/null +++ b/pkg/tangy/npm.go @@ -0,0 +1,544 @@ +package tangy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type NpmVersionInfo struct { + Version string `json:"version"` + CreatedAt string `json:"created_at"` +} + +type NpmPackageListItem struct { + Name string `json:"name"` + Versions []string `json:"versions"` + LatestVersions []NpmVersionInfo `json:"latest_versions"` +} + +type NpmPackageListResponse struct { + Results []NpmPackageListItem `json:"results"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type NpmPackageListFilters struct { + Search string +} + +type NpmBuildListItem struct { + Name string `json:"name"` + Version string `json:"version"` + CreatedAt string `json:"created_at"` +} + +type NpmBuildListResponse struct { + Results []NpmBuildListItem `json:"results"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type NpmTarballInfo struct { + RelativePath string `json:"relative_path"` + Filename string `json:"filename"` + Sha256 string `json:"sha256"` + Size int64 `json:"size"` +} + +type NpmPackageDetail struct { + Name string `json:"name"` + Version string `json:"version"` + CreatedAt string `json:"created_at"` + Tarball NpmTarballInfo `json:"tarball"` + Versions []string `json:"versions"` + LatestVersions []NpmVersionInfo `json:"latest_versions"` +} + +var ErrNpmPackageNotFound = errors.New("npm package not found") + +type npmPackageVersionRow struct { + Name string + Version string + CreatedAt time.Time +} + +type npmPackageDetailRow struct { + Name string + Version string + CreatedAt time.Time + RelativePath *string + Sha256 *string + Size *int64 + Versions []string + LatestVersionsJSON []byte +} + +// NpmPackageList lists npm packages from the latest version of a repository, +// grouped by name with SQL-level pagination. +func (t *tangyImpl) NpmPackageList(ctx context.Context, repositoryHref string, filterOpts NpmPackageListFilters, pageOpts PageOptions) (NpmPackageListResponse, error) { + if repositoryHref == "" { + return NpmPackageListResponse{}, nil + } + + conn, err := t.pool.Acquire(ctx) + if err != nil { + return NpmPackageListResponse{}, err + } + defer conn.Release() + + if pageOpts.Limit == 0 { + pageOpts.Limit = DefaultLimit + } + + repoUUID, err := parseNpmRepositoryHref(repositoryHref) + if err != nil { + return NpmPackageListResponse{}, fmt.Errorf("error parsing repository href: %w", err) + } + + latestVersion, err := getLatestRepositoryVersion(ctx, conn, repoUUID) + if err != nil { + return NpmPackageListResponse{}, fmt.Errorf("error getting latest repository version: %w", err) + } + + repoVerMap := []ParsedRepoVersion{{ + RepositoryUUID: repoUUID, + Version: latestVersion, + }} + + args := pgx.NamedArgs{ + "limit": pageOpts.Limit, + "offset": pageOpts.Offset, + } + searchFilter := "" + if filterOpts.Search != "" { + args["searchFilter"] = filterOpts.Search + searchFilter = ` AND rp.name ILIKE CONCAT(@searchFilter::text, '%')` + } + innerUnion, err := contentIdsInVersions(ctx, conn, repoVerMap, &args) + if err != nil { + return NpmPackageListResponse{}, err + } + + countQuery := ` + SELECT COUNT(DISTINCT rp.name) + FROM npm_package rp + ` + innerUnion + searchFilter + + var countTotal int + err = conn.QueryRow(ctx, countQuery, args).Scan(&countTotal) + if err != nil { + return NpmPackageListResponse{}, err + } + + query := ` + WITH filtered AS ( + SELECT rp.name, rp.version, cc.pulp_created + FROM npm_package rp + INNER JOIN core_content cc ON rp.content_ptr_id = cc.pulp_id + ` + innerUnion + searchFilter + ` + ), + package_versions AS ( + SELECT name, version, MAX(pulp_created) AS created_at + FROM filtered + GROUP BY name, version + ), + paginated_packages AS ( + SELECT name + FROM package_versions + GROUP BY name + ORDER BY name + LIMIT @limit OFFSET @offset + ) + SELECT pv.name, pv.version, pv.created_at + FROM package_versions pv + INNER JOIN paginated_packages pp ON pv.name = pp.name + ORDER BY pv.name, pv.version` + + rows, err := conn.Query(ctx, query, args) + if err != nil { + return NpmPackageListResponse{}, err + } + + versionRows, err := pgx.CollectRows(rows, pgx.RowToStructByName[npmPackageVersionRow]) + if err != nil { + return NpmPackageListResponse{}, err + } + + return NpmPackageListResponse{ + Results: assembleNpmPackageListFromRows(versionRows), + Total: countTotal, + Limit: pageOpts.Limit, + Offset: pageOpts.Offset, + }, nil +} + +// NpmPackageGet returns tarball info and timestamps for a specific package name and version +// from the latest version of a repository, plus all other versions available in that repository. +func (t *tangyImpl) NpmPackageGet(ctx context.Context, repositoryHref, name, version string) (NpmPackageDetail, error) { + if repositoryHref == "" { + return NpmPackageDetail{}, nil + } + + conn, innerUnion, args, err := t.prepareNpmPackageQuery(ctx, repositoryHref, name) + if err != nil { + return NpmPackageDetail{}, err + } + defer conn.Release() + + detailRows, err := fetchNpmPackageDetailRows(ctx, conn, innerUnion, args, version) + if err != nil { + return NpmPackageDetail{}, err + } + if len(detailRows) == 0 { + return NpmPackageDetail{}, fmt.Errorf("%w: %s@%s", ErrNpmPackageNotFound, name, version) + } + + row := detailRows[0] + latestVersions, err := parseNpmLatestVersionsJSON(row.LatestVersionsJSON) + if err != nil { + return NpmPackageDetail{}, err + } + + return npmPackageDetailFromRow(row, latestVersions), nil +} + +// NpmPackageVersionsGet returns tarball info for every version of a package name +// from the latest version of a repository. +func (t *tangyImpl) NpmPackageVersionsGet(ctx context.Context, repositoryHref, name string) ([]NpmPackageDetail, error) { + if repositoryHref == "" { + return nil, nil + } + + conn, innerUnion, args, err := t.prepareNpmPackageQuery(ctx, repositoryHref, name) + if err != nil { + return nil, err + } + defer conn.Release() + + detailRows, err := fetchNpmPackageDetailRows(ctx, conn, innerUnion, args, "") + if err != nil { + return nil, err + } + if len(detailRows) == 0 { + return nil, fmt.Errorf("%w: %s", ErrNpmPackageNotFound, name) + } + + latestVersions, err := parseNpmLatestVersionsJSON(detailRows[0].LatestVersionsJSON) + if err != nil { + return nil, err + } + + results := make([]NpmPackageDetail, len(detailRows)) + for i, row := range detailRows { + results[i] = npmPackageDetailFromRow(row, latestVersions) + } + + return results, nil +} + +// NpmBuildList lists all npm package builds (name + version pairs), optionally filtered by name +// and version, from the latest version of a repository. +func (t *tangyImpl) NpmBuildList(ctx context.Context, repositoryHref, name, version string, pageOpts PageOptions) (NpmBuildListResponse, error) { + if repositoryHref == "" { + return NpmBuildListResponse{}, nil + } + + conn, err := t.pool.Acquire(ctx) + if err != nil { + return NpmBuildListResponse{}, err + } + defer conn.Release() + + if pageOpts.Limit == 0 { + pageOpts.Limit = DefaultLimit + } + + repoUUID, err := parseNpmRepositoryHref(repositoryHref) + if err != nil { + return NpmBuildListResponse{}, fmt.Errorf("error parsing repository href: %w", err) + } + + latestVersion, err := getLatestRepositoryVersion(ctx, conn, repoUUID) + if err != nil { + return NpmBuildListResponse{}, fmt.Errorf("error getting latest repository version: %w", err) + } + + repoVerMap := []ParsedRepoVersion{{ + RepositoryUUID: repoUUID, + Version: latestVersion, + }} + + args := pgx.NamedArgs{} + + var whereClause string + if name != "" { + args["name"] = name + whereClause += "\n\t\tAND rp.name = @name" + } + if version != "" { + args["version"] = version + whereClause += "\n\t\tAND rp.version = @version" + } + + innerUnion, err := contentIdsInVersions(ctx, conn, repoVerMap, &args) + if err != nil { + return NpmBuildListResponse{}, err + } + + buildFrom := ` + FROM npm_package rp + INNER JOIN core_content cc ON rp.content_ptr_id = cc.pulp_id + ` + innerUnion + whereClause + + countQuery := ` + SELECT COUNT(*) + FROM ( + SELECT rp.name, rp.version + ` + buildFrom + ` + GROUP BY rp.name, rp.version + ) builds` + + var countTotal int + err = conn.QueryRow(ctx, countQuery, args).Scan(&countTotal) + if err != nil { + return NpmBuildListResponse{}, err + } + + args["limit"] = pageOpts.Limit + args["offset"] = pageOpts.Offset + + query := ` + SELECT rp.name, rp.version, MAX(cc.pulp_created) AS created_at + ` + buildFrom + ` + GROUP BY rp.name, rp.version + ORDER BY created_at DESC + LIMIT @limit OFFSET @offset` + + rows, err := conn.Query(ctx, query, args) + if err != nil { + return NpmBuildListResponse{}, err + } + + buildRows, err := pgx.CollectRows(rows, pgx.RowToStructByName[npmPackageVersionRow]) + if err != nil { + return NpmBuildListResponse{}, err + } + + results := make([]NpmBuildListItem, len(buildRows)) + for i, row := range buildRows { + results[i] = NpmBuildListItem{ + Name: row.Name, + Version: row.Version, + CreatedAt: row.CreatedAt.Format(time.RFC3339), + } + } + + return NpmBuildListResponse{ + Results: results, + Total: countTotal, + Limit: pageOpts.Limit, + Offset: pageOpts.Offset, + }, nil +} + +func (t *tangyImpl) prepareNpmPackageQuery(ctx context.Context, repositoryHref, name string) (*pgxpool.Conn, string, pgx.NamedArgs, error) { + conn, err := t.pool.Acquire(ctx) + if err != nil { + return nil, "", nil, err + } + + repoUUID, err := parseNpmRepositoryHref(repositoryHref) + if err != nil { + conn.Release() + return nil, "", nil, fmt.Errorf("error parsing repository href: %w", err) + } + + latestVersion, err := getLatestRepositoryVersion(ctx, conn, repoUUID) + if err != nil { + conn.Release() + return nil, "", nil, fmt.Errorf("error getting latest repository version: %w", err) + } + + repoVerMap := []ParsedRepoVersion{{ + RepositoryUUID: repoUUID, + Version: latestVersion, + }} + + args := pgx.NamedArgs{ + "name": name, + } + innerUnion, err := contentIdsInVersions(ctx, conn, repoVerMap, &args) + if err != nil { + conn.Release() + return nil, "", nil, err + } + + return conn, innerUnion, args, nil +} + +func fetchNpmPackageDetailRows(ctx context.Context, conn *pgxpool.Conn, innerUnion string, args pgx.NamedArgs, version string) ([]npmPackageDetailRow, error) { + detailFilter := "" + orderBy := "ORDER BY d.version" + if version != "" { + args["version"] = version + detailFilter = "WHERE f.version = @version" + orderBy = "" + } + + query := ` + WITH filtered AS ( + SELECT rp.name, rp.version, cc.pulp_created, + cca.relative_path, ca.sha256, ca.size + FROM npm_package rp + INNER JOIN core_content cc ON rp.content_ptr_id = cc.pulp_id + LEFT JOIN core_contentartifact cca ON cca.content_id = rp.content_ptr_id + LEFT JOIN core_artifact ca ON ca.pulp_id = cca.artifact_id + ` + innerUnion + ` + AND rp.name = @name + ), + version_agg AS ( + SELECT + ARRAY_AGG(version ORDER BY version) AS versions, + COALESCE( + JSON_AGG( + JSON_BUILD_OBJECT('version', version, 'created_at', created_at) + ORDER BY version + ), + '[]'::json + ) AS latest_versions_json + FROM ( + SELECT version, MAX(pulp_created) AS created_at + FROM filtered + GROUP BY version + ) v + ), + detail AS ( + SELECT f.* + FROM filtered f + ` + detailFilter + ` + ) + SELECT d.name, d.version, d.pulp_created AS created_at, + d.relative_path, d.sha256, d.size, + va.versions, va.latest_versions_json + FROM detail d + CROSS JOIN version_agg va + ` + orderBy + + rows, err := conn.Query(ctx, query, args) + if err != nil { + return nil, err + } + + return pgx.CollectRows(rows, pgx.RowToStructByName[npmPackageDetailRow]) +} + +func npmPackageDetailFromRow(row npmPackageDetailRow, latestVersions []NpmVersionInfo) NpmPackageDetail { + return NpmPackageDetail{ + Name: row.Name, + Version: row.Version, + CreatedAt: row.CreatedAt.Format(time.RFC3339), + Tarball: npmTarballFromRow(row.RelativePath, row.Sha256, row.Size), + Versions: row.Versions, + LatestVersions: latestVersions, + } +} + +func npmTarballFromRow(relativePath, sha256 *string, size *int64) NpmTarballInfo { + info := NpmTarballInfo{} + if relativePath != nil { + info.RelativePath = *relativePath + info.Filename = path.Base(*relativePath) + } + if sha256 != nil { + info.Sha256 = *sha256 + } + if size != nil { + info.Size = *size + } + return info +} + +func parseNpmLatestVersionsJSON(data []byte) ([]NpmVersionInfo, error) { + if len(data) == 0 { + return nil, nil + } + + var raw []struct { + Version string `json:"version"` + CreatedAt time.Time `json:"created_at"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse latest_versions: %w", err) + } + + latestVersions := make([]NpmVersionInfo, len(raw)) + for i, item := range raw { + latestVersions[i] = NpmVersionInfo{ + Version: item.Version, + CreatedAt: item.CreatedAt.Format(time.RFC3339), + } + } + return latestVersions, nil +} + +func assembleNpmPackageListFromRows(rows []npmPackageVersionRow) []NpmPackageListItem { + if len(rows) == 0 { + return nil + } + + results := make([]NpmPackageListItem, 0) + var current NpmPackageListItem + + for i, row := range rows { + if i == 0 || row.Name != current.Name { + if i > 0 { + results = append(results, current) + } + current = NpmPackageListItem{ + Name: row.Name, + Versions: []string{row.Version}, + LatestVersions: []NpmVersionInfo{{ + Version: row.Version, + CreatedAt: row.CreatedAt.Format(time.RFC3339), + }}, + } + continue + } + + current.Versions = append(current.Versions, row.Version) + current.LatestVersions = append(current.LatestVersions, NpmVersionInfo{ + Version: row.Version, + CreatedAt: row.CreatedAt.Format(time.RFC3339), + }) + } + + return append(results, current) +} + +// parseNpmRepositoryHref extracts the repository UUID from an npm repository href. +// Example: /api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4/ +func parseNpmRepositoryHref(href string) (string, error) { + parts := strings.Split(href, "/") + var nonEmptyParts []string + for _, part := range parts { + if part != "" { + nonEmptyParts = append(nonEmptyParts, part) + } + } + + if len(nonEmptyParts) < 8 { + return "", fmt.Errorf("invalid repository href format: %s", href) + } + + return nonEmptyParts[len(nonEmptyParts)-1], nil +} diff --git a/pkg/tangy/tangy_mock.go b/pkg/tangy/tangy_mock.go index 0f49af1..3917c4d 100644 --- a/pkg/tangy/tangy_mock.go +++ b/pkg/tangy/tangy_mock.go @@ -304,6 +304,320 @@ func (_c *MockTangy_MavenRepositoryMetrics_Call) RunAndReturn(run func(ctx conte return _c } +// NpmBuildList provides a mock function for the type MockTangy +func (_mock *MockTangy) NpmBuildList(ctx context.Context, repositoryHref string, name string, version string, pageOpts PageOptions) (NpmBuildListResponse, error) { + ret := _mock.Called(ctx, repositoryHref, name, version, pageOpts) + + if len(ret) == 0 { + panic("no return value specified for NpmBuildList") + } + + var r0 NpmBuildListResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, PageOptions) (NpmBuildListResponse, error)); ok { + return returnFunc(ctx, repositoryHref, name, version, pageOpts) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string, PageOptions) NpmBuildListResponse); ok { + r0 = returnFunc(ctx, repositoryHref, name, version, pageOpts) + } else { + r0 = ret.Get(0).(NpmBuildListResponse) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string, PageOptions) error); ok { + r1 = returnFunc(ctx, repositoryHref, name, version, pageOpts) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTangy_NpmBuildList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NpmBuildList' +type MockTangy_NpmBuildList_Call struct { + *mock.Call +} + +// NpmBuildList is a helper method to define mock.On call +// - ctx context.Context +// - repositoryHref string +// - name string +// - version string +// - pageOpts PageOptions +func (_e *MockTangy_Expecter) NpmBuildList(ctx any, repositoryHref any, name any, version any, pageOpts any) *MockTangy_NpmBuildList_Call { + return &MockTangy_NpmBuildList_Call{Call: _e.mock.On("NpmBuildList", ctx, repositoryHref, name, version, pageOpts)} +} + +func (_c *MockTangy_NpmBuildList_Call) Run(run func(ctx context.Context, repositoryHref string, name string, version string, pageOpts PageOptions)) *MockTangy_NpmBuildList_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 PageOptions + if args[4] != nil { + arg4 = args[4].(PageOptions) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *MockTangy_NpmBuildList_Call) Return(npmBuildListResponse NpmBuildListResponse, err error) *MockTangy_NpmBuildList_Call { + _c.Call.Return(npmBuildListResponse, err) + return _c +} + +func (_c *MockTangy_NpmBuildList_Call) RunAndReturn(run func(ctx context.Context, repositoryHref string, name string, version string, pageOpts PageOptions) (NpmBuildListResponse, error)) *MockTangy_NpmBuildList_Call { + _c.Call.Return(run) + return _c +} + +// NpmPackageGet provides a mock function for the type MockTangy +func (_mock *MockTangy) NpmPackageGet(ctx context.Context, repositoryHref string, name string, version string) (NpmPackageDetail, error) { + ret := _mock.Called(ctx, repositoryHref, name, version) + + if len(ret) == 0 { + panic("no return value specified for NpmPackageGet") + } + + var r0 NpmPackageDetail + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (NpmPackageDetail, error)); ok { + return returnFunc(ctx, repositoryHref, name, version) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) NpmPackageDetail); ok { + r0 = returnFunc(ctx, repositoryHref, name, version) + } else { + r0 = ret.Get(0).(NpmPackageDetail) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { + r1 = returnFunc(ctx, repositoryHref, name, version) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTangy_NpmPackageGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NpmPackageGet' +type MockTangy_NpmPackageGet_Call struct { + *mock.Call +} + +// NpmPackageGet is a helper method to define mock.On call +// - ctx context.Context +// - repositoryHref string +// - name string +// - version string +func (_e *MockTangy_Expecter) NpmPackageGet(ctx any, repositoryHref any, name any, version any) *MockTangy_NpmPackageGet_Call { + return &MockTangy_NpmPackageGet_Call{Call: _e.mock.On("NpmPackageGet", ctx, repositoryHref, name, version)} +} + +func (_c *MockTangy_NpmPackageGet_Call) Run(run func(ctx context.Context, repositoryHref string, name string, version string)) *MockTangy_NpmPackageGet_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockTangy_NpmPackageGet_Call) Return(npmPackageDetail NpmPackageDetail, err error) *MockTangy_NpmPackageGet_Call { + _c.Call.Return(npmPackageDetail, err) + return _c +} + +func (_c *MockTangy_NpmPackageGet_Call) RunAndReturn(run func(ctx context.Context, repositoryHref string, name string, version string) (NpmPackageDetail, error)) *MockTangy_NpmPackageGet_Call { + _c.Call.Return(run) + return _c +} + +// NpmPackageList provides a mock function for the type MockTangy +func (_mock *MockTangy) NpmPackageList(ctx context.Context, repositoryHref string, filterOpts NpmPackageListFilters, pageOpts PageOptions) (NpmPackageListResponse, error) { + ret := _mock.Called(ctx, repositoryHref, filterOpts, pageOpts) + + if len(ret) == 0 { + panic("no return value specified for NpmPackageList") + } + + var r0 NpmPackageListResponse + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, NpmPackageListFilters, PageOptions) (NpmPackageListResponse, error)); ok { + return returnFunc(ctx, repositoryHref, filterOpts, pageOpts) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, NpmPackageListFilters, PageOptions) NpmPackageListResponse); ok { + r0 = returnFunc(ctx, repositoryHref, filterOpts, pageOpts) + } else { + r0 = ret.Get(0).(NpmPackageListResponse) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, NpmPackageListFilters, PageOptions) error); ok { + r1 = returnFunc(ctx, repositoryHref, filterOpts, pageOpts) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTangy_NpmPackageList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NpmPackageList' +type MockTangy_NpmPackageList_Call struct { + *mock.Call +} + +// NpmPackageList is a helper method to define mock.On call +// - ctx context.Context +// - repositoryHref string +// - filterOpts NpmPackageListFilters +// - pageOpts PageOptions +func (_e *MockTangy_Expecter) NpmPackageList(ctx any, repositoryHref any, filterOpts any, pageOpts any) *MockTangy_NpmPackageList_Call { + return &MockTangy_NpmPackageList_Call{Call: _e.mock.On("NpmPackageList", ctx, repositoryHref, filterOpts, pageOpts)} +} + +func (_c *MockTangy_NpmPackageList_Call) Run(run func(ctx context.Context, repositoryHref string, filterOpts NpmPackageListFilters, pageOpts PageOptions)) *MockTangy_NpmPackageList_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 NpmPackageListFilters + if args[2] != nil { + arg2 = args[2].(NpmPackageListFilters) + } + var arg3 PageOptions + if args[3] != nil { + arg3 = args[3].(PageOptions) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockTangy_NpmPackageList_Call) Return(npmPackageListResponse NpmPackageListResponse, err error) *MockTangy_NpmPackageList_Call { + _c.Call.Return(npmPackageListResponse, err) + return _c +} + +func (_c *MockTangy_NpmPackageList_Call) RunAndReturn(run func(ctx context.Context, repositoryHref string, filterOpts NpmPackageListFilters, pageOpts PageOptions) (NpmPackageListResponse, error)) *MockTangy_NpmPackageList_Call { + _c.Call.Return(run) + return _c +} + +// NpmPackageVersionsGet provides a mock function for the type MockTangy +func (_mock *MockTangy) NpmPackageVersionsGet(ctx context.Context, repositoryHref string, name string) ([]NpmPackageDetail, error) { + ret := _mock.Called(ctx, repositoryHref, name) + + if len(ret) == 0 { + panic("no return value specified for NpmPackageVersionsGet") + } + + var r0 []NpmPackageDetail + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]NpmPackageDetail, error)); ok { + return returnFunc(ctx, repositoryHref, name) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []NpmPackageDetail); ok { + r0 = returnFunc(ctx, repositoryHref, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]NpmPackageDetail) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, repositoryHref, name) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTangy_NpmPackageVersionsGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NpmPackageVersionsGet' +type MockTangy_NpmPackageVersionsGet_Call struct { + *mock.Call +} + +// NpmPackageVersionsGet is a helper method to define mock.On call +// - ctx context.Context +// - repositoryHref string +// - name string +func (_e *MockTangy_Expecter) NpmPackageVersionsGet(ctx any, repositoryHref any, name any) *MockTangy_NpmPackageVersionsGet_Call { + return &MockTangy_NpmPackageVersionsGet_Call{Call: _e.mock.On("NpmPackageVersionsGet", ctx, repositoryHref, name)} +} + +func (_c *MockTangy_NpmPackageVersionsGet_Call) Run(run func(ctx context.Context, repositoryHref string, name string)) *MockTangy_NpmPackageVersionsGet_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTangy_NpmPackageVersionsGet_Call) Return(npmPackageDetails []NpmPackageDetail, err error) *MockTangy_NpmPackageVersionsGet_Call { + _c.Call.Return(npmPackageDetails, err) + return _c +} + +func (_c *MockTangy_NpmPackageVersionsGet_Call) RunAndReturn(run func(ctx context.Context, repositoryHref string, name string) ([]NpmPackageDetail, error)) *MockTangy_NpmPackageVersionsGet_Call { + _c.Call.Return(run) + return _c +} + // PythonBuildList provides a mock function for the type MockTangy func (_mock *MockTangy) PythonBuildList(ctx context.Context, repositoryHref string, nameNormalized string, version string, pageOpts PageOptions) (PythonBuildListResponse, error) { ret := _mock.Called(ctx, repositoryHref, nameNormalized, version, pageOpts) From ab99eb9280d78a2862ebfcfc6cb046681cab2f43 Mon Sep 17 00:00:00 2001 From: TenSt Date: Tue, 7 Jul 2026 17:55:49 +0200 Subject: [PATCH 2/3] HMS-10971: add npm packages unit and integration tests Co-authored-by: Cursor --- internal/test/integration/npm_test.go | 394 ++++++++++++++++++++++++++ internal/zestwrapper/npm.go | 126 ++++++++ pkg/tangy/npm_test.go | 279 ++++++++++++++++++ 3 files changed, 799 insertions(+) create mode 100644 internal/test/integration/npm_test.go create mode 100644 internal/zestwrapper/npm.go create mode 100644 pkg/tangy/npm_test.go diff --git a/internal/test/integration/npm_test.go b/internal/test/integration/npm_test.go new file mode 100644 index 0000000..abc152e --- /dev/null +++ b/internal/test/integration/npm_test.go @@ -0,0 +1,394 @@ +package integration + +import ( + "context" + "strings" + "testing" + + "github.com/content-services/tang/internal/config" + "github.com/content-services/tang/internal/zestwrapper" + "github.com/content-services/tang/pkg/tangy" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const ( + testNpmRepoName = "is-odd-fixture" + testNpmPackageName = "is-odd" + testNpmVersion = "3.0.1" + testNpmRemoteURL = "https://registry.npmjs.org/is-odd/3.0.1" +) + +type NpmSuite struct { + suite.Suite + client *zestwrapper.NpmZest + tangy tangy.Tangy + domainName string + repositoryHref string +} + +func (n *NpmSuite) createTestRepository(t *testing.T) { + _, err := n.client.LookupOrCreateDomain(n.domainName) + require.NoError(t, err) + + repoHref, remoteHref, err := n.client.CreateRepository( + n.domainName, + testNpmRepoName, + testNpmRemoteURL, + ) + require.NoError(t, err) + + syncTask, err := n.client.SyncNpmRepository(repoHref, remoteHref) + require.NoError(t, err) + + _, err = n.client.PollTask(syncTask) + require.NoError(t, err) + + n.repositoryHref = repoHref +} + +func TestNpmSuite(t *testing.T) { + s := config.Get().Server + npmZest := zestwrapper.NewNpmZest(context.Background(), s) + + dbConfig := config.Get().Database + ta, err := tangy.New(tangy.Database{ + Name: dbConfig.Name, + Host: dbConfig.Host, + Port: dbConfig.Port, + User: dbConfig.User, + Password: dbConfig.Password, + }, tangy.Logger{Enabled: true, Logger: &log.Logger, LogLevel: zerolog.LevelDebugValue}) + require.NoError(t, err) + t.Cleanup(ta.Close) + + n := NpmSuite{} + n.client = &npmZest + n.tangy = ta + n.domainName = RandStringBytes(10) + + n.createTestRepository(t) + + suite.Run(t, &n) +} + +func (n *NpmSuite) TestNpmPackageList() { + response, err := n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: testNpmPackageName}, tangy.PageOptions{ + Offset: 0, + Limit: 10, + }) + require.NoError(n.T(), err) + require.NotEmpty(n.T(), response.Results) + assert.Equal(n.T(), 1, response.Total) + + pkg := response.Results[0] + assert.Equal(n.T(), testNpmPackageName, pkg.Name) + assert.Contains(n.T(), pkg.Versions, testNpmVersion) + require.NotEmpty(n.T(), pkg.LatestVersions) + + foundVersion := false + for _, latest := range pkg.LatestVersions { + if latest.Version == testNpmVersion { + foundVersion = true + assert.NotEmpty(n.T(), latest.CreatedAt) + } + } + assert.True(n.T(), foundVersion) +} + +func (n *NpmSuite) TestNpmPackageListSearchFilter() { + response, err := n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: "is-odd"}, tangy.PageOptions{ + Offset: 0, + Limit: 10, + }) + require.NoError(n.T(), err) + require.NotEmpty(n.T(), response.Results) + assert.Equal(n.T(), 1, response.Total) + assert.Equal(n.T(), testNpmPackageName, response.Results[0].Name) + + response, err = n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: "nonexistent-package"}, tangy.PageOptions{ + Offset: 0, + Limit: 10, + }) + require.NoError(n.T(), err) + assert.Empty(n.T(), response.Results) + assert.Zero(n.T(), response.Total) +} + +func (n *NpmSuite) TestNpmPackageListPagination() { + response, err := n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: testNpmPackageName}, tangy.PageOptions{ + Offset: 0, + Limit: 1, + }) + require.NoError(n.T(), err) + assert.Len(n.T(), response.Results, 1) + assert.Equal(n.T(), 1, response.Total) + assert.Equal(n.T(), 1, response.Limit) + + response, err = n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: testNpmPackageName}, tangy.PageOptions{ + Offset: 1, + Limit: 1, + }) + require.NoError(n.T(), err) + assert.Empty(n.T(), response.Results) + assert.Equal(n.T(), 1, response.Total) +} + +func (n *NpmSuite) TestNpmPackageListEmptyHref() { + response, err := n.tangy.NpmPackageList(context.Background(), "", tangy.NpmPackageListFilters{}, tangy.PageOptions{Limit: 10}) + require.NoError(n.T(), err) + assert.Empty(n.T(), response.Results) + assert.Zero(n.T(), response.Total) +} + +func (n *NpmSuite) TestNpmPackageGet() { + detail, err := n.tangy.NpmPackageGet( + context.Background(), + n.repositoryHref, + testNpmPackageName, + testNpmVersion, + ) + require.NoError(n.T(), err) + assert.Equal(n.T(), testNpmPackageName, detail.Name) + assert.Equal(n.T(), testNpmVersion, detail.Version) + assert.NotEmpty(n.T(), detail.CreatedAt) + assert.Contains(n.T(), detail.Versions, testNpmVersion) + require.NotEmpty(n.T(), detail.LatestVersions) + assert.NotEmpty(n.T(), detail.Tarball.RelativePath) + assert.True(n.T(), strings.HasSuffix(detail.Tarball.Filename, ".tgz"), detail.Tarball.Filename) + assert.NotEmpty(n.T(), detail.Tarball.Sha256) + assert.NotZero(n.T(), detail.Tarball.Size) +} + +func (n *NpmSuite) TestNpmPackageVersionsGet() { + details, err := n.tangy.NpmPackageVersionsGet( + context.Background(), + n.repositoryHref, + testNpmPackageName, + ) + require.NoError(n.T(), err) + require.Len(n.T(), details, 1) + + detail := details[0] + assert.Equal(n.T(), testNpmPackageName, detail.Name) + assert.Equal(n.T(), testNpmVersion, detail.Version) + assert.NotEmpty(n.T(), detail.CreatedAt) + assert.Equal(n.T(), []string{testNpmVersion}, detail.Versions) + assert.Len(n.T(), detail.LatestVersions, 1) + assert.NotEmpty(n.T(), detail.Tarball.RelativePath) + assert.NotEmpty(n.T(), detail.Tarball.Sha256) + assert.NotZero(n.T(), detail.Tarball.Size) +} + +func (n *NpmSuite) TestNpmPackageGetNotFound() { + _, err := n.tangy.NpmPackageGet( + context.Background(), + n.repositoryHref, + testNpmPackageName, + "9.9.9", + ) + require.Error(n.T(), err) + assert.ErrorIs(n.T(), err, tangy.ErrNpmPackageNotFound) +} + +func (n *NpmSuite) TestNpmPackageVersionsGetNotFound() { + _, err := n.tangy.NpmPackageVersionsGet( + context.Background(), + n.repositoryHref, + "nonexistent-package", + ) + require.Error(n.T(), err) + assert.ErrorIs(n.T(), err, tangy.ErrNpmPackageNotFound) +} + +func (n *NpmSuite) TestNpmPackageGetEmptyHref() { + detail, err := n.tangy.NpmPackageGet(context.Background(), "", testNpmPackageName, testNpmVersion) + require.NoError(n.T(), err) + assert.Empty(n.T(), detail.Name) +} + +func (n *NpmSuite) TestNpmPackageVersionsGetEmptyHref() { + details, err := n.tangy.NpmPackageVersionsGet(context.Background(), "", testNpmPackageName) + require.NoError(n.T(), err) + assert.Nil(n.T(), details) +} + +func (n *NpmSuite) TestNpmBuildList() { + response, err := n.tangy.NpmBuildList( + context.Background(), + n.repositoryHref, + "", + "", + tangy.PageOptions{Offset: 0, Limit: 10}, + ) + require.NoError(n.T(), err) + require.NotEmpty(n.T(), response.Results) + assert.GreaterOrEqual(n.T(), response.Total, 1) + assert.Equal(n.T(), 10, response.Limit) + + found := false + for _, build := range response.Results { + if build.Name == testNpmPackageName && build.Version == testNpmVersion { + found = true + assert.NotEmpty(n.T(), build.CreatedAt) + } + } + assert.True(n.T(), found) + + filtered, err := n.tangy.NpmBuildList( + context.Background(), + n.repositoryHref, + testNpmPackageName, + testNpmVersion, + tangy.PageOptions{Offset: 0, Limit: 10}, + ) + require.NoError(n.T(), err) + require.Len(n.T(), filtered.Results, 1) + assert.Equal(n.T(), 1, filtered.Total) + assert.Equal(n.T(), testNpmPackageName, filtered.Results[0].Name) + assert.Equal(n.T(), testNpmVersion, filtered.Results[0].Version) +} + +func (n *NpmSuite) TestNpmBuildListPagination() { + response, err := n.tangy.NpmBuildList( + context.Background(), + n.repositoryHref, + testNpmPackageName, + "", + tangy.PageOptions{Offset: 0, Limit: 1}, + ) + require.NoError(n.T(), err) + assert.Len(n.T(), response.Results, 1) + assert.Equal(n.T(), 1, response.Total) + + response, err = n.tangy.NpmBuildList( + context.Background(), + n.repositoryHref, + testNpmPackageName, + "", + tangy.PageOptions{Offset: 1, Limit: 1}, + ) + require.NoError(n.T(), err) + assert.Empty(n.T(), response.Results) + assert.Equal(n.T(), 1, response.Total) +} + +func (n *NpmSuite) TestNpmBuildListEmptyHref() { + response, err := n.tangy.NpmBuildList(context.Background(), "", testNpmPackageName, testNpmVersion, tangy.PageOptions{Limit: 10}) + require.NoError(n.T(), err) + assert.Empty(n.T(), response.Results) + assert.Zero(n.T(), response.Total) +} + +const ( + testNpmScopedRepoName = "types-is-odd-fixture" + testNpmScopedPackageName = "@types/is-odd" + testNpmScopedVersion = "3.0.0" + testNpmScopedRemoteURL = "https://registry.npmjs.org/@types/is-odd/3.0.0" +) + +type NpmScopedSuite struct { + suite.Suite + client *zestwrapper.NpmZest + tangy tangy.Tangy + domainName string + repositoryHref string +} + +func (n *NpmScopedSuite) createTestRepository(t *testing.T) { + _, err := n.client.LookupOrCreateDomain(n.domainName) + require.NoError(t, err) + + repoHref, remoteHref, err := n.client.CreateRepository( + n.domainName, + testNpmScopedRepoName, + testNpmScopedRemoteURL, + ) + require.NoError(t, err) + + syncTask, err := n.client.SyncNpmRepository(repoHref, remoteHref) + require.NoError(t, err) + + _, err = n.client.PollTask(syncTask) + require.NoError(t, err) + + n.repositoryHref = repoHref +} + +func TestNpmScopedSuite(t *testing.T) { + s := config.Get().Server + npmZest := zestwrapper.NewNpmZest(context.Background(), s) + + dbConfig := config.Get().Database + ta, err := tangy.New(tangy.Database{ + Name: dbConfig.Name, + Host: dbConfig.Host, + Port: dbConfig.Port, + User: dbConfig.User, + Password: dbConfig.Password, + }, tangy.Logger{Enabled: true, Logger: &log.Logger, LogLevel: zerolog.LevelDebugValue}) + require.NoError(t, err) + t.Cleanup(ta.Close) + + n := NpmScopedSuite{} + n.client = &npmZest + n.tangy = ta + n.domainName = RandStringBytes(10) + + n.createTestRepository(t) + + suite.Run(t, &n) +} + +func (n *NpmScopedSuite) TestNpmScopedPackage() { + listResponse, err := n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: "@types/"}, tangy.PageOptions{ + Offset: 0, + Limit: 10, + }) + require.NoError(n.T(), err) + require.NotEmpty(n.T(), listResponse.Results) + assert.Equal(n.T(), 1, listResponse.Total) + assert.Equal(n.T(), testNpmScopedPackageName, listResponse.Results[0].Name) + assert.Contains(n.T(), listResponse.Results[0].Versions, testNpmScopedVersion) + + detail, err := n.tangy.NpmPackageGet( + context.Background(), + n.repositoryHref, + testNpmScopedPackageName, + testNpmScopedVersion, + ) + require.NoError(n.T(), err) + assert.Equal(n.T(), testNpmScopedPackageName, detail.Name) + assert.Equal(n.T(), testNpmScopedVersion, detail.Version) + assert.NotEmpty(n.T(), detail.CreatedAt) + assert.NotEmpty(n.T(), detail.Tarball.RelativePath) + assert.True(n.T(), strings.HasSuffix(detail.Tarball.Filename, ".tgz"), detail.Tarball.Filename) + assert.NotEmpty(n.T(), detail.Tarball.Sha256) + assert.NotZero(n.T(), detail.Tarball.Size) + + details, err := n.tangy.NpmPackageVersionsGet( + context.Background(), + n.repositoryHref, + testNpmScopedPackageName, + ) + require.NoError(n.T(), err) + require.Len(n.T(), details, 1) + assert.Equal(n.T(), testNpmScopedPackageName, details[0].Name) + assert.Equal(n.T(), testNpmScopedVersion, details[0].Version) + + buildResponse, err := n.tangy.NpmBuildList( + context.Background(), + n.repositoryHref, + testNpmScopedPackageName, + testNpmScopedVersion, + tangy.PageOptions{Offset: 0, Limit: 10}, + ) + require.NoError(n.T(), err) + require.Len(n.T(), buildResponse.Results, 1) + assert.Equal(n.T(), 1, buildResponse.Total) + assert.Equal(n.T(), testNpmScopedPackageName, buildResponse.Results[0].Name) + assert.Equal(n.T(), testNpmScopedVersion, buildResponse.Results[0].Version) +} diff --git a/internal/zestwrapper/npm.go b/internal/zestwrapper/npm.go new file mode 100644 index 0000000..9d614a8 --- /dev/null +++ b/internal/zestwrapper/npm.go @@ -0,0 +1,126 @@ +package zestwrapper + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/content-services/tang/internal/config" + zest "github.com/content-services/zest/release/v2026" +) + +func NewNpmZest(ctx context.Context, server config.Server) NpmZest { + ctx2 := context.WithValue(ctx, zest.ContextServerIndex, 0) + timeout := 120 * time.Second + transport := &http.Transport{ResponseHeaderTimeout: timeout} + httpClient := http.Client{Transport: transport, Timeout: timeout} + + pulpConfig := zest.NewConfiguration() + pulpConfig.HTTPClient = &httpClient + pulpConfig.Servers = zest.ServerConfigurations{zest.ServerConfiguration{ + URL: server.Url, + }} + ctx2 = context.WithValue(ctx2, zest.ContextBasicAuth, zest.BasicAuth{ + UserName: server.Username, + Password: server.Password, + }) + + return NpmZest{ + client: zest.NewAPIClient(pulpConfig), + ctx: ctx2, + } +} + +type NpmZest struct { + client *zest.APIClient + ctx context.Context +} + +func (n *NpmZest) LookupOrCreateDomain(name string) (string, error) { + d, err := n.LookupDomain(name) + if err != nil { + return "", err + } + if d != "" { + return d, nil + } + + localStorage := zest.STORAGECLASSENUM_PULPCORE_APP_MODELS_STORAGE_FILE_SYSTEM + domain := *zest.NewDomain(name, localStorage, map[string]interface{}{ + "location": fmt.Sprintf("/var/lib/pulp/%v/", name), + }) + domainResp, resp, err := n.client.DomainsAPI.DomainsCreate(n.ctx, DefaultDomain).Domain(domain).Execute() + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + return "", err + } + return *domainResp.PulpHref, nil +} + +func (n *NpmZest) LookupDomain(name string) (string, error) { + list, resp, err := n.client.DomainsAPI.DomainsList(n.ctx, DefaultDomain).Name(name).Execute() + if err != nil { + return "", err + } + defer resp.Body.Close() + + if len(list.Results) == 0 { + return "", nil + } + if list.Results[0].PulpHref == nil { + return "", fmt.Errorf("unexpectedly got a nil href for domain %v", name) + } + return *list.Results[0].PulpHref, nil +} + +func (n *NpmZest) CreateRepository(domain, name, url string) (repoHref string, remoteHref string, err error) { + npmRemote := zest.NewNpmNpmRemote(name, url) + policy := zest.POLICY692ENUM_IMMEDIATE + npmRemote.Policy = &policy + + remoteResponse, httpResp, err := n.client.RemotesNpmAPI.RemotesNpmNpmCreate(n.ctx, domain). + NpmNpmRemote(*npmRemote).Execute() + if err != nil { + return "", "", err + } + defer httpResp.Body.Close() + + npmRepository := zest.NewNpmNpmRepository(name) + if remoteResponse.PulpHref != nil { + npmRepository.SetRemote(*remoteResponse.PulpHref) + } + + repoResponse, httpResp, err := n.client.RepositoriesNpmAPI.RepositoriesNpmNpmCreate(n.ctx, domain). + NpmNpmRepository(*npmRepository).Execute() + if err != nil { + return "", "", err + } + defer httpResp.Body.Close() + + return *repoResponse.PulpHref, *remoteResponse.PulpHref, nil +} + +func (n *NpmZest) SyncNpmRepository(repoHref, remoteHref string) (string, error) { + syncURL := zest.NewRepositorySyncURL() + syncURL.SetRemote(remoteHref) + mirror := true + syncURL.SetMirror(mirror) + + resp, httpResp, err := n.client.RepositoriesNpmAPI.RepositoriesNpmNpmSync(n.ctx, normalizePulpHref(repoHref)). + RepositorySyncURL(*syncURL).Execute() + if httpResp != nil { + defer httpResp.Body.Close() + } + if err != nil { + return "", err + } + return resp.Task, nil +} + +func (n *NpmZest) PollTask(taskHref string) (*zest.TaskResponse, error) { + rpmZest := RpmZest{client: n.client, ctx: n.ctx} + return rpmZest.PollTask(taskHref) +} diff --git a/pkg/tangy/npm_test.go b/pkg/tangy/npm_test.go new file mode 100644 index 0000000..d7d5bab --- /dev/null +++ b/pkg/tangy/npm_test.go @@ -0,0 +1,279 @@ +package tangy + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseNpmRepositoryHref(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + href string + want string + wantErr bool + }{ + { + name: "valid npm repository href", + href: "/api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4/", + want: "018c1c95-4281-76eb-b277-842cbad524f4", + }, + { + name: "valid href without trailing slash", + href: "/api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4", + want: "018c1c95-4281-76eb-b277-842cbad524f4", + }, + { + name: "invalid href", + href: "/api/pulp/default/api/v3/repositories/npm/", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseNpmRepositoryHref(tt.href) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestAssembleNpmPackageListFromRows(t *testing.T) { + t.Parallel() + + createdAt1 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + createdAt2 := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + rows []npmPackageVersionRow + want []NpmPackageListItem + }{ + { + name: "empty rows", + rows: nil, + want: nil, + }, + { + name: "single package single version", + rows: []npmPackageVersionRow{ + {Name: "is-odd", Version: "3.0.1", CreatedAt: createdAt1}, + }, + want: []NpmPackageListItem{ + { + Name: "is-odd", + Versions: []string{"3.0.1"}, + LatestVersions: []NpmVersionInfo{ + {Version: "3.0.1", CreatedAt: "2024-01-01T12:00:00Z"}, + }, + }, + }, + }, + { + name: "single package multiple versions", + rows: []npmPackageVersionRow{ + {Name: "is-odd", Version: "1.0.0", CreatedAt: createdAt1}, + {Name: "is-odd", Version: "3.0.1", CreatedAt: createdAt2}, + }, + want: []NpmPackageListItem{ + { + Name: "is-odd", + Versions: []string{"1.0.0", "3.0.1"}, + LatestVersions: []NpmVersionInfo{ + {Version: "1.0.0", CreatedAt: "2024-01-01T12:00:00Z"}, + {Version: "3.0.1", CreatedAt: "2024-06-01T12:00:00Z"}, + }, + }, + }, + }, + { + name: "multiple packages", + rows: []npmPackageVersionRow{ + {Name: "is-number", Version: "6.0.0", CreatedAt: createdAt1}, + {Name: "is-odd", Version: "3.0.1", CreatedAt: createdAt2}, + }, + want: []NpmPackageListItem{ + { + Name: "is-number", + Versions: []string{"6.0.0"}, + LatestVersions: []NpmVersionInfo{ + {Version: "6.0.0", CreatedAt: "2024-01-01T12:00:00Z"}, + }, + }, + { + Name: "is-odd", + Versions: []string{"3.0.1"}, + LatestVersions: []NpmVersionInfo{ + {Version: "3.0.1", CreatedAt: "2024-06-01T12:00:00Z"}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, assembleNpmPackageListFromRows(tt.rows)) + }) + } +} + +func TestNpmTarballFromRow(t *testing.T) { + t.Parallel() + + relativePath := "is-odd/-/is-odd-3.0.1.tgz" + sha256 := "abc123" + size := int64(1024) + + got := npmTarballFromRow(&relativePath, &sha256, &size) + assert.Equal(t, NpmTarballInfo{ + RelativePath: relativePath, + Filename: "is-odd-3.0.1.tgz", + Sha256: sha256, + Size: size, + }, got) + assert.Empty(t, npmTarballFromRow(nil, nil, nil)) +} + +func TestParseNpmLatestVersionsJSON(t *testing.T) { + t.Parallel() + + createdAt := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + got, err := parseNpmLatestVersionsJSON([]byte(`[{"version":"3.0.1","created_at":"2024-01-01T12:00:00Z"}]`)) + require.NoError(t, err) + assert.Equal(t, []NpmVersionInfo{{Version: "3.0.1", CreatedAt: createdAt.Format(time.RFC3339)}}, got) + + got, err = parseNpmLatestVersionsJSON(nil) + require.NoError(t, err) + assert.Nil(t, got) + + _, err = parseNpmLatestVersionsJSON([]byte(`not json`)) + require.Error(t, err) +} + +func TestMockTangyNpmPackageList(t *testing.T) { + t.Parallel() + + mockTangy := NewMockTangy(t) + ctx := context.Background() + repoHref := "/api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4/" + pageOpts := PageOptions{Offset: 0, Limit: 10} + filterOpts := NpmPackageListFilters{Search: "is-odd"} + + expected := NpmPackageListResponse{ + Results: []NpmPackageListItem{ + { + Name: "is-odd", + Versions: []string{"3.0.1"}, + LatestVersions: []NpmVersionInfo{ + {Version: "3.0.1", CreatedAt: "2024-01-01T12:00:00Z"}, + }, + }, + }, + Total: 1, + Limit: 10, + Offset: 0, + } + + mockTangy.On("NpmPackageList", ctx, repoHref, filterOpts, pageOpts).Return(expected, nil) + + got, err := mockTangy.NpmPackageList(ctx, repoHref, filterOpts, pageOpts) + require.NoError(t, err) + assert.Equal(t, expected, got) +} + +func TestMockTangyNpmPackageGet(t *testing.T) { + t.Parallel() + + mockTangy := NewMockTangy(t) + ctx := context.Background() + repoHref := "/api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4/" + + expected := NpmPackageDetail{ + Name: "is-odd", + Version: "3.0.1", + CreatedAt: "2024-01-01T12:00:00Z", + Tarball: NpmTarballInfo{ + RelativePath: "is-odd/-/is-odd-3.0.1.tgz", + Filename: "is-odd-3.0.1.tgz", + Sha256: "abc123", + Size: 1024, + }, + Versions: []string{"3.0.1"}, + LatestVersions: []NpmVersionInfo{{Version: "3.0.1", CreatedAt: "2024-01-01T12:00:00Z"}}, + } + + mockTangy.On("NpmPackageGet", ctx, repoHref, "is-odd", "3.0.1").Return(expected, nil) + + got, err := mockTangy.NpmPackageGet(ctx, repoHref, "is-odd", "3.0.1") + require.NoError(t, err) + assert.Equal(t, expected, got) +} + +func TestMockTangyNpmPackageVersionsGet(t *testing.T) { + t.Parallel() + + mockTangy := NewMockTangy(t) + ctx := context.Background() + repoHref := "/api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4/" + + expected := []NpmPackageDetail{ + { + Name: "is-odd", + Version: "3.0.1", + CreatedAt: "2024-01-01T12:00:00Z", + Tarball: NpmTarballInfo{ + RelativePath: "is-odd/-/is-odd-3.0.1.tgz", + Filename: "is-odd-3.0.1.tgz", + Sha256: "abc123", + Size: 1024, + }, + Versions: []string{"3.0.1"}, + LatestVersions: []NpmVersionInfo{{Version: "3.0.1", CreatedAt: "2024-01-01T12:00:00Z"}}, + }, + } + + mockTangy.On("NpmPackageVersionsGet", ctx, repoHref, "is-odd").Return(expected, nil) + + got, err := mockTangy.NpmPackageVersionsGet(ctx, repoHref, "is-odd") + require.NoError(t, err) + assert.Equal(t, expected, got) +} + +func TestMockTangyNpmBuildList(t *testing.T) { + t.Parallel() + + mockTangy := NewMockTangy(t) + ctx := context.Background() + repoHref := "/api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4/" + pageOpts := PageOptions{Offset: 0, Limit: 10} + + expected := NpmBuildListResponse{ + Results: []NpmBuildListItem{ + {Name: "is-odd", Version: "3.0.1", CreatedAt: "2024-01-01T12:00:00Z"}, + }, + Total: 1, + Limit: 10, + Offset: 0, + } + + mockTangy.On("NpmBuildList", ctx, repoHref, "", "", pageOpts).Return(expected, nil) + + got, err := mockTangy.NpmBuildList(ctx, repoHref, "", "", pageOpts) + require.NoError(t, err) + assert.Equal(t, expected, got) +} From 58b1a1c9a1880619c60a19d40ade4e23a4bcc09d Mon Sep 17 00:00:00 2001 From: TenSt Date: Tue, 7 Jul 2026 17:55:49 +0200 Subject: [PATCH 3/3] HMS-10971: update README.md with npm packages info Co-authored-by: Cursor --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 218382d..1ae410e 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,31 @@ mavenMetrics, err := t.MavenRepositoryMetrics(context.Background(), repositoryHr if err != nil { return err } + +// Use Tangy to list npm packages from the latest version of a repository, grouped by name +repositoryHref := "/api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4/" +packages, err := t.NpmPackageList(context.Background(), repositoryHref, tangy.NpmPackageListFilters{Search: "is-odd"}, tangy.PageOptions{Offset: 0, Limit: 10}) +if err != nil { + return err +} + +// Use Tangy to get tarball info for a specific npm package version +detail, err := t.NpmPackageGet(context.Background(), repositoryHref, "is-odd", "3.0.1") +if err != nil { + return err +} + +// Use Tangy to get tarball info for every version of an npm package +versions, err := t.NpmPackageVersionsGet(context.Background(), repositoryHref, "is-odd") +if err != nil { + return err +} + +// Use Tangy to list all npm package builds (name + version pairs) +buildResponse, err := t.NpmBuildList(context.Background(), repositoryHref, "is-odd", "3.0.1", tangy.PageOptions{Offset: 0, Limit: 10}) +if err != nil { + return err +} ``` See example.go for a complete RPM example. @@ -116,6 +141,23 @@ Repository href format: `PythonDistributionList` filters by `name_normalized` (PEP 503), not the display `name`. +### npm packages + +npm support queries the `npm_package` table. Each row is one package version (typically one tarball). Pulp stores only `name` and `version` in the database; rich metadata (`description`, `license`, `dependencies`, etc.) lives in the tarball's `package.json` and is not persisted by pulp_npm. + +- **`NpmPackageList`** — lists packages in the latest repository version, grouped by `name`, with all versions and `latest_versions` (most recent `pulp_created` per version). Supports optional `Search` prefix filter on `name` (including scoped names like `@scope/pkg`). Pagination is done in SQL. +- **`NpmBuildList`** — lists builds (`name` + `version` pairs) in the latest repository version, optionally filtered by `name` and `version`. Pagination is done in SQL. +- **`NpmPackageGet`** — returns tarball info (`relative_path`, `filename`, `sha256`, `size`) and timestamps for a given `name` and `version`, plus all other versions available in the repository. Returns `ErrNpmPackageNotFound` when the package version is not in the repository. +- **`NpmPackageVersionsGet`** — returns tarball info for every version of a given `name`, each entry matching `NpmPackageGet` for that version. Returns `ErrNpmPackageNotFound` when the package is not in the repository. + +Repository href format: + +``` +/api/pulp/{domain}/api/v3/repositories/npm/npm/{uuid}/ +``` + +When syncing npm content into Pulp (e.g. for integration tests), the remote URL must be **version-specific** metadata, not the package index — e.g. `https://registry.npmjs.org/is-odd/3.0.1`, not `https://registry.npmjs.org/is-odd`. + ## Developing To develop for tangy, there are a few more things to know. @@ -172,9 +214,10 @@ Or run a specific suite: CONFIG_PATH="$(pwd)/configs/" go test ./internal/test/integration/ -run TestPythonSuite -v CONFIG_PATH="$(pwd)/configs/" go test ./internal/test/integration/ -run TestRpmSuite -v CONFIG_PATH="$(pwd)/configs/" go test ./internal/test/integration/ -run TestMavenSuite -v +CONFIG_PATH="$(pwd)/configs/" go test ./internal/test/integration/ -run TestNpmSuite -v ``` -The Python integration test syncs `shelf-reader` from PyPI into a random domain via the Pulp API, then asserts tangy can read it from the database. The Maven integration test pull-through caches `junit:junit:4.13.2` from a local nginx fixture in the compose stack (avoids Maven Central rate limits in CI), adds the cached content to a repository, then asserts tangy can read it from the database. Test data is left in the database after a run; use `make compose-clean` to wipe volumes and start fresh. +The Python integration test syncs `shelf-reader` from PyPI into a random domain via the Pulp API, then asserts tangy can read it from the database. The Maven integration test pull-through caches `junit:junit:4.13.2` from a local nginx fixture in the compose stack (avoids Maven Central rate limits in CI), adds the cached content to a repository, then asserts tangy can read it from the database. The npm integration test syncs `is-odd@3.0.1` from registry.npmjs.org (version-specific metadata URL), then asserts tangy can read it from the database. Test data is left in the database after a run; use `make compose-clean` to wipe volumes and start fresh. ### Mocking Tangy also exports a mock interface you can regenerate using the [mockery](https://github.com/vektra/mockery) tool.