Browse Source
* start adding client support for making status edits and viewing history * modify 'freshest' freshness window to be 5s, add typeutils test for status -> api edits * only populate the status edits when specifically requested * start adding some simple processor status edit tests * add test editing status but adding a poll * test edits appropriately adding poll expiry handlers * finish adding status edit tests * store both new and old revision emojis in status * add code comment * ensure the requester's account is populated before status edits * add code comments for status edit tests * update status edit form swagger comments * remove unused function * fix status source test * add more code comments, move media description check back to media process in status create * fix tests, add necessary form struct tagpull/3632/head
29 changed files with 2546 additions and 523 deletions
@ -0,0 +1,249 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 statuses |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"net/http" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/gin-gonic/gin/binding" |
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util" |
||||
) |
||||
|
||||
// StatusEditPUTHandler swagger:operation PUT /api/v1/statuses statusEdit
|
||||
//
|
||||
// Edit an existing status using the given form field parameters.
|
||||
//
|
||||
// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - statuses
|
||||
//
|
||||
// consumes:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: status
|
||||
// x-go-name: Status
|
||||
// description: |-
|
||||
// Text content of the status.
|
||||
// If media_ids is provided, this becomes optional.
|
||||
// Attaching a poll is optional while status is provided.
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: media_ids
|
||||
// x-go-name: MediaIDs
|
||||
// description: |-
|
||||
// Array of Attachment ids to be attached as media.
|
||||
// If provided, status becomes optional, and poll cannot be used.
|
||||
//
|
||||
// If the status is being submitted as a form, the key is 'media_ids[]',
|
||||
// but if it's json or xml, the key is 'media_ids'.
|
||||
// type: array
|
||||
// items:
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: poll[options][]
|
||||
// x-go-name: PollOptions
|
||||
// description: |-
|
||||
// Array of possible poll answers.
|
||||
// If provided, media_ids cannot be used, and poll[expires_in] must be provided.
|
||||
// type: array
|
||||
// items:
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: poll[expires_in]
|
||||
// x-go-name: PollExpiresIn
|
||||
// description: |-
|
||||
// Duration the poll should be open, in seconds.
|
||||
// If provided, media_ids cannot be used, and poll[options] must be provided.
|
||||
// type: integer
|
||||
// format: int64
|
||||
// in: formData
|
||||
// -
|
||||
// name: poll[multiple]
|
||||
// x-go-name: PollMultiple
|
||||
// description: Allow multiple choices on this poll.
|
||||
// type: boolean
|
||||
// default: false
|
||||
// in: formData
|
||||
// -
|
||||
// name: poll[hide_totals]
|
||||
// x-go-name: PollHideTotals
|
||||
// description: Hide vote counts until the poll ends.
|
||||
// type: boolean
|
||||
// default: true
|
||||
// in: formData
|
||||
// -
|
||||
// name: sensitive
|
||||
// x-go-name: Sensitive
|
||||
// description: Status and attached media should be marked as sensitive.
|
||||
// type: boolean
|
||||
// in: formData
|
||||
// -
|
||||
// name: spoiler_text
|
||||
// x-go-name: SpoilerText
|
||||
// description: |-
|
||||
// Text to be shown as a warning or subject before the actual content.
|
||||
// Statuses are generally collapsed behind this field.
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: language
|
||||
// x-go-name: Language
|
||||
// description: ISO 639 language code for this status.
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: content_type
|
||||
// x-go-name: ContentType
|
||||
// description: Content type to use when parsing this status.
|
||||
// type: string
|
||||
// enum:
|
||||
// - text/plain
|
||||
// - text/markdown
|
||||
// in: formData
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:statuses
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: "The latest status revision."
|
||||
// schema:
|
||||
// "$ref": "#/definitions/status"
|
||||
// '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) StatusEditPUTHandler(c *gin.Context) { |
||||
authed, err := oauth.Authed(c, true, true, true, true) |
||||
if err != nil { |
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) |
||||
return |
||||
} |
||||
|
||||
if authed.Account.IsMoving() { |
||||
apiutil.ForbiddenAfterMove(c) |
||||
return |
||||
} |
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { |
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) |
||||
return |
||||
} |
||||
|
||||
form, errWithCode := parseStatusEditForm(c) |
||||
if errWithCode != nil { |
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) |
||||
return |
||||
} |
||||
|
||||
apiStatus, errWithCode := m.processor.Status().Edit( |
||||
c.Request.Context(), |
||||
authed.Account, |
||||
c.Param(IDKey), |
||||
form, |
||||
) |
||||
if errWithCode != nil { |
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) |
||||
return |
||||
} |
||||
|
||||
apiutil.JSON(c, http.StatusOK, apiStatus) |
||||
} |
||||
|
||||
func parseStatusEditForm(c *gin.Context) (*apimodel.StatusEditRequest, gtserror.WithCode) { |
||||
form := new(apimodel.StatusEditRequest) |
||||
|
||||
switch ct := c.ContentType(); ct { |
||||
case binding.MIMEJSON: |
||||
// Just bind with default json binding.
|
||||
if err := c.ShouldBindWith(form, binding.JSON); err != nil { |
||||
return nil, gtserror.NewErrorBadRequest( |
||||
err, |
||||
err.Error(), |
||||
) |
||||
} |
||||
|
||||
case binding.MIMEPOSTForm: |
||||
// Bind with default form binding first.
|
||||
if err := c.ShouldBindWith(form, binding.FormPost); err != nil { |
||||
return nil, gtserror.NewErrorBadRequest( |
||||
err, |
||||
err.Error(), |
||||
) |
||||
} |
||||
|
||||
case binding.MIMEMultipartPOSTForm: |
||||
// Bind with default form binding first.
|
||||
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil { |
||||
return nil, gtserror.NewErrorBadRequest( |
||||
err, |
||||
err.Error(), |
||||
) |
||||
} |
||||
|
||||
default: |
||||
text := fmt.Sprintf("content-type %s not supported for this endpoint; supported content-types are %s, %s, %s", |
||||
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm) |
||||
return nil, gtserror.NewErrorNotAcceptable(errors.New(text), text) |
||||
} |
||||
|
||||
// Normalize poll expiry time if a poll was given.
|
||||
if form.Poll != nil && form.Poll.ExpiresInI != nil { |
||||
|
||||
// If we parsed this as JSON, expires_in
|
||||
// may be either a float64 or a string.
|
||||
expiresIn, err := apiutil.ParseDuration( |
||||
form.Poll.ExpiresInI, |
||||
"expires_in", |
||||
) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error()) |
||||
} |
||||
form.Poll.ExpiresIn = util.PtrOrZero(expiresIn) |
||||
} |
||||
|
||||
return form, nil |
||||
|
||||
} |
||||
@ -0,0 +1,32 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 statuses_test |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/suite" |
||||
) |
||||
|
||||
type StatusEditTestSuite struct { |
||||
StatusStandardTestSuite |
||||
} |
||||
|
||||
func TestStatusEditTestSuite(t *testing.T) { |
||||
suite.Run(t, new(StatusEditTestSuite)) |
||||
} |
||||
@ -1,62 +0,0 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 media |
||||
|
||||
import ( |
||||
"fmt" |
||||
"strconv" |
||||
"strings" |
||||
) |
||||
|
||||
func parseFocus(focus string) (focusx, focusy float32, err error) { |
||||
if focus == "" { |
||||
return |
||||
} |
||||
spl := strings.Split(focus, ",") |
||||
if len(spl) != 2 { |
||||
err = fmt.Errorf("improperly formatted focus %s", focus) |
||||
return |
||||
} |
||||
xStr := spl[0] |
||||
yStr := spl[1] |
||||
if xStr == "" || yStr == "" { |
||||
err = fmt.Errorf("improperly formatted focus %s", focus) |
||||
return |
||||
} |
||||
fx, err := strconv.ParseFloat(xStr, 32) |
||||
if err != nil { |
||||
err = fmt.Errorf("improperly formatted focus %s: %s", focus, err) |
||||
return |
||||
} |
||||
if fx > 1 || fx < -1 { |
||||
err = fmt.Errorf("improperly formatted focus %s", focus) |
||||
return |
||||
} |
||||
focusx = float32(fx) |
||||
fy, err := strconv.ParseFloat(yStr, 32) |
||||
if err != nil { |
||||
err = fmt.Errorf("improperly formatted focus %s: %s", focus, err) |
||||
return |
||||
} |
||||
if fy > 1 || fy < -1 { |
||||
err = fmt.Errorf("improperly formatted focus %s", focus) |
||||
return |
||||
} |
||||
focusy = float32(fy) |
||||
return |
||||
} |
||||
@ -0,0 +1,351 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 status |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"time" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/config" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/id" |
||||
"github.com/superseriousbusiness/gotosocial/internal/text" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util/xslices" |
||||
"github.com/superseriousbusiness/gotosocial/internal/validate" |
||||
) |
||||
|
||||
// validateStatusContent will validate the common
|
||||
// content fields across status write endpoints against
|
||||
// current server configuration (e.g. max char counts).
|
||||
func validateStatusContent( |
||||
status string, |
||||
spoiler string, |
||||
mediaIDs []string, |
||||
poll *apimodel.PollRequest, |
||||
) gtserror.WithCode { |
||||
totalChars := len([]rune(status)) + |
||||
len([]rune(spoiler)) |
||||
|
||||
if totalChars == 0 && len(mediaIDs) == 0 && poll == nil { |
||||
const text = "status contains no text, media or poll" |
||||
return gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
} |
||||
|
||||
if max := config.GetStatusesMaxChars(); totalChars > max { |
||||
text := fmt.Sprintf("text with spoiler exceed max chars (%d)", max) |
||||
return gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
} |
||||
|
||||
if max := config.GetStatusesMediaMaxFiles(); len(mediaIDs) > max { |
||||
text := fmt.Sprintf("media files exceed max count (%d)", max) |
||||
return gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
} |
||||
|
||||
if poll != nil { |
||||
switch max := config.GetStatusesPollMaxOptions(); { |
||||
case len(poll.Options) == 0: |
||||
const text = "poll cannot have no options" |
||||
return gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
|
||||
case len(poll.Options) > max: |
||||
text := fmt.Sprintf("poll options exceed max count (%d)", max) |
||||
return gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
} |
||||
|
||||
max := config.GetStatusesPollOptionMaxChars() |
||||
for i, option := range poll.Options { |
||||
switch l := len([]rune(option)); { |
||||
case l == 0: |
||||
const text = "poll option cannot be empty" |
||||
return gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
|
||||
case l > max: |
||||
text := fmt.Sprintf("poll option %d exceed max chars (%d)", i, max) |
||||
return gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
} |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// statusContent encompasses the set of common processed
|
||||
// status content fields from status write operations for
|
||||
// an easily returnable type, without needing to allocate
|
||||
// an entire gtsmodel.Status{} model.
|
||||
type statusContent struct { |
||||
Content string |
||||
ContentWarning string |
||||
PollOptions []string |
||||
Language string |
||||
MentionIDs []string |
||||
Mentions []*gtsmodel.Mention |
||||
EmojiIDs []string |
||||
Emojis []*gtsmodel.Emoji |
||||
TagIDs []string |
||||
Tags []*gtsmodel.Tag |
||||
} |
||||
|
||||
func (p *Processor) processContent( |
||||
ctx context.Context, |
||||
author *gtsmodel.Account, |
||||
statusID string, |
||||
contentType string, |
||||
content string, |
||||
contentWarning string, |
||||
language string, |
||||
poll *apimodel.PollRequest, |
||||
) ( |
||||
*statusContent, |
||||
gtserror.WithCode, |
||||
) { |
||||
if language == "" { |
||||
// Ensure we have a status language.
|
||||
language = author.Settings.Language |
||||
if language == "" { |
||||
const text = "account default language unset" |
||||
return nil, gtserror.NewErrorInternalError( |
||||
errors.New(text), |
||||
) |
||||
} |
||||
} |
||||
|
||||
var err error |
||||
|
||||
// Validate + normalize determined language.
|
||||
language, err = validate.Language(language) |
||||
if err != nil { |
||||
text := fmt.Sprintf("invalid language tag: %v", err) |
||||
return nil, gtserror.NewErrorBadRequest( |
||||
errors.New(text), |
||||
text, |
||||
) |
||||
} |
||||
|
||||
// format is the currently set text formatting
|
||||
// function, according to the provided content-type.
|
||||
var format text.FormatFunc |
||||
|
||||
if contentType == "" { |
||||
// If content type wasn't specified, use
|
||||
// the author's preferred content-type.
|
||||
contentType = author.Settings.StatusContentType |
||||
} |
||||
|
||||
switch contentType { |
||||
|
||||
// Format status according to text/plain.
|
||||
case "", string(apimodel.StatusContentTypePlain): |
||||
format = p.formatter.FromPlain |
||||
|
||||
// Format status according to text/markdown.
|
||||
case string(apimodel.StatusContentTypeMarkdown): |
||||
format = p.formatter.FromMarkdown |
||||
|
||||
// Unknown.
|
||||
default: |
||||
const text = "invalid status format" |
||||
return nil, gtserror.NewErrorBadRequest( |
||||
errors.New(text), |
||||
text, |
||||
) |
||||
} |
||||
|
||||
// Allocate a structure to hold the
|
||||
// majority of formatted content without
|
||||
// needing to alloc a whole gtsmodel.Status{}.
|
||||
var status statusContent |
||||
status.Language = language |
||||
|
||||
// formatInput is a shorthand function to format the given input string with the
|
||||
// currently set 'formatFunc', passing in all required args and returning result.
|
||||
formatInput := func(formatFunc text.FormatFunc, input string) *text.FormatResult { |
||||
return formatFunc(ctx, p.parseMention, author.ID, statusID, input) |
||||
} |
||||
|
||||
// Sanitize input status text and format.
|
||||
contentRes := formatInput(format, content) |
||||
|
||||
// Gather results of formatted.
|
||||
status.Content = contentRes.HTML |
||||
status.Mentions = contentRes.Mentions |
||||
status.Emojis = contentRes.Emojis |
||||
status.Tags = contentRes.Tags |
||||
|
||||
// From here-on-out just use emoji-only
|
||||
// plain-text formatting as the FormatFunc.
|
||||
format = p.formatter.FromPlainEmojiOnly |
||||
|
||||
// Sanitize content warning and format.
|
||||
warning := text.SanitizeToPlaintext(contentWarning) |
||||
warningRes := formatInput(format, warning) |
||||
|
||||
// Gather results of the formatted.
|
||||
status.ContentWarning = warningRes.HTML |
||||
status.Emojis = append(status.Emojis, warningRes.Emojis...) |
||||
|
||||
if poll != nil { |
||||
// Pre-allocate slice of poll options of expected length.
|
||||
status.PollOptions = make([]string, len(poll.Options)) |
||||
for i, option := range poll.Options { |
||||
|
||||
// Sanitize each poll option and format.
|
||||
option = text.SanitizeToPlaintext(option) |
||||
optionRes := formatInput(format, option) |
||||
|
||||
// Gather results of the formatted.
|
||||
status.PollOptions[i] = optionRes.HTML |
||||
status.Emojis = append(status.Emojis, optionRes.Emojis...) |
||||
} |
||||
|
||||
// Also update options on the form.
|
||||
poll.Options = status.PollOptions |
||||
} |
||||
|
||||
// We may have received multiple copies of the same emoji, deduplicate these first.
|
||||
status.Emojis = xslices.DeduplicateFunc(status.Emojis, func(e *gtsmodel.Emoji) string { |
||||
return e.ID |
||||
}) |
||||
|
||||
// Gather up the IDs of mentions from parsed content.
|
||||
status.MentionIDs = xslices.Gather(nil, status.Mentions, |
||||
func(m *gtsmodel.Mention) string { |
||||
return m.ID |
||||
}, |
||||
) |
||||
|
||||
// Gather up the IDs of tags from parsed content.
|
||||
status.TagIDs = xslices.Gather(nil, status.Tags, |
||||
func(t *gtsmodel.Tag) string { |
||||
return t.ID |
||||
}, |
||||
) |
||||
|
||||
// Gather up the IDs of emojis in updated content.
|
||||
status.EmojiIDs = xslices.Gather(nil, status.Emojis, |
||||
func(e *gtsmodel.Emoji) string { |
||||
return e.ID |
||||
}, |
||||
) |
||||
|
||||
return &status, nil |
||||
} |
||||
|
||||
func (p *Processor) processMedia( |
||||
ctx context.Context, |
||||
authorID string, |
||||
statusID string, |
||||
mediaIDs []string, |
||||
) ( |
||||
[]*gtsmodel.MediaAttachment, |
||||
gtserror.WithCode, |
||||
) { |
||||
// No media provided!
|
||||
if len(mediaIDs) == 0 { |
||||
return nil, nil |
||||
} |
||||
|
||||
// Get configured min/max supported descr chars.
|
||||
minChars := config.GetMediaDescriptionMinChars() |
||||
maxChars := config.GetMediaDescriptionMaxChars() |
||||
|
||||
// Pre-allocate slice of media attachments of expected length.
|
||||
attachments := make([]*gtsmodel.MediaAttachment, len(mediaIDs)) |
||||
for i, id := range mediaIDs { |
||||
|
||||
// Look for media attachment by ID in database.
|
||||
media, err := p.state.DB.GetAttachmentByID(ctx, id) |
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) { |
||||
err := gtserror.Newf("error getting media from db: %w", err) |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// Check media exists and is owned by author
|
||||
// (this masks finding out media ownership info).
|
||||
if media == nil || media.AccountID != authorID { |
||||
text := fmt.Sprintf("media not found: %s", id) |
||||
return nil, gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
} |
||||
|
||||
// Check media isn't already attached to another status.
|
||||
if (media.StatusID != "" && media.StatusID != statusID) || |
||||
(media.ScheduledStatusID != "" && media.ScheduledStatusID != statusID) { |
||||
text := fmt.Sprintf("media already attached to status: %s", id) |
||||
return nil, gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
} |
||||
|
||||
// Check media description chars within range,
|
||||
// this needs to be done here as lots of clients
|
||||
// only update media description on status post.
|
||||
switch chars := len([]rune(media.Description)); { |
||||
case chars < minChars: |
||||
text := fmt.Sprintf("media description less than min chars (%d)", minChars) |
||||
return nil, gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
|
||||
case chars > maxChars: |
||||
text := fmt.Sprintf("media description exceeds max chars (%d)", maxChars) |
||||
return nil, gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
} |
||||
|
||||
// Set media at index.
|
||||
attachments[i] = media |
||||
} |
||||
|
||||
return attachments, nil |
||||
} |
||||
|
||||
func (p *Processor) processPoll( |
||||
ctx context.Context, |
||||
statusID string, |
||||
form *apimodel.PollRequest, |
||||
now time.Time, // used for expiry time
|
||||
) ( |
||||
*gtsmodel.Poll, |
||||
gtserror.WithCode, |
||||
) { |
||||
var expiresAt time.Time |
||||
|
||||
// Set an expiry time if one given.
|
||||
if in := form.ExpiresIn; in > 0 { |
||||
expiresIn := time.Duration(in) |
||||
expiresAt = now.Add(expiresIn * time.Second) |
||||
} |
||||
|
||||
// Create new poll model.
|
||||
poll := >smodel.Poll{ |
||||
ID: id.NewULIDFromTime(now), |
||||
Multiple: &form.Multiple, |
||||
HideCounts: &form.HideTotals, |
||||
Options: form.Options, |
||||
StatusID: statusID, |
||||
ExpiresAt: expiresAt, |
||||
} |
||||
|
||||
// Insert the newly created poll model in the database.
|
||||
if err := p.state.DB.PutPoll(ctx, poll); err != nil { |
||||
err := gtserror.Newf("error inserting poll in db: %w", err) |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
return poll, nil |
||||
} |
||||
@ -0,0 +1,555 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 status |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"slices" |
||||
"time" |
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap" |
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/id" |
||||
"github.com/superseriousbusiness/gotosocial/internal/log" |
||||
"github.com/superseriousbusiness/gotosocial/internal/messages" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util/xslices" |
||||
) |
||||
|
||||
// Edit ...
|
||||
func (p *Processor) Edit( |
||||
ctx context.Context, |
||||
requester *gtsmodel.Account, |
||||
statusID string, |
||||
form *apimodel.StatusEditRequest, |
||||
) ( |
||||
*apimodel.Status, |
||||
gtserror.WithCode, |
||||
) { |
||||
// Fetch status and ensure it's owned by requesting account.
|
||||
status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID) |
||||
if errWithCode != nil { |
||||
return nil, errWithCode |
||||
} |
||||
|
||||
// Ensure this isn't a boost.
|
||||
if status.BoostOfID != "" { |
||||
return nil, gtserror.NewErrorNotFound( |
||||
errors.New("status is a boost wrapper"), |
||||
"target status not found", |
||||
) |
||||
} |
||||
|
||||
// Ensure account populated; we'll need their settings.
|
||||
if err := p.state.DB.PopulateAccount(ctx, requester); err != nil { |
||||
log.Errorf(ctx, "error(s) populating account, will continue: %s", err) |
||||
} |
||||
|
||||
// We need the status populated including all historical edits.
|
||||
if err := p.state.DB.PopulateStatusEdits(ctx, status); err != nil { |
||||
err := gtserror.Newf("error getting status edits from db: %w", err) |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// Time of edit.
|
||||
now := time.Now() |
||||
|
||||
// Validate incoming form edit content.
|
||||
if errWithCode := validateStatusContent( |
||||
form.Status, |
||||
form.SpoilerText, |
||||
form.MediaIDs, |
||||
form.Poll, |
||||
); errWithCode != nil { |
||||
return nil, errWithCode |
||||
} |
||||
|
||||
// Process incoming status edit content fields.
|
||||
content, errWithCode := p.processContent(ctx, |
||||
requester, |
||||
statusID, |
||||
string(form.ContentType), |
||||
form.Status, |
||||
form.SpoilerText, |
||||
form.Language, |
||||
form.Poll, |
||||
) |
||||
if errWithCode != nil { |
||||
return nil, errWithCode |
||||
} |
||||
|
||||
// Process new status attachments to use.
|
||||
media, errWithCode := p.processMedia(ctx, |
||||
requester.ID, |
||||
statusID, |
||||
form.MediaIDs, |
||||
) |
||||
if errWithCode != nil { |
||||
return nil, errWithCode |
||||
} |
||||
|
||||
// Process incoming edits of any attached media.
|
||||
mediaEdited, errWithCode := p.processMediaEdits(ctx, |
||||
media, |
||||
form.MediaAttributes, |
||||
) |
||||
if errWithCode != nil { |
||||
return nil, errWithCode |
||||
} |
||||
|
||||
// Process incoming edits of any attached status poll.
|
||||
poll, pollEdited, errWithCode := p.processPollEdit(ctx, |
||||
statusID, |
||||
status.Poll, |
||||
form.Poll, |
||||
now, |
||||
) |
||||
if errWithCode != nil { |
||||
return nil, errWithCode |
||||
} |
||||
|
||||
// Check if new status poll was set.
|
||||
pollChanged := (poll != status.Poll) |
||||
|
||||
// Determine whether there were any changes possibly
|
||||
// causing a change to embedded mentions, tags, emojis.
|
||||
contentChanged := (status.Content != content.Content) |
||||
warningChanged := (status.ContentWarning != content.ContentWarning) |
||||
languageChanged := (status.Language != content.Language) |
||||
anyContentChanged := contentChanged || warningChanged || |
||||
pollEdited // encapsulates pollChanged too
|
||||
|
||||
// Check if status media attachments have changed.
|
||||
mediaChanged := !slices.Equal(status.AttachmentIDs, |
||||
form.MediaIDs, |
||||
) |
||||
|
||||
// Track status columns we
|
||||
// need to update in database.
|
||||
cols := make([]string, 2, 13) |
||||
cols[0] = "updated_at" |
||||
cols[1] = "edits" |
||||
|
||||
if contentChanged { |
||||
// Update status text.
|
||||
//
|
||||
// Note we don't update these
|
||||
// status fields right away so
|
||||
// we can save current version.
|
||||
cols = append(cols, "content") |
||||
cols = append(cols, "text") |
||||
} |
||||
|
||||
if warningChanged { |
||||
// Update status content warning.
|
||||
//
|
||||
// Note we don't update these
|
||||
// status fields right away so
|
||||
// we can save current version.
|
||||
cols = append(cols, "content_warning") |
||||
} |
||||
|
||||
if languageChanged { |
||||
// Update status language pref.
|
||||
//
|
||||
// Note we don't update these
|
||||
// status fields right away so
|
||||
// we can save current version.
|
||||
cols = append(cols, "language") |
||||
} |
||||
|
||||
if *status.Sensitive != form.Sensitive { |
||||
// Update status sensitivity pref.
|
||||
//
|
||||
// Note we don't update these
|
||||
// status fields right away so
|
||||
// we can save current version.
|
||||
cols = append(cols, "sensitive") |
||||
} |
||||
|
||||
if mediaChanged { |
||||
// Updated status media attachments.
|
||||
//
|
||||
// Note we don't update these
|
||||
// status fields right away so
|
||||
// we can save current version.
|
||||
cols = append(cols, "attachments") |
||||
} |
||||
|
||||
if pollChanged { |
||||
// Updated attached status poll.
|
||||
//
|
||||
// Note we don't update these
|
||||
// status fields right away so
|
||||
// we can save current version.
|
||||
cols = append(cols, "poll_id") |
||||
|
||||
if status.Poll == nil || poll == nil { |
||||
// Went from with-poll to without-poll
|
||||
// or vice-versa. This changes AP type.
|
||||
cols = append(cols, "activity_streams_type") |
||||
} |
||||
} |
||||
|
||||
if anyContentChanged { |
||||
if !slices.Equal(status.MentionIDs, content.MentionIDs) { |
||||
// Update attached status mentions.
|
||||
cols = append(cols, "mentions") |
||||
status.MentionIDs = content.MentionIDs |
||||
status.Mentions = content.Mentions |
||||
} |
||||
|
||||
if !slices.Equal(status.TagIDs, content.TagIDs) { |
||||
// Updated attached status tags.
|
||||
cols = append(cols, "tags") |
||||
status.TagIDs = content.TagIDs |
||||
status.Tags = content.Tags |
||||
} |
||||
|
||||
if !slices.Equal(status.EmojiIDs, content.EmojiIDs) { |
||||
// We specifically store both *new* AND *old* edit
|
||||
// revision emojis in the statuses.emojis column.
|
||||
emojiByID := func(e *gtsmodel.Emoji) string { return e.ID } |
||||
status.Emojis = append(status.Emojis, content.Emojis...) |
||||
status.Emojis = xslices.DeduplicateFunc(status.Emojis, emojiByID) |
||||
status.EmojiIDs = xslices.Gather(status.EmojiIDs[:0], status.Emojis, emojiByID) |
||||
|
||||
// Update attached status emojis.
|
||||
cols = append(cols, "emojis") |
||||
} |
||||
} |
||||
|
||||
// If no status columns were updated, no media and
|
||||
// no poll were edited, there's nothing to do!
|
||||
if len(cols) == 2 && !mediaEdited && !pollEdited { |
||||
const text = "status was not changed" |
||||
return nil, gtserror.NewErrorUnprocessableEntity( |
||||
errors.New(text), |
||||
text, |
||||
) |
||||
} |
||||
|
||||
// Create an edit to store a
|
||||
// historical snapshot of status.
|
||||
var edit gtsmodel.StatusEdit |
||||
edit.ID = id.NewULIDFromTime(now) |
||||
edit.Content = status.Content |
||||
edit.ContentWarning = status.ContentWarning |
||||
edit.Text = status.Text |
||||
edit.Language = status.Language |
||||
edit.Sensitive = status.Sensitive |
||||
edit.StatusID = status.ID |
||||
edit.CreatedAt = status.UpdatedAt |
||||
|
||||
// Copy existing media and descriptions.
|
||||
edit.AttachmentIDs = status.AttachmentIDs |
||||
if l := len(status.Attachments); l > 0 { |
||||
edit.AttachmentDescriptions = make([]string, l) |
||||
for i, attach := range status.Attachments { |
||||
edit.AttachmentDescriptions[i] = attach.Description |
||||
} |
||||
} |
||||
|
||||
if status.Poll != nil { |
||||
// Poll only set if existed previously.
|
||||
edit.PollOptions = status.Poll.Options |
||||
|
||||
if pollChanged || !*status.Poll.HideCounts || |
||||
!status.Poll.ClosedAt.IsZero() { |
||||
// If the counts are allowed to be
|
||||
// shown, or poll has changed, then
|
||||
// include poll vote counts in edit.
|
||||
edit.PollVotes = status.Poll.Votes |
||||
} |
||||
} |
||||
|
||||
// Insert this new edit of existing status into database.
|
||||
if err := p.state.DB.PutStatusEdit(ctx, &edit); err != nil { |
||||
err := gtserror.Newf("error putting edit in database: %w", err) |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// Add edit to list of edits on the status.
|
||||
status.EditIDs = append(status.EditIDs, edit.ID) |
||||
status.Edits = append(status.Edits, &edit) |
||||
|
||||
// Now historical status data is stored,
|
||||
// update the other necessary status fields.
|
||||
status.Content = content.Content |
||||
status.ContentWarning = content.ContentWarning |
||||
status.Text = form.Status |
||||
status.Language = content.Language |
||||
status.Sensitive = &form.Sensitive |
||||
status.AttachmentIDs = form.MediaIDs |
||||
status.Attachments = media |
||||
status.UpdatedAt = now |
||||
|
||||
if poll != nil { |
||||
// Set relevent fields for latest with poll.
|
||||
status.ActivityStreamsType = ap.ActivityQuestion |
||||
status.PollID = poll.ID |
||||
status.Poll = poll |
||||
} else { |
||||
// Set relevant fields for latest without poll.
|
||||
status.ActivityStreamsType = ap.ObjectNote |
||||
status.PollID = "" |
||||
status.Poll = nil |
||||
} |
||||
|
||||
// Finally update the existing status model in the database.
|
||||
if err := p.state.DB.UpdateStatus(ctx, status, cols...); err != nil { |
||||
err := gtserror.Newf("error updating status in db: %w", err) |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
if pollChanged && status.Poll != nil && !status.Poll.ExpiresAt.IsZero() { |
||||
// Now the status is updated, attempt to schedule
|
||||
// an expiry handler for the changed status poll.
|
||||
if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil { |
||||
log.Errorf(ctx, "error scheduling poll expiry: %v", err) |
||||
} |
||||
} |
||||
|
||||
// Send it to the client API worker for async side-effects.
|
||||
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ |
||||
APObjectType: ap.ObjectNote, |
||||
APActivityType: ap.ActivityUpdate, |
||||
GTSModel: status, |
||||
Origin: requester, |
||||
}) |
||||
|
||||
// Return an API model of the updated status.
|
||||
return p.c.GetAPIStatus(ctx, requester, status) |
||||
} |
||||
|
||||
// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc.
|
||||
func (p *Processor) HistoryGet(ctx context.Context, requester *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) { |
||||
target, errWithCode := p.c.GetVisibleTargetStatus(ctx, |
||||
requester, |
||||
targetStatusID, |
||||
nil, // default freshness
|
||||
) |
||||
if errWithCode != nil { |
||||
return nil, errWithCode |
||||
} |
||||
|
||||
if err := p.state.DB.PopulateStatusEdits(ctx, target); err != nil { |
||||
err := gtserror.Newf("error getting status edits from db: %w", err) |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
edits, err := p.converter.StatusToAPIEdits(ctx, target) |
||||
if err != nil { |
||||
err := gtserror.Newf("error converting status edits: %w", err) |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
return edits, nil |
||||
} |
||||
|
||||
func (p *Processor) processMediaEdits( |
||||
ctx context.Context, |
||||
attachs []*gtsmodel.MediaAttachment, |
||||
attrs []apimodel.AttachmentAttributesRequest, |
||||
) ( |
||||
bool, |
||||
gtserror.WithCode, |
||||
) { |
||||
var edited bool |
||||
|
||||
for _, attr := range attrs { |
||||
// Search the media attachments slice for index of media with attr.ID.
|
||||
i := slices.IndexFunc(attachs, func(m *gtsmodel.MediaAttachment) bool { |
||||
return m.ID == attr.ID |
||||
}) |
||||
if i == -1 { |
||||
text := fmt.Sprintf("media not found: %s", attr.ID) |
||||
return false, gtserror.NewErrorBadRequest(errors.New(text), text) |
||||
} |
||||
|
||||
// Get attach at index.
|
||||
attach := attachs[i] |
||||
|
||||
// Track which columns need
|
||||
// updating in database query.
|
||||
cols := make([]string, 0, 2) |
||||
|
||||
// Check for description change.
|
||||
if attr.Description != attach.Description { |
||||
attach.Description = attr.Description |
||||
cols = append(cols, "description") |
||||
} |
||||
|
||||
if attr.Focus != "" { |
||||
// Parse provided media focus parameters from string.
|
||||
fx, fy, errWithCode := apiutil.ParseFocus(attr.Focus) |
||||
if errWithCode != nil { |
||||
return false, errWithCode |
||||
} |
||||
|
||||
// Check for change in focus coords.
|
||||
if attach.FileMeta.Focus.X != fx || |
||||
attach.FileMeta.Focus.Y != fy { |
||||
attach.FileMeta.Focus.X = fx |
||||
attach.FileMeta.Focus.Y = fy |
||||
cols = append(cols, "focus_x", "focus_y") |
||||
} |
||||
} |
||||
|
||||
if len(cols) > 0 { |
||||
// Media attachment was changed, update this in database.
|
||||
err := p.state.DB.UpdateAttachment(ctx, attach, cols...) |
||||
if err != nil { |
||||
err := gtserror.Newf("error updating attachment in db: %w", err) |
||||
return false, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// Set edited.
|
||||
edited = true |
||||
} |
||||
} |
||||
|
||||
return edited, nil |
||||
} |
||||
|
||||
func (p *Processor) processPollEdit( |
||||
ctx context.Context, |
||||
statusID string, |
||||
original *gtsmodel.Poll, |
||||
form *apimodel.PollRequest, |
||||
now time.Time, // used for expiry time
|
||||
) ( |
||||
*gtsmodel.Poll, |
||||
bool, |
||||
gtserror.WithCode, |
||||
) { |
||||
if form == nil { |
||||
if original != nil { |
||||
// No poll was given but there's an existing poll,
|
||||
// this indicates the original needs to be deleted.
|
||||
if err := p.deletePoll(ctx, original); err != nil { |
||||
return nil, true, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// Existing was deleted.
|
||||
return nil, true, nil |
||||
} |
||||
|
||||
// No change in poll.
|
||||
return nil, false, nil |
||||
} |
||||
|
||||
switch { |
||||
// No existing poll.
|
||||
case original == nil: |
||||
|
||||
// Any change that effects voting, i.e. options, allow multiple
|
||||
// or re-opening a closed poll requires deleting the existing poll.
|
||||
case !slices.Equal(form.Options, original.Options) || |
||||
(form.Multiple != *original.Multiple) || |
||||
(!original.ClosedAt.IsZero() && form.ExpiresIn != 0): |
||||
if err := p.deletePoll(ctx, original); err != nil { |
||||
return nil, true, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// Any other changes only require a model
|
||||
// update, and at-most a new expiry handler.
|
||||
default: |
||||
var cols []string |
||||
|
||||
// Check if the hide counts field changed.
|
||||
if form.HideTotals != *original.HideCounts { |
||||
cols = append(cols, "hide_counts") |
||||
original.HideCounts = &form.HideTotals |
||||
} |
||||
|
||||
var expiresAt time.Time |
||||
|
||||
// Determine expiry time if given.
|
||||
if in := form.ExpiresIn; in > 0 { |
||||
expiresIn := time.Duration(in) |
||||
expiresAt = now.Add(expiresIn * time.Second) |
||||
} |
||||
|
||||
// Check for expiry time.
|
||||
if !expiresAt.IsZero() { |
||||
|
||||
if !original.ExpiresAt.IsZero() { |
||||
// Existing had expiry, cancel scheduled handler.
|
||||
_ = p.state.Workers.Scheduler.Cancel(original.ID) |
||||
} |
||||
|
||||
// Since expiry is given as a duration
|
||||
// we always treat > 0 as a change as
|
||||
// we can't know otherwise unfortunately.
|
||||
cols = append(cols, "expires_at") |
||||
original.ExpiresAt = expiresAt |
||||
} |
||||
|
||||
if len(cols) == 0 { |
||||
// Were no changes to poll.
|
||||
return original, false, nil |
||||
} |
||||
|
||||
// Update the original poll model in the database with these columns.
|
||||
if err := p.state.DB.UpdatePoll(ctx, original, cols...); err != nil { |
||||
err := gtserror.Newf("error updating poll.expires_at in db: %w", err) |
||||
return nil, true, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
if !expiresAt.IsZero() { |
||||
// Updated poll has an expiry, schedule a new expiry handler.
|
||||
if err := p.polls.ScheduleExpiry(ctx, original); err != nil { |
||||
log.Errorf(ctx, "error scheduling poll expiry: %v", err) |
||||
} |
||||
} |
||||
|
||||
// Existing poll was updated.
|
||||
return original, true, nil |
||||
} |
||||
|
||||
// If we reached here then an entirely
|
||||
// new status poll needs to be created.
|
||||
poll, errWithCode := p.processPoll(ctx, |
||||
statusID, |
||||
form, |
||||
now, |
||||
) |
||||
return poll, true, errWithCode |
||||
} |
||||
|
||||
func (p *Processor) deletePoll(ctx context.Context, poll *gtsmodel.Poll) error { |
||||
if !poll.ExpiresAt.IsZero() && !poll.ClosedAt.IsZero() { |
||||
// Poll has an expiry and has not yet closed,
|
||||
// cancel any expiry handler before deletion.
|
||||
_ = p.state.Workers.Scheduler.Cancel(poll.ID) |
||||
} |
||||
|
||||
// Delete the given poll from the database.
|
||||
err := p.state.DB.DeletePollByID(ctx, poll.ID) |
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) { |
||||
return gtserror.Newf("error deleting poll from db: %w", err) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
@ -0,0 +1,544 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// 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 status_test |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/stretchr/testify/suite" |
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util/xslices" |
||||
) |
||||
|
||||
type StatusEditTestSuite struct { |
||||
StatusStandardTestSuite |
||||
} |
||||
|
||||
func (suite *StatusEditTestSuite) TestSimpleEdit() { |
||||
// Create cancellable context to use for test.
|
||||
ctx, cncl := context.WithCancel(context.Background()) |
||||
defer cncl() |
||||
|
||||
// Get a local account to use as test requester.
|
||||
requester := suite.testAccounts["local_account_1"] |
||||
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) |
||||
|
||||
// Get requester's existing status to perform an edit on.
|
||||
status := suite.testStatuses["local_account_1_status_9"] |
||||
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
|
||||
// Prepare a simple status edit.
|
||||
form := &apimodel.StatusEditRequest{ |
||||
Status: "<p>this is some edited status text!</p>", |
||||
SpoilerText: "shhhhh", |
||||
Sensitive: true, |
||||
Language: "fr", // hoh hoh hoh
|
||||
MediaIDs: nil, |
||||
MediaAttributes: nil, |
||||
Poll: nil, |
||||
} |
||||
|
||||
// Pass the prepared form to the status processor to perform the edit.
|
||||
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) |
||||
suite.NotNil(apiStatus) |
||||
suite.NoError(errWithCode) |
||||
|
||||
// Check response against input form data.
|
||||
suite.Equal(form.Status, apiStatus.Text) |
||||
suite.Equal(form.SpoilerText, apiStatus.SpoilerText) |
||||
suite.Equal(form.Sensitive, apiStatus.Sensitive) |
||||
suite.Equal(form.Language, *apiStatus.Language) |
||||
suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) |
||||
|
||||
// Fetched the latest version of edited status from the database.
|
||||
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
suite.NoError(err) |
||||
|
||||
// Check latest status against input form data.
|
||||
suite.Equal(form.Status, latestStatus.Text) |
||||
suite.Equal(form.SpoilerText, latestStatus.ContentWarning) |
||||
suite.Equal(form.Sensitive, *latestStatus.Sensitive) |
||||
suite.Equal(form.Language, latestStatus.Language) |
||||
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) |
||||
suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) |
||||
|
||||
// Populate all historical edits for this status.
|
||||
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) |
||||
suite.NoError(err) |
||||
|
||||
// Check previous status edit matches original status content.
|
||||
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] |
||||
suite.Equal(status.Content, previousEdit.Content) |
||||
suite.Equal(status.Text, previousEdit.Text) |
||||
suite.Equal(status.ContentWarning, previousEdit.ContentWarning) |
||||
suite.Equal(*status.Sensitive, *previousEdit.Sensitive) |
||||
suite.Equal(status.Language, previousEdit.Language) |
||||
suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) |
||||
} |
||||
|
||||
func (suite *StatusEditTestSuite) TestEditAddPoll() { |
||||
// Create cancellable context to use for test.
|
||||
ctx, cncl := context.WithCancel(context.Background()) |
||||
defer cncl() |
||||
|
||||
// Get a local account to use as test requester.
|
||||
requester := suite.testAccounts["local_account_1"] |
||||
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) |
||||
|
||||
// Get requester's existing status to perform an edit on.
|
||||
status := suite.testStatuses["local_account_1_status_9"] |
||||
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
|
||||
// Prepare edit adding a status poll.
|
||||
form := &apimodel.StatusEditRequest{ |
||||
Status: "<p>this is some edited status text!</p>", |
||||
SpoilerText: "", |
||||
Sensitive: true, |
||||
Language: "fr", // hoh hoh hoh
|
||||
MediaIDs: nil, |
||||
MediaAttributes: nil, |
||||
Poll: &apimodel.PollRequest{ |
||||
Options: []string{"yes", "no", "spiderman"}, |
||||
ExpiresIn: int(time.Minute), |
||||
Multiple: true, |
||||
HideTotals: false, |
||||
}, |
||||
} |
||||
|
||||
// Pass the prepared form to the status processor to perform the edit.
|
||||
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) |
||||
suite.NotNil(apiStatus) |
||||
suite.NoError(errWithCode) |
||||
|
||||
// Check response against input form data.
|
||||
suite.Equal(form.Status, apiStatus.Text) |
||||
suite.Equal(form.SpoilerText, apiStatus.SpoilerText) |
||||
suite.Equal(form.Sensitive, apiStatus.Sensitive) |
||||
suite.Equal(form.Language, *apiStatus.Language) |
||||
suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) |
||||
suite.NotNil(apiStatus.Poll) |
||||
suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string { |
||||
return opt.Title |
||||
})) |
||||
|
||||
// Fetched the latest version of edited status from the database.
|
||||
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
suite.NoError(err) |
||||
|
||||
// Check latest status against input form data.
|
||||
suite.Equal(form.Status, latestStatus.Text) |
||||
suite.Equal(form.SpoilerText, latestStatus.ContentWarning) |
||||
suite.Equal(form.Sensitive, *latestStatus.Sensitive) |
||||
suite.Equal(form.Language, latestStatus.Language) |
||||
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) |
||||
suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) |
||||
suite.NotNil(latestStatus.Poll) |
||||
suite.Equal(form.Poll.Options, latestStatus.Poll.Options) |
||||
|
||||
// Ensure that a poll expiry handler was scheduled on status edit.
|
||||
expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID) |
||||
suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker) |
||||
|
||||
// Populate all historical edits for this status.
|
||||
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) |
||||
suite.NoError(err) |
||||
|
||||
// Check previous status edit matches original status content.
|
||||
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] |
||||
suite.Equal(status.Content, previousEdit.Content) |
||||
suite.Equal(status.Text, previousEdit.Text) |
||||
suite.Equal(status.ContentWarning, previousEdit.ContentWarning) |
||||
suite.Equal(*status.Sensitive, *previousEdit.Sensitive) |
||||
suite.Equal(status.Language, previousEdit.Language) |
||||
suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) |
||||
suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0) |
||||
} |
||||
|
||||
func (suite *StatusEditTestSuite) TestEditAddPollNoExpiry() { |
||||
// Create cancellable context to use for test.
|
||||
ctx, cncl := context.WithCancel(context.Background()) |
||||
defer cncl() |
||||
|
||||
// Get a local account to use as test requester.
|
||||
requester := suite.testAccounts["local_account_1"] |
||||
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) |
||||
|
||||
// Get requester's existing status to perform an edit on.
|
||||
status := suite.testStatuses["local_account_1_status_9"] |
||||
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
|
||||
// Prepare edit adding an endless poll.
|
||||
form := &apimodel.StatusEditRequest{ |
||||
Status: "<p>this is some edited status text!</p>", |
||||
SpoilerText: "", |
||||
Sensitive: true, |
||||
Language: "fr", // hoh hoh hoh
|
||||
MediaIDs: nil, |
||||
MediaAttributes: nil, |
||||
Poll: &apimodel.PollRequest{ |
||||
Options: []string{"yes", "no", "spiderman"}, |
||||
ExpiresIn: 0, |
||||
Multiple: true, |
||||
HideTotals: false, |
||||
}, |
||||
} |
||||
|
||||
// Pass the prepared form to the status processor to perform the edit.
|
||||
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) |
||||
suite.NotNil(apiStatus) |
||||
suite.NoError(errWithCode) |
||||
|
||||
// Check response against input form data.
|
||||
suite.Equal(form.Status, apiStatus.Text) |
||||
suite.Equal(form.SpoilerText, apiStatus.SpoilerText) |
||||
suite.Equal(form.Sensitive, apiStatus.Sensitive) |
||||
suite.Equal(form.Language, *apiStatus.Language) |
||||
suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) |
||||
suite.NotNil(apiStatus.Poll) |
||||
suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string { |
||||
return opt.Title |
||||
})) |
||||
|
||||
// Fetched the latest version of edited status from the database.
|
||||
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
suite.NoError(err) |
||||
|
||||
// Check latest status against input form data.
|
||||
suite.Equal(form.Status, latestStatus.Text) |
||||
suite.Equal(form.SpoilerText, latestStatus.ContentWarning) |
||||
suite.Equal(form.Sensitive, *latestStatus.Sensitive) |
||||
suite.Equal(form.Language, latestStatus.Language) |
||||
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) |
||||
suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) |
||||
suite.NotNil(latestStatus.Poll) |
||||
suite.Equal(form.Poll.Options, latestStatus.Poll.Options) |
||||
|
||||
// Ensure that a poll expiry handler was *not* scheduled on status edit.
|
||||
expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID) |
||||
suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker) |
||||
|
||||
// Populate all historical edits for this status.
|
||||
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) |
||||
suite.NoError(err) |
||||
|
||||
// Check previous status edit matches original status content.
|
||||
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] |
||||
suite.Equal(status.Content, previousEdit.Content) |
||||
suite.Equal(status.Text, previousEdit.Text) |
||||
suite.Equal(status.ContentWarning, previousEdit.ContentWarning) |
||||
suite.Equal(*status.Sensitive, *previousEdit.Sensitive) |
||||
suite.Equal(status.Language, previousEdit.Language) |
||||
suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) |
||||
suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0) |
||||
} |
||||
|
||||
func (suite *StatusEditTestSuite) TestEditMediaDescription() { |
||||
// Create cancellable context to use for test.
|
||||
ctx, cncl := context.WithCancel(context.Background()) |
||||
defer cncl() |
||||
|
||||
// Get a local account to use as test requester.
|
||||
requester := suite.testAccounts["local_account_1"] |
||||
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) |
||||
|
||||
// Get requester's existing status to perform an edit on.
|
||||
status := suite.testStatuses["local_account_1_status_4"] |
||||
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
|
||||
// Prepare edit changing media description.
|
||||
form := &apimodel.StatusEditRequest{ |
||||
Status: "<p>this is some edited status text!</p>", |
||||
SpoilerText: "this status is now missing media", |
||||
Sensitive: true, |
||||
Language: "en", |
||||
MediaIDs: status.AttachmentIDs, |
||||
MediaAttributes: []apimodel.AttachmentAttributesRequest{ |
||||
{ID: status.AttachmentIDs[0], Description: "hello world!"}, |
||||
{ID: status.AttachmentIDs[1], Description: "media attachment numero two"}, |
||||
}, |
||||
} |
||||
|
||||
// Pass the prepared form to the status processor to perform the edit.
|
||||
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) |
||||
suite.NoError(errWithCode) |
||||
|
||||
// Check response against input form data.
|
||||
suite.Equal(form.Status, apiStatus.Text) |
||||
suite.Equal(form.SpoilerText, apiStatus.SpoilerText) |
||||
suite.Equal(form.Sensitive, apiStatus.Sensitive) |
||||
suite.Equal(form.Language, *apiStatus.Language) |
||||
suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) |
||||
suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { |
||||
return media.ID |
||||
})) |
||||
suite.Equal( |
||||
xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string { |
||||
return attr.Description |
||||
}), |
||||
xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { |
||||
return *media.Description |
||||
}), |
||||
) |
||||
|
||||
// Fetched the latest version of edited status from the database.
|
||||
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
suite.NoError(err) |
||||
|
||||
// Check latest status against input form data.
|
||||
suite.Equal(form.Status, latestStatus.Text) |
||||
suite.Equal(form.SpoilerText, latestStatus.ContentWarning) |
||||
suite.Equal(form.Sensitive, *latestStatus.Sensitive) |
||||
suite.Equal(form.Language, latestStatus.Language) |
||||
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) |
||||
suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) |
||||
suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs) |
||||
suite.Equal( |
||||
xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string { |
||||
return attr.Description |
||||
}), |
||||
xslices.Gather(nil, latestStatus.Attachments, func(media *gtsmodel.MediaAttachment) string { |
||||
return media.Description |
||||
}), |
||||
) |
||||
|
||||
// Populate all historical edits for this status.
|
||||
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) |
||||
suite.NoError(err) |
||||
|
||||
// Further populate edits to get attachments.
|
||||
for _, edit := range latestStatus.Edits { |
||||
err = suite.state.DB.PopulateStatusEdit(ctx, edit) |
||||
suite.NoError(err) |
||||
} |
||||
|
||||
// Check previous status edit matches original status content.
|
||||
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] |
||||
suite.Equal(status.Content, previousEdit.Content) |
||||
suite.Equal(status.Text, previousEdit.Text) |
||||
suite.Equal(status.ContentWarning, previousEdit.ContentWarning) |
||||
suite.Equal(*status.Sensitive, *previousEdit.Sensitive) |
||||
suite.Equal(status.Language, previousEdit.Language) |
||||
suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) |
||||
suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs) |
||||
suite.Equal( |
||||
xslices.Gather(nil, status.Attachments, func(media *gtsmodel.MediaAttachment) string { |
||||
return media.Description |
||||
}), |
||||
previousEdit.AttachmentDescriptions, |
||||
) |
||||
} |
||||
|
||||
func (suite *StatusEditTestSuite) TestEditAddMedia() { |
||||
// Create cancellable context to use for test.
|
||||
ctx, cncl := context.WithCancel(context.Background()) |
||||
defer cncl() |
||||
|
||||
// Get a local account to use as test requester.
|
||||
requester := suite.testAccounts["local_account_1"] |
||||
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) |
||||
|
||||
// Get some of requester's existing media, and unattach from existing status.
|
||||
media1 := suite.testAttachments["local_account_1_status_4_attachment_1"] |
||||
media2 := suite.testAttachments["local_account_1_status_4_attachment_2"] |
||||
media1.StatusID, media2.StatusID = "", "" |
||||
suite.NoError(suite.state.DB.UpdateAttachment(ctx, media1, "status_id")) |
||||
suite.NoError(suite.state.DB.UpdateAttachment(ctx, media2, "status_id")) |
||||
media1, _ = suite.state.DB.GetAttachmentByID(ctx, media1.ID) |
||||
media2, _ = suite.state.DB.GetAttachmentByID(ctx, media2.ID) |
||||
|
||||
// Get requester's existing status to perform an edit on.
|
||||
status := suite.testStatuses["local_account_1_status_9"] |
||||
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
|
||||
// Prepare edit addding status media.
|
||||
form := &apimodel.StatusEditRequest{ |
||||
Status: "<p>this is some edited status text!</p>", |
||||
SpoilerText: "this status now has media", |
||||
Sensitive: true, |
||||
Language: "en", |
||||
MediaIDs: []string{media1.ID, media2.ID}, |
||||
MediaAttributes: nil, |
||||
} |
||||
|
||||
// Pass the prepared form to the status processor to perform the edit.
|
||||
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) |
||||
suite.NotNil(apiStatus) |
||||
suite.NoError(errWithCode) |
||||
|
||||
// Check response against input form data.
|
||||
suite.Equal(form.Status, apiStatus.Text) |
||||
suite.Equal(form.SpoilerText, apiStatus.SpoilerText) |
||||
suite.Equal(form.Sensitive, apiStatus.Sensitive) |
||||
suite.Equal(form.Language, *apiStatus.Language) |
||||
suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) |
||||
suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { |
||||
return media.ID |
||||
})) |
||||
|
||||
// Fetched the latest version of edited status from the database.
|
||||
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
suite.NoError(err) |
||||
|
||||
// Check latest status against input form data.
|
||||
suite.Equal(form.Status, latestStatus.Text) |
||||
suite.Equal(form.SpoilerText, latestStatus.ContentWarning) |
||||
suite.Equal(form.Sensitive, *latestStatus.Sensitive) |
||||
suite.Equal(form.Language, latestStatus.Language) |
||||
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) |
||||
suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) |
||||
suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs) |
||||
|
||||
// Populate all historical edits for this status.
|
||||
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) |
||||
suite.NoError(err) |
||||
|
||||
// Check previous status edit matches original status content.
|
||||
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] |
||||
suite.Equal(status.Content, previousEdit.Content) |
||||
suite.Equal(status.Text, previousEdit.Text) |
||||
suite.Equal(status.ContentWarning, previousEdit.ContentWarning) |
||||
suite.Equal(*status.Sensitive, *previousEdit.Sensitive) |
||||
suite.Equal(status.Language, previousEdit.Language) |
||||
suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) |
||||
suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs) |
||||
} |
||||
|
||||
func (suite *StatusEditTestSuite) TestEditRemoveMedia() { |
||||
// Create cancellable context to use for test.
|
||||
ctx, cncl := context.WithCancel(context.Background()) |
||||
defer cncl() |
||||
|
||||
// Get a local account to use as test requester.
|
||||
requester := suite.testAccounts["local_account_1"] |
||||
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) |
||||
|
||||
// Get requester's existing status to perform an edit on.
|
||||
status := suite.testStatuses["local_account_1_status_4"] |
||||
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
|
||||
// Prepare edit removing status media.
|
||||
form := &apimodel.StatusEditRequest{ |
||||
Status: "<p>this is some edited status text!</p>", |
||||
SpoilerText: "this status is now missing media", |
||||
Sensitive: true, |
||||
Language: "en", |
||||
MediaIDs: nil, |
||||
MediaAttributes: nil, |
||||
} |
||||
|
||||
// Pass the prepared form to the status processor to perform the edit.
|
||||
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) |
||||
suite.NotNil(apiStatus) |
||||
suite.NoError(errWithCode) |
||||
|
||||
// Check response against input form data.
|
||||
suite.Equal(form.Status, apiStatus.Text) |
||||
suite.Equal(form.SpoilerText, apiStatus.SpoilerText) |
||||
suite.Equal(form.Sensitive, apiStatus.Sensitive) |
||||
suite.Equal(form.Language, *apiStatus.Language) |
||||
suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) |
||||
suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { |
||||
return media.ID |
||||
})) |
||||
|
||||
// Fetched the latest version of edited status from the database.
|
||||
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
suite.NoError(err) |
||||
|
||||
// Check latest status against input form data.
|
||||
suite.Equal(form.Status, latestStatus.Text) |
||||
suite.Equal(form.SpoilerText, latestStatus.ContentWarning) |
||||
suite.Equal(form.Sensitive, *latestStatus.Sensitive) |
||||
suite.Equal(form.Language, latestStatus.Language) |
||||
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) |
||||
suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) |
||||
suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs) |
||||
|
||||
// Populate all historical edits for this status.
|
||||
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) |
||||
suite.NoError(err) |
||||
|
||||
// Check previous status edit matches original status content.
|
||||
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] |
||||
suite.Equal(status.Content, previousEdit.Content) |
||||
suite.Equal(status.Text, previousEdit.Text) |
||||
suite.Equal(status.ContentWarning, previousEdit.ContentWarning) |
||||
suite.Equal(*status.Sensitive, *previousEdit.Sensitive) |
||||
suite.Equal(status.Language, previousEdit.Language) |
||||
suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) |
||||
suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs) |
||||
} |
||||
|
||||
func (suite *StatusEditTestSuite) TestEditOthersStatus1() { |
||||
// Create cancellable context to use for test.
|
||||
ctx, cncl := context.WithCancel(context.Background()) |
||||
defer cncl() |
||||
|
||||
// Get a local account to use as test requester.
|
||||
requester := suite.testAccounts["local_account_1"] |
||||
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) |
||||
|
||||
// Get remote accounts's status to attempt an edit on.
|
||||
status := suite.testStatuses["remote_account_1_status_1"] |
||||
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
|
||||
// Prepare an empty request form, this
|
||||
// should be all we need to trigger it.
|
||||
form := &apimodel.StatusEditRequest{} |
||||
|
||||
// Attempt to edit other remote account's status, this should return an error.
|
||||
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) |
||||
suite.Nil(apiStatus) |
||||
suite.Equal(http.StatusNotFound, errWithCode.Code()) |
||||
suite.Equal("status does not belong to requester", errWithCode.Error()) |
||||
suite.Equal("Not Found: target status not found", errWithCode.Safe()) |
||||
} |
||||
|
||||
func (suite *StatusEditTestSuite) TestEditOthersStatus2() { |
||||
// Create cancellable context to use for test.
|
||||
ctx, cncl := context.WithCancel(context.Background()) |
||||
defer cncl() |
||||
|
||||
// Get a local account to use as test requester.
|
||||
requester := suite.testAccounts["local_account_1"] |
||||
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) |
||||
|
||||
// Get other local accounts's status to attempt edit on.
|
||||
status := suite.testStatuses["local_account_2_status_1"] |
||||
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) |
||||
|
||||
// Prepare an empty request form, this
|
||||
// should be all we need to trigger it.
|
||||
form := &apimodel.StatusEditRequest{} |
||||
|
||||
// Attempt to edit other local account's status, this should return an error.
|
||||
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) |
||||
suite.Nil(apiStatus) |
||||
suite.Equal(http.StatusNotFound, errWithCode.Code()) |
||||
suite.Equal("status does not belong to requester", errWithCode.Error()) |
||||
suite.Equal("Not Found: target status not found", errWithCode.Safe()) |
||||
} |
||||
|
||||
func TestStatusEditTestSuite(t *testing.T) { |
||||
suite.Run(t, new(StatusEditTestSuite)) |
||||
} |
||||
Loading…
Reference in new issue