// Copyright (c) Mainflux
// SPDX-License-Identifier: Apache-2.0

package http_test

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/mainflux/mainflux/things"
	httpapi "github.com/mainflux/mainflux/things/api/things/http"
	"github.com/mainflux/mainflux/things/mocks"
	"github.com/opentracing/opentracing-go/mocktracer"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const (
	contentType = "application/json"
	email       = "user@example.com"
	token       = "token"
	wrongValue  = "wrong_value"
	wrongID     = 0
	maxNameSize = 1024
)

var (
	thing = things.Thing{
		Name:     "test_app",
		Metadata: map[string]interface{}{"test": "data"},
	}
	channel = things.Channel{
		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()})
)

type testRequest struct {
	client      *http.Client
	method      string
	url         string
	contentType string
	token       string
	body        io.Reader
}

func (tr testRequest) make() (*http.Response, error) {
	req, err := http.NewRequest(tr.method, tr.url, tr.body)
	if err != nil {
		return nil, err
	}
	if tr.token != "" {
		req.Header.Set("Authorization", tr.token)
	}
	if tr.contentType != "" {
		req.Header.Set("Content-Type", tr.contentType)
	}
	return tr.client.Do(req)
}

func newService(tokens map[string]string) things.Service {
	auth := mocks.NewAuthService(tokens)
	conns := make(chan mocks.Connection)
	thingsRepo := mocks.NewThingRepository(conns)
	channelsRepo := mocks.NewChannelRepository(thingsRepo, conns)
	chanCache := mocks.NewChannelCache()
	thingCache := mocks.NewThingCache()
	idp := mocks.NewIdentityProvider()

	return things.New(auth, thingsRepo, channelsRepo, chanCache, thingCache, idp)
}

func newServer(svc things.Service) *httptest.Server {
	mux := httpapi.MakeHandler(mocktracer.New(), svc)
	return httptest.NewServer(mux)
}

func toJSON(data interface{}) string {
	jsonData, _ := json.Marshal(data)
	return string(jsonData)
}

func TestCreateThing(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	th := thing
	th.Key = "key"
	data := toJSON(th)

	th.Name = invalidName
	invalidData := toJSON(th)

	cases := []struct {
		desc        string
		req         string
		contentType string
		auth        string
		status      int
		location    string
	}{
		{
			desc:        "add valid thing",
			req:         data,
			contentType: contentType,
			auth:        token,
			status:      http.StatusCreated,
			location:    "/things/1",
		},
		{
			desc:        "add thing with existing key",
			req:         data,
			contentType: contentType,
			auth:        token,
			status:      http.StatusUnprocessableEntity,
			location:    "",
		},
		{
			desc:        "add thing with empty JSON request",
			req:         "{}",
			contentType: contentType,
			auth:        token,
			status:      http.StatusCreated,
			location:    "/things/2",
		},
		{
			desc:        "add thing with invalid auth token",
			req:         data,
			contentType: contentType,
			auth:        wrongValue,
			status:      http.StatusForbidden,
			location:    "",
		},
		{
			desc:        "add thing with empty auth token",
			req:         data,
			contentType: contentType,
			auth:        "",
			status:      http.StatusForbidden,
			location:    "",
		},
		{
			desc:        "add thing with invalid request format",
			req:         "}",
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			location:    "",
		},
		{
			desc:        "add thing with empty request",
			req:         "",
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			location:    "",
		},
		{
			desc:        "add thing without content type",
			req:         data,
			contentType: "",
			auth:        token,
			status:      http.StatusUnsupportedMediaType,
			location:    "",
		},
		{
			desc:        "add thing with invalid name",
			req:         invalidData,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			location:    "",
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client:      ts.Client(),
			method:      http.MethodPost,
			url:         fmt.Sprintf("%s/things", ts.URL),
			contentType: tc.contentType,
			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))

		location := res.Header.Get("Location")
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
		assert.Equal(t, tc.location, location, fmt.Sprintf("%s: expected location %s got %s", tc.desc, tc.location, location))
	}
}

func TestCreateThings(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	data := `[{"name": "1", "key": "1"}, {"name": "2", "key": "2"}]`
	invalidData := fmt.Sprintf(`[{"name": "%s", "key": "10"}]`, invalidName)

	cases := []struct {
		desc        string
		data        string
		contentType string
		auth        string
		status      int
		response    string
	}{
		{
			desc:        "create valid things",
			data:        data,
			contentType: contentType,
			auth:        token,
			status:      http.StatusCreated,
			response:    "",
		},
		{
			desc:        "create things with empty request",
			data:        "",
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			response:    "",
		},
		{
			desc:        "create thing with invalid request format",
			data:        "}",
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			response:    "",
		},
		{
			desc:        "create thing with invalid name",
			data:        invalidData,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			response:    "",
		},
		{
			desc:        "create things with empty JSON array",
			data:        "[]",
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			response:    "",
		},
		{
			desc:        "create thing with existing key",
			data:        data,
			contentType: contentType,
			auth:        token,
			status:      http.StatusUnprocessableEntity,
			response:    "",
		},
		{
			desc:        "create thing with invalid auth token",
			data:        data,
			contentType: contentType,
			auth:        wrongValue,
			status:      http.StatusForbidden,
			response:    "",
		},
		{
			desc:        "create thing with empty auth token",
			data:        data,
			contentType: contentType,
			auth:        "",
			status:      http.StatusForbidden,
			response:    "",
		},
		{
			desc:        "create thing without content type",
			data:        data,
			contentType: "",
			auth:        token,
			status:      http.StatusUnsupportedMediaType,
			response:    "",
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client:      ts.Client(),
			method:      http.MethodPost,
			url:         fmt.Sprintf("%s/things/bulk", ts.URL),
			contentType: tc.contentType,
			token:       tc.auth,
			body:        strings.NewReader(tc.data),
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))

		location := res.Header.Get("Location")
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
		assert.Equal(t, tc.response, location, fmt.Sprintf("%s: expected response %s got %s", tc.desc, tc.response, location))
	}
}

func TestUpdateThing(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	data := toJSON(thing)
	sths, _ := svc.CreateThings(context.Background(), token, thing)
	sth := sths[0]

	th := thing
	th.Name = invalidName
	invalidData := toJSON(th)

	cases := []struct {
		desc        string
		req         string
		id          string
		contentType string
		auth        string
		status      int
	}{
		{
			desc:        "update existing thing",
			req:         data,
			id:          sth.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusOK,
		},
		{
			desc:        "update thing with empty JSON request",
			req:         "{}",
			id:          sth.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusOK,
		},
		{
			desc:        "update non-existent thing",
			req:         data,
			id:          strconv.FormatUint(wrongID, 10),
			contentType: contentType,
			auth:        token,
			status:      http.StatusNotFound,
		},
		{
			desc:        "update thing with invalid id",
			req:         data,
			id:          "invalid",
			contentType: contentType,
			auth:        token,
			status:      http.StatusNotFound,
		},
		{
			desc:        "update thing with invalid user token",
			req:         data,
			id:          sth.ID,
			contentType: contentType,
			auth:        wrongValue,
			status:      http.StatusForbidden,
		},
		{
			desc:        "update thing with empty user token",
			req:         data,
			id:          sth.ID,
			contentType: contentType,
			auth:        "",
			status:      http.StatusForbidden,
		},
		{
			desc:        "update thing with invalid data format",
			req:         "{",
			id:          sth.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "update thing with empty request",
			req:         "",
			id:          sth.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "update thing without content type",
			req:         data,
			id:          sth.ID,
			contentType: "",
			auth:        token,
			status:      http.StatusUnsupportedMediaType,
		},
		{
			desc:        "update thing with invalid name",
			req:         invalidData,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client:      ts.Client(),
			method:      http.MethodPut,
			url:         fmt.Sprintf("%s/things/%s", ts.URL, tc.id),
			contentType: tc.contentType,
			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))
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
	}
}

func TestUpdateKey(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	th := thing
	th.Key = "key"
	sths, _ := svc.CreateThings(context.Background(), token, th)
	sth := sths[0]

	sth.Key = "new-key"
	data := toJSON(sth)

	sth.Key = "key"
	dummyData := toJSON(sth)

	cases := []struct {
		desc        string
		req         string
		id          string
		contentType string
		auth        string
		status      int
	}{
		{
			desc:        "update key for an existing thing",
			req:         data,
			id:          sth.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusOK,
		},
		{
			desc:        "update thing with conflicting key",
			req:         data,
			id:          sth.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusUnprocessableEntity,
		},
		{
			desc:        "update key with empty JSON request",
			req:         "{}",
			id:          sth.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "update key of non-existent thing",
			req:         dummyData,
			id:          strconv.FormatUint(wrongID, 10),
			contentType: contentType,
			auth:        token,
			status:      http.StatusNotFound,
		},
		{
			desc:        "update thing with invalid id",
			req:         dummyData,
			id:          "invalid",
			contentType: contentType,
			auth:        token,
			status:      http.StatusNotFound,
		},
		{
			desc:        "update thing with invalid user token",
			req:         data,
			id:          sth.ID,
			contentType: contentType,
			auth:        wrongValue,
			status:      http.StatusForbidden,
		},
		{
			desc:        "update thing with empty user token",
			req:         data,
			id:          sth.ID,
			contentType: contentType,
			auth:        "",
			status:      http.StatusForbidden,
		},
		{
			desc:        "update thing with invalid data format",
			req:         "{",
			id:          sth.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "update thing with empty request",
			req:         "",
			id:          sth.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "update thing without content type",
			req:         data,
			id:          sth.ID,
			contentType: "",
			auth:        token,
			status:      http.StatusUnsupportedMediaType,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client:      ts.Client(),
			method:      http.MethodPatch,
			url:         fmt.Sprintf("%s/things/%s/key", ts.URL, tc.id),
			contentType: tc.contentType,
			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))
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
	}
}

