diff options
Diffstat (limited to 'internal/api/client')
| -rw-r--r-- | internal/api/client/admin/admin.go | 1 | ||||
| -rw-r--r-- | internal/api/client/admin/emojiupdate.go | 221 | ||||
| -rw-r--r-- | internal/api/client/admin/emojiupdate_test.go | 549 | 
3 files changed, 771 insertions, 0 deletions
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index e34bac1cf..7032a5b95 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -83,6 +83,7 @@ func (m *Module) Route(r router.Router) error {  	r.AttachHandler(http.MethodGet, EmojiPath, m.EmojisGETHandler)  	r.AttachHandler(http.MethodDelete, EmojiPathWithID, m.EmojiDELETEHandler)  	r.AttachHandler(http.MethodGet, EmojiPathWithID, m.EmojiGETHandler) +	r.AttachHandler(http.MethodPatch, EmojiPathWithID, m.EmojiPATCHHandler)  	r.AttachHandler(http.MethodPost, DomainBlocksPath, m.DomainBlocksPOSTHandler)  	r.AttachHandler(http.MethodGet, DomainBlocksPath, m.DomainBlocksGETHandler)  	r.AttachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler) diff --git a/internal/api/client/admin/emojiupdate.go b/internal/api/client/admin/emojiupdate.go new file mode 100644 index 000000000..695c6bcde --- /dev/null +++ b/internal/api/client/admin/emojiupdate.go @@ -0,0 +1,221 @@ +/* +   GoToSocial +   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + +   This program is free software: you can redistribute it and/or modify +   it under the terms of the GNU Affero General Public License as published by +   the Free Software Foundation, either version 3 of the License, or +   (at your option) any later version. + +   This program is distributed in the hope that it will be useful, +   but WITHOUT ANY WARRANTY; without even the implied warranty of +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +   GNU Affero General Public License for more details. + +   You should have received a copy of the GNU Affero General Public License +   along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +package admin + +import ( +	"errors" +	"fmt" +	"net/http" +	"strings" + +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/api" +	"github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// EmojiPATCHHandler swagger:operation PATCH /api/v1/admin/custom_emojis/{id} emojiUpdate +// +// Perform admin action on a local or remote emoji known to this instance. +// +// Action performed depends upon the action `type` provided. +// +// `disable`: disable a REMOTE emoji from being used/displayed on this instance. Does not work for local emojis. +// +// `copy`: copy a REMOTE emoji to this instance. When doing this action, a shortcode MUST be provided, and it must +// be unique among emojis already present on this instance. A category MAY be provided, and the copied emoji will then +// be put into the provided category. +// +// `modify`: modify a LOCAL emoji. You can provide a new image for the emoji and/or update the category. +// +// Local emojis cannot be deleted using this endpoint. To delete a local emoji, check DELETE /api/v1/admin/custom_emojis/{id} instead. +// +//	--- +//	tags: +//	- admin +// +//	consumes: +//	- multipart/form-data +// +//	produces: +//	- application/json +// +//	parameters: +//	- +//		name: id +//		type: string +//		description: The id of the emoji. +//		in: path +//		required: true +//	- +//		name: type +//		in: formData +//		description: |- +//			Type of action to be taken. One of: (`disable`, `copy`, `modify`). +//			For REMOTE emojis, `copy` or `disable` are supported. +//			For LOCAL emojis, only `modify` is supported. +//		type: string +//		required: true +//	- +//		name: shortcode +//		in: formData +//		description: >- +//			The code to use for the emoji, which will be used by instance denizens to select it. +//			This must be unique on the instance. Works for the `copy` action type only. +//		type: string +//		pattern: \w{2,30} +//	- +//		name: image +//		in: formData +//		description: >- +//			A new png or gif image to use for the emoji. Animated pngs work too! +//			To ensure compatibility with other fedi implementations, emoji size limit is 50kb by default. +//			Works for LOCAL emojis only. +//		type: file +//	- +//		name: category +//		in: formData +//		description: >- +//			Category in which to place the emoji. 64 characters or less. +//			If a category with the given name doesn't exist yet, it will be created. +//		type: string +// +//	security: +//	- OAuth2 Bearer: +//		- admin +// +//	responses: +//		'200': +//			description: The updated emoji. +//			schema: +//				"$ref": "#/definitions/adminEmoji" +//		'400': +//			description: bad request +//		'401': +//			description: unauthorized +//		'403': +//			description: forbidden +//		'404': +//			description: not found +//		'406': +//			description: not acceptable +//		'500': +//			description: internal server error +func (m *Module) EmojiPATCHHandler(c *gin.Context) { +	authed, err := oauth.Authed(c, true, true, true, true) +	if err != nil { +		api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) +		return +	} + +	if !*authed.User.Admin { +		err := fmt.Errorf("user %s not an admin", authed.User.ID) +		api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) +		return +	} + +	if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { +		api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) +		return +	} + +	emojiID := c.Param(IDKey) +	if emojiID == "" { +		err := errors.New("no emoji id specified") +		api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) +		return +	} + +	form := &model.EmojiUpdateRequest{} +	if err := c.ShouldBind(form); err != nil { +		api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) +		return +	} + +	if err := validateUpdateEmoji(form); err != nil { +		api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) +		return +	} + +	emoji, errWithCode := m.processor.AdminEmojiUpdate(c.Request.Context(), emojiID, form) +	if errWithCode != nil { +		api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) +		return +	} + +	c.JSON(http.StatusOK, emoji) +} + +// do a first pass on the form here +func validateUpdateEmoji(form *model.EmojiUpdateRequest) error { +	// check + normalize update type so we don't need +	// to do this trimming + lowercasing again later +	switch strings.TrimSpace(strings.ToLower(string(form.Type))) { +	case string(model.EmojiUpdateDisable): +		// no params required for this one, so don't bother checking +		form.Type = model.EmojiUpdateDisable +	case string(model.EmojiUpdateCopy): +		// need at least a valid shortcode when doing a copy +		if form.Shortcode == nil { +			return errors.New("emoji action type was 'copy' but no shortcode was provided") +		} + +		if err := validate.EmojiShortcode(*form.Shortcode); err != nil { +			return err +		} + +		// category optional during copy +		if form.CategoryName != nil { +			if err := validate.EmojiCategory(*form.CategoryName); err != nil { +				return err +			} +		} + +		form.Type = model.EmojiUpdateCopy +	case string(model.EmojiUpdateModify): +		// need either image or category name for modify +		hasImage := form.Image != nil && form.Image.Size != 0 +		hasCategoryName := form.CategoryName != nil +		if !hasImage && !hasCategoryName { +			return errors.New("emoji action type was 'modify' but no image or category name was provided") +		} + +		if hasImage { +			maxSize := config.GetMediaEmojiLocalMaxSize() +			if form.Image.Size > int64(maxSize) { +				return fmt.Errorf("emoji image too large: image is %dKB but size limit for custom emojis is %dKB", form.Image.Size/1024, maxSize/1024) +			} +		} + +		if hasCategoryName { +			if err := validate.EmojiCategory(*form.CategoryName); err != nil { +				return err +			} +		} + +		form.Type = model.EmojiUpdateModify +	default: +		return errors.New("emoji action type must be one of 'disable', 'copy', 'modify'") +	} + +	return nil +} diff --git a/internal/api/client/admin/emojiupdate_test.go b/internal/api/client/admin/emojiupdate_test.go new file mode 100644 index 000000000..9a7d924ff --- /dev/null +++ b/internal/api/client/admin/emojiupdate_test.go @@ -0,0 +1,549 @@ +/* +   GoToSocial +   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + +   This program is free software: you can redistribute it and/or modify +   it under the terms of the GNU Affero General Public License as published by +   the Free Software Foundation, either version 3 of the License, or +   (at your option) any later version. + +   This program is distributed in the hope that it will be useful, +   but WITHOUT ANY WARRANTY; without even the implied warranty of +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +   GNU Affero General Public License for more details. + +   You should have received a copy of the GNU Affero General Public License +   along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +package admin_test + +import ( +	"context" +	"encoding/json" +	"io/ioutil" +	"net/http" +	"net/http/httptest" +	"testing" + +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/api/client/admin" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +type EmojiUpdateTestSuite struct { +	AdminStandardTestSuite +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateNewCategory() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["rainbow"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"category": "New Category", // this category doesn't exist yet +			"type":     "modify", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) + +	// 1. we should have OK because our request was valid +	suite.Equal(http.StatusOK, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) +	suite.NotEmpty(b) + +	// response should be an admin model emoji +	adminEmoji := &apimodel.AdminEmoji{} +	err = json.Unmarshal(b, adminEmoji) +	suite.NoError(err) + +	// appropriate fields should be set +	suite.Equal("rainbow", adminEmoji.Shortcode) +	suite.NotEmpty(adminEmoji.URL) +	suite.NotEmpty(adminEmoji.StaticURL) +	suite.True(adminEmoji.VisibleInPicker) + +	// emoji should be in the db +	dbEmoji, err := suite.db.GetEmojiByShortcodeDomain(context.Background(), adminEmoji.Shortcode, "") +	suite.NoError(err) + +	// check fields on the emoji +	suite.NotEmpty(dbEmoji.ID) +	suite.Equal("rainbow", dbEmoji.Shortcode) +	suite.Empty(dbEmoji.Domain) +	suite.Empty(dbEmoji.ImageRemoteURL) +	suite.Empty(dbEmoji.ImageStaticRemoteURL) +	suite.Equal(adminEmoji.URL, dbEmoji.ImageURL) +	suite.Equal(adminEmoji.StaticURL, dbEmoji.ImageStaticURL) +	suite.NotEmpty(dbEmoji.ImagePath) +	suite.NotEmpty(dbEmoji.ImageStaticPath) +	suite.Equal("image/png", dbEmoji.ImageContentType) +	suite.Equal("image/png", dbEmoji.ImageStaticContentType) +	suite.Equal(36702, dbEmoji.ImageFileSize) +	suite.Equal(10413, dbEmoji.ImageStaticFileSize) +	suite.False(*dbEmoji.Disabled) +	suite.NotEmpty(dbEmoji.URI) +	suite.True(*dbEmoji.VisibleInPicker) +	suite.NotEmpty(dbEmoji.CategoryID) + +	// emoji should be in storage +	emojiBytes, err := suite.storage.Get(ctx, dbEmoji.ImagePath) +	suite.NoError(err) +	suite.Len(emojiBytes, dbEmoji.ImageFileSize) +	emojiStaticBytes, err := suite.storage.Get(ctx, dbEmoji.ImageStaticPath) +	suite.NoError(err) +	suite.Len(emojiStaticBytes, dbEmoji.ImageStaticFileSize) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateSwitchCategory() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["rainbow"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"type":     "modify", +			"category": "cute stuff", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) + +	// 1. we should have OK because our request was valid +	suite.Equal(http.StatusOK, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) +	suite.NotEmpty(b) + +	// response should be an admin model emoji +	adminEmoji := &apimodel.AdminEmoji{} +	err = json.Unmarshal(b, adminEmoji) +	suite.NoError(err) + +	// appropriate fields should be set +	suite.Equal("rainbow", adminEmoji.Shortcode) +	suite.NotEmpty(adminEmoji.URL) +	suite.NotEmpty(adminEmoji.StaticURL) +	suite.True(adminEmoji.VisibleInPicker) + +	// emoji should be in the db +	dbEmoji, err := suite.db.GetEmojiByShortcodeDomain(context.Background(), adminEmoji.Shortcode, "") +	suite.NoError(err) + +	// check fields on the emoji +	suite.NotEmpty(dbEmoji.ID) +	suite.Equal("rainbow", dbEmoji.Shortcode) +	suite.Empty(dbEmoji.Domain) +	suite.Empty(dbEmoji.ImageRemoteURL) +	suite.Empty(dbEmoji.ImageStaticRemoteURL) +	suite.Equal(adminEmoji.URL, dbEmoji.ImageURL) +	suite.Equal(adminEmoji.StaticURL, dbEmoji.ImageStaticURL) +	suite.NotEmpty(dbEmoji.ImagePath) +	suite.NotEmpty(dbEmoji.ImageStaticPath) +	suite.Equal("image/png", dbEmoji.ImageContentType) +	suite.Equal("image/png", dbEmoji.ImageStaticContentType) +	suite.Equal(36702, dbEmoji.ImageFileSize) +	suite.Equal(10413, dbEmoji.ImageStaticFileSize) +	suite.False(*dbEmoji.Disabled) +	suite.NotEmpty(dbEmoji.URI) +	suite.True(*dbEmoji.VisibleInPicker) +	suite.NotEmpty(dbEmoji.CategoryID) + +	// emoji should be in storage +	emojiBytes, err := suite.storage.Get(ctx, dbEmoji.ImagePath) +	suite.NoError(err) +	suite.Len(emojiBytes, dbEmoji.ImageFileSize) +	emojiStaticBytes, err := suite.storage.Get(ctx, dbEmoji.ImageStaticPath) +	suite.NoError(err) +	suite.Len(emojiStaticBytes, dbEmoji.ImageStaticFileSize) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyRemoteToLocal() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["yell"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"type":      "copy", +			"category":  "emojis i stole", +			"shortcode": "yell", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) + +	// 1. we should have OK because our request was valid +	suite.Equal(http.StatusOK, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) +	suite.NotEmpty(b) + +	// response should be an admin model emoji +	adminEmoji := &apimodel.AdminEmoji{} +	err = json.Unmarshal(b, adminEmoji) +	suite.NoError(err) + +	// appropriate fields should be set +	suite.Equal("yell", adminEmoji.Shortcode) +	suite.NotEmpty(adminEmoji.URL) +	suite.NotEmpty(adminEmoji.StaticURL) +	suite.True(adminEmoji.VisibleInPicker) + +	// emoji should be in the db +	dbEmoji, err := suite.db.GetEmojiByShortcodeDomain(context.Background(), adminEmoji.Shortcode, "") +	suite.NoError(err) + +	// check fields on the emoji +	suite.NotEmpty(dbEmoji.ID) +	suite.Equal("yell", dbEmoji.Shortcode) +	suite.Empty(dbEmoji.Domain) +	suite.Empty(dbEmoji.ImageRemoteURL) +	suite.Empty(dbEmoji.ImageStaticRemoteURL) +	suite.Equal(adminEmoji.URL, dbEmoji.ImageURL) +	suite.Equal(adminEmoji.StaticURL, dbEmoji.ImageStaticURL) +	suite.NotEmpty(dbEmoji.ImagePath) +	suite.NotEmpty(dbEmoji.ImageStaticPath) +	suite.Equal("image/png", dbEmoji.ImageContentType) +	suite.Equal("image/png", dbEmoji.ImageStaticContentType) +	suite.Equal(10889, dbEmoji.ImageFileSize) +	suite.Equal(10672, dbEmoji.ImageStaticFileSize) +	suite.False(*dbEmoji.Disabled) +	suite.NotEmpty(dbEmoji.URI) +	suite.True(*dbEmoji.VisibleInPicker) +	suite.NotEmpty(dbEmoji.CategoryID) + +	// emoji should be in storage +	emojiBytes, err := suite.storage.Get(ctx, dbEmoji.ImagePath) +	suite.NoError(err) +	suite.Len(emojiBytes, dbEmoji.ImageFileSize) +	emojiStaticBytes, err := suite.storage.Get(ctx, dbEmoji.ImageStaticPath) +	suite.NoError(err) +	suite.Len(emojiStaticBytes, dbEmoji.ImageStaticFileSize) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableEmoji() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["yell"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"type": "disable", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) + +	// 1. we should have OK because our request was valid +	suite.Equal(http.StatusOK, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) +	suite.NotEmpty(b) + +	// response should be an admin model emoji +	adminEmoji := &apimodel.AdminEmoji{} +	err = json.Unmarshal(b, adminEmoji) +	suite.NoError(err) + +	suite.True(adminEmoji.Disabled) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableLocalEmoji() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["rainbow"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"type": "disable", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) +	suite.Equal(http.StatusBadRequest, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) + +	suite.Equal(`{"error":"Bad Request: emojiUpdateDisable: emoji 01F8MH9H8E4VG3KDYJR9EGPXCQ is not a remote emoji, cannot disable it via this endpoint"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyRemoteEmoji() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["yell"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"image", "../../../../testrig/media/kip-original.gif", +		map[string]string{ +			"type": "modify", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) +	suite.Equal(http.StatusBadRequest, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) + +	suite.Equal(`{"error":"Bad Request: emojiUpdateModify: emoji 01GD5KP5CQEE1R3X43Y1EHS2CW is not a local emoji, cannot do a modify action on it"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyNoParams() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["rainbow"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"type": "modify", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) +	suite.Equal(http.StatusBadRequest, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) + +	suite.Equal(`{"error":"Bad Request: emoji action type was 'modify' but no image or category name was provided"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyLocalToLocal() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["rainbow"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"type":      "copy", +			"shortcode": "bottoms", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) +	suite.Equal(http.StatusBadRequest, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) + +	suite.Equal(`{"error":"Bad Request: emojiUpdateCopy: emoji 01F8MH9H8E4VG3KDYJR9EGPXCQ is not a remote emoji, cannot copy it to local"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyEmptyShortcode() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["yell"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"type":      "copy", +			"shortcode": "", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) +	suite.Equal(http.StatusBadRequest, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) + +	suite.Equal(`{"error":"Bad Request: shortcode  did not pass validation, must be between 2 and 30 characters, lowercase letters, numbers, and underscores only"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyNoShortcode() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["yell"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"type": "copy", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) +	suite.Equal(http.StatusBadRequest, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) + +	suite.Equal(`{"error":"Bad Request: emoji action type was 'copy' but no shortcode was provided"}`, string(b)) +} + +func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyShortcodeAlreadyInUse() { +	testEmoji := >smodel.Emoji{} +	*testEmoji = *suite.testEmojis["yell"] + +	// set up the request +	requestBody, w, err := testrig.CreateMultipartFormData( +		"", "", +		map[string]string{ +			"type":      "copy", +			"shortcode": "rainbow", +		}) +	if err != nil { +		panic(err) +	} +	bodyBytes := requestBody.Bytes() +	recorder := httptest.NewRecorder() +	ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType()) +	ctx.AddParam(admin.IDKey, testEmoji.ID) + +	// call the handler +	suite.adminModule.EmojiPATCHHandler(ctx) +	suite.Equal(http.StatusConflict, recorder.Code) + +	// 2. we should have no error message in the result body +	result := recorder.Result() +	defer result.Body.Close() + +	// check the response +	b, err := ioutil.ReadAll(result.Body) +	suite.NoError(err) + +	suite.Equal(`{"error":"Conflict: emojiUpdateCopy: emoji 01GD5KP5CQEE1R3X43Y1EHS2CW could not be copied, emoji with shortcode rainbow already exists on this instance"}`, string(b)) +} + +func TestEmojiUpdateTestSuite(t *testing.T) { +	suite.Run(t, &EmojiUpdateTestSuite{}) +}  | 
