1
0
mirror of https://github.com/mainflux/mainflux.git synced 2025-04-26 13:48:53 +08:00

MF-1357 - Add new endpoint for searching things (#1383)

* Add new enpoint for thing search

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* Rename endpoint to /search
Use same request as list endpoint

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* Add optional parameters in body (offset, limit)
Add swagger file

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* move all parameters into body

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* fix swagger

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* fix error description

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* Add tests

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* remove dead code
fix tests

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* remove unused var

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* fix sdk tests

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* add url endpoint for search test

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* description in swagger
fix tracer string
change test offset

Signed-off-by: Ivan Milosevic <iva@blokovi.com>

* rename in tests searchThReq to searchThingReq

Signed-off-by: Ivan Milosevic <iva@blokovi.com>
This commit is contained in:
Ivan Milošević 2021-03-11 10:28:44 +01:00 committed by GitHub
parent a1e18a770a
commit 30ba38c919
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 386 additions and 76 deletions

View File

@ -6,7 +6,6 @@ package sdk_test
import (
"fmt"
"net/http"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
@ -16,7 +15,7 @@ import (
)
var (
channel = sdk.Channel{ID: "1", Name: "test"}
channel = sdk.Channel{ID: "001", Name: "test"}
emptyChannel = sdk.Channel{}
)
@ -99,8 +98,8 @@ func TestCreateChannels(t *testing.T) {
mainfluxSDK := sdk.NewSDK(sdkConf)
channels := []sdk.Channel{
sdk.Channel{ID: "1", Name: "1"},
sdk.Channel{ID: "2", Name: "2"},
sdk.Channel{ID: "001", Name: "1"},
sdk.Channel{ID: "002", Name: "2"},
}
cases := []struct {
@ -221,7 +220,7 @@ func TestChannels(t *testing.T) {
var channels []sdk.Channel
mainfluxSDK := sdk.NewSDK(sdkConf)
for i := 1; i < 101; i++ {
ch := sdk.Channel{ID: strconv.Itoa(i), Name: "test"}
ch := sdk.Channel{ID: fmt.Sprintf("%03d", i), Name: "test"}
mainfluxSDK.CreateChannel(ch, token)
channels = append(channels, ch)
}
@ -260,12 +259,12 @@ func TestChannels(t *testing.T) {
response: nil,
},
{
desc: "get a list of channels with zero limit",
desc: "get a list of channels without limit, default 10",
token: token,
offset: 0,
limit: 0,
err: createError(sdk.ErrFailedFetch, http.StatusBadRequest),
response: nil,
err: nil,
response: channels[0:10],
},
{
desc: "get a list of channels with limit greater than max",
@ -283,14 +282,6 @@ func TestChannels(t *testing.T) {
err: nil,
response: []sdk.Channel{},
},
{
desc: "get a list of channels with invalid args (zero limit) and invalid token",
token: wrongValue,
offset: 0,
limit: 0,
err: createError(sdk.ErrFailedFetch, http.StatusBadRequest),
response: nil,
},
}
for _, tc := range cases {
page, err := mainfluxSDK.Channels(tc.token, tc.offset, tc.limit, tc.name)
@ -323,7 +314,7 @@ func TestChannelsByThing(t *testing.T) {
var channels []sdk.Channel
for i := 1; i < n+1; i++ {
ch := sdk.Channel{
ID: strconv.Itoa(i),
ID: fmt.Sprintf("%03d", i),
Name: "test",
}
cid, err := mainfluxSDK.CreateChannel(ch, token)

View File

@ -7,7 +7,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
sdk "github.com/mainflux/mainflux/pkg/sdk/go"
@ -36,7 +35,7 @@ const (
var (
metadata = map[string]interface{}{"meta": "data"}
metadata2 = map[string]interface{}{"meta": "data2"}
thing = sdk.Thing{ID: "1", Name: "test_device", Metadata: metadata}
thing = sdk.Thing{ID: "001", Name: "test_device", Metadata: metadata}
emptyThing = sdk.Thing{}
)
@ -86,14 +85,14 @@ func TestCreateThing(t *testing.T) {
thing: thing,
token: token,
err: nil,
location: "1",
location: "001",
},
{
desc: "create new empty thing",
thing: emptyThing,
token: token,
err: nil,
location: "2",
location: "002",
},
{
desc: "create new thing with empty token",
@ -136,8 +135,8 @@ func TestCreateThings(t *testing.T) {
mainfluxSDK := sdk.NewSDK(sdkConf)
things := []sdk.Thing{
sdk.Thing{ID: "1", Name: "1", Key: "1"},
sdk.Thing{ID: "2", Name: "2", Key: "2"},
sdk.Thing{ID: "001", Name: "1", Key: "1"},
sdk.Thing{ID: "002", Name: "2", Key: "2"},
}
cases := []struct {
@ -262,7 +261,7 @@ func TestThings(t *testing.T) {
mainfluxSDK := sdk.NewSDK(sdkConf)
for i := 1; i < 101; i++ {
th := sdk.Thing{ID: strconv.Itoa(i), Name: "test_device", Metadata: metadata}
th := sdk.Thing{ID: fmt.Sprintf("%03d", i), Name: "test_device", Metadata: metadata}
mainfluxSDK.CreateThing(th, token)
th.Key = fmt.Sprintf("%s%012d", keyPrefix, 2*i)
things = append(things, th)
@ -306,8 +305,8 @@ func TestThings(t *testing.T) {
token: token,
offset: 0,
limit: 0,
err: createError(sdk.ErrFailedFetch, http.StatusBadRequest),
response: nil,
err: nil,
response: things[0:10],
},
{
desc: "get a list of things with limit greater than max",
@ -325,14 +324,6 @@ func TestThings(t *testing.T) {
err: nil,
response: []sdk.Thing{},
},
{
desc: "get a list of things with invalid args (zero limit) and invalid token",
token: wrongValue,
offset: 0,
limit: 0,
err: createError(sdk.ErrFailedFetch, http.StatusBadRequest),
response: nil,
},
}
for _, tc := range cases {
page, err := mainfluxSDK.Things(tc.token, tc.offset, tc.limit, tc.name)
@ -366,7 +357,7 @@ func TestThingsByChannel(t *testing.T) {
var things []sdk.Thing
for i := 1; i < n+1; i++ {
th := sdk.Thing{
ID: strconv.Itoa(i),
ID: fmt.Sprintf("%03d", i),
Name: "test_device",
Metadata: metadata,
Key: fmt.Sprintf("%s%012d", keyPrefix, 2*i+1),

View File

@ -46,9 +46,13 @@ var (
Name: "test",
Metadata: map[string]interface{}{"test": "data"},
}
invalidName = strings.Repeat("m", maxNameSize+1)
notFoundRes = toJSON(errorRes{things.ErrNotFound.Error()})
unauthRes = toJSON(errorRes{things.ErrUnauthorizedAccess.Error()})
invalidName = strings.Repeat("m", maxNameSize+1)
notFoundRes = toJSON(errorRes{things.ErrNotFound.Error()})
unauthRes = toJSON(errorRes{things.ErrUnauthorizedAccess.Error()})
searchThingReq = things.PageMetadata{
Limit: 5,
Offset: 0,
}
)
type testRequest struct {
@ -122,7 +126,7 @@ func TestCreateThing(t *testing.T) {
contentType: contentType,
auth: token,
status: http.StatusCreated,
location: "/things/1",
location: "/things/001",
},
{
desc: "add thing with existing key",
@ -138,7 +142,7 @@ func TestCreateThing(t *testing.T) {
contentType: contentType,
auth: token,
status: http.StatusCreated,
location: "/things/2",
location: "/things/002",
},
{
desc: "add thing with invalid auth token",
@ -723,11 +727,11 @@ func TestListThings(t *testing.T) {
res: nil,
},
{
desc: "get a list of things with zero limit",
desc: "get a list of things with zero limit and offset 1",
auth: token,
status: http.StatusBadRequest,
status: http.StatusOK,
url: fmt.Sprintf("%s?offset=%d&limit=%d", thingURL, 1, 0),
res: nil,
res: data[1:11],
},
{
desc: "get a list of things without offset",
@ -838,6 +842,201 @@ func TestListThings(t *testing.T) {
}
}
func TestSearchThings(t *testing.T) {
svc := newService(map[string]string{token: email})
ts := newServer(svc)
defer ts.Close()
th := searchThingReq
validData := toJSON(th)
th.Dir = "desc"
th.Order = "name"
descData := toJSON(th)
th.Dir = "asc"
ascData := toJSON(th)
th.Order = "wrong"
invalidOrderData := toJSON(th)
th = searchThingReq
th.Dir = "wrong"
invalidDirData := toJSON(th)
th = searchThingReq
th.Limit = 110
limitMaxData := toJSON(th)
th.Limit = 0
zeroLimitData := toJSON(th)
th = searchThingReq
th.Name = invalidName
invalidNameData := toJSON(th)
th.Name = invalidName
invalidData := toJSON(th)
data := []thingRes{}
for i := 0; i < 100; i++ {
name := "name_" + fmt.Sprintf("%03d", i+1)
ths, err := svc.CreateThings(context.Background(), token, things.Thing{Name: name, Metadata: map[string]interface{}{"test": name}})
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
th := ths[0]
data = append(data, thingRes{
ID: th.ID,
Name: th.Name,
Key: th.Key,
Metadata: th.Metadata,
})
}
cases := []struct {
desc string
auth string
status int
req string
res []thingRes
}{
{
desc: "search things",
auth: token,
status: http.StatusOK,
req: validData,
res: data[0:5],
},
{
desc: "search things ordered by name descendent",
auth: token,
status: http.StatusOK,
req: descData,
res: data[0:5],
},
{
desc: "search things ordered by name ascendent",
auth: token,
status: http.StatusOK,
req: ascData,
res: data[0:5],
},
{
desc: "search things with invalid order",
auth: token,
status: http.StatusBadRequest,
req: invalidOrderData,
res: nil,
},
{
desc: "search things with invalid dir",
auth: token,
status: http.StatusBadRequest,
req: invalidDirData,
res: nil,
},
{
desc: "search things with invalid token",
auth: wrongValue,
status: http.StatusUnauthorized,
req: validData,
res: nil,
},
{
desc: "search things with invalid data",
auth: token,
status: http.StatusBadRequest,
req: invalidData,
res: nil,
},
{
desc: "search things with empty token",
auth: "",
status: http.StatusUnauthorized,
req: validData,
res: nil,
},
{
desc: "search things with zero limit",
auth: token,
status: http.StatusOK,
req: zeroLimitData,
res: data[0:10],
},
{
desc: "search things without offset",
auth: token,
status: http.StatusOK,
req: validData,
res: data[0:5],
},
{
desc: "search things with limit greater than max",
auth: token,
status: http.StatusBadRequest,
req: limitMaxData,
res: nil,
},
{
desc: "search things with default URL",
auth: token,
status: http.StatusOK,
req: validData,
res: data[0:5],
},
{
desc: "search things filtering with invalid name",
auth: token,
status: http.StatusBadRequest,
req: invalidNameData,
res: nil,
},
{
desc: "search things sorted by name ascendent",
auth: token,
status: http.StatusOK,
req: validData,
res: data[0:5],
},
{
desc: "search things sorted by name descendent",
auth: token,
status: http.StatusOK,
req: validData,
res: data[0:5],
},
{
desc: "search things sorted with invalid order",
auth: token,
status: http.StatusBadRequest,
req: invalidOrderData,
res: nil,
},
{
desc: "search things sorted by name with invalid direction",
auth: token,
status: http.StatusBadRequest,
req: invalidDirData,
res: nil,
},
}
for _, tc := range cases {
req := testRequest{
client: ts.Client(),
method: http.MethodPost,
url: fmt.Sprintf("%s/things/search", ts.URL),
token: tc.auth,
body: strings.NewReader(tc.req),
}
res, err := req.make()
assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
var data thingsPageRes
json.NewDecoder(res.Body).Decode(&data)
assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
assert.ElementsMatch(t, tc.res, data.Things, fmt.Sprintf("%s: expected body %v got %v", tc.desc, tc.res, data.Things))
}
}
func TestListThingsByChannel(t *testing.T) {
svc := newService(map[string]string{token: email})
ts := newServer(svc)
@ -1097,7 +1296,7 @@ func TestCreateChannel(t *testing.T) {
contentType: contentType,
auth: token,
status: http.StatusCreated,
location: "/channels/1",
location: "/channels/001",
},
{
desc: "create new channel with invalid token",
@ -1129,7 +1328,7 @@ func TestCreateChannel(t *testing.T) {
contentType: contentType,
auth: token,
status: http.StatusCreated,
location: "/channels/2",
location: "/channels/002",
},
{
desc: "create new channel with empty request",
@ -1482,7 +1681,12 @@ func TestListChannels(t *testing.T) {
channels := []channelRes{}
for i := 0; i < 101; i++ {
chs, err := svc.CreateChannels(context.Background(), token, channel)
name := "name_" + fmt.Sprintf("%03d", i+1)
chs, err := svc.CreateChannels(context.Background(), token,
things.Channel{
Name: name,
Metadata: map[string]interface{}{"test": "data"},
})
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
ch := chs[0]
ths, err := svc.CreateThings(context.Background(), token, thing)
@ -1513,17 +1717,17 @@ func TestListChannels(t *testing.T) {
res: channels[0:6],
},
{
desc: "get a list of channels ordered by name descendent",
desc: "get a list of channels ordered by id descendent",
auth: token,
status: http.StatusOK,
url: fmt.Sprintf("%s?offset=%d&limit=%d&order=name&dir=desc", channelURL, 0, 6),
res: channels[0:6],
url: fmt.Sprintf("%s?offset=%d&limit=%d&order=id&dir=desc", channelURL, 0, 6),
res: channels[len(channels)-6:],
},
{
desc: "get a list of channels ordered by name ascendent",
desc: "get a list of channels ordered by id ascendent",
auth: token,
status: http.StatusOK,
url: fmt.Sprintf("%s?offset=%d&limit=%d&order=name&dir=asc", channelURL, 0, 6),
url: fmt.Sprintf("%s?offset=%d&limit=%d&order=id&dir=asc", channelURL, 0, 6),
res: channels[0:6],
},
{
@ -1569,11 +1773,11 @@ func TestListChannels(t *testing.T) {
res: nil,
},
{
desc: "get a list of channels with zero limit",
desc: "get a list of channels with zero limit and offset 1",
auth: token,
status: http.StatusBadRequest,
status: http.StatusOK,
url: fmt.Sprintf("%s?offset=%d&limit=%d", channelURL, 1, 0),
res: nil,
res: channels[1:11],
},
{
desc: "get a list of channels with no offset provided",
@ -1650,7 +1854,7 @@ func TestListChannels(t *testing.T) {
auth: token,
status: http.StatusOK,
url: fmt.Sprintf("%s?offset=%d&limit=%d&order=%s&dir=%s", channelURL, 0, 6, nameKey, descKey),
res: channels[0:6],
res: channels[len(channels)-6:],
},
{
desc: "get a list of channels sorted with invalid order",

View File

@ -185,7 +185,11 @@ func (req *listResourcesReq) validate() error {
return things.ErrUnauthorizedAccess
}
if req.pageMetadata.Limit == 0 || req.pageMetadata.Limit > maxLimitSize {
if req.pageMetadata.Limit == 0 {
req.pageMetadata.Limit = defLimit
}
if req.pageMetadata.Limit > maxLimitSize {
return things.ErrMalformedEntity
}

View File

@ -105,6 +105,13 @@ func MakeHandler(tracer opentracing.Tracer, svc things.Service) http.Handler {
opts...,
))
r.Post("/things/search", kithttp.NewServer(
kitot.TraceServer(tracer, "search_things")(listThingsEndpoint(svc)),
decodeListByMetadata,
encodeResponse,
opts...,
))
r.Post("/channels", kithttp.NewServer(
kitot.TraceServer(tracer, "create_channel")(createChannelEndpoint(svc)),
decodeChannelCreation,
@ -344,6 +351,15 @@ func decodeList(_ context.Context, r *http.Request) (interface{}, error) {
return req, nil
}
func decodeListByMetadata(_ context.Context, r *http.Request) (interface{}, error) {
req := listResourcesReq{token: r.Header.Get("Authorization")}
if err := json.NewDecoder(r.Body).Decode(&req.pageMetadata); err != nil {
return nil, errors.Wrap(things.ErrMalformedEntity, err)
}
return req, nil
}
func decodeListByConnection(_ context.Context, r *http.Request) (interface{}, error) {
o, err := readUintQuery(r, offsetKey, defOffset)
if err != nil {

View File

@ -48,7 +48,7 @@ func (crm *channelRepositoryMock) Save(_ context.Context, channels ...things.Cha
for i := range channels {
crm.counter++
channels[i].ID = strconv.FormatUint(crm.counter, 10)
channels[i].ID = fmt.Sprintf("%03d", crm.counter)
crm.channels[key(channels[i].Owner, channels[i].ID)] = channels[i]
}
@ -78,12 +78,15 @@ func (crm *channelRepositoryMock) RetrieveByID(_ context.Context, owner, id stri
}
func (crm *channelRepositoryMock) RetrieveAll(_ context.Context, owner string, pm things.PageMetadata) (things.ChannelsPage, error) {
if pm.Limit <= 0 {
if pm.Limit < 0 {
return things.ChannelsPage{}, nil
}
if pm.Limit == 0 {
pm.Limit = 10
}
first := uint64(pm.Offset) + 1
last := first + uint64(pm.Limit)
first := int(pm.Offset)
last := first + int(pm.Limit)
var chs []things.Channel
@ -91,8 +94,7 @@ func (crm *channelRepositoryMock) RetrieveAll(_ context.Context, owner string, p
// itself (see mocks/commons.go).
prefix := fmt.Sprintf("%s-", owner)
for k, v := range crm.channels {
id, _ := strconv.ParseUint(v.ID, 10, 64)
if strings.HasPrefix(k, prefix) && id >= first && id < last {
if strings.HasPrefix(k, prefix) {
chs = append(chs, v)
}
}
@ -100,8 +102,16 @@ func (crm *channelRepositoryMock) RetrieveAll(_ context.Context, owner string, p
// Sort Channels list
chs = sortChannels(pm, chs)
if last > len(chs) {
last = len(chs)
}
if first > last {
return things.ChannelsPage{}, nil
}
page := things.ChannelsPage{
Channels: chs,
Channels: chs[first:last],
PageMetadata: things.PageMetadata{
Total: crm.counter,
Offset: pm.Offset,

View File

@ -31,6 +31,17 @@ func sortThings(pm things.PageMetadata, ths []things.Thing) []things.Thing {
return ths[i].Name > ths[j].Name
})
}
case "id":
if pm.Dir == "asc" {
sort.SliceStable(ths, func(i, j int) bool {
return ths[i].ID < ths[j].ID
})
}
if pm.Dir == "desc" {
sort.SliceStable(ths, func(i, j int) bool {
return ths[i].ID > ths[j].ID
})
}
default:
sort.SliceStable(ths, func(i, j int) bool {
return ths[i].ID < ths[j].ID
@ -53,6 +64,17 @@ func sortChannels(pm things.PageMetadata, chs []things.Channel) []things.Channel
return chs[i].Name > chs[j].Name
})
}
case "id":
if pm.Dir == "asc" {
sort.SliceStable(chs, func(i, j int) bool {
return chs[i].ID < chs[j].ID
})
}
if pm.Dir == "desc" {
sort.SliceStable(chs, func(i, j int) bool {
return chs[i].ID > chs[j].ID
})
}
default:
sort.SliceStable(chs, func(i, j int) bool {
return chs[i].ID < chs[j].ID

View File

@ -55,7 +55,7 @@ func (trm *thingRepositoryMock) Save(_ context.Context, ths ...things.Thing) ([]
}
trm.counter++
ths[i].ID = strconv.FormatUint(trm.counter, 10)
ths[i].ID = fmt.Sprintf("%03d", trm.counter)
trm.things[key(ths[i].Owner, ths[i].ID)] = ths[i]
}
@ -115,7 +115,7 @@ func (trm *thingRepositoryMock) RetrieveAll(_ context.Context, owner string, pm
trm.mu.Lock()
defer trm.mu.Unlock()
if pm.Limit <= 0 {
if pm.Limit < 0 {
return things.Page{}, nil
}

View File

@ -62,6 +62,34 @@ paths:
description: Database can't process request.
'500':
$ref: "#/components/responses/ServiceError"
/things/search:
post:
summary: Search and retrieves things
description: |
Retrieves a list of things with name and metadata filtering.
Due to performance concerns, data is retrieved in subsets.
The API things must ensure that the entire
dataset is consumed either by making subsequent requests, or by
increasing the subset size of the initial request.
tags:
- things
parameters:
- $ref: "#/components/parameters/Authorization"
requestBody:
$ref: "#/components/requestBodies/ThingsSearchReq"
responses:
'200':
$ref: "#/components/responses/ThingsPageRes"
'400':
description: Failed due to malformed query parameters.
'401':
description: Missing or invalid access token provided.
'404':
description: A non-existent entity request.
'422':
description: Unprocessable Entity
'500':
$ref: "#/components/responses/ServiceError"
/things/bulk:
post:
summary: Bulk provisions new things
@ -569,6 +597,43 @@ components:
metadata:
type: object
description: Arbitrary, object-encoded thing's data.
ThingsReqSchema:
type: object
properties:
name:
type: string
description: Name filter. Filtering is performed as a case-insensitive partial match.
metadata:
type: object
description: Metadata filter. Filtering is performed matching the parameter with metadata on top level. Parameter is json.
total:
type: integer
description: Total number of items.
offset:
type: integer
description: Number of items to skip during retrieval.
default: 0
minimum: 0
limit:
type: integer
description: Size of the subset to retrieve.
default: 10
maximum: 100
minimum: 1
order:
type: string
description: Order type.
default: id
enum:
- name
- id
dir:
type: string
description: Order direction.
default: desc
enum:
- asc
- desc
ThingResSchema:
type: object
properties:
@ -800,6 +865,13 @@ components:
description: Free-form thing name.
metadata:
type: object
ThingsSearchReq:
description: JSON-formatted document describing search parameters.
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ThingsReqSchema"
KeyUpdateReq:
required: true
description: JSON containing thing.

View File

@ -72,7 +72,7 @@ func TestCreateThings(t *testing.T) {
key: token,
err: nil,
event: map[string]interface{}{
"id": "1",
"id": "001",
"name": "a",
"owner": email,
"metadata": "{\"test\":\"test\"}",
@ -298,7 +298,7 @@ func TestCreateChannels(t *testing.T) {
key: token,
err: nil,
event: map[string]interface{}{
"id": "1",
"id": "001",
"name": "a",
"metadata": "{\"test\":\"test\"}",
"owner": email,

View File

@ -126,13 +126,13 @@ type Service interface {
// PageMetadata contains page metadata that helps navigation.
type PageMetadata struct {
Total uint64
Offset uint64
Limit uint64
Name string
Order string
Dir string
Metadata map[string]interface{}
Connected bool // Used for connected or disconnected lists
Offset uint64 `json:"offset,omitempty"`
Limit uint64 `json:"limit,omitempty"`
Name string `json:"name,omitempty"`
Order string `json:"order,omitempty"`
Dir string `json:"dir,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Connected bool // Used for connected or disconnected lists
}
var _ Service = (*thingsService)(nil)

View File

@ -711,13 +711,13 @@ func TestListChannels(t *testing.T) {
size: 0,
err: nil,
},
"list with zero limit": {
"list with zero limit and offset 1": {
token: token,
pageMetadata: things.PageMetadata{
Offset: 1,
Limit: 0,
},
size: 0,
size: n - 1,
err: nil,
},
"list with wrong credentials": {