func TestViewThing(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	sths, err := svc.CreateThings(context.Background(), token, thing)
	require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
	sth := sths[0]

	thres := thingRes{
		ID:       sth.ID,
		Name:     sth.Name,
		Key:      sth.Key,
		Metadata: sth.Metadata,
	}
	data := toJSON(thres)

	cases := []struct {
		desc   string
		id     string
		auth   string
		status int
		res    string
	}{
		{
			desc:   "view existing thing",
			id:     sth.ID,
			auth:   token,
			status: http.StatusOK,
			res:    data,
		},
		{
			desc:   "view non-existent thing",
			id:     strconv.FormatUint(wrongID, 10),
			auth:   token,
			status: http.StatusNotFound,
			res:    notFoundRes,
		},
		{
			desc:   "view thing by passing invalid token",
			id:     sth.ID,
			auth:   wrongValue,
			status: http.StatusForbidden,
			res:    unauthRes,
		},
		{
			desc:   "view thing by passing empty token",
			id:     sth.ID,
			auth:   "",
			status: http.StatusForbidden,
			res:    unauthRes,
		},
		{
			desc:   "view thing by passing invalid id",
			id:     "invalid",
			auth:   token,
			status: http.StatusNotFound,
			res:    notFoundRes,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodGet,
			url:    fmt.Sprintf("%s/things/%s", ts.URL, tc.id),
			token:  tc.auth,
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		body, err := ioutil.ReadAll(res.Body)
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		data := strings.Trim(string(body), "\n")
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
		assert.Equal(t, tc.res, data, fmt.Sprintf("%s: expected body %s got %s", tc.desc, tc.res, data))
	}
}

func TestListThings(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	data := []thingRes{}
	for i := 0; i < 100; i++ {
		sths, err := svc.CreateThings(context.Background(), token, thing)
		require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
		sth := sths[0]
		thres := thingRes{
			ID:       sth.ID,
			Name:     sth.Name,
			Key:      sth.Key,
			Metadata: sth.Metadata,
		}
		data = append(data, thres)
	}

	thingURL := fmt.Sprintf("%s/things", ts.URL)
	cases := []struct {
		desc   string
		auth   string
		status int
		url    string
		res    []thingRes
	}{
		{
			desc:   "get a list of things",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", thingURL, 0, 5),
			res:    data[0:5],
		},
		{
			desc:   "get a list of things with invalid token",
			auth:   wrongValue,
			status: http.StatusForbidden,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", thingURL, 0, 1),
			res:    nil,
		},
		{
			desc:   "get a list of things with empty token",
			auth:   "",
			status: http.StatusForbidden,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", thingURL, 0, 1),
			res:    nil,
		},
		{
			desc:   "get a list of things with negative offset",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", thingURL, -1, 5),
			res:    nil,
		},
		{
			desc:   "get a list of things with negative limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", thingURL, 1, -5),
			res:    nil,
		},
		{
			desc:   "get a list of things with zero limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", thingURL, 1, 0),
			res:    nil,
		},
		{
			desc:   "get a list of things without offset",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s?limit=%d", thingURL, 5),
			res:    data[0:5],
		},
		{
			desc:   "get a list of things without limit",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s?offset=%d", thingURL, 1),
			res:    data[1:11],
		},
		{
			desc:   "get a list of things with redundant query params",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d&value=something", thingURL, 0, 5),
			res:    data[0:5],
		},
		{
			desc:   "get a list of things with limit greater than max",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", thingURL, 0, 110),
			res:    nil,
		},
		{
			desc:   "get a list of things with default URL",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s%s", thingURL, ""),
			res:    data[0:10],
		},
		{
			desc:   "get a list of things with invalid number of params",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s%s", thingURL, "?offset=4&limit=4&limit=5&offset=5"),
			res:    nil,
		},
		{
			desc:   "get a list of things with invalid offset",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s%s", thingURL, "?offset=e&limit=5"),
			res:    nil,
		},
		{
			desc:   "get a list of things with invalid limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s%s", thingURL, "?offset=5&limit=e"),
			res:    nil,
		},
		{
			desc:   "get a list of things filtering with invalid name",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d&name=%s", thingURL, 0, 5, invalidName),
			res:    nil,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodGet,
			url:    tc.url,
			token:  tc.auth,
		}
		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)
	defer ts.Close()

	schs, err := svc.CreateChannels(context.Background(), token, channel)
	require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
	sch := schs[0]

	data := []thingRes{}
	for i := 0; i < 101; i++ {
		sths, err := svc.CreateThings(context.Background(), token, thing)
		require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
		sth := sths[0]
		err = svc.Connect(context.Background(), token, []string{sch.ID}, []string{sth.ID})
		require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))

		thres := thingRes{
			ID:       sth.ID,
			Name:     sth.Name,
			Key:      sth.Key,
			Metadata: sth.Metadata,
		}
		data = append(data, thres)
	}
	thingURL := fmt.Sprintf("%s/channels", ts.URL)

	// Wait for things and channels to connect.
	time.Sleep(time.Second)

	cases := []struct {
		desc   string
		auth   string
		status int
		url    string
		res    []thingRes
	}{
		{
			desc:   "get a list of things by channel",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/things?offset=%d&limit=%d", thingURL, sch.ID, 0, 5),
			res:    data[0:5],
		},
		{
			desc:   "get a list of things by channel with invalid token",
			auth:   wrongValue,
			status: http.StatusForbidden,
			url:    fmt.Sprintf("%s/%s/things?offset=%d&limit=%d", thingURL, sch.ID, 0, 1),
			res:    nil,
		},
		{
			desc:   "get a list of things by channel with empty token",
			auth:   "",
			status: http.StatusForbidden,
			url:    fmt.Sprintf("%s/%s/things?offset=%d&limit=%d", thingURL, sch.ID, 0, 1),
			res:    nil,
		},
		{
			desc:   "get a list of things by channel with negative offset",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/things?offset=%d&limit=%d", thingURL, sch.ID, -1, 5),
			res:    nil,
		},
		{
			desc:   "get a list of things by channel with negative limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/things?offset=%d&limit=%d", thingURL, sch.ID, 1, -5),
			res:    nil,
		},
		{
			desc:   "get a list of things by channel with zero limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/things?offset=%d&limit=%d", thingURL, sch.ID, 1, 0),
			res:    nil,
		},
		{
			desc:   "get a list of things by channel without offset",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/things?limit=%d", thingURL, sch.ID, 5),
			res:    data[0:5],
		},
		{
			desc:   "get a list of things by channel without limit",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/things?offset=%d", thingURL, sch.ID, 1),
			res:    data[1:11],
		},
		{
			desc:   "get a list of things by channel with redundant query params",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/things?offset=%d&limit=%d&value=something", thingURL, sch.ID, 0, 5),
			res:    data[0:5],
		},
		{
			desc:   "get a list of things by channel with limit greater than max",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/things?offset=%d&limit=%d", thingURL, sch.ID, 0, 110),
			res:    nil,
		},
		{
			desc:   "get a list of things by channel with default URL",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/things", thingURL, sch.ID),
			res:    data[0:10],
		},
		{
			desc:   "get a list of things by channel with invalid number of params",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/things%s", thingURL, sch.ID, "?offset=4&limit=4&limit=5&offset=5"),
			res:    nil,
		},
		{
			desc:   "get a list of things by channel with invalid offset",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/things%s", thingURL, sch.ID, "?offset=e&limit=5"),
			res:    nil,
		},
		{
			desc:   "get a list of things by channel with invalid limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/things%s", thingURL, sch.ID, "?offset=5&limit=e"),
			res:    nil,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodGet,
			url:    tc.url,
			token:  tc.auth,
		}
		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 TestRemoveThing(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	sths, _ := svc.CreateThings(context.Background(), token, thing)
	sth := sths[0]

	cases := []struct {
		desc   string
		id     string
		auth   string
		status int
	}{
		{
			desc:   "delete existing thing",
			id:     sth.ID,
			auth:   token,
			status: http.StatusNoContent,
		},
		{
			desc:   "delete non-existent thing",
			id:     strconv.FormatUint(wrongID, 10),
			auth:   token,
			status: http.StatusNoContent,
		},
		{
			desc:   "delete thing with invalid token",
			id:     sth.ID,
			auth:   wrongValue,
			status: http.StatusForbidden,
		},
		{
			desc:   "delete thing with empty token",
			id:     sth.ID,
			auth:   "",
			status: http.StatusForbidden,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodDelete,
			url:    fmt.Sprintf("%s/things/%s", ts.URL, tc.id),
			token:  tc.auth,
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
	}
}

func TestCreateChannel(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	data := toJSON(channel)

	th := channel
	th.Name = invalidName
	invalidData := toJSON(th)

	cases := []struct {
		desc        string
		req         string
		contentType string
		auth        string
		status      int
		location    string
	}{
		{
			desc:        "create new channel",
			req:         data,
			contentType: contentType,
			auth:        token,
			status:      http.StatusCreated,
			location:    "/channels/1",
		},
		{
			desc:        "create new channel with invalid token",
			req:         data,
			contentType: contentType,
			auth:        wrongValue,
			status:      http.StatusForbidden,
			location:    "",
		},
		{
			desc:        "create new channel with empty token",
			req:         data,
			contentType: contentType,
			auth:        "",
			status:      http.StatusForbidden,
			location:    "",
		},
		{
			desc:        "create new channel with invalid data format",
			req:         "{",
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			location:    "",
		},
		{
			desc:        "create new channel with empty JSON request",
			req:         "{}",
			contentType: contentType,
			auth:        token,
			status:      http.StatusCreated,
			location:    "/channels/2",
		},
		{
			desc:        "create new channel with empty request",
			req:         "",
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			location:    "",
		},
		{
			desc:        "create new channel without content type",
			req:         data,
			contentType: "",
			auth:        token,
			status:      http.StatusUnsupportedMediaType,
			location:    "",
		},
		{
			desc:        "create new channel with invalid name",
			req:         invalidData,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			location:    "",
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client:      ts.Client(),
			method:      http.MethodPost,
			url:         fmt.Sprintf("%s/channels", ts.URL),
			contentType: tc.contentType,
			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))

		location := res.Header.Get("Location")
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
		assert.Equal(t, tc.location, location, fmt.Sprintf("%s: expected location %s got %s", tc.desc, tc.location, location))
	}
}

func TestCreateChannels(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	data := `[{"name": "1"}, {"name": "2"}]`
	invalidData := fmt.Sprintf(`[{"name": "%s"}]`, invalidName)

	cases := []struct {
		desc        string
		data        string
		contentType string
		auth        string
		status      int
		response    string
	}{
		{
			desc:        "create valid channels",
			data:        data,
			contentType: contentType,
			auth:        token,
			status:      http.StatusCreated,
			response:    "",
		},
		{
			desc:        "create channel with empty request",
			data:        "",
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			response:    "",
		},
		{
			desc:        "create channels with empty JSON",
			data:        "[]",
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			response:    "",
		},
		{
			desc:        "create channel with invalid auth token",
			data:        data,
			contentType: contentType,
			auth:        wrongValue,
			status:      http.StatusForbidden,
			response:    "",
		},
		{
			desc:        "create channel with empty auth token",
			data:        data,
			contentType: contentType,
			auth:        "",
			status:      http.StatusForbidden,
			response:    "",
		},
		{
			desc:     "create channel with invalid request format",
			data:     "}",
			auth:     token,
			status:   http.StatusUnsupportedMediaType,
			response: "",
		},
		{
			desc:        "create channel without content type",
			data:        data,
			contentType: "",
			auth:        token,
			status:      http.StatusUnsupportedMediaType,
			response:    "",
		},
		{
			desc:        "create channel with invalid name",
			data:        invalidData,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
			response:    "",
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client:      ts.Client(),
			method:      http.MethodPost,
			url:         fmt.Sprintf("%s/channels/bulk", ts.URL),
			contentType: tc.contentType,
			token:       tc.auth,
			body:        strings.NewReader(tc.data),
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))

		location := res.Header.Get("Location")
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
		assert.Equal(t, tc.response, location, fmt.Sprintf("%s: expected response %s got %s", tc.desc, tc.response, location))
	}
}

func TestUpdateChannel(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	schs, _ := svc.CreateChannels(context.Background(), token, channel)
	sch := schs[0]

	ch := channel
	ch.Name = "updated_channel"
	updateData := toJSON(ch)

	ch.Name = invalidName
	invalidData := toJSON(ch)

	cases := []struct {
		desc        string
		req         string
		id          string
		contentType string
		auth        string
		status      int
	}{
		{
			desc:        "update existing channel",
			req:         updateData,
			id:          sch.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusOK,
		},
		{
			desc:        "update non-existing channel",
			req:         updateData,
			id:          strconv.FormatUint(wrongID, 10),
			contentType: contentType,
			auth:        token,
			status:      http.StatusNotFound,
		},
		{
			desc:        "update channel with invalid id",
			req:         updateData,
			id:          "invalid",
			contentType: contentType,
			auth:        token,
			status:      http.StatusNotFound,
		},
		{
			desc:        "update channel with invalid token",
			req:         updateData,
			id:          sch.ID,
			contentType: contentType,
			auth:        wrongValue,
			status:      http.StatusForbidden,
		},
		{
			desc:        "update channel with empty token",
			req:         updateData,
			id:          sch.ID,
			contentType: contentType,
			auth:        "",
			status:      http.StatusForbidden,
		},
		{
			desc:        "update channel with invalid data format",
			req:         "}",
			id:          sch.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "update channel with empty JSON object",
			req:         "{}",
			id:          sch.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusOK,
		},
		{
			desc:        "update channel with empty request",
			req:         "",
			id:          sch.ID,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "update channel with missing content type",
			req:         updateData,
			id:          sch.ID,
			contentType: "",
			auth:        token,
			status:      http.StatusUnsupportedMediaType,
		},
		{
			desc:        "update channel with invalid name",
			req:         invalidData,
			contentType: contentType,
			auth:        token,
			status:      http.StatusBadRequest,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client:      ts.Client(),
			method:      http.MethodPut,
			url:         fmt.Sprintf("%s/channels/%s", ts.URL, tc.id),
			contentType: tc.contentType,
			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))
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
	}
}

func TestViewChannel(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	schs, _ := svc.CreateChannels(context.Background(), token, channel)
	sch := schs[0]

	sths, _ := svc.CreateThings(context.Background(), token, thing)
	sth := sths[0]
	svc.Connect(context.Background(), token, []string{sch.ID}, []string{sth.ID})

	chres := channelRes{
		ID:       sch.ID,
		Name:     sch.Name,
		Metadata: sch.Metadata,
	}
	data := toJSON(chres)

	cases := []struct {
		desc   string
		id     string
		auth   string
		status int
		res    string
	}{
		{
			desc:   "view existing channel",
			id:     sch.ID,
			auth:   token,
			status: http.StatusOK,
			res:    data,
		},
		{
			desc:   "view non-existent channel",
			id:     strconv.FormatUint(wrongID, 10),
			auth:   token,
			status: http.StatusNotFound,
			res:    notFoundRes,
		},
		{
			desc:   "view channel with invalid token",
			id:     sch.ID,
			auth:   wrongValue,
			status: http.StatusForbidden,
			res:    unauthRes,
		},
		{
			desc:   "view channel with empty token",
			id:     sch.ID,
			auth:   "",
			status: http.StatusForbidden,
			res:    unauthRes,
		},
		{
			desc:   "view channel with invalid id",
			id:     "invalid",
			auth:   token,
			status: http.StatusNotFound,
			res:    notFoundRes,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodGet,
			url:    fmt.Sprintf("%s/channels/%s", ts.URL, tc.id),
			token:  tc.auth,
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		data, err := ioutil.ReadAll(res.Body)
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		body := strings.Trim(string(data), "\n")
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
		assert.Equal(t, tc.res, body, fmt.Sprintf("%s: expected body %s got %s", tc.desc, tc.res, body))
	}
}

func TestListChannels(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	channels := []channelRes{}
	for i := 0; i < 101; i++ {
		schs, err := svc.CreateChannels(context.Background(), token, channel)
		require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
		sch := schs[0]
		sths, err := svc.CreateThings(context.Background(), token, thing)
		require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
		sth := sths[0]
		svc.Connect(context.Background(), token, []string{sch.ID}, []string{sth.ID})

		chres := channelRes{
			ID:       sch.ID,
			Name:     sch.Name,
			Metadata: sch.Metadata,
		}
		channels = append(channels, chres)
	}
	channelURL := fmt.Sprintf("%s/channels", ts.URL)

	cases := []struct {
		desc   string
		auth   string
		status int
		url    string
		res    []channelRes
	}{
		{
			desc:   "get a list of channels",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", channelURL, 0, 6),
			res:    channels[0:6],
		},
		{
			desc:   "get a list of channels with invalid token",
			auth:   wrongValue,
			status: http.StatusForbidden,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", channelURL, 0, 1),
			res:    nil,
		},
		{
			desc:   "get a list of channels with empty token",
			auth:   "",
			status: http.StatusForbidden,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", channelURL, 0, 1),
			res:    nil,
		},
		{
			desc:   "get a list of channels with negative offset",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", channelURL, -1, 5),
			res:    nil,
		},
		{
			desc:   "get a list of channels with negative limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", channelURL, -1, 5),
			res:    nil,
		},
		{
			desc:   "get a list of channels with zero limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", channelURL, 1, 0),
			res:    nil,
		},
		{
			desc:   "get a list of channels with no offset provided",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s?limit=%d", channelURL, 5),
			res:    channels[0:5],
		},
		{
			desc:   "get a list of channels with no limit provided",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s?offset=%d", channelURL, 1),
			res:    channels[1:11],
		},
		{
			desc:   "get a list of channels with redundant query params",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d&value=something", channelURL, 0, 5),
			res:    channels[0:5],
		},
		{
			desc:   "get a list of channels with limit greater than max",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d", channelURL, 0, 110),
			res:    nil,
		},
		{
			desc:   "get a list of channels with default URL",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s%s", channelURL, ""),
			res:    channels[0:10],
		},
		{
			desc:   "get a list of channels with invalid number of params",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s%s", channelURL, "?offset=4&limit=4&limit=5&offset=5"),
			res:    nil,
		},
		{
			desc:   "get a list of channels with invalid offset",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s%s", channelURL, "?offset=e&limit=5"),
			res:    nil,
		},
		{
			desc:   "get a list of channels with invalid limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s%s", channelURL, "?offset=5&limit=e"),
			res:    nil,
		},
		{
			desc:   "get a list of channels with invalid name",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s?offset=%d&limit=%d&name=%s", channelURL, 0, 10, invalidName),
			res:    nil,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodGet,
			url:    tc.url,
			token:  tc.auth,
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		var body channelsPageRes
		json.NewDecoder(res.Body).Decode(&body)
		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, body.Channels, fmt.Sprintf("%s: expected body %v got %v", tc.desc, tc.res, body.Channels))
	}
}

func TestListChannelsByThing(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	sths, err := svc.CreateThings(context.Background(), token, thing)
	require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
	sth := sths[0]

	channels := []channelRes{}
	for i := 0; i < 101; i++ {
		schs, err := svc.CreateChannels(context.Background(), token, channel)
		require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
		sch := schs[0]
		err = svc.Connect(context.Background(), token, []string{sch.ID}, []string{sth.ID})
		require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))

		chres := channelRes{
			ID:       sch.ID,
			Name:     sch.Name,
			Metadata: sch.Metadata,
		}
		channels = append(channels, chres)
	}
	channelURL := fmt.Sprintf("%s/things", ts.URL)

	cases := []struct {
		desc   string
		auth   string
		status int
		url    string
		res    []channelRes
	}{
		{
			desc:   "get a list of channels by thing",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/channels?offset=%d&limit=%d", channelURL, sth.ID, 0, 6),
			res:    channels[0:6],
		},
		{
			desc:   "get a list of channels by thing with invalid token",
			auth:   wrongValue,
			status: http.StatusForbidden,
			url:    fmt.Sprintf("%s/%s/channels?offset=%d&limit=%d", channelURL, sth.ID, 0, 1),
			res:    nil,
		},
		{
			desc:   "get a list of channels by thing with empty token",
			auth:   "",
			status: http.StatusForbidden,
			url:    fmt.Sprintf("%s/%s/channels?offset=%d&limit=%d", channelURL, sth.ID, 0, 1),
			res:    nil,
		},
		{
			desc:   "get a list of channels by thing with negative offset",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/channels?offset=%d&limit=%d", channelURL, sth.ID, -1, 5),
			res:    nil,
		},
		{
			desc:   "get a list of channels by thing with negative limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/channels?offset=%d&limit=%d", channelURL, sth.ID, -1, 5),
			res:    nil,
		},
		{
			desc:   "get a list of channels by thing with zero limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/channels?offset=%d&limit=%d", channelURL, sth.ID, 1, 0),
			res:    nil,
		},
		{
			desc:   "get a list of channels by thing with no offset provided",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/channels?limit=%d", channelURL, sth.ID, 5),
			res:    channels[0:5],
		},
		{
			desc:   "get a list of channels by thing with no limit provided",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/channels?offset=%d", channelURL, sth.ID, 1),
			res:    channels[1:11],
		},
		{
			desc:   "get a list of channels by thing with redundant query params",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/channels?offset=%d&limit=%d&value=something", channelURL, sth.ID, 0, 5),
			res:    channels[0:5],
		},
		{
			desc:   "get a list of channels by thing with limit greater than max",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/channels?offset=%d&limit=%d", channelURL, sth.ID, 0, 110),
			res:    nil,
		},
		{
			desc:   "get a list of channels by thing with default URL",
			auth:   token,
			status: http.StatusOK,
			url:    fmt.Sprintf("%s/%s/channels", channelURL, sth.ID),
			res:    channels[0:10],
		},
		{
			desc:   "get a list of channels by thing with invalid number of params",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/channels%s", channelURL, sth.ID, "?offset=4&limit=4&limit=5&offset=5"),
			res:    nil,
		},
		{
			desc:   "get a list of channels by thing with invalid offset",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/channels%s", channelURL, sth.ID, "?offset=e&limit=5"),
			res:    nil,
		},
		{
			desc:   "get a list of channels by thing with invalid limit",
			auth:   token,
			status: http.StatusBadRequest,
			url:    fmt.Sprintf("%s/%s/channels%s", channelURL, sth.ID, "?offset=5&limit=e"),
			res:    nil,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodGet,
			url:    tc.url,
			token:  tc.auth,
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		var body channelsPageRes
		json.NewDecoder(res.Body).Decode(&body)
		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, body.Channels, fmt.Sprintf("%s: expected body %v got %v", tc.desc, tc.res, body.Channels))
	}
}

func TestRemoveChannel(t *testing.T) {
	svc := newService(map[string]string{token: email})
	ts := newServer(svc)
	defer ts.Close()

	schs, _ := svc.CreateChannels(context.Background(), token, channel)
	sch := schs[0]

	cases := []struct {
		desc   string
		id     string
		auth   string
		status int
	}{
		{
			desc:   "remove channel with invalid token",
			id:     sch.ID,
			auth:   wrongValue,
			status: http.StatusForbidden,
		},
		{
			desc:   "remove existing channel",
			id:     sch.ID,
			auth:   token,
			status: http.StatusNoContent,
		},
		{
			desc:   "remove removed channel",
			id:     sch.ID,
			auth:   token,
			status: http.StatusNoContent,
		},
		{
			desc:   "remove channel with invalid token",
			id:     sch.ID,
			auth:   wrongValue,
			status: http.StatusForbidden,
		},
		{
			desc:   "remove channel with empty token",
			id:     sch.ID,
			auth:   "",
			status: http.StatusForbidden,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodDelete,
			url:    fmt.Sprintf("%s/channels/%s", ts.URL, tc.id),
			token:  tc.auth,
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
	}
}

func TestConnect(t *testing.T) {
	otherToken := "other_token"
	otherEmail := "other_user@example.com"
	svc := newService(map[string]string{
		token:      email,
		otherToken: otherEmail,
	})
	ts := newServer(svc)
	defer ts.Close()

	sths, _ := svc.CreateThings(context.Background(), token, thing)
	ath := sths[0]
	schs, _ := svc.CreateChannels(context.Background(), token, channel)
	ach := schs[0]
	schs, _ = svc.CreateChannels(context.Background(), otherToken, channel)
	bch := schs[0]

	cases := []struct {
		desc    string
		chanID  string
		thingID string
		auth    string
		status  int
	}{
		{
			desc:    "connect existing thing to existing channel",
			chanID:  ach.ID,
			thingID: ath.ID,
			auth:    token,
			status:  http.StatusOK,
		},
		{
			desc:    "connect existing thing to non-existent channel",
			chanID:  strconv.FormatUint(wrongID, 10),
			thingID: ath.ID,
			auth:    token,
			status:  http.StatusNotFound,
		},
		{
			desc:    "connect non-existing thing to existing channel",
			chanID:  ach.ID,
			thingID: strconv.FormatUint(wrongID, 10),
			auth:    token,
			status:  http.StatusNotFound,
		},
		{
			desc:    "connect existing thing to channel with invalid id",
			chanID:  "invalid",
			thingID: ath.ID,
			auth:    token,
			status:  http.StatusNotFound,
		},
		{
			desc:    "connect thing with invalid id to existing channel",
			chanID:  ach.ID,
			thingID: "invalid",
			auth:    token,
			status:  http.StatusNotFound,
		},
		{
			desc:    "connect existing thing to existing channel with invalid token",
			chanID:  ach.ID,
			thingID: ath.ID,
			auth:    wrongValue,
			status:  http.StatusForbidden,
		},
		{
			desc:    "connect existing thing to existing channel with empty token",
			chanID:  ach.ID,
			thingID: ath.ID,
			auth:    "",
			status:  http.StatusForbidden,
		},
		{
			desc:    "connect thing from owner to channel of other user",
			chanID:  bch.ID,
			thingID: ath.ID,
			auth:    token,
			status:  http.StatusNotFound,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodPut,
			url:    fmt.Sprintf("%s/channels/%s/things/%s", ts.URL, tc.chanID, tc.thingID),
			token:  tc.auth,
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
	}
}

func TestCreateConnections(t *testing.T) {
	otherToken := "other_token"
	otherEmail := "other_user@example.com"
	svc := newService(map[string]string{
		token:      email,
		otherToken: otherEmail,
	})
	ts := newServer(svc)
	defer ts.Close()

	sths, _ := svc.CreateThings(context.Background(), token, thing)
	ths := []string{}
	for _, th := range sths {
		ths = append(ths, th.ID)
	}

	schs, _ := svc.CreateChannels(context.Background(), token, channel)
	achs := []string{}
	for _, ch := range schs {
		achs = append(achs, ch.ID)
	}
	schs, _ = svc.CreateChannels(context.Background(), otherToken, channel)
	bchs := []string{}
	for _, ch := range schs {
		bchs = append(bchs, ch.ID)
	}

	cases := []struct {
		desc        string
		channelIDs  []string
		thingIDs    []string
		auth        string
		contentType string
		body        string
		status      int
	}{
		{
			desc:        "connect existing things to existing channels",
			channelIDs:  achs,
			thingIDs:    ths,
			auth:        token,
			contentType: contentType,
			status:      http.StatusOK,
		},
		{
			desc:        "connect existing things to non-existent channels",
			channelIDs:  []string{strconv.FormatUint(wrongID, 10)},
			thingIDs:    ths,
			auth:        token,
			contentType: contentType,
			status:      http.StatusNotFound,
		},
		{
			desc:        "connect non-existing things to existing channels",
			channelIDs:  achs,
			thingIDs:    []string{strconv.FormatUint(wrongID, 10)},
			auth:        token,
			contentType: contentType,
			status:      http.StatusNotFound,
		},
		{
			desc:        "connect existing things to channel with invalid id",
			channelIDs:  []string{"invalid"},
			thingIDs:    ths,
			auth:        token,
			contentType: contentType,
			status:      http.StatusNotFound,
		},
		{
			desc:        "connect things with invalid id to existing channels",
			channelIDs:  achs,
			thingIDs:    []string{"invalid"},
			auth:        token,
			contentType: contentType,
			status:      http.StatusNotFound,
		},
		{
			desc:        "connect existing things to empty channel ids",
			channelIDs:  []string{""},
			thingIDs:    ths,
			auth:        token,
			contentType: contentType,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "connect empty things id to existing channels",
			channelIDs:  achs,
			thingIDs:    []string{""},
			auth:        token,
			contentType: contentType,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "connect existing things to existing channels with invalid token",
			channelIDs:  achs,
			thingIDs:    ths,
			auth:        wrongValue,
			contentType: contentType,
			status:      http.StatusForbidden,
		},
		{
			desc:        "connect existing things to existing channels with empty token",
			channelIDs:  achs,
			thingIDs:    ths,
			auth:        "",
			contentType: contentType,
			status:      http.StatusForbidden,
		},
		{
			desc:        "connect things from owner to channels of other user",
			channelIDs:  bchs,
			thingIDs:    ths,
			auth:        token,
			contentType: contentType,
			status:      http.StatusNotFound,
		},
		{
			desc:        "connect with invalid content type",
			channelIDs:  bchs,
			thingIDs:    ths,
			auth:        token,
			contentType: "invalid",
			status:      http.StatusUnsupportedMediaType,
		},
		{
			desc:        "connect with invalid JSON",
			auth:        token,
			contentType: contentType,
			status:      http.StatusBadRequest,
			body:        "{",
		},
		{
			desc:        "connect valid thing ids with empty channel ids",
			channelIDs:  []string{},
			thingIDs:    ths,
			auth:        token,
			contentType: contentType,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "connect valid channel ids with empty thing ids",
			channelIDs:  achs,
			thingIDs:    []string{},
			auth:        token,
			contentType: contentType,
			status:      http.StatusBadRequest,
		},
		{
			desc:        "connect empty channel ids and empty thing ids",
			channelIDs:  []string{},
			thingIDs:    []string{},
			auth:        token,
			contentType: contentType,
			status:      http.StatusBadRequest,
		},
	}

	for _, tc := range cases {
		data := struct {
			ChannelIDs []string `json:"channel_ids"`
			ThingIDs   []string `json:"thing_ids"`
		}{
			tc.channelIDs,
			tc.thingIDs,
		}
		body := toJSON(data)

		if tc.body != "" {
			body = tc.body
		}

		req := testRequest{
			client:      ts.Client(),
			method:      http.MethodPost,
			url:         fmt.Sprintf("%s/connect", ts.URL),
			contentType: tc.contentType,
			token:       tc.auth,
			body:        strings.NewReader(body),
		}

		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
	}
}

func TestDisconnnect(t *testing.T) {
	otherToken := "other_token"
	otherEmail := "other_user@example.com"
	svc := newService(map[string]string{
		token:      email,
		otherToken: otherEmail,
	})
	ts := newServer(svc)
	defer ts.Close()

	sths, _ := svc.CreateThings(context.Background(), token, thing)
	ath := sths[0]
	schs, _ := svc.CreateChannels(context.Background(), token, channel)
	ach := schs[0]
	svc.Connect(context.Background(), token, []string{ach.ID}, []string{ath.ID})
	schs, _ = svc.CreateChannels(context.Background(), otherToken, channel)
	bch := schs[0]

	cases := []struct {
		desc    string
		chanID  string
		thingID string
		auth    string
		status  int
	}{
		{
			desc:    "disconnect connected thing from channel",
			chanID:  ach.ID,
			thingID: ath.ID,
			auth:    token,
			status:  http.StatusNoContent,
		},
		{
			desc:    "disconnect non-connected thing from channel",
			chanID:  ach.ID,
			thingID: ath.ID,
			auth:    token,
			status:  http.StatusNotFound,
		},
		{
			desc:    "disconnect non-existent thing from channel",
			chanID:  ach.ID,
			thingID: strconv.FormatUint(wrongID, 10),
			auth:    token,
			status:  http.StatusNotFound,
		},
		{
			desc:    "disconnect thing from non-existent channel",
			chanID:  strconv.FormatUint(wrongID, 10),
			thingID: ath.ID,
			auth:    token,
			status:  http.StatusNotFound,
		},
		{
			desc:    "disconnect thing from channel with invalid token",
			chanID:  ach.ID,
			thingID: ath.ID,
			auth:    wrongValue,
			status:  http.StatusForbidden,
		},
		{
			desc:    "disconnect thing from channel with empty token",
			chanID:  ach.ID,
			thingID: ath.ID,
			auth:    "",
			status:  http.StatusForbidden,
		},
		{
			desc:    "disconnect owner's thing from someone elses channel",
			chanID:  bch.ID,
			thingID: ath.ID,
			auth:    token,
			status:  http.StatusNotFound,
		},
		{
			desc:    "disconnect thing with invalid id from channel",
			chanID:  ach.ID,
			thingID: "invalid",
			auth:    token,
			status:  http.StatusNotFound,
		},
		{
			desc:    "disconnect thing from channel with invalid id",
			chanID:  "invalid",
			thingID: ath.ID,
			auth:    token,
			status:  http.StatusNotFound,
		},
	}

	for _, tc := range cases {
		req := testRequest{
			client: ts.Client(),
			method: http.MethodDelete,
			url:    fmt.Sprintf("%s/channels/%s/things/%s", ts.URL, tc.chanID, tc.thingID),
			token:  tc.auth,
		}
		res, err := req.make()
		assert.Nil(t, err, fmt.Sprintf("%s: unexpected error %s", tc.desc, err))
		assert.Equal(t, tc.status, res.StatusCode, fmt.Sprintf("%s: expected status code %d got %d", tc.desc, tc.status, res.StatusCode))
	}
}

type thingRes struct {
	ID       string                 `json:"id"`
	Name     string                 `json:"name,omitempty"`
	Key      string                 `json:"key"`
	Metadata map[string]interface{} `json:"metadata,omitempty"`
}

type thingsRes struct {
	Things  []things.Thing
	created bool
}

type channelRes struct {
	ID       string                 `json:"id"`
	Name     string                 `json:"name,omitempty"`
	Metadata map[string]interface{} `json:"metadata,omitempty"`
}

type thingsPageRes struct {
	Things []thingRes `json:"things"`
	Total  uint64     `json:"total"`
	Offset uint64     `json:"offset"`
	Limit  uint64     `json:"limit"`
}

type channelsPageRes struct {
	Channels []channelRes `json:"channels"`
	Total    uint64       `json:"total"`
	Offset   uint64       `json:"offset"`
	Limit    uint64       `json:"limit"`
}

type errorRes struct {
	Err string `json:"error"`
}
