mirror of
https://github.com/portainer/portainer.git
synced 2025-08-06 14:25:31 +02:00
fix(app/edge-jobs): edge job results page crash at scale (#954)
This commit is contained in:
parent
d306d7a983
commit
a472de1919
27 changed files with 2595 additions and 107 deletions
|
@ -1,21 +1,25 @@
|
|||
package edgejobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/utils/filters"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
)
|
||||
|
||||
type taskContainer struct {
|
||||
ID string `json:"Id"`
|
||||
EndpointID portainer.EndpointID `json:"EndpointId"`
|
||||
LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"`
|
||||
ID string `json:"Id"`
|
||||
EndpointID portainer.EndpointID `json:"EndpointId"`
|
||||
EndpointName string `json:"EndpointName"`
|
||||
LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"`
|
||||
}
|
||||
|
||||
// @id EdgeJobTasksList
|
||||
|
@ -37,16 +41,42 @@ func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request)
|
|||
return httperror.BadRequest("Invalid Edge job identifier route variable", err)
|
||||
}
|
||||
|
||||
var tasks []taskContainer
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
params := filters.ExtractListModifiersQueryParams(r)
|
||||
|
||||
var tasks []*taskContainer
|
||||
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
tasks, err = listEdgeJobTasks(tx, portainer.EdgeJobID(edgeJobID))
|
||||
return err
|
||||
})
|
||||
|
||||
return txResponse(w, tasks, err)
|
||||
results := filters.SearchOrderAndPaginate(tasks, params, filters.Config[*taskContainer]{
|
||||
SearchAccessors: []filters.SearchAccessor[*taskContainer]{
|
||||
func(tc *taskContainer) (string, error) {
|
||||
switch tc.LogsStatus {
|
||||
case portainer.EdgeJobLogsStatusPending:
|
||||
return "pending", nil
|
||||
case 0, portainer.EdgeJobLogsStatusIdle:
|
||||
return "idle", nil
|
||||
case portainer.EdgeJobLogsStatusCollected:
|
||||
return "collected", nil
|
||||
}
|
||||
return "", errors.New("unknown state")
|
||||
},
|
||||
func(tc *taskContainer) (string, error) {
|
||||
return tc.EndpointName, nil
|
||||
},
|
||||
},
|
||||
SortBindings: []filters.SortBinding[*taskContainer]{
|
||||
{Key: "EndpointName", Fn: func(a, b *taskContainer) int { return strings.Compare(a.EndpointName, b.EndpointName) }},
|
||||
},
|
||||
})
|
||||
|
||||
filters.ApplyFilterResultsHeaders(&w, results)
|
||||
|
||||
return txResponse(w, results.Items, err)
|
||||
}
|
||||
|
||||
func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) ([]taskContainer, error) {
|
||||
func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) ([]*taskContainer, error) {
|
||||
edgeJob, err := tx.EdgeJob().Read(edgeJobID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err)
|
||||
|
@ -54,7 +84,12 @@ func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID
|
|||
return nil, httperror.InternalServerError("Unable to find an Edge job with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
tasks := make([]taskContainer, 0)
|
||||
endpoints, err := tx.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tasks := make([]*taskContainer, 0)
|
||||
|
||||
endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{}
|
||||
if len(edgeJob.EdgeGroups) > 0 {
|
||||
|
@ -70,10 +105,19 @@ func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID
|
|||
maps.Copy(endpointsMap, edgeJob.Endpoints)
|
||||
|
||||
for endpointID, meta := range endpointsMap {
|
||||
tasks = append(tasks, taskContainer{
|
||||
ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID),
|
||||
EndpointID: endpointID,
|
||||
LogsStatus: meta.LogsStatus,
|
||||
|
||||
endpointName := ""
|
||||
for idx := range endpoints {
|
||||
if endpoints[idx].ID == endpointID {
|
||||
endpointName = endpoints[idx].Name
|
||||
}
|
||||
}
|
||||
|
||||
tasks = append(tasks, &taskContainer{
|
||||
ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID),
|
||||
EndpointID: endpointID,
|
||||
EndpointName: endpointName,
|
||||
LogsStatus: meta.LogsStatus,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
131
api/http/handler/edgejobs/edgejob_tasks_list_test.go
Normal file
131
api/http/handler/edgejobs/edgejob_tasks_list_test.go
Normal file
|
@ -0,0 +1,131 @@
|
|||
package edgejobs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/roar"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_EdgeJobTasksListHandler(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
|
||||
addEnv := func(env *portainer.Endpoint) {
|
||||
err := store.EndpointService.Create(env)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
addEdgeGroup := func(group *portainer.EdgeGroup) {
|
||||
err := store.EdgeGroupService.Create(group)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
addJob := func(job *portainer.EdgeJob) {
|
||||
err := store.EdgeJobService.Create(job)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
envCount := 6
|
||||
|
||||
for i := range envCount {
|
||||
addEnv(&portainer.Endpoint{ID: portainer.EndpointID(i + 1), Name: "env_" + strconv.Itoa(i+1)})
|
||||
}
|
||||
|
||||
addEdgeGroup(&portainer.EdgeGroup{ID: 1, Name: "edge_group_1", EndpointIDs: roar.FromSlice([]portainer.EndpointID{5, 6})})
|
||||
|
||||
addJob(&portainer.EdgeJob{
|
||||
ID: 1,
|
||||
Endpoints: map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{
|
||||
1: {},
|
||||
2: {LogsStatus: portainer.EdgeJobLogsStatusIdle},
|
||||
3: {LogsStatus: portainer.EdgeJobLogsStatusPending},
|
||||
4: {LogsStatus: portainer.EdgeJobLogsStatusCollected}},
|
||||
EdgeGroups: []portainer.EdgeGroupID{1},
|
||||
})
|
||||
|
||||
test := func(params string, expect []taskContainer, expectedCount int) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/edge_jobs/1/tasks"+params, nil)
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
|
||||
|
||||
var response []taskContainer
|
||||
err := json.NewDecoder(rr.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, expect, response)
|
||||
|
||||
tcStr := rr.Header().Get("x-total-count")
|
||||
assert.NotEmpty(t, tcStr)
|
||||
totalCount, err := strconv.Atoi(tcStr)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedCount, totalCount)
|
||||
|
||||
taStr := rr.Header().Get("x-total-available")
|
||||
assert.NotEmpty(t, taStr)
|
||||
totalAvailable, err := strconv.Atoi(taStr)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, envCount, totalAvailable)
|
||||
|
||||
}
|
||||
|
||||
tasks := []taskContainer{
|
||||
{},
|
||||
{"edgejob_task_1_1", 1, "env_1", 0},
|
||||
{"edgejob_task_1_2", 2, "env_2", portainer.EdgeJobLogsStatusIdle},
|
||||
{"edgejob_task_1_3", 3, "env_3", portainer.EdgeJobLogsStatusPending},
|
||||
{"edgejob_task_1_4", 4, "env_4", portainer.EdgeJobLogsStatusCollected},
|
||||
{"edgejob_task_1_5", 5, "env_5", 0},
|
||||
{"edgejob_task_1_6", 6, "env_6", 0},
|
||||
}
|
||||
|
||||
t.Run("should return no results", func(t *testing.T) {
|
||||
test("?search=foo", []taskContainer{}, 0) // unknown search
|
||||
test("?start=100&limit=1", []taskContainer{}, 6) // overflowing start. Still return the correct count header
|
||||
})
|
||||
|
||||
t.Run("should return one element", func(t *testing.T) {
|
||||
// limit the *returned* results but not the total count
|
||||
test("?start=0&limit=1&sort=EndpointName&order=asc", []taskContainer{tasks[1]}, envCount) // limit
|
||||
test("?start=5&limit=10&sort=EndpointName&order=asc", []taskContainer{tasks[6]}, envCount) // start = last element + overflowing limit
|
||||
// limit the number of results
|
||||
test("?search=env_1", []taskContainer{tasks[1]}, 1) // only 1 result
|
||||
})
|
||||
|
||||
t.Run("should filter by status", func(t *testing.T) {
|
||||
test("?search=idle", []taskContainer{tasks[1], tasks[2], tasks[5], tasks[6]}, 4) // 0 (default value) is IDLE
|
||||
test("?search=pending", []taskContainer{tasks[3]}, 1)
|
||||
test("?search=collected", []taskContainer{tasks[4]}, 1)
|
||||
})
|
||||
|
||||
t.Run("should return all elements", func(t *testing.T) {
|
||||
test("", tasks[1:], envCount) // default
|
||||
test("?some=invalid_param", tasks[1:], envCount) // unknown query params
|
||||
test("?limit=-1", tasks[1:], envCount) // underflowing limit
|
||||
test("?start=100", tasks[1:], envCount) // overflowing start without limit
|
||||
test("?search=env", tasks[1:], envCount) // search in a match-all keyword
|
||||
})
|
||||
|
||||
testError := func(params string, status int) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/edge_jobs/2/tasks"+params, nil)
|
||||
handler.ServeHTTP(rr, req)
|
||||
require.Equal(t, status, rr.Result().StatusCode)
|
||||
}
|
||||
|
||||
t.Run("errors", func(t *testing.T) {
|
||||
testError("", http.StatusNotFound) // unknown job id
|
||||
})
|
||||
|
||||
}
|
38
api/http/utils/filters/filters.go
Normal file
38
api/http/utils/filters/filters.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type FilterResult[T any] struct {
|
||||
Items []T
|
||||
TotalCount int
|
||||
TotalAvailable int
|
||||
}
|
||||
|
||||
type Config[T any] struct {
|
||||
SearchAccessors []SearchAccessor[T]
|
||||
SortBindings []SortBinding[T]
|
||||
}
|
||||
|
||||
func SearchOrderAndPaginate[T any](items []T, params QueryParams, searchConfig Config[T]) FilterResult[T] {
|
||||
totalAvailable := len(items)
|
||||
|
||||
items = searchFn(items, params.SearchQueryParams, searchConfig.SearchAccessors)
|
||||
items = sortFn(items, params.SortQueryParams, searchConfig.SortBindings)
|
||||
|
||||
totalCount := len(items)
|
||||
items = paginateFn(items, params.PaginationQueryParams)
|
||||
|
||||
return FilterResult[T]{
|
||||
Items: items,
|
||||
TotalCount: totalCount,
|
||||
TotalAvailable: totalAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyFilterResultsHeaders[T any](w *http.ResponseWriter, result FilterResult[T]) {
|
||||
(*w).Header().Set("X-Total-Count", strconv.Itoa(result.TotalCount))
|
||||
(*w).Header().Set("X-Total-Available", strconv.Itoa(result.TotalAvailable))
|
||||
}
|
465
api/http/utils/filters/filters_test.go
Normal file
465
api/http/utils/filters/filters_test.go
Normal file
|
@ -0,0 +1,465 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper functions for creating test data
|
||||
func createUsers() []User {
|
||||
return []User{
|
||||
{ID: 1, Name: "Alice Johnson", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Smith", Email: "bob@example.com", Age: 30},
|
||||
{ID: 3, Name: "Charlie Brown", Email: "charlie@example.com", Age: 35},
|
||||
{ID: 4, Name: "Diana Prince", Email: "diana@example.com", Age: 28},
|
||||
{ID: 5, Name: "Eve Adams", Email: "eve@example.com", Age: 22},
|
||||
}
|
||||
}
|
||||
|
||||
func createProducts() []Product {
|
||||
return []Product{
|
||||
{ID: 1, Name: "Laptop", Description: "High-performance laptop", Price: 999, Category: "Electronics"},
|
||||
{ID: 2, Name: "Mouse", Description: "Wireless mouse", Price: 29, Category: "Electronics"},
|
||||
{ID: 3, Name: "Book", Description: "Programming book", Price: 49, Category: "Books"},
|
||||
{ID: 4, Name: "Keyboard", Description: "Mechanical keyboard", Price: 129, Category: "Electronics"},
|
||||
{ID: 5, Name: "Chair", Description: "Office chair", Price: 199, Category: "Furniture"},
|
||||
}
|
||||
}
|
||||
|
||||
// Sort functions
|
||||
func userNameSort(a, b User) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
|
||||
func userAgeSort(a, b User) int {
|
||||
return a.Age - b.Age
|
||||
}
|
||||
|
||||
func productPriceSort(a, b Product) int {
|
||||
if a.Price < b.Price {
|
||||
return -1
|
||||
}
|
||||
if a.Price > b.Price {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func productNameSort(a, b Product) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
|
||||
func TestSearchOrderAndPaginate(t *testing.T) {
|
||||
users := createUsers()
|
||||
products := createProducts()
|
||||
|
||||
userConfig := Config[User]{
|
||||
SearchAccessors: []SearchAccessor[User]{userNameAccessor, userEmailAccessor},
|
||||
SortBindings: []SortBinding[User]{
|
||||
{Key: "name", Fn: userNameSort},
|
||||
{Key: "age", Fn: userAgeSort},
|
||||
},
|
||||
}
|
||||
|
||||
productConfig := Config[Product]{
|
||||
SearchAccessors: []SearchAccessor[Product]{productNameAccessor, productDescriptionAccessor, productCategoryAccessor},
|
||||
SortBindings: []SortBinding[Product]{
|
||||
{Key: "price", Fn: productPriceSort},
|
||||
{Key: "name", Fn: productNameSort},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("no filters applied", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 5, len(result.Items), "Should return all items when no filters applied")
|
||||
require.Equal(t, 5, result.TotalCount, "TotalCount should equal filtered items")
|
||||
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should equal original items")
|
||||
require.Equal(t, users, result.Items, "Items should be unchanged")
|
||||
})
|
||||
|
||||
t.Run("search only", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "alice"},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 1, len(result.Items), "Should find one user matching 'alice'")
|
||||
require.Equal(t, 1, result.TotalCount, "TotalCount should reflect filtered items")
|
||||
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should be original count")
|
||||
require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice")
|
||||
})
|
||||
|
||||
t.Run("search case insensitive", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "ALICE"},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 1, len(result.Items), "Search should be case insensitive")
|
||||
require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice")
|
||||
})
|
||||
|
||||
t.Run("search by email", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "bob@example"},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 1, len(result.Items), "Should find user by email")
|
||||
require.Equal(t, "Bob Smith", result.Items[0].Name, "Should return Bob")
|
||||
})
|
||||
|
||||
t.Run("search no matches", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "nonexistent"},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 0, len(result.Items), "Should return empty when no matches")
|
||||
require.Equal(t, 0, result.TotalCount, "TotalCount should be 0")
|
||||
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should remain original count")
|
||||
})
|
||||
|
||||
t.Run("search with whitespace", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: " alice "},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 1, len(result.Items), "Should trim whitespace from search")
|
||||
require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice")
|
||||
})
|
||||
|
||||
t.Run("sort ascending", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "name", order: SortAsc},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 5, len(result.Items), "Should return all items")
|
||||
require.Equal(t, "Alice Johnson", result.Items[0].Name, "First should be Alice")
|
||||
require.Equal(t, "Eve Adams", result.Items[4].Name, "Last should be Eve")
|
||||
})
|
||||
|
||||
t.Run("sort descending", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "name", order: SortDesc},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 5, len(result.Items), "Should return all items")
|
||||
require.Equal(t, "Eve Adams", result.Items[0].Name, "First should be Eve (desc order)")
|
||||
require.Equal(t, "Alice Johnson", result.Items[4].Name, "Last should be Alice (desc order)")
|
||||
})
|
||||
|
||||
t.Run("sort by age", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "age", order: SortAsc},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 5, len(result.Items), "Should return all items")
|
||||
require.Equal(t, 22, result.Items[0].Age, "First should be youngest (22)")
|
||||
require.Equal(t, 35, result.Items[4].Age, "Last should be oldest (35)")
|
||||
})
|
||||
|
||||
t.Run("sort invalid key", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "invalid", order: SortAsc},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 5, len(result.Items), "Should return all items")
|
||||
// Items should remain in original order since no valid sort key
|
||||
require.Equal(t, users, result.Items, "Should maintain original order with invalid sort key")
|
||||
})
|
||||
|
||||
t.Run("pagination basic", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 1, limit: 2},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 2, len(result.Items), "Should return 2 items")
|
||||
require.Equal(t, 5, result.TotalCount, "TotalCount should be all items")
|
||||
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should be original count")
|
||||
require.Equal(t, users[1], result.Items[0], "Should start from index 1")
|
||||
require.Equal(t, users[2], result.Items[1], "Should include index 2")
|
||||
})
|
||||
|
||||
t.Run("pagination zero limit", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 1, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 5, len(result.Items), "Should return all items when limit is 0")
|
||||
require.Equal(t, users, result.Items, "Should return all original items")
|
||||
})
|
||||
|
||||
t.Run("pagination negative limit", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 1, limit: -1},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 5, len(result.Items), "Should return all items when limit is negative")
|
||||
})
|
||||
|
||||
t.Run("pagination start beyond length", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 10, limit: 2},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 0, len(result.Items), "Should return empty slice when start is beyond length")
|
||||
require.Equal(t, 5, result.TotalCount, "TotalCount should still be original count")
|
||||
})
|
||||
|
||||
t.Run("pagination negative start", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: ""},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: -1, limit: 2},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
require.Equal(t, 2, len(result.Items), "Should return 2 items starting from 0")
|
||||
require.Equal(t, users[0], result.Items[0], "Should start from index 0")
|
||||
require.Equal(t, users[1], result.Items[1], "Should include index 1")
|
||||
})
|
||||
|
||||
t.Run("combined search sort and pagination", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "example.com"},
|
||||
SortQueryParams: SortQueryParams{sort: "age", order: SortAsc},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 1, limit: 2},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, userConfig)
|
||||
|
||||
// All users have "example.com" in email, so all 5 should match search
|
||||
// Then sorted by age: Eve(22), Alice(25), Diana(28), Bob(30), Charlie(35)
|
||||
// Then paginated: start=1, limit=2 should give Alice(25), Diana(28)
|
||||
require.Equal(t, 2, len(result.Items), "Should return 2 items after pagination")
|
||||
require.Equal(t, 5, result.TotalCount, "TotalCount should be all filtered items")
|
||||
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should be original count")
|
||||
require.Equal(t, 25, result.Items[0].Age, "First item should be Alice (age 25)")
|
||||
require.Equal(t, 28, result.Items[1].Age, "Second item should be Diana (age 28)")
|
||||
})
|
||||
|
||||
t.Run("products test", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "electronics"},
|
||||
SortQueryParams: SortQueryParams{sort: "price", order: SortAsc},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 2},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(products, params, productConfig)
|
||||
|
||||
// Should find 3 electronics, sorted by price: Mouse(29.99), Keyboard(129.99), Laptop(999.99)
|
||||
// Paginated to first 2: Mouse, Keyboard
|
||||
require.Equal(t, 2, len(result.Items), "Should return 2 items")
|
||||
require.Equal(t, 3, result.TotalCount, "Should find 3 electronics items")
|
||||
require.Equal(t, 5, result.TotalAvailable, "Should have 5 total products")
|
||||
require.Equal(t, "Mouse", result.Items[0].Name, "First should be Mouse (cheapest)")
|
||||
require.Equal(t, "Keyboard", result.Items[1].Name, "Second should be Keyboard")
|
||||
})
|
||||
|
||||
t.Run("empty input slice", func(t *testing.T) {
|
||||
emptyUsers := []User{}
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "test"},
|
||||
SortQueryParams: SortQueryParams{sort: "name", order: SortAsc},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 10},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(emptyUsers, params, userConfig)
|
||||
|
||||
require.Equal(t, 0, len(result.Items), "Should return empty slice")
|
||||
require.Equal(t, 0, result.TotalCount, "TotalCount should be 0")
|
||||
require.Equal(t, 0, result.TotalAvailable, "TotalAvailable should be 0")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchOrderAndPaginateWithErrors(t *testing.T) {
|
||||
users := createUsers()
|
||||
|
||||
// Config with error-prone accessor
|
||||
errorConfig := Config[User]{
|
||||
SearchAccessors: []SearchAccessor[User]{errorAccessor[User], userNameAccessor},
|
||||
SortBindings: []SortBinding[User]{
|
||||
{Key: "name", Fn: userNameSort},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("search with accessor errors", func(t *testing.T) {
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "alice"},
|
||||
SortQueryParams: SortQueryParams{sort: "", order: ""},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
|
||||
}
|
||||
|
||||
result := SearchOrderAndPaginate(users, params, errorConfig)
|
||||
|
||||
// Should still find Alice through the working accessor
|
||||
require.Equal(t, 1, len(result.Items), "Should find user despite error in first accessor")
|
||||
require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice")
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyFilterResultsHeaders(t *testing.T) {
|
||||
t.Run("sets headers correctly", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
var responseWriter http.ResponseWriter = w
|
||||
result := FilterResult[User]{
|
||||
Items: createUsers()[:3],
|
||||
TotalCount: 10,
|
||||
TotalAvailable: 25,
|
||||
}
|
||||
|
||||
ApplyFilterResultsHeaders(&responseWriter, result)
|
||||
|
||||
require.Equal(t, "10", w.Header().Get("X-Total-Count"), "Should set X-Total-Count header")
|
||||
require.Equal(t, "25", w.Header().Get("X-Total-Available"), "Should set X-Total-Available header")
|
||||
})
|
||||
|
||||
t.Run("sets headers with zero values", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
var responseWriter http.ResponseWriter = w
|
||||
result := FilterResult[User]{
|
||||
Items: []User{},
|
||||
TotalCount: 0,
|
||||
TotalAvailable: 0,
|
||||
}
|
||||
|
||||
ApplyFilterResultsHeaders(&responseWriter, result)
|
||||
|
||||
require.Equal(t, "0", w.Header().Get("X-Total-Count"), "Should set X-Total-Count to 0")
|
||||
require.Equal(t, "0", w.Header().Get("X-Total-Available"), "Should set X-Total-Available to 0")
|
||||
})
|
||||
|
||||
t.Run("overwrites existing headers", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
var responseWriter http.ResponseWriter = w
|
||||
w.Header().Set("X-Total-Count", "999")
|
||||
w.Header().Set("X-Total-Available", "999")
|
||||
|
||||
result := FilterResult[User]{
|
||||
Items: createUsers()[:2],
|
||||
TotalCount: 5,
|
||||
TotalAvailable: 15,
|
||||
}
|
||||
|
||||
ApplyFilterResultsHeaders(&responseWriter, result)
|
||||
|
||||
require.Equal(t, "5", w.Header().Get("X-Total-Count"), "Should overwrite existing X-Total-Count")
|
||||
require.Equal(t, "15", w.Header().Get("X-Total-Available"), "Should overwrite existing X-Total-Available")
|
||||
})
|
||||
|
||||
t.Run("simulates real handler usage", func(t *testing.T) {
|
||||
// Simulate how it's actually used in handlers
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
result := FilterResult[Product]{
|
||||
Items: createProducts(),
|
||||
TotalCount: 5,
|
||||
TotalAvailable: 10,
|
||||
}
|
||||
ApplyFilterResultsHeaders(&w, result)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
|
||||
handler(w, req)
|
||||
|
||||
require.Equal(t, "5", w.Header().Get("X-Total-Count"), "Should work in handler context")
|
||||
require.Equal(t, "10", w.Header().Get("X-Total-Available"), "Should work in handler context")
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkSearchOrderAndPaginate(b *testing.B) {
|
||||
users := createUsers()
|
||||
config := Config[User]{
|
||||
SearchAccessors: []SearchAccessor[User]{userNameAccessor, userEmailAccessor},
|
||||
SortBindings: []SortBinding[User]{
|
||||
{Key: "name", Fn: userNameSort},
|
||||
{Key: "age", Fn: userAgeSort},
|
||||
},
|
||||
}
|
||||
params := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "example"},
|
||||
SortQueryParams: SortQueryParams{sort: "name", order: SortAsc},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 10},
|
||||
}
|
||||
|
||||
for b.Loop() {
|
||||
SearchOrderAndPaginate(users, params, config)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkApplyFilterResultsHeaders(b *testing.B) {
|
||||
w := httptest.NewRecorder()
|
||||
var responseWriter http.ResponseWriter = w
|
||||
result := FilterResult[User]{
|
||||
Items: createUsers(),
|
||||
TotalCount: 100,
|
||||
TotalAvailable: 500,
|
||||
}
|
||||
|
||||
for b.Loop() {
|
||||
ApplyFilterResultsHeaders(&responseWriter, result)
|
||||
}
|
||||
}
|
22
api/http/utils/filters/pagination.go
Normal file
22
api/http/utils/filters/pagination.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package filters
|
||||
|
||||
type PaginationQueryParams struct {
|
||||
start int
|
||||
limit int
|
||||
}
|
||||
|
||||
func paginateFn[T any](items []T, params PaginationQueryParams) []T {
|
||||
if params.limit <= 0 {
|
||||
return items
|
||||
}
|
||||
|
||||
itemsCount := len(items)
|
||||
|
||||
// enforce start in [0, len(items)]
|
||||
start := min(max(params.start, 0), itemsCount)
|
||||
|
||||
// enforce end <= len(items) (max is unnecessary since limit > 0 and start >= 0)
|
||||
end := min(start+params.limit, itemsCount)
|
||||
|
||||
return items[start:end]
|
||||
}
|
245
api/http/utils/filters/pagination_test.go
Normal file
245
api/http/utils/filters/pagination_test.go
Normal file
|
@ -0,0 +1,245 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPaginateFn_BasicPagination(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
|
||||
// First page
|
||||
params := PaginationQueryParams{start: 0, limit: 3}
|
||||
result := paginateFn(items, params)
|
||||
require.Equal(t, []int{1, 2, 3}, result)
|
||||
|
||||
// Second page
|
||||
params = PaginationQueryParams{start: 3, limit: 3}
|
||||
result = paginateFn(items, params)
|
||||
require.Equal(t, []int{4, 5, 6}, result)
|
||||
|
||||
// Third page
|
||||
params = PaginationQueryParams{start: 6, limit: 3}
|
||||
result = paginateFn(items, params)
|
||||
require.Equal(t, []int{7, 8, 9}, result)
|
||||
|
||||
// Last partial page
|
||||
params = PaginationQueryParams{start: 9, limit: 3}
|
||||
result = paginateFn(items, params)
|
||||
require.Equal(t, []int{10}, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_ZeroLimit(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
params := PaginationQueryParams{start: 2, limit: 0}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
// Should return all items when limit is 0
|
||||
require.Equal(t, items, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_NegativeLimit(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
params := PaginationQueryParams{start: 2, limit: -5}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
// Should return all items when limit is negative
|
||||
require.Equal(t, items, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_NegativeStart(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
params := PaginationQueryParams{start: -3, limit: 2}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
// Should start from index 0 when start is negative
|
||||
require.Equal(t, []int{1, 2}, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_StartBeyondLength(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
params := PaginationQueryParams{start: 10, limit: 3}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
// Should return empty slice when start is beyond length
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_StartAtLength(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
params := PaginationQueryParams{start: 5, limit: 3}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
// Should return empty slice when start equals length
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_LimitLargerThanRemaining(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
params := PaginationQueryParams{start: 3, limit: 10}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
// Should return remaining items when limit exceeds available items
|
||||
require.Equal(t, []int{4, 5}, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_EmptySlice(t *testing.T) {
|
||||
items := []int{}
|
||||
|
||||
params := PaginationQueryParams{start: 0, limit: 5}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
// Should return empty slice
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_EmptySliceWithNegativeStart(t *testing.T) {
|
||||
items := []int{}
|
||||
|
||||
params := PaginationQueryParams{start: -5, limit: 3}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
// Should return empty slice
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_SingleElement(t *testing.T) {
|
||||
items := []int{42}
|
||||
|
||||
// Take the single element
|
||||
params := PaginationQueryParams{start: 0, limit: 1}
|
||||
result := paginateFn(items, params)
|
||||
require.Equal(t, []int{42}, result)
|
||||
|
||||
// Start beyond the single element
|
||||
params = PaginationQueryParams{start: 1, limit: 1}
|
||||
result = paginateFn(items, params)
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_LimitOfOne(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
results := [][]int{}
|
||||
for i := range items {
|
||||
params := PaginationQueryParams{start: i, limit: 1}
|
||||
result := paginateFn(items, params)
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
expected := [][]int{
|
||||
{1}, {2}, {3}, {4}, {5},
|
||||
}
|
||||
require.Equal(t, expected, results)
|
||||
}
|
||||
|
||||
func TestPaginateFn_StringSlice(t *testing.T) {
|
||||
items := []string{"apple", "banana", "cherry", "date", "elderberry"}
|
||||
|
||||
params := PaginationQueryParams{start: 1, limit: 3}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
require.Equal(t, []string{"banana", "cherry", "date"}, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_StructSlice(t *testing.T) {
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice"},
|
||||
{ID: 2, Name: "Bob"},
|
||||
{ID: 3, Name: "Charlie"},
|
||||
{ID: 4, Name: "David"},
|
||||
}
|
||||
|
||||
params := PaginationQueryParams{start: 1, limit: 2}
|
||||
result := paginateFn(users, params)
|
||||
|
||||
expected := []User{
|
||||
{ID: 2, Name: "Bob"},
|
||||
{ID: 3, Name: "Charlie"},
|
||||
}
|
||||
require.Equal(t, expected, result)
|
||||
}
|
||||
|
||||
func TestPaginateFn_BoundaryConditions(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
start int
|
||||
limit int
|
||||
expected []int
|
||||
}{
|
||||
{"start=0, limit=0", 0, 0, []int{1, 2, 3, 4, 5}},
|
||||
{"start=0, limit=5", 0, 5, []int{1, 2, 3, 4, 5}},
|
||||
{"start=0, limit=6", 0, 6, []int{1, 2, 3, 4, 5}},
|
||||
{"start=4, limit=1", 4, 1, []int{5}},
|
||||
{"start=4, limit=2", 4, 2, []int{5}},
|
||||
{"start=5, limit=1", 5, 1, []int{}},
|
||||
{"start=-1, limit=1", -1, 1, []int{1}},
|
||||
{"start=-10, limit=3", -10, 3, []int{1, 2, 3}},
|
||||
{"start=100, limit=1", 100, 1, []int{}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
params := PaginationQueryParams{start: tc.start, limit: tc.limit}
|
||||
result := paginateFn(items, params)
|
||||
require.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateFn_ReturnsSliceView(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
|
||||
params := PaginationQueryParams{start: 1, limit: 3}
|
||||
result := paginateFn(items, params)
|
||||
|
||||
// Result should be a slice view of the original
|
||||
require.Equal(t, []int{2, 3, 4}, result)
|
||||
|
||||
// Modifying result WILL affect the original slice (shares underlying array)
|
||||
if len(result) > 0 {
|
||||
result[0] = 999
|
||||
require.Equal(t, 999, items[1]) // Original is modified because they share memory
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateFn_TypicalAPIUseCases(t *testing.T) {
|
||||
// Simulate API responses with different page sizes
|
||||
items := make([]int, 100)
|
||||
for i := range items {
|
||||
items[i] = i + 1
|
||||
}
|
||||
|
||||
// Page size 10
|
||||
params := PaginationQueryParams{start: 0, limit: 10}
|
||||
page1 := paginateFn(items, params)
|
||||
require.Len(t, page1, 10)
|
||||
require.Equal(t, []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, page1)
|
||||
|
||||
// Page size 20, offset 20
|
||||
params = PaginationQueryParams{start: 20, limit: 20}
|
||||
page2 := paginateFn(items, params)
|
||||
require.Len(t, page2, 20)
|
||||
require.Equal(t, 21, page2[0])
|
||||
require.Equal(t, 40, page2[19])
|
||||
|
||||
// Last page (partial)
|
||||
params = PaginationQueryParams{start: 95, limit: 10}
|
||||
lastPage := paginateFn(items, params)
|
||||
require.Len(t, lastPage, 5)
|
||||
require.Equal(t, []int{96, 97, 98, 99, 100}, lastPage)
|
||||
}
|
38
api/http/utils/filters/query_params.go
Normal file
38
api/http/utils/filters/query_params.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
)
|
||||
|
||||
type QueryParams struct {
|
||||
SearchQueryParams
|
||||
SortQueryParams
|
||||
PaginationQueryParams
|
||||
}
|
||||
|
||||
func ExtractListModifiersQueryParams(r *http.Request) QueryParams {
|
||||
// search
|
||||
search, _ := request.RetrieveQueryParameter(r, "search", true)
|
||||
// sorting
|
||||
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
|
||||
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
|
||||
// pagination
|
||||
start, _ := request.RetrieveNumericQueryParameter(r, "start", true)
|
||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||
|
||||
return QueryParams{
|
||||
SearchQueryParams{
|
||||
search: search,
|
||||
},
|
||||
SortQueryParams{
|
||||
sort: sortField,
|
||||
order: SortOrder(sortOrder),
|
||||
},
|
||||
PaginationQueryParams{
|
||||
start: start,
|
||||
limit: limit,
|
||||
},
|
||||
}
|
||||
}
|
300
api/http/utils/filters/query_params_test.go
Normal file
300
api/http/utils/filters/query_params_test.go
Normal file
|
@ -0,0 +1,300 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractListModifiersQueryParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queryParams map[string]string
|
||||
expectedResult QueryParams
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "all parameters provided",
|
||||
queryParams: map[string]string{
|
||||
"search": "test query",
|
||||
"sort": "name",
|
||||
"order": "asc",
|
||||
"start": "10",
|
||||
"limit": "25",
|
||||
},
|
||||
expectedResult: QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{
|
||||
search: "test query",
|
||||
},
|
||||
SortQueryParams: SortQueryParams{
|
||||
sort: "name",
|
||||
order: SortAsc,
|
||||
},
|
||||
PaginationQueryParams: PaginationQueryParams{
|
||||
start: 10,
|
||||
limit: 25,
|
||||
},
|
||||
},
|
||||
description: "Should correctly parse all query parameters when provided",
|
||||
},
|
||||
{
|
||||
name: "descending sort order",
|
||||
queryParams: map[string]string{
|
||||
"search": "another test",
|
||||
"sort": "date",
|
||||
"order": "desc",
|
||||
"start": "0",
|
||||
"limit": "50",
|
||||
},
|
||||
expectedResult: QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{
|
||||
search: "another test",
|
||||
},
|
||||
SortQueryParams: SortQueryParams{
|
||||
sort: "date",
|
||||
order: SortDesc,
|
||||
},
|
||||
PaginationQueryParams: PaginationQueryParams{
|
||||
start: 0,
|
||||
limit: 50,
|
||||
},
|
||||
},
|
||||
description: "Should correctly handle descending sort order",
|
||||
},
|
||||
{
|
||||
name: "no parameters provided",
|
||||
queryParams: map[string]string{},
|
||||
expectedResult: QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{
|
||||
search: "",
|
||||
},
|
||||
SortQueryParams: SortQueryParams{
|
||||
sort: "",
|
||||
order: SortOrder(""),
|
||||
},
|
||||
PaginationQueryParams: PaginationQueryParams{
|
||||
start: 0,
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
description: "Should return zero values when no parameters are provided",
|
||||
},
|
||||
{
|
||||
name: "partial parameters - search only",
|
||||
queryParams: map[string]string{
|
||||
"search": "partial test",
|
||||
},
|
||||
expectedResult: QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{
|
||||
search: "partial test",
|
||||
},
|
||||
SortQueryParams: SortQueryParams{
|
||||
sort: "",
|
||||
order: SortOrder(""),
|
||||
},
|
||||
PaginationQueryParams: PaginationQueryParams{
|
||||
start: 0,
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
description: "Should handle partial parameters correctly",
|
||||
},
|
||||
{
|
||||
name: "partial parameters - pagination only",
|
||||
queryParams: map[string]string{
|
||||
"start": "5",
|
||||
"limit": "15",
|
||||
},
|
||||
expectedResult: QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{
|
||||
search: "",
|
||||
},
|
||||
SortQueryParams: SortQueryParams{
|
||||
sort: "",
|
||||
order: SortOrder(""),
|
||||
},
|
||||
PaginationQueryParams: PaginationQueryParams{
|
||||
start: 5,
|
||||
limit: 15,
|
||||
},
|
||||
},
|
||||
description: "Should handle pagination parameters when other params are missing",
|
||||
},
|
||||
{
|
||||
name: "invalid sort order",
|
||||
queryParams: map[string]string{
|
||||
"search": "test",
|
||||
"sort": "name",
|
||||
"order": "invalid",
|
||||
"start": "0",
|
||||
"limit": "10",
|
||||
},
|
||||
expectedResult: QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{
|
||||
search: "test",
|
||||
},
|
||||
SortQueryParams: SortQueryParams{
|
||||
sort: "name",
|
||||
order: SortOrder("invalid"),
|
||||
},
|
||||
PaginationQueryParams: PaginationQueryParams{
|
||||
start: 0,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
description: "Should accept invalid sort order as SortOrder type",
|
||||
},
|
||||
{
|
||||
name: "empty string values",
|
||||
queryParams: map[string]string{
|
||||
"search": "",
|
||||
"sort": "",
|
||||
"order": "",
|
||||
"start": "0",
|
||||
"limit": "0",
|
||||
},
|
||||
expectedResult: QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{
|
||||
search: "",
|
||||
},
|
||||
SortQueryParams: SortQueryParams{
|
||||
sort: "",
|
||||
order: SortOrder(""),
|
||||
},
|
||||
PaginationQueryParams: PaginationQueryParams{
|
||||
start: 0,
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
description: "Should handle empty string values correctly",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create HTTP request with query parameters
|
||||
req := createRequestWithParams(tt.queryParams)
|
||||
|
||||
// Execute the function
|
||||
result := ExtractListModifiersQueryParams(req)
|
||||
|
||||
// Assertions
|
||||
require.Equal(t, tt.expectedResult.SearchQueryParams.search, result.SearchQueryParams.search,
|
||||
"Search parameter should match expected value")
|
||||
require.Equal(t, tt.expectedResult.SortQueryParams.sort, result.SortQueryParams.sort,
|
||||
"Sort parameter should match expected value")
|
||||
require.Equal(t, tt.expectedResult.SortQueryParams.order, result.SortQueryParams.order,
|
||||
"Order parameter should match expected value")
|
||||
require.Equal(t, tt.expectedResult.PaginationQueryParams.start, result.PaginationQueryParams.start,
|
||||
"Start parameter should match expected value")
|
||||
require.Equal(t, tt.expectedResult.PaginationQueryParams.limit, result.PaginationQueryParams.limit,
|
||||
"Limit parameter should match expected value")
|
||||
|
||||
// Verify the complete struct
|
||||
require.Equal(t, tt.expectedResult, result, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortOrderConstants(t *testing.T) {
|
||||
t.Run("sort order constants", func(t *testing.T) {
|
||||
require.Equal(t, SortOrder("asc"), SortAsc, "SortAsc constant should equal 'asc'")
|
||||
require.Equal(t, SortOrder("desc"), SortDesc, "SortDesc constant should equal 'desc'")
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryParamsStructEmbedding(t *testing.T) {
|
||||
t.Run("struct embedding", func(t *testing.T) {
|
||||
qp := QueryParams{
|
||||
SearchQueryParams: SearchQueryParams{search: "test"},
|
||||
SortQueryParams: SortQueryParams{sort: "name", order: SortAsc},
|
||||
PaginationQueryParams: PaginationQueryParams{start: 10, limit: 20},
|
||||
}
|
||||
|
||||
// Test that embedded fields are accessible
|
||||
require.Equal(t, "test", qp.search, "Embedded search field should be accessible")
|
||||
require.Equal(t, "name", qp.sort, "Embedded sort field should be accessible")
|
||||
require.Equal(t, SortAsc, qp.order, "Embedded order field should be accessible")
|
||||
require.Equal(t, 10, qp.start, "Embedded start field should be accessible")
|
||||
require.Equal(t, 20, qp.limit, "Embedded limit field should be accessible")
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractListModifiersQueryParamsEdgeCases(t *testing.T) {
|
||||
t.Run("special characters in search", func(t *testing.T) {
|
||||
req := createRequestWithParams(map[string]string{
|
||||
"search": "test & special chars %20",
|
||||
})
|
||||
|
||||
result := ExtractListModifiersQueryParams(req)
|
||||
require.Equal(t, "test & special chars %20", result.search,
|
||||
"Should handle special characters in search parameter")
|
||||
})
|
||||
|
||||
t.Run("unicode characters", func(t *testing.T) {
|
||||
req := createRequestWithParams(map[string]string{
|
||||
"search": "test 测试 🔍",
|
||||
"sort": "título",
|
||||
})
|
||||
|
||||
result := ExtractListModifiersQueryParams(req)
|
||||
require.Equal(t, "test 测试 🔍", result.search, "Should handle unicode in search")
|
||||
require.Equal(t, "título", result.sort, "Should handle unicode in sort field")
|
||||
})
|
||||
|
||||
t.Run("very long values", func(t *testing.T) {
|
||||
longSearch := "a very long search query that contains many words and goes on for quite some time to test handling of long strings"
|
||||
req := createRequestWithParams(map[string]string{
|
||||
"search": longSearch,
|
||||
})
|
||||
|
||||
result := ExtractListModifiersQueryParams(req)
|
||||
require.Equal(t, longSearch, result.search, "Should handle long search strings")
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create HTTP request with query parameters
|
||||
func createRequestWithParams(params map[string]string) *http.Request {
|
||||
// Create URL with query parameters
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.com",
|
||||
Path: "/test",
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
q := u.Query()
|
||||
for key, value := range params {
|
||||
q.Set(key, value)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
// Create request
|
||||
req, _ := http.NewRequest("GET", u.String(), nil)
|
||||
return req
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkExtractListModifiersQueryParams(b *testing.B) {
|
||||
req := createRequestWithParams(map[string]string{
|
||||
"search": "benchmark test",
|
||||
"sort": "name",
|
||||
"order": "asc",
|
||||
"start": "10",
|
||||
"limit": "25",
|
||||
})
|
||||
|
||||
for b.Loop() {
|
||||
ExtractListModifiersQueryParams(req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExtractListModifiersQueryParamsEmpty(b *testing.B) {
|
||||
req := createRequestWithParams(map[string]string{})
|
||||
|
||||
for b.Loop() {
|
||||
ExtractListModifiersQueryParams(req)
|
||||
}
|
||||
}
|
36
api/http/utils/filters/search.go
Normal file
36
api/http/utils/filters/search.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Return any error to skip the field (for when matching an unknown state on an enum)
|
||||
//
|
||||
// Note: returning ("", nil) will match!
|
||||
type SearchAccessor[T any] = func(T) (string, error)
|
||||
|
||||
type SearchQueryParams struct {
|
||||
search string
|
||||
}
|
||||
|
||||
func searchFn[T any](items []T, params SearchQueryParams, accessors []SearchAccessor[T]) []T {
|
||||
search := strings.TrimSpace(params.search)
|
||||
|
||||
if search == "" {
|
||||
return items
|
||||
}
|
||||
|
||||
results := []T{}
|
||||
|
||||
for iIdx := range items {
|
||||
for aIdx := range accessors {
|
||||
value, err := accessors[aIdx](items[iIdx])
|
||||
if err == nil && strings.Contains(strings.ToLower(value), strings.ToLower(search)) {
|
||||
results = append(results, items[iIdx])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
283
api/http/utils/filters/search_test.go
Normal file
283
api/http/utils/filters/search_test.go
Normal file
|
@ -0,0 +1,283 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSearchFn_BasicSearch(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
|
||||
{ID: 3, Name: "Charlie Brown", Email: "charlie@test.org", Age: 35},
|
||||
}
|
||||
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
|
||||
params := SearchQueryParams{search: "Alice"}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "Alice Smith", result[0].Name)
|
||||
}
|
||||
|
||||
func TestSearchFn_EmptySearch(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
|
||||
}
|
||||
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
|
||||
params := SearchQueryParams{search: ""}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
// Should return all items when search is empty
|
||||
require.Equal(t, users, result)
|
||||
}
|
||||
|
||||
func TestSearchFn_NoMatches(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
|
||||
}
|
||||
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
|
||||
params := SearchQueryParams{search: "nonexistent"}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestSearchFn_MultipleMatches(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Smith", Email: "bob@company.com", Age: 30},
|
||||
{ID: 3, Name: "Charlie Brown", Email: "charlie@smith.org", Age: 35},
|
||||
}
|
||||
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
|
||||
params := SearchQueryParams{search: "Smith"}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
require.Len(t, result, 3)
|
||||
require.Equal(t, "Alice Smith", result[0].Name)
|
||||
require.Equal(t, "Bob Smith", result[1].Name)
|
||||
require.Equal(t, "Charlie Brown", result[2].Name) // Matches via email
|
||||
}
|
||||
|
||||
func TestSearchFn_MultipleAccessors(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
|
||||
{ID: 3, Name: "Charlie Brown", Email: "charlie@test.org", Age: 35},
|
||||
}
|
||||
|
||||
// Search across name, email, and ID
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor, userIDAccessor}
|
||||
|
||||
// Search by ID
|
||||
params := SearchQueryParams{search: "2"}
|
||||
result := searchFn(users, params, accessors)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, 2, result[0].ID)
|
||||
|
||||
// Search by email domain
|
||||
params = SearchQueryParams{search: "company.com"}
|
||||
result = searchFn(users, params, accessors)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "Bob Johnson", result[0].Name)
|
||||
}
|
||||
|
||||
func TestSearchFn_CaseSensitive(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
|
||||
}
|
||||
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
|
||||
|
||||
// Case sensitive search - should not match
|
||||
params := SearchQueryParams{search: "alice"}
|
||||
result := searchFn(users, params, accessors)
|
||||
require.Len(t, result, 1) // Matches email which is lowercase
|
||||
|
||||
// Exact case match
|
||||
params = SearchQueryParams{search: "Alice"}
|
||||
result = searchFn(users, params, accessors)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "Alice Smith", result[0].Name)
|
||||
}
|
||||
|
||||
func TestSearchFn_PartialMatches(t *testing.T) {
|
||||
products := []Product{
|
||||
{ID: 1, Name: "Wireless Mouse", Description: "Ergonomic wireless mouse", Price: 25, Category: "Electronics"},
|
||||
{ID: 2, Name: "Mechanical Keyboard", Description: "RGB gaming keyboard", Price: 150, Category: "Electronics"},
|
||||
{ID: 3, Name: "Coffee Mug", Description: "Ceramic coffee mug", Price: 15, Category: "Kitchen"},
|
||||
}
|
||||
|
||||
accessors := []SearchAccessor[Product]{productNameAccessor, productDescriptionAccessor}
|
||||
|
||||
// Partial word match
|
||||
params := SearchQueryParams{search: "wire"}
|
||||
result := searchFn(products, params, accessors)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "Wireless Mouse", result[0].Name)
|
||||
|
||||
// Match in description
|
||||
params = SearchQueryParams{search: "RGB"}
|
||||
result = searchFn(products, params, accessors)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "Mechanical Keyboard", result[0].Name)
|
||||
}
|
||||
|
||||
func TestSearchFn_EmptySlice(t *testing.T) {
|
||||
users := []User{}
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
|
||||
params := SearchQueryParams{search: "anything"}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestSearchFn_EmptyAccessors(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
}
|
||||
|
||||
accessors := []SearchAccessor[User]{} // No accessors
|
||||
params := SearchQueryParams{search: "Alice"}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
// Should return empty since no accessors to search through
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestSearchFn_SingleAccessor(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
|
||||
}
|
||||
|
||||
// Only search by name
|
||||
accessors := []SearchAccessor[User]{userNameAccessor}
|
||||
params := SearchQueryParams{search: "company.com"}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
// Should not match since we're only searching names, not emails
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestSearchFn_NumericSearch(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
|
||||
{ID: 3, Name: "Charlie Brown", Email: "charlie@test.org", Age: 35},
|
||||
}
|
||||
|
||||
// Search by age (converted to string)
|
||||
accessors := []SearchAccessor[User]{userAgeAccessor}
|
||||
params := SearchQueryParams{search: "30"}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, 30, result[0].Age)
|
||||
}
|
||||
|
||||
func TestSearchFn_FormattedAccessor(t *testing.T) {
|
||||
products := []Product{
|
||||
{ID: 1, Name: "Mouse", Description: "Wireless mouse", Price: 25, Category: "Electronics"},
|
||||
{ID: 2, Name: "Keyboard", Description: "Gaming keyboard", Price: 150, Category: "Electronics"},
|
||||
}
|
||||
|
||||
// Search by formatted price (e.g., "$25")
|
||||
accessors := []SearchAccessor[Product]{productPriceAccessor}
|
||||
params := SearchQueryParams{search: "$25"}
|
||||
|
||||
result := searchFn(products, params, accessors)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "Mouse", result[0].Name)
|
||||
}
|
||||
|
||||
func TestSearchFn_FirstMatchOnly(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "test user", Email: "test@example.com", Age: 25},
|
||||
}
|
||||
|
||||
// Both accessors would match the search term
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
|
||||
params := SearchQueryParams{search: "test"}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
// Should only include the item once, even though multiple accessors match
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "test user", result[0].Name)
|
||||
}
|
||||
|
||||
func TestSearchFn_PreservesOrder(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Test", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Johnson", Email: "bob@test.com", Age: 30},
|
||||
{ID: 3, Name: "Charlie Test", Email: "charlie@example.com", Age: 35},
|
||||
}
|
||||
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
|
||||
params := SearchQueryParams{search: "Test"}
|
||||
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
require.Len(t, result, 3)
|
||||
// Should preserve original order
|
||||
require.Equal(t, 1, result[0].ID)
|
||||
require.Equal(t, 2, result[1].ID)
|
||||
require.Equal(t, 3, result[2].ID)
|
||||
}
|
||||
|
||||
func TestSearchFn_ComplexSearch(t *testing.T) {
|
||||
products := []Product{
|
||||
{ID: 1, Name: "Gaming Mouse", Description: "High-DPI gaming mouse", Price: 75, Category: "Gaming"},
|
||||
{ID: 2, Name: "Office Mouse", Description: "Ergonomic office mouse", Price: 25, Category: "Office"},
|
||||
{ID: 3, Name: "Gaming Keyboard", Description: "Mechanical gaming keyboard", Price: 150, Category: "Gaming"},
|
||||
{ID: 4, Name: "Wireless Headset", Description: "Gaming headset with mic", Price: 100, Category: "Gaming"},
|
||||
}
|
||||
|
||||
// Search across multiple fields
|
||||
accessors := []SearchAccessor[Product]{
|
||||
productNameAccessor,
|
||||
productDescriptionAccessor,
|
||||
productCategoryAccessor,
|
||||
}
|
||||
|
||||
params := SearchQueryParams{search: "Gaming"}
|
||||
|
||||
result := searchFn(products, params, accessors)
|
||||
|
||||
require.Len(t, result, 3)
|
||||
require.Equal(t, "Gaming Mouse", result[0].Name)
|
||||
require.Equal(t, "Gaming Keyboard", result[1].Name)
|
||||
require.Equal(t, "Wireless Headset", result[2].Name)
|
||||
}
|
||||
|
||||
func TestSearchFn_WhitespaceSearch(t *testing.T) {
|
||||
users := []User{
|
||||
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
|
||||
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
|
||||
}
|
||||
|
||||
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
|
||||
|
||||
// Search with just whitespace should be treated as empty
|
||||
params := SearchQueryParams{search: " "}
|
||||
result := searchFn(users, params, accessors)
|
||||
|
||||
require.Len(t, result, 2)
|
||||
}
|
40
api/http/utils/filters/sort.go
Normal file
40
api/http/utils/filters/sort.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
package filters
|
||||
|
||||
import "slices"
|
||||
|
||||
type SortOrder string
|
||||
|
||||
const (
|
||||
SortAsc SortOrder = "asc"
|
||||
SortDesc SortOrder = "desc"
|
||||
)
|
||||
|
||||
type SortQueryParams struct {
|
||||
sort string
|
||||
order SortOrder
|
||||
}
|
||||
|
||||
type SortOption[T any] func(a, b T) int
|
||||
type SortBinding[T any] struct {
|
||||
Key string
|
||||
Fn SortOption[T]
|
||||
}
|
||||
|
||||
func sortFn[T any](items []T, params SortQueryParams, sorts []SortBinding[T]) []T {
|
||||
for _, sort := range sorts {
|
||||
if sort.Key == params.sort {
|
||||
fn := sort.Fn
|
||||
if params.order == SortDesc {
|
||||
fn = reverSortFn(fn)
|
||||
}
|
||||
slices.SortStableFunc(items, fn)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func reverSortFn[T any](fn SortOption[T]) SortOption[T] {
|
||||
return func(a, b T) int {
|
||||
return -1 * fn(a, b)
|
||||
}
|
||||
}
|
287
api/http/utils/filters/sort_test.go
Normal file
287
api/http/utils/filters/sort_test.go
Normal file
|
@ -0,0 +1,287 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper sort functions
|
||||
func compareUserByName(a, b User) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
|
||||
func compareUserByAge(a, b User) int {
|
||||
return a.Age - b.Age
|
||||
}
|
||||
|
||||
func compareProductByName(a, b Product) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
|
||||
func compareProductByPrice(a, b Product) int {
|
||||
return a.Price - b.Price
|
||||
}
|
||||
|
||||
func TestSortFn_BasicAscending(t *testing.T) {
|
||||
users := []User{
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Alice", Age: 30},
|
||||
{Name: "Bob", Age: 20},
|
||||
}
|
||||
|
||||
sorts := []SortBinding[User]{
|
||||
{Key: "name", Fn: compareUserByName},
|
||||
{Key: "age", Fn: compareUserByAge},
|
||||
}
|
||||
|
||||
params := SortQueryParams{sort: "name", order: SortAsc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
require.Equal(t, []User{
|
||||
{Name: "Alice", Age: 30},
|
||||
{Name: "Bob", Age: 20},
|
||||
{Name: "Charlie", Age: 25},
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestSortFn_BasicDescending(t *testing.T) {
|
||||
users := []User{
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Alice", Age: 30},
|
||||
{Name: "Bob", Age: 20},
|
||||
}
|
||||
|
||||
sorts := []SortBinding[User]{
|
||||
{Key: "name", Fn: compareUserByName},
|
||||
{Key: "age", Fn: compareUserByAge},
|
||||
}
|
||||
|
||||
params := SortQueryParams{sort: "name", order: SortDesc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
require.Equal(t, []User{
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Bob", Age: 20},
|
||||
{Name: "Alice", Age: 30},
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestSortFn_SortByAge(t *testing.T) {
|
||||
users := []User{
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Alice", Age: 30},
|
||||
{Name: "Bob", Age: 20},
|
||||
}
|
||||
|
||||
sorts := []SortBinding[User]{
|
||||
{Key: "name", Fn: compareUserByName},
|
||||
{Key: "age", Fn: compareUserByAge},
|
||||
}
|
||||
|
||||
// Test ascending by age
|
||||
params := SortQueryParams{sort: "age", order: SortAsc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
require.Equal(t, []User{
|
||||
{Name: "Bob", Age: 20},
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Alice", Age: 30},
|
||||
}, result)
|
||||
|
||||
// Test descending by age
|
||||
params = SortQueryParams{sort: "age", order: SortDesc}
|
||||
result = sortFn(users, params, sorts)
|
||||
|
||||
require.Equal(t, []User{
|
||||
{Name: "Alice", Age: 30},
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Bob", Age: 20},
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestSortFn_UnknownSortKey(t *testing.T) {
|
||||
users := []User{
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Alice", Age: 30},
|
||||
{Name: "Bob", Age: 20},
|
||||
}
|
||||
|
||||
sorts := []SortBinding[User]{
|
||||
{Key: "name", Fn: compareUserByName},
|
||||
}
|
||||
|
||||
params := SortQueryParams{sort: "unknown", order: SortAsc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
// Should return original slice unchanged
|
||||
require.Equal(t, []User{
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Alice", Age: 30},
|
||||
{Name: "Bob", Age: 20},
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestSortFn_EmptySlice(t *testing.T) {
|
||||
users := []User{}
|
||||
|
||||
sorts := []SortBinding[User]{
|
||||
{Key: "name", Fn: compareUserByName},
|
||||
}
|
||||
|
||||
params := SortQueryParams{sort: "name", order: SortAsc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
require.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestSortFn_SingleElement(t *testing.T) {
|
||||
users := []User{{Name: "Alice", Age: 30}}
|
||||
|
||||
sorts := []SortBinding[User]{
|
||||
{Key: "name", Fn: compareUserByName},
|
||||
}
|
||||
|
||||
params := SortQueryParams{sort: "name", order: SortAsc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
require.Equal(t, []User{{Name: "Alice", Age: 30}}, result)
|
||||
}
|
||||
|
||||
func TestSortFn_EmptySortBindings(t *testing.T) {
|
||||
users := []User{
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Alice", Age: 30},
|
||||
}
|
||||
|
||||
sorts := []SortBinding[User]{} // Empty sorts
|
||||
|
||||
params := SortQueryParams{sort: "name", order: SortAsc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
// Should return original slice unchanged
|
||||
require.Equal(t, []User{
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Alice", Age: 30},
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestSortFn_DifferentType(t *testing.T) {
|
||||
products := []Product{
|
||||
{Name: "Laptop", Price: 1000},
|
||||
{Name: "Mouse", Price: 25},
|
||||
{Name: "Keyboard", Price: 100},
|
||||
}
|
||||
|
||||
sorts := []SortBinding[Product]{
|
||||
{Key: "name", Fn: compareProductByName},
|
||||
{Key: "price", Fn: compareProductByPrice},
|
||||
}
|
||||
|
||||
// Test by price ascending
|
||||
params := SortQueryParams{sort: "price", order: SortAsc}
|
||||
result := sortFn(products, params, sorts)
|
||||
|
||||
require.Equal(t, []Product{
|
||||
{Name: "Mouse", Price: 25},
|
||||
{Name: "Keyboard", Price: 100},
|
||||
{Name: "Laptop", Price: 1000},
|
||||
}, result)
|
||||
|
||||
// Test by name descending
|
||||
params = SortQueryParams{sort: "name", order: SortDesc}
|
||||
result = sortFn(products, params, sorts)
|
||||
|
||||
require.Equal(t, []Product{
|
||||
{Name: "Mouse", Price: 25},
|
||||
{Name: "Laptop", Price: 1000},
|
||||
{Name: "Keyboard", Price: 100},
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestSortFn_StableSort(t *testing.T) {
|
||||
// Test that sorting is stable (maintains relative order of equal elements)
|
||||
users := []User{
|
||||
{Name: "Alice", Age: 25},
|
||||
{Name: "Bob", Age: 25},
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "David", Age: 30},
|
||||
}
|
||||
|
||||
sorts := []SortBinding[User]{
|
||||
{Key: "age", Fn: compareUserByAge},
|
||||
}
|
||||
|
||||
params := SortQueryParams{sort: "age", order: SortAsc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
// All users with age 25 should maintain their original relative order
|
||||
require.Equal(t, []User{
|
||||
{Name: "Alice", Age: 25},
|
||||
{Name: "Bob", Age: 25},
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "David", Age: 30},
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestReverseSortFn(t *testing.T) {
|
||||
originalFn := compareUserByAge
|
||||
reversedFn := reverSortFn(originalFn)
|
||||
|
||||
userA := User{Name: "Alice", Age: 20}
|
||||
userB := User{Name: "Bob", Age: 30}
|
||||
|
||||
// Original function: A < B (returns negative)
|
||||
require.Less(t, originalFn(userA, userB), 0)
|
||||
|
||||
// Reversed function: A > B (returns positive)
|
||||
require.Greater(t, reversedFn(userA, userB), 0)
|
||||
|
||||
// Test symmetry
|
||||
require.Equal(t, -originalFn(userA, userB), reversedFn(userA, userB))
|
||||
require.Equal(t, -originalFn(userB, userA), reversedFn(userB, userA))
|
||||
}
|
||||
|
||||
func TestSortFn_CaseSensitive(t *testing.T) {
|
||||
users := []User{
|
||||
{Name: "alice", Age: 25},
|
||||
{Name: "Bob", Age: 30},
|
||||
{Name: "Charlie", Age: 20},
|
||||
}
|
||||
|
||||
sorts := []SortBinding[User]{
|
||||
{Key: "name", Fn: compareUserByName},
|
||||
}
|
||||
|
||||
params := SortQueryParams{sort: "name", order: SortAsc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
// strings.Compare is case-sensitive, uppercase comes before lowercase
|
||||
require.Equal(t, []User{
|
||||
{Name: "Bob", Age: 30},
|
||||
{Name: "Charlie", Age: 20},
|
||||
{Name: "alice", Age: 25},
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestSortFn_ModifiesOriginalSlice(t *testing.T) {
|
||||
users := []User{
|
||||
{Name: "Charlie", Age: 25},
|
||||
{Name: "Alice", Age: 30},
|
||||
{Name: "Bob", Age: 20},
|
||||
}
|
||||
original := make([]User, len(users))
|
||||
copy(original, users)
|
||||
|
||||
sorts := []SortBinding[User]{
|
||||
{Key: "name", Fn: compareUserByName},
|
||||
}
|
||||
|
||||
params := SortQueryParams{sort: "name", order: SortAsc}
|
||||
result := sortFn(users, params, sorts)
|
||||
|
||||
// The function modifies the original slice
|
||||
require.Equal(t, result, users)
|
||||
require.NotEqual(t, original, users)
|
||||
}
|
63
api/http/utils/filters/types_test.go
Normal file
63
api/http/utils/filters/types_test.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Test data structures
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
Email string
|
||||
Age int
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int
|
||||
Name string
|
||||
Description string
|
||||
Price int
|
||||
Category string
|
||||
}
|
||||
|
||||
// User accessors
|
||||
func userIDAccessor(u User) (string, error) {
|
||||
return strconv.Itoa(u.ID), nil
|
||||
}
|
||||
|
||||
func userNameAccessor(u User) (string, error) {
|
||||
return u.Name, nil
|
||||
}
|
||||
|
||||
func userEmailAccessor(u User) (string, error) {
|
||||
return u.Email, nil
|
||||
}
|
||||
|
||||
func userAgeAccessor(u User) (string, error) {
|
||||
return strconv.Itoa(u.Age), nil
|
||||
}
|
||||
|
||||
// Product accessors
|
||||
|
||||
func productNameAccessor(p Product) (string, error) {
|
||||
return p.Name, nil
|
||||
}
|
||||
|
||||
func productDescriptionAccessor(p Product) (string, error) {
|
||||
return p.Description, nil
|
||||
}
|
||||
|
||||
func productPriceAccessor(p Product) (string, error) {
|
||||
return fmt.Sprintf("$%d", p.Price), nil
|
||||
}
|
||||
|
||||
func productCategoryAccessor(p Product) (string, error) {
|
||||
return p.Category, nil
|
||||
}
|
||||
|
||||
// Other accessors
|
||||
func errorAccessor[T any](t T) (string, error) {
|
||||
return "", errors.New("accessor error")
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue