mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 18:02:25 -05:00 
			
		
		
		
	[feature] Show info for pending replies, allow implicit accept of pending replies (#3322)
* [feature] Allow implicit accept of pending replies * update wording
This commit is contained in:
		
					parent
					
						
							
								2f13b72e2e
							
						
					
				
			
			
				commit
				
					
						1ce854358d
					
				
			
		
					 15 changed files with 1318 additions and 377 deletions
				
			
		|  | @ -18,6 +18,12 @@ | ||||||
| package statuses_test | package statuses_test | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"bytes" | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http/httptest" | ||||||
|  | 	"strings" | ||||||
|  | 
 | ||||||
| 	"github.com/stretchr/testify/suite" | 	"github.com/stretchr/testify/suite" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" | 	"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/db" | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
|  | @ -25,6 +31,7 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/federation" | 	"github.com/superseriousbusiness/gotosocial/internal/federation" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/filter/visibility" | 	"github.com/superseriousbusiness/gotosocial/internal/filter/visibility" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/id" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/media" | 	"github.com/superseriousbusiness/gotosocial/internal/media" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/processing" | 	"github.com/superseriousbusiness/gotosocial/internal/processing" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/state" | 	"github.com/superseriousbusiness/gotosocial/internal/state" | ||||||
|  | @ -59,6 +66,113 @@ type StatusStandardTestSuite struct { | ||||||
| 	statusModule *statuses.Module | 	statusModule *statuses.Module | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // Normalizes a status response to a determinate | ||||||
|  | // form, and pretty-prints it to JSON. | ||||||
|  | func (suite *StatusStandardTestSuite) parseStatusResponse( | ||||||
|  | 	recorder *httptest.ResponseRecorder, | ||||||
|  | ) (string, *httptest.ResponseRecorder) { | ||||||
|  | 
 | ||||||
|  | 	result := recorder.Result() | ||||||
|  | 	defer result.Body.Close() | ||||||
|  | 
 | ||||||
|  | 	data, err := io.ReadAll(result.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	rawMap := make(map[string]any) | ||||||
|  | 	if err := json.Unmarshal(data, &rawMap); err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Make status fields determinate. | ||||||
|  | 	suite.determinateStatus(rawMap) | ||||||
|  | 
 | ||||||
|  | 	// For readability, don't | ||||||
|  | 	// escape HTML, and indent json. | ||||||
|  | 	out := new(bytes.Buffer) | ||||||
|  | 	enc := json.NewEncoder(out) | ||||||
|  | 	enc.SetEscapeHTML(false) | ||||||
|  | 	enc.SetIndent("", "  ") | ||||||
|  | 
 | ||||||
|  | 	if err := enc.Encode(&rawMap); err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return strings.TrimSpace(out.String()), recorder | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *StatusStandardTestSuite) determinateStatus(rawMap map[string]any) { | ||||||
|  | 	// Replace any fields from the raw map that | ||||||
|  | 	// aren't determinate (date, id, url, etc). | ||||||
|  | 	if _, ok := rawMap["id"]; ok { | ||||||
|  | 		rawMap["id"] = id.Highest | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if _, ok := rawMap["uri"]; ok { | ||||||
|  | 		rawMap["uri"] = "http://localhost:8080/some/determinate/url" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if _, ok := rawMap["url"]; ok { | ||||||
|  | 		rawMap["url"] = "http://localhost:8080/some/determinate/url" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if _, ok := rawMap["created_at"]; ok { | ||||||
|  | 		rawMap["created_at"] = "right the hell just now babyee" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Make ID of any mentions determinate. | ||||||
|  | 	if menchiesRaw, ok := rawMap["mentions"]; ok { | ||||||
|  | 		menchies, ok := menchiesRaw.([]any) | ||||||
|  | 		if !ok { | ||||||
|  | 			suite.FailNow("couldn't coerce menchies") | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		for _, menchieRaw := range menchies { | ||||||
|  | 			menchie, ok := menchieRaw.(map[string]any) | ||||||
|  | 			if !ok { | ||||||
|  | 				suite.FailNow("couldn't coerce menchie") | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			if _, ok := menchie["id"]; ok { | ||||||
|  | 				menchie["id"] = id.Highest | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Make fields of any poll determinate. | ||||||
|  | 	if pollRaw, ok := rawMap["poll"]; ok && pollRaw != nil { | ||||||
|  | 		poll, ok := pollRaw.(map[string]any) | ||||||
|  | 		if !ok { | ||||||
|  | 			suite.FailNow("couldn't coerce poll") | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if _, ok := poll["id"]; ok { | ||||||
|  | 			poll["id"] = id.Highest | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if _, ok := poll["expires_at"]; ok { | ||||||
|  | 			poll["expires_at"] = "ah like you know whatever dude it's chill" | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Replace account since that's not really | ||||||
|  | 	// what we care about for these tests. | ||||||
|  | 	if _, ok := rawMap["account"]; ok { | ||||||
|  | 		rawMap["account"] = "yeah this is my account, what about it punk" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// If status contains an embedded | ||||||
|  | 	// reblog do the same thing for that. | ||||||
|  | 	if reblogRaw, ok := rawMap["reblog"]; ok && reblogRaw != nil { | ||||||
|  | 		reblog, ok := reblogRaw.(map[string]any) | ||||||
|  | 		if !ok { | ||||||
|  | 			suite.FailNow("couldn't coerce reblog") | ||||||
|  | 		} | ||||||
|  | 		suite.determinateStatus(reblog) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (suite *StatusStandardTestSuite) SetupSuite() { | func (suite *StatusStandardTestSuite) SetupSuite() { | ||||||
| 	suite.testTokens = testrig.NewTestTokens() | 	suite.testTokens = testrig.NewTestTokens() | ||||||
| 	suite.testClients = testrig.NewTestClients() | 	suite.testClients = testrig.NewTestClients() | ||||||
|  |  | ||||||
|  | @ -17,9 +17,6 @@ package statuses_test | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"io/ioutil" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/http/httptest" | 	"net/http/httptest" | ||||||
| 	"strings" | 	"strings" | ||||||
|  | @ -28,7 +25,7 @@ import ( | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
| 	"github.com/stretchr/testify/suite" | 	"github.com/stretchr/testify/suite" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" | 	"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" | ||||||
| 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" | 	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/oauth" | 	"github.com/superseriousbusiness/gotosocial/internal/oauth" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/testrig" | 	"github.com/superseriousbusiness/gotosocial/testrig" | ||||||
|  | @ -38,212 +35,596 @@ type StatusBoostTestSuite struct { | ||||||
| 	StatusStandardTestSuite | 	StatusStandardTestSuite | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *StatusBoostTestSuite) TestPostBoost() { | func (suite *StatusBoostTestSuite) postStatusBoost( | ||||||
| 	t := suite.testTokens["local_account_1"] | 	targetStatusID string, | ||||||
| 	oauthToken := oauth.DBTokenToToken(t) | 	app *gtsmodel.Application, | ||||||
| 
 | 	token *gtsmodel.Token, | ||||||
| 	targetStatus := suite.testStatuses["admin_account_status_1"] | 	user *gtsmodel.User, | ||||||
| 
 | 	account *gtsmodel.Account, | ||||||
| 	// setup | ) (string, *httptest.ResponseRecorder) { | ||||||
| 	recorder := httptest.NewRecorder() | 	recorder := httptest.NewRecorder() | ||||||
| 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) | 	ctx.Set(oauth.SessionAuthorizedApplication, app) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedToken, oauthToken) | 	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token)) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) | 	ctx.Set(oauth.SessionAuthorizedUser, user) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) | 	ctx.Set(oauth.SessionAuthorizedAccount, account) | ||||||
| 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting | 
 | ||||||
|  | 	const pathBase = "http://localhost:8080/api" + statuses.ReblogPath | ||||||
|  | 	path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID) | ||||||
|  | 	ctx.Request = httptest.NewRequest(http.MethodPost, path, nil) | ||||||
| 	ctx.Request.Header.Set("accept", "application/json") | 	ctx.Request.Header.Set("accept", "application/json") | ||||||
| 
 | 
 | ||||||
| 	// normally the router would populate these params from the path values, | 	// Populate target status ID. | ||||||
| 	// but because we're calling the function directly, we need to set them manually. |  | ||||||
| 	ctx.Params = gin.Params{ | 	ctx.Params = gin.Params{ | ||||||
| 		gin.Param{ | 		gin.Param{ | ||||||
| 			Key:   statuses.IDKey, | 			Key:   apiutil.IDKey, | ||||||
| 			Value: targetStatus.ID, | 			Value: targetStatusID, | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Trigger handler. | ||||||
| 	suite.statusModule.StatusBoostPOSTHandler(ctx) | 	suite.statusModule.StatusBoostPOSTHandler(ctx) | ||||||
|  | 	return suite.parseStatusResponse(recorder) | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| 	// check response | func (suite *StatusBoostTestSuite) TestPostBoost() { | ||||||
| 	suite.EqualValues(http.StatusOK, recorder.Code) | 	var ( | ||||||
|  | 		targetStatus = suite.testStatuses["admin_account_status_1"] | ||||||
|  | 		app          = suite.testApplications["application_1"] | ||||||
|  | 		token        = suite.testTokens["local_account_1"] | ||||||
|  | 		user         = suite.testUsers["local_account_1"] | ||||||
|  | 		account      = suite.testAccounts["local_account_1"] | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	result := recorder.Result() | 	out, recorder := suite.postStatusBoost( | ||||||
| 	defer result.Body.Close() | 		targetStatus.ID, | ||||||
| 	b, err := ioutil.ReadAll(result.Body) | 		app, | ||||||
| 	suite.NoError(err) | 		token, | ||||||
|  | 		user, | ||||||
|  | 		account, | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	statusReply := &apimodel.Status{} | 	// We should have OK from | ||||||
| 	err = json.Unmarshal(b, statusReply) | 	// our call to the function. | ||||||
| 	suite.NoError(err) | 	suite.Equal(http.StatusOK, recorder.Code) | ||||||
| 
 | 
 | ||||||
| 	suite.False(statusReply.Sensitive) | 	// Target status should now | ||||||
| 	suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility) | 	// be "reblogged" by us. | ||||||
| 
 | 	suite.Equal(`{ | ||||||
| 	suite.Empty(statusReply.SpoilerText) |   "account": "yeah this is my account, what about it punk", | ||||||
| 	suite.Empty(statusReply.Content) |   "application": { | ||||||
| 	suite.Equal("the_mighty_zork", statusReply.Account.Username) |     "name": "really cool gts application", | ||||||
| 	suite.Len(statusReply.MediaAttachments, 0) |     "website": "https://reallycool.app" | ||||||
| 	suite.Len(statusReply.Mentions, 0) |   }, | ||||||
| 	suite.Len(statusReply.Emojis, 0) |   "bookmarked": true, | ||||||
| 	suite.Len(statusReply.Tags, 0) |   "card": null, | ||||||
| 
 |   "content": "", | ||||||
| 	suite.NotNil(statusReply.Application) |   "created_at": "right the hell just now babyee", | ||||||
| 	suite.Equal("really cool gts application", statusReply.Application.Name) |   "emojis": [], | ||||||
| 
 |   "favourited": true, | ||||||
| 	suite.NotNil(statusReply.Reblog) |   "favourites_count": 0, | ||||||
| 	suite.Equal(1, statusReply.Reblog.ReblogsCount) |   "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
| 	suite.Equal(1, statusReply.Reblog.FavouritesCount) |   "in_reply_to_account_id": null, | ||||||
| 	suite.Equal(targetStatus.Content, statusReply.Reblog.Content) |   "in_reply_to_id": null, | ||||||
| 	suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText) |   "interaction_policy": { | ||||||
| 	suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID) |     "can_favourite": { | ||||||
| 	suite.Len(statusReply.Reblog.MediaAttachments, 1) |       "always": [ | ||||||
| 	suite.Len(statusReply.Reblog.Tags, 1) |         "public", | ||||||
| 	suite.Len(statusReply.Reblog.Emojis, 1) |         "me" | ||||||
| 	suite.True(statusReply.Reblogged) |       ], | ||||||
| 	suite.True(statusReply.Reblog.Reblogged) |       "with_approval": [] | ||||||
| 	suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name) |     }, | ||||||
|  |     "can_reblog": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reply": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "language": null, | ||||||
|  |   "media_attachments": [], | ||||||
|  |   "mentions": [], | ||||||
|  |   "muted": false, | ||||||
|  |   "pinned": false, | ||||||
|  |   "poll": null, | ||||||
|  |   "reblog": { | ||||||
|  |     "account": "yeah this is my account, what about it punk", | ||||||
|  |     "application": { | ||||||
|  |       "name": "superseriousbusiness", | ||||||
|  |       "website": "https://superserious.business" | ||||||
|  |     }, | ||||||
|  |     "bookmarked": true, | ||||||
|  |     "card": null, | ||||||
|  |     "content": "hello world! #welcome ! first post on the instance :rainbow: !", | ||||||
|  |     "created_at": "right the hell just now babyee", | ||||||
|  |     "emojis": [ | ||||||
|  |       { | ||||||
|  |         "category": "reactions", | ||||||
|  |         "shortcode": "rainbow", | ||||||
|  |         "static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png", | ||||||
|  |         "url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png", | ||||||
|  |         "visible_in_picker": true | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "favourited": true, | ||||||
|  |     "favourites_count": 1, | ||||||
|  |     "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
|  |     "in_reply_to_account_id": null, | ||||||
|  |     "in_reply_to_id": null, | ||||||
|  |     "interaction_policy": { | ||||||
|  |       "can_favourite": { | ||||||
|  |         "always": [ | ||||||
|  |           "public", | ||||||
|  |           "me" | ||||||
|  |         ], | ||||||
|  |         "with_approval": [] | ||||||
|  |       }, | ||||||
|  |       "can_reblog": { | ||||||
|  |         "always": [ | ||||||
|  |           "public", | ||||||
|  |           "me" | ||||||
|  |         ], | ||||||
|  |         "with_approval": [] | ||||||
|  |       }, | ||||||
|  |       "can_reply": { | ||||||
|  |         "always": [ | ||||||
|  |           "public", | ||||||
|  |           "me" | ||||||
|  |         ], | ||||||
|  |         "with_approval": [] | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "language": "en", | ||||||
|  |     "media_attachments": [ | ||||||
|  |       { | ||||||
|  |         "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj", | ||||||
|  |         "description": "Black and white image of some 50's style text saying: Welcome On Board", | ||||||
|  |         "id": "01F8MH6NEM8D7527KZAECTCR76", | ||||||
|  |         "meta": { | ||||||
|  |           "focus": { | ||||||
|  |             "x": 0, | ||||||
|  |             "y": 0 | ||||||
|  |           }, | ||||||
|  |           "original": { | ||||||
|  |             "aspect": 1.9047619, | ||||||
|  |             "height": 630, | ||||||
|  |             "size": "1200x630", | ||||||
|  |             "width": 1200 | ||||||
|  |           }, | ||||||
|  |           "small": { | ||||||
|  |             "aspect": 1.9104477, | ||||||
|  |             "height": 268, | ||||||
|  |             "size": "512x268", | ||||||
|  |             "width": 512 | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "preview_remote_url": null, | ||||||
|  |         "preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp", | ||||||
|  |         "remote_url": null, | ||||||
|  |         "text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", | ||||||
|  |         "type": "image", | ||||||
|  |         "url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg" | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "mentions": [], | ||||||
|  |     "muted": false, | ||||||
|  |     "pinned": false, | ||||||
|  |     "poll": null, | ||||||
|  |     "reblog": null, | ||||||
|  |     "reblogged": true, | ||||||
|  |     "reblogs_count": 1, | ||||||
|  |     "replies_count": 1, | ||||||
|  |     "sensitive": false, | ||||||
|  |     "spoiler_text": "", | ||||||
|  |     "tags": [ | ||||||
|  |       { | ||||||
|  |         "name": "welcome", | ||||||
|  |         "url": "http://localhost:8080/tags/welcome" | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "text": "hello world! #welcome ! first post on the instance :rainbow: !", | ||||||
|  |     "uri": "http://localhost:8080/some/determinate/url", | ||||||
|  |     "url": "http://localhost:8080/some/determinate/url", | ||||||
|  |     "visibility": "public" | ||||||
|  |   }, | ||||||
|  |   "reblogged": true, | ||||||
|  |   "reblogs_count": 0, | ||||||
|  |   "replies_count": 0, | ||||||
|  |   "sensitive": false, | ||||||
|  |   "spoiler_text": "", | ||||||
|  |   "tags": [], | ||||||
|  |   "uri": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "url": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "visibility": "public" | ||||||
|  | }`, out) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() { | func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() { | ||||||
| 	t := suite.testTokens["local_account_1"] | 	var ( | ||||||
| 	oauthToken := oauth.DBTokenToToken(t) | 		targetStatus = suite.testStatuses["local_account_1_status_5"] | ||||||
|  | 		app          = suite.testApplications["application_1"] | ||||||
|  | 		token        = suite.testTokens["local_account_1"] | ||||||
|  | 		user         = suite.testUsers["local_account_1"] | ||||||
|  | 		account      = suite.testAccounts["local_account_1"] | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	testStatus := suite.testStatuses["local_account_1_status_5"] | 	out, recorder := suite.postStatusBoost( | ||||||
| 	testAccount := suite.testAccounts["local_account_1"] | 		targetStatus.ID, | ||||||
| 	testUser := suite.testUsers["local_account_1"] | 		app, | ||||||
|  | 		token, | ||||||
|  | 		user, | ||||||
|  | 		account, | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	recorder := httptest.NewRecorder() | 	// We should have OK from | ||||||
| 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | 	// our call to the function. | ||||||
| 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) | 	suite.Equal(http.StatusOK, recorder.Code) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedToken, oauthToken) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedUser, testUser) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedAccount, testAccount) |  | ||||||
| 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", testStatus.ID, 1)), nil) |  | ||||||
| 	ctx.Request.Header.Set("accept", "application/json") |  | ||||||
| 
 | 
 | ||||||
| 	ctx.Params = gin.Params{ | 	// Target status should now | ||||||
| 		gin.Param{ | 	// be "reblogged" by us. | ||||||
| 			Key:   statuses.IDKey, | 	suite.Equal(`{ | ||||||
| 			Value: testStatus.ID, |   "account": "yeah this is my account, what about it punk", | ||||||
|  |   "application": { | ||||||
|  |     "name": "really cool gts application", | ||||||
|  |     "website": "https://reallycool.app" | ||||||
|   }, |   }, | ||||||
|  |   "bookmarked": false, | ||||||
|  |   "card": null, | ||||||
|  |   "content": "", | ||||||
|  |   "created_at": "right the hell just now babyee", | ||||||
|  |   "emojis": [], | ||||||
|  |   "favourited": false, | ||||||
|  |   "favourites_count": 0, | ||||||
|  |   "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
|  |   "in_reply_to_account_id": null, | ||||||
|  |   "in_reply_to_id": null, | ||||||
|  |   "interaction_policy": { | ||||||
|  |     "can_favourite": { | ||||||
|  |       "always": [ | ||||||
|  |         "author", | ||||||
|  |         "followers", | ||||||
|  |         "mentioned", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reblog": { | ||||||
|  |       "always": [ | ||||||
|  |         "author", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reply": { | ||||||
|  |       "always": [ | ||||||
|  |         "author", | ||||||
|  |         "followers", | ||||||
|  |         "mentioned", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|     } |     } | ||||||
| 
 |   }, | ||||||
| 	suite.statusModule.StatusBoostPOSTHandler(ctx) |   "language": null, | ||||||
| 
 |   "media_attachments": [], | ||||||
| 	// check response |   "mentions": [], | ||||||
| 	suite.EqualValues(http.StatusOK, recorder.Code) |   "muted": false, | ||||||
| 
 |   "pinned": false, | ||||||
| 	result := recorder.Result() |   "poll": null, | ||||||
| 	defer result.Body.Close() |   "reblog": { | ||||||
| 	b, err := ioutil.ReadAll(result.Body) |     "account": "yeah this is my account, what about it punk", | ||||||
| 	suite.NoError(err) |     "application": { | ||||||
| 
 |       "name": "really cool gts application", | ||||||
| 	responseStatus := &apimodel.Status{} |       "website": "https://reallycool.app" | ||||||
| 	err = json.Unmarshal(b, responseStatus) |     }, | ||||||
| 	suite.NoError(err) |     "bookmarked": false, | ||||||
| 
 |     "card": null, | ||||||
| 	suite.False(responseStatus.Sensitive) |     "content": "hi!", | ||||||
| 	suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility) |     "created_at": "right the hell just now babyee", | ||||||
| 
 |     "emojis": [], | ||||||
| 	suite.Empty(responseStatus.SpoilerText) |     "favourited": false, | ||||||
| 	suite.Empty(responseStatus.Content) |     "favourites_count": 0, | ||||||
| 	suite.Equal("the_mighty_zork", responseStatus.Account.Username) |     "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
| 	suite.Len(responseStatus.MediaAttachments, 0) |     "in_reply_to_account_id": null, | ||||||
| 	suite.Len(responseStatus.Mentions, 0) |     "in_reply_to_id": null, | ||||||
| 	suite.Len(responseStatus.Emojis, 0) |     "interaction_policy": { | ||||||
| 	suite.Len(responseStatus.Tags, 0) |       "can_favourite": { | ||||||
| 
 |         "always": [ | ||||||
| 	suite.NotNil(responseStatus.Application) |           "author", | ||||||
| 	suite.Equal("really cool gts application", responseStatus.Application.Name) |           "followers", | ||||||
| 
 |           "mentioned", | ||||||
| 	suite.NotNil(responseStatus.Reblog) |           "me" | ||||||
| 	suite.Equal(1, responseStatus.Reblog.ReblogsCount) |         ], | ||||||
| 	suite.Equal(0, responseStatus.Reblog.FavouritesCount) |         "with_approval": [] | ||||||
| 	suite.Equal(testStatus.Content, responseStatus.Reblog.Content) |       }, | ||||||
| 	suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText) |       "can_reblog": { | ||||||
| 	suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID) |         "always": [ | ||||||
| 	suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility) |           "author", | ||||||
| 	suite.Empty(responseStatus.Reblog.MediaAttachments) |           "me" | ||||||
| 	suite.Empty(responseStatus.Reblog.Tags) |         ], | ||||||
| 	suite.Empty(responseStatus.Reblog.Emojis) |         "with_approval": [] | ||||||
| 	suite.True(responseStatus.Reblogged) |       }, | ||||||
| 	suite.True(responseStatus.Reblog.Reblogged) |       "can_reply": { | ||||||
| 	suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name) |         "always": [ | ||||||
|  |           "author", | ||||||
|  |           "followers", | ||||||
|  |           "mentioned", | ||||||
|  |           "me" | ||||||
|  |         ], | ||||||
|  |         "with_approval": [] | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "language": "en", | ||||||
|  |     "media_attachments": [], | ||||||
|  |     "mentions": [], | ||||||
|  |     "muted": false, | ||||||
|  |     "pinned": false, | ||||||
|  |     "poll": null, | ||||||
|  |     "reblog": null, | ||||||
|  |     "reblogged": true, | ||||||
|  |     "reblogs_count": 1, | ||||||
|  |     "replies_count": 0, | ||||||
|  |     "sensitive": false, | ||||||
|  |     "spoiler_text": "", | ||||||
|  |     "tags": [], | ||||||
|  |     "text": "hi!", | ||||||
|  |     "uri": "http://localhost:8080/some/determinate/url", | ||||||
|  |     "url": "http://localhost:8080/some/determinate/url", | ||||||
|  |     "visibility": "private" | ||||||
|  |   }, | ||||||
|  |   "reblogged": true, | ||||||
|  |   "reblogs_count": 0, | ||||||
|  |   "replies_count": 0, | ||||||
|  |   "sensitive": false, | ||||||
|  |   "spoiler_text": "", | ||||||
|  |   "tags": [], | ||||||
|  |   "uri": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "url": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "visibility": "private" | ||||||
|  | }`, out) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // try to boost a status that's not boostable / visible to us | // Try to boost a status that's | ||||||
|  | // not boostable / visible to us. | ||||||
| func (suite *StatusBoostTestSuite) TestPostUnboostable() { | func (suite *StatusBoostTestSuite) TestPostUnboostable() { | ||||||
| 	t := suite.testTokens["local_account_1"] | 	var ( | ||||||
| 	oauthToken := oauth.DBTokenToToken(t) | 		targetStatus = suite.testStatuses["local_account_2_status_4"] | ||||||
|  | 		app          = suite.testApplications["application_1"] | ||||||
|  | 		token        = suite.testTokens["local_account_1"] | ||||||
|  | 		user         = suite.testUsers["local_account_1"] | ||||||
|  | 		account      = suite.testAccounts["local_account_1"] | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	targetStatus := suite.testStatuses["local_account_2_status_4"] | 	out, recorder := suite.postStatusBoost( | ||||||
|  | 		targetStatus.ID, | ||||||
|  | 		app, | ||||||
|  | 		token, | ||||||
|  | 		user, | ||||||
|  | 		account, | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	// setup | 	// We should have 403 from | ||||||
| 	recorder := httptest.NewRecorder() | 	// our call to the function. | ||||||
| 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedToken, oauthToken) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) |  | ||||||
| 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting |  | ||||||
| 	ctx.Request.Header.Set("accept", "application/json") |  | ||||||
| 
 |  | ||||||
| 	// normally the router would populate these params from the path values, |  | ||||||
| 	// but because we're calling the function directly, we need to set them manually. |  | ||||||
| 	ctx.Params = gin.Params{ |  | ||||||
| 		gin.Param{ |  | ||||||
| 			Key:   statuses.IDKey, |  | ||||||
| 			Value: targetStatus.ID, |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	suite.statusModule.StatusBoostPOSTHandler(ctx) |  | ||||||
| 
 |  | ||||||
| 	// check response |  | ||||||
| 	suite.Equal(http.StatusForbidden, recorder.Code) | 	suite.Equal(http.StatusForbidden, recorder.Code) | ||||||
| 
 | 
 | ||||||
| 	result := recorder.Result() | 	// We should have a helpful message. | ||||||
| 	defer result.Body.Close() | 	suite.Equal(`{ | ||||||
| 	b, err := ioutil.ReadAll(result.Body) |   "error": "Forbidden: you do not have permission to boost this status" | ||||||
| 	suite.NoError(err) | }`, out) | ||||||
| 	suite.Equal(`{"error":"Forbidden: you do not have permission to boost this status"}`, string(b)) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // try to boost a status that's not visible to the user | // Try to boost a status that's not visible to the user. | ||||||
| func (suite *StatusBoostTestSuite) TestPostNotVisible() { | func (suite *StatusBoostTestSuite) TestPostNotVisible() { | ||||||
| 	// stop local_account_2 following zork | 	// Stop local_account_2 following zork. | ||||||
| 	err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, >smodel.Follow{}) | 	err := suite.db.DeleteFollowByID( | ||||||
| 	suite.NoError(err) | 		context.Background(), | ||||||
| 
 | 		suite.testFollows["local_account_2_local_account_1"].ID, | ||||||
| 	t := suite.testTokens["local_account_2"] | 	) | ||||||
| 	oauthToken := oauth.DBTokenToToken(t) | 	if err != nil { | ||||||
| 
 | 		suite.FailNow(err.Error()) | ||||||
| 	targetStatus := suite.testStatuses["local_account_1_status_3"] // this is a mutual only status and these accounts aren't mutuals |  | ||||||
| 
 |  | ||||||
| 	// setup |  | ||||||
| 	recorder := httptest.NewRecorder() |  | ||||||
| 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedToken, oauthToken) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"]) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"]) |  | ||||||
| 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting |  | ||||||
| 	ctx.Request.Header.Set("accept", "application/json") |  | ||||||
| 
 |  | ||||||
| 	// normally the router would populate these params from the path values, |  | ||||||
| 	// but because we're calling the function directly, we need to set them manually. |  | ||||||
| 	ctx.Params = gin.Params{ |  | ||||||
| 		gin.Param{ |  | ||||||
| 			Key:   statuses.IDKey, |  | ||||||
| 			Value: targetStatus.ID, |  | ||||||
| 		}, |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	suite.statusModule.StatusBoostPOSTHandler(ctx) | 	var ( | ||||||
|  | 		// This is a mutual only status and | ||||||
|  | 		// these accounts aren't mutuals anymore. | ||||||
|  | 		targetStatus = suite.testStatuses["local_account_1_status_3"] | ||||||
|  | 		app          = suite.testApplications["application_1"] | ||||||
|  | 		token        = suite.testTokens["local_account_2"] | ||||||
|  | 		user         = suite.testUsers["local_account_2"] | ||||||
|  | 		account      = suite.testAccounts["local_account_2"] | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	// check response | 	out, recorder := suite.postStatusBoost( | ||||||
| 	suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible | 		targetStatus.ID, | ||||||
|  | 		app, | ||||||
|  | 		token, | ||||||
|  | 		user, | ||||||
|  | 		account, | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	// We should have 404 from | ||||||
|  | 	// our call to the function. | ||||||
|  | 	suite.Equal(http.StatusNotFound, recorder.Code) | ||||||
|  | 
 | ||||||
|  | 	// We should have a helpful message. | ||||||
|  | 	suite.Equal(`{ | ||||||
|  |   "error": "Not Found: target status not found" | ||||||
|  | }`, out) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Boost a status that's pending approval by us. | ||||||
|  | func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() { | ||||||
|  | 	var ( | ||||||
|  | 		targetStatus = suite.testStatuses["admin_account_status_5"] | ||||||
|  | 		app          = suite.testApplications["application_1"] | ||||||
|  | 		token        = suite.testTokens["local_account_2"] | ||||||
|  | 		user         = suite.testUsers["local_account_2"] | ||||||
|  | 		account      = suite.testAccounts["local_account_2"] | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	out, recorder := suite.postStatusBoost( | ||||||
|  | 		targetStatus.ID, | ||||||
|  | 		app, | ||||||
|  | 		token, | ||||||
|  | 		user, | ||||||
|  | 		account, | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	// We should have OK from | ||||||
|  | 	// our call to the function. | ||||||
|  | 	suite.Equal(http.StatusOK, recorder.Code) | ||||||
|  | 
 | ||||||
|  | 	// Target status should now | ||||||
|  | 	// be "reblogged" by us. | ||||||
|  | 	suite.Equal(`{ | ||||||
|  |   "account": "yeah this is my account, what about it punk", | ||||||
|  |   "application": { | ||||||
|  |     "name": "really cool gts application", | ||||||
|  |     "website": "https://reallycool.app" | ||||||
|  |   }, | ||||||
|  |   "bookmarked": false, | ||||||
|  |   "card": null, | ||||||
|  |   "content": "", | ||||||
|  |   "created_at": "right the hell just now babyee", | ||||||
|  |   "emojis": [], | ||||||
|  |   "favourited": false, | ||||||
|  |   "favourites_count": 0, | ||||||
|  |   "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
|  |   "in_reply_to_account_id": null, | ||||||
|  |   "in_reply_to_id": null, | ||||||
|  |   "interaction_policy": { | ||||||
|  |     "can_favourite": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reblog": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reply": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "language": null, | ||||||
|  |   "media_attachments": [], | ||||||
|  |   "mentions": [], | ||||||
|  |   "muted": false, | ||||||
|  |   "pinned": false, | ||||||
|  |   "poll": null, | ||||||
|  |   "reblog": { | ||||||
|  |     "account": "yeah this is my account, what about it punk", | ||||||
|  |     "application": { | ||||||
|  |       "name": "superseriousbusiness", | ||||||
|  |       "website": "https://superserious.business" | ||||||
|  |     }, | ||||||
|  |     "bookmarked": false, | ||||||
|  |     "card": null, | ||||||
|  |     "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", | ||||||
|  |     "created_at": "right the hell just now babyee", | ||||||
|  |     "emojis": [], | ||||||
|  |     "favourited": false, | ||||||
|  |     "favourites_count": 0, | ||||||
|  |     "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
|  |     "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  |     "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5", | ||||||
|  |     "interaction_policy": { | ||||||
|  |       "can_favourite": { | ||||||
|  |         "always": [ | ||||||
|  |           "public", | ||||||
|  |           "me" | ||||||
|  |         ], | ||||||
|  |         "with_approval": [] | ||||||
|  |       }, | ||||||
|  |       "can_reblog": { | ||||||
|  |         "always": [ | ||||||
|  |           "public", | ||||||
|  |           "me" | ||||||
|  |         ], | ||||||
|  |         "with_approval": [] | ||||||
|  |       }, | ||||||
|  |       "can_reply": { | ||||||
|  |         "always": [ | ||||||
|  |           "public", | ||||||
|  |           "me" | ||||||
|  |         ], | ||||||
|  |         "with_approval": [] | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "language": null, | ||||||
|  |     "media_attachments": [], | ||||||
|  |     "mentions": [ | ||||||
|  |       { | ||||||
|  |         "acct": "1happyturtle", | ||||||
|  |         "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
|  |         "url": "http://localhost:8080/@1happyturtle", | ||||||
|  |         "username": "1happyturtle" | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "muted": false, | ||||||
|  |     "pinned": false, | ||||||
|  |     "poll": null, | ||||||
|  |     "reblog": null, | ||||||
|  |     "reblogged": true, | ||||||
|  |     "reblogs_count": 1, | ||||||
|  |     "replies_count": 0, | ||||||
|  |     "sensitive": false, | ||||||
|  |     "spoiler_text": "", | ||||||
|  |     "tags": [], | ||||||
|  |     "text": "Hi @1happyturtle, can I reply?", | ||||||
|  |     "uri": "http://localhost:8080/some/determinate/url", | ||||||
|  |     "url": "http://localhost:8080/some/determinate/url", | ||||||
|  |     "visibility": "unlisted" | ||||||
|  |   }, | ||||||
|  |   "reblogged": true, | ||||||
|  |   "reblogs_count": 0, | ||||||
|  |   "replies_count": 0, | ||||||
|  |   "sensitive": false, | ||||||
|  |   "spoiler_text": "", | ||||||
|  |   "tags": [], | ||||||
|  |   "uri": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "url": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "visibility": "unlisted" | ||||||
|  | }`, out) | ||||||
|  | 
 | ||||||
|  | 	// Target status should no | ||||||
|  | 	// longer be pending approval. | ||||||
|  | 	dbStatus, err := suite.state.DB.GetStatusByID( | ||||||
|  | 		context.Background(), | ||||||
|  | 		targetStatus.ID, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 	suite.False(*dbStatus.PendingApproval) | ||||||
|  | 
 | ||||||
|  | 	// There should be an Accept | ||||||
|  | 	// stored for the target status. | ||||||
|  | 	intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI( | ||||||
|  | 		context.Background(), targetStatus.URI, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 	suite.NotZero(intReq.AcceptedAt) | ||||||
|  | 	suite.NotEmpty(intReq.URI) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestStatusBoostTestSuite(t *testing.T) { | func TestStatusBoostTestSuite(t *testing.T) { | ||||||
|  |  | ||||||
|  | @ -20,18 +20,14 @@ package statuses_test | ||||||
| import ( | import ( | ||||||
| 	"bytes" | 	"bytes" | ||||||
| 	"context" | 	"context" | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"io" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/http/httptest" | 	"net/http/httptest" | ||||||
| 	"strings" |  | ||||||
| 	"testing" | 	"testing" | ||||||
| 
 | 
 | ||||||
| 	"github.com/stretchr/testify/suite" | 	"github.com/stretchr/testify/suite" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" | 	"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" | ||||||
| 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" | 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/id" |  | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/oauth" | 	"github.com/superseriousbusiness/gotosocial/internal/oauth" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/testrig" | 	"github.com/superseriousbusiness/gotosocial/testrig" | ||||||
| ) | ) | ||||||
|  | @ -81,91 +77,7 @@ func (suite *StatusCreateTestSuite) postStatus( | ||||||
| 
 | 
 | ||||||
| 	// Trigger handler. | 	// Trigger handler. | ||||||
| 	suite.statusModule.StatusCreatePOSTHandler(ctx) | 	suite.statusModule.StatusCreatePOSTHandler(ctx) | ||||||
| 
 | 	return suite.parseStatusResponse(recorder) | ||||||
| 	result := recorder.Result() |  | ||||||
| 	defer result.Body.Close() |  | ||||||
| 
 |  | ||||||
| 	data, err := io.ReadAll(result.Body) |  | ||||||
| 	if err != nil { |  | ||||||
| 		suite.FailNow(err.Error()) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	rawMap := make(map[string]any) |  | ||||||
| 	if err := json.Unmarshal(data, &rawMap); err != nil { |  | ||||||
| 		suite.FailNow(err.Error()) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Replace any fields from the raw map that |  | ||||||
| 	// aren't determinate (date, id, url, etc). |  | ||||||
| 	if _, ok := rawMap["id"]; ok { |  | ||||||
| 		rawMap["id"] = id.Highest |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if _, ok := rawMap["uri"]; ok { |  | ||||||
| 		rawMap["uri"] = "http://localhost:8080/some/determinate/url" |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if _, ok := rawMap["url"]; ok { |  | ||||||
| 		rawMap["url"] = "http://localhost:8080/some/determinate/url" |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if _, ok := rawMap["created_at"]; ok { |  | ||||||
| 		rawMap["created_at"] = "right the hell just now babyee" |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Make ID of any mentions determinate. |  | ||||||
| 	if menchiesRaw, ok := rawMap["mentions"]; ok { |  | ||||||
| 		menchies, ok := menchiesRaw.([]any) |  | ||||||
| 		if !ok { |  | ||||||
| 			suite.FailNow("couldn't coerce menchies") |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		for _, menchieRaw := range menchies { |  | ||||||
| 			menchie, ok := menchieRaw.(map[string]any) |  | ||||||
| 			if !ok { |  | ||||||
| 				suite.FailNow("couldn't coerce menchie") |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if _, ok := menchie["id"]; ok { |  | ||||||
| 				menchie["id"] = id.Highest |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Make fields of any poll determinate. |  | ||||||
| 	if pollRaw, ok := rawMap["poll"]; ok && pollRaw != nil { |  | ||||||
| 		poll, ok := pollRaw.(map[string]any) |  | ||||||
| 		if !ok { |  | ||||||
| 			suite.FailNow("couldn't coerce poll") |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if _, ok := poll["id"]; ok { |  | ||||||
| 			poll["id"] = id.Highest |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if _, ok := poll["expires_at"]; ok { |  | ||||||
| 			poll["expires_at"] = "ah like you know whatever dude it's chill" |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Replace account since that's not really |  | ||||||
| 	// what we care about for these tests. |  | ||||||
| 	if _, ok := rawMap["account"]; ok { |  | ||||||
| 		rawMap["account"] = "yeah this is my account, what about it punk" |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// For readability, don't |  | ||||||
| 	// escape HTML, and indent json. |  | ||||||
| 	out := new(bytes.Buffer) |  | ||||||
| 	enc := json.NewEncoder(out) |  | ||||||
| 	enc.SetEscapeHTML(false) |  | ||||||
| 	enc.SetIndent("", "  ") |  | ||||||
| 
 |  | ||||||
| 	if err := enc.Encode(&rawMap); err != nil { |  | ||||||
| 		suite.FailNow(err.Error()) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return strings.TrimSpace(out.String()), recorder |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Post a new status with some custom visibility settings | // Post a new status with some custom visibility settings | ||||||
|  |  | ||||||
|  | @ -18,20 +18,18 @@ | ||||||
| package statuses_test | package statuses_test | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"context" | ||||||
| 	"fmt" |  | ||||||
| 	"io/ioutil" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/http/httptest" | 	"net/http/httptest" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"testing" | 	"testing" | ||||||
| 
 | 
 | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
| 	"github.com/stretchr/testify/assert" |  | ||||||
| 	"github.com/stretchr/testify/suite" | 	"github.com/stretchr/testify/suite" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" | 	"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 
 | 
 | ||||||
| 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" | 	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/oauth" | 	"github.com/superseriousbusiness/gotosocial/internal/oauth" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/testrig" | 	"github.com/superseriousbusiness/gotosocial/testrig" | ||||||
| ) | ) | ||||||
|  | @ -40,90 +38,260 @@ type StatusFaveTestSuite struct { | ||||||
| 	StatusStandardTestSuite | 	StatusStandardTestSuite | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // fave a status | func (suite *StatusFaveTestSuite) postStatusFave( | ||||||
| func (suite *StatusFaveTestSuite) TestPostFave() { | 	targetStatusID string, | ||||||
| 	t := suite.testTokens["local_account_1"] | 	app *gtsmodel.Application, | ||||||
| 	oauthToken := oauth.DBTokenToToken(t) | 	token *gtsmodel.Token, | ||||||
| 
 | 	user *gtsmodel.User, | ||||||
| 	targetStatus := suite.testStatuses["admin_account_status_2"] | 	account *gtsmodel.Account, | ||||||
| 
 | ) (string, *httptest.ResponseRecorder) { | ||||||
| 	// setup |  | ||||||
| 	recorder := httptest.NewRecorder() | 	recorder := httptest.NewRecorder() | ||||||
| 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) | 	ctx.Set(oauth.SessionAuthorizedApplication, app) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedToken, oauthToken) | 	ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token)) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) | 	ctx.Set(oauth.SessionAuthorizedUser, user) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) | 	ctx.Set(oauth.SessionAuthorizedAccount, account) | ||||||
| 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting | 
 | ||||||
|  | 	const pathBase = "http://localhost:8080/api" + statuses.FavouritePath | ||||||
|  | 	path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID) | ||||||
|  | 	ctx.Request = httptest.NewRequest(http.MethodPost, path, nil) | ||||||
| 	ctx.Request.Header.Set("accept", "application/json") | 	ctx.Request.Header.Set("accept", "application/json") | ||||||
| 
 | 
 | ||||||
| 	// normally the router would populate these params from the path values, | 	// Populate target status ID. | ||||||
| 	// but because we're calling the function directly, we need to set them manually. |  | ||||||
| 	ctx.Params = gin.Params{ | 	ctx.Params = gin.Params{ | ||||||
| 		gin.Param{ | 		gin.Param{ | ||||||
| 			Key:   statuses.IDKey, | 			Key:   apiutil.IDKey, | ||||||
| 			Value: targetStatus.ID, | 			Value: targetStatusID, | ||||||
| 		}, | 		}, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Trigger handler. | ||||||
| 	suite.statusModule.StatusFavePOSTHandler(ctx) | 	suite.statusModule.StatusFavePOSTHandler(ctx) | ||||||
| 
 | 	return suite.parseStatusResponse(recorder) | ||||||
| 	// check response |  | ||||||
| 	suite.EqualValues(http.StatusOK, recorder.Code) |  | ||||||
| 
 |  | ||||||
| 	result := recorder.Result() |  | ||||||
| 	defer result.Body.Close() |  | ||||||
| 	b, err := ioutil.ReadAll(result.Body) |  | ||||||
| 	assert.NoError(suite.T(), err) |  | ||||||
| 
 |  | ||||||
| 	statusReply := &apimodel.Status{} |  | ||||||
| 	err = json.Unmarshal(b, statusReply) |  | ||||||
| 	assert.NoError(suite.T(), err) |  | ||||||
| 
 |  | ||||||
| 	assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) |  | ||||||
| 	assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) |  | ||||||
| 	assert.True(suite.T(), statusReply.Sensitive) |  | ||||||
| 	assert.Equal(suite.T(), apimodel.VisibilityPublic, statusReply.Visibility) |  | ||||||
| 	assert.True(suite.T(), statusReply.Favourited) |  | ||||||
| 	assert.Equal(suite.T(), 1, statusReply.FavouritesCount) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // try to fave a status that's not faveable | // Fave a status we haven't faved yet. | ||||||
| func (suite *StatusFaveTestSuite) TestPostUnfaveable() { | func (suite *StatusFaveTestSuite) TestPostFave() { | ||||||
| 	t := suite.testTokens["admin_account"] | 	var ( | ||||||
| 	oauthToken := oauth.DBTokenToToken(t) | 		targetStatus = suite.testStatuses["admin_account_status_2"] | ||||||
|  | 		app          = suite.testApplications["application_1"] | ||||||
|  | 		token        = suite.testTokens["local_account_1"] | ||||||
|  | 		user         = suite.testUsers["local_account_1"] | ||||||
|  | 		account      = suite.testAccounts["local_account_1"] | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	targetStatus := suite.testStatuses["local_account_1_status_3"] // this one is unlikeable | 	out, recorder := suite.postStatusFave( | ||||||
|  | 		targetStatus.ID, | ||||||
|  | 		app, | ||||||
|  | 		token, | ||||||
|  | 		user, | ||||||
|  | 		account, | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	// setup | 	// We should have OK from | ||||||
| 	recorder := httptest.NewRecorder() | 	// our call to the function. | ||||||
| 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | 	suite.Equal(http.StatusOK, recorder.Code) | ||||||
| 	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedToken, oauthToken) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["admin_account"]) |  | ||||||
| 	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["admin_account"]) |  | ||||||
| 	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting |  | ||||||
| 	ctx.Request.Header.Set("accept", "application/json") |  | ||||||
| 
 | 
 | ||||||
| 	// normally the router would populate these params from the path values, | 	// Target status should now | ||||||
| 	// but because we're calling the function directly, we need to set them manually. | 	// be "favourited" by us. | ||||||
| 	ctx.Params = gin.Params{ | 	suite.Equal(`{ | ||||||
| 		gin.Param{ |   "account": "yeah this is my account, what about it punk", | ||||||
| 			Key:   statuses.IDKey, |   "application": { | ||||||
| 			Value: targetStatus.ID, |     "name": "superseriousbusiness", | ||||||
|  |     "website": "https://superserious.business" | ||||||
|   }, |   }, | ||||||
|  |   "bookmarked": false, | ||||||
|  |   "card": null, | ||||||
|  |   "content": "🐕🐕🐕🐕🐕", | ||||||
|  |   "created_at": "right the hell just now babyee", | ||||||
|  |   "emojis": [], | ||||||
|  |   "favourited": true, | ||||||
|  |   "favourites_count": 1, | ||||||
|  |   "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
|  |   "in_reply_to_account_id": null, | ||||||
|  |   "in_reply_to_id": null, | ||||||
|  |   "interaction_policy": { | ||||||
|  |     "can_favourite": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reblog": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reply": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|     } |     } | ||||||
|  |   }, | ||||||
|  |   "language": "en", | ||||||
|  |   "media_attachments": [], | ||||||
|  |   "mentions": [], | ||||||
|  |   "muted": false, | ||||||
|  |   "pinned": false, | ||||||
|  |   "poll": null, | ||||||
|  |   "reblog": null, | ||||||
|  |   "reblogged": false, | ||||||
|  |   "reblogs_count": 0, | ||||||
|  |   "replies_count": 0, | ||||||
|  |   "sensitive": true, | ||||||
|  |   "spoiler_text": "open to see some puppies", | ||||||
|  |   "tags": [], | ||||||
|  |   "text": "🐕🐕🐕🐕🐕", | ||||||
|  |   "uri": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "url": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "visibility": "public" | ||||||
|  | }`, out) | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| 	suite.statusModule.StatusFavePOSTHandler(ctx) | // Try to fave a status | ||||||
|  | // that's not faveable by us. | ||||||
|  | func (suite *StatusFaveTestSuite) TestPostUnfaveable() { | ||||||
|  | 	var ( | ||||||
|  | 		targetStatus = suite.testStatuses["local_account_1_status_3"] | ||||||
|  | 		app          = suite.testApplications["application_1"] | ||||||
|  | 		token        = suite.testTokens["admin_account"] | ||||||
|  | 		user         = suite.testUsers["admin_account"] | ||||||
|  | 		account      = suite.testAccounts["admin_account"] | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	// check response | 	out, recorder := suite.postStatusFave( | ||||||
| 	suite.EqualValues(http.StatusForbidden, recorder.Code) | 		targetStatus.ID, | ||||||
|  | 		app, | ||||||
|  | 		token, | ||||||
|  | 		user, | ||||||
|  | 		account, | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	result := recorder.Result() | 	// We should have 403 from | ||||||
| 	defer result.Body.Close() | 	// our call to the function. | ||||||
| 	b, err := ioutil.ReadAll(result.Body) | 	suite.Equal(http.StatusForbidden, recorder.Code) | ||||||
| 	assert.NoError(suite.T(), err) | 
 | ||||||
| 	assert.Equal(suite.T(), `{"error":"Forbidden: you do not have permission to fave this status"}`, string(b)) | 	// We should get a helpful error. | ||||||
|  | 	suite.Equal(`{ | ||||||
|  |   "error": "Forbidden: you do not have permission to fave this status" | ||||||
|  | }`, out) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Fave a status that's pending approval by us. | ||||||
|  | func (suite *StatusFaveTestSuite) TestPostFaveImplicitAccept() { | ||||||
|  | 	var ( | ||||||
|  | 		targetStatus = suite.testStatuses["admin_account_status_5"] | ||||||
|  | 		app          = suite.testApplications["application_1"] | ||||||
|  | 		token        = suite.testTokens["local_account_2"] | ||||||
|  | 		user         = suite.testUsers["local_account_2"] | ||||||
|  | 		account      = suite.testAccounts["local_account_2"] | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	out, recorder := suite.postStatusFave( | ||||||
|  | 		targetStatus.ID, | ||||||
|  | 		app, | ||||||
|  | 		token, | ||||||
|  | 		user, | ||||||
|  | 		account, | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	// We should have OK from | ||||||
|  | 	// our call to the function. | ||||||
|  | 	suite.Equal(http.StatusOK, recorder.Code) | ||||||
|  | 
 | ||||||
|  | 	// Target status should now | ||||||
|  | 	// be "favourited" by us. | ||||||
|  | 	suite.Equal(`{ | ||||||
|  |   "account": "yeah this is my account, what about it punk", | ||||||
|  |   "application": { | ||||||
|  |     "name": "superseriousbusiness", | ||||||
|  |     "website": "https://superserious.business" | ||||||
|  |   }, | ||||||
|  |   "bookmarked": false, | ||||||
|  |   "card": null, | ||||||
|  |   "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", | ||||||
|  |   "created_at": "right the hell just now babyee", | ||||||
|  |   "emojis": [], | ||||||
|  |   "favourited": true, | ||||||
|  |   "favourites_count": 1, | ||||||
|  |   "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
|  |   "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  |   "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5", | ||||||
|  |   "interaction_policy": { | ||||||
|  |     "can_favourite": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reblog": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reply": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "language": null, | ||||||
|  |   "media_attachments": [], | ||||||
|  |   "mentions": [ | ||||||
|  |     { | ||||||
|  |       "acct": "1happyturtle", | ||||||
|  |       "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", | ||||||
|  |       "url": "http://localhost:8080/@1happyturtle", | ||||||
|  |       "username": "1happyturtle" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "muted": false, | ||||||
|  |   "pinned": false, | ||||||
|  |   "poll": null, | ||||||
|  |   "reblog": null, | ||||||
|  |   "reblogged": false, | ||||||
|  |   "reblogs_count": 0, | ||||||
|  |   "replies_count": 0, | ||||||
|  |   "sensitive": false, | ||||||
|  |   "spoiler_text": "", | ||||||
|  |   "tags": [], | ||||||
|  |   "text": "Hi @1happyturtle, can I reply?", | ||||||
|  |   "uri": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "url": "http://localhost:8080/some/determinate/url", | ||||||
|  |   "visibility": "unlisted" | ||||||
|  | }`, out) | ||||||
|  | 
 | ||||||
|  | 	// Target status should no | ||||||
|  | 	// longer be pending approval. | ||||||
|  | 	dbStatus, err := suite.state.DB.GetStatusByID( | ||||||
|  | 		context.Background(), | ||||||
|  | 		targetStatus.ID, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 	suite.False(*dbStatus.PendingApproval) | ||||||
|  | 
 | ||||||
|  | 	// There should be an Accept | ||||||
|  | 	// stored for the target status. | ||||||
|  | 	intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI( | ||||||
|  | 		context.Background(), targetStatus.URI, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 	suite.NotZero(intReq.AcceptedAt) | ||||||
|  | 	suite.NotEmpty(intReq.URI) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestStatusFaveTestSuite(t *testing.T) { | func TestStatusFaveTestSuite(t *testing.T) { | ||||||
|  |  | ||||||
|  | @ -223,7 +223,7 @@ func NewProcessor( | ||||||
| 	processor.tags = tags.New(state, converter) | 	processor.tags = tags.New(state, converter) | ||||||
| 	processor.timeline = timeline.New(state, converter, visFilter) | 	processor.timeline = timeline.New(state, converter, visFilter) | ||||||
| 	processor.search = search.New(state, federator, converter, visFilter) | 	processor.search = search.New(state, federator, converter, visFilter) | ||||||
| 	processor.status = status.New(state, &common, &processor.polls, federator, converter, visFilter, intFilter, parseMentionFunc) | 	processor.status = status.New(state, &common, &processor.polls, &processor.interactionRequests, federator, converter, visFilter, intFilter, parseMentionFunc) | ||||||
| 	processor.user = user.New(state, converter, oauthServer, emailSender) | 	processor.user = user.New(state, converter, oauthServer, emailSender) | ||||||
| 
 | 
 | ||||||
| 	// The advanced migrations processor sequences advanced migrations from all other processors. | 	// The advanced migrations processor sequences advanced migrations from all other processors. | ||||||
|  |  | ||||||
|  | @ -28,6 +28,7 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/messages" | 	"github.com/superseriousbusiness/gotosocial/internal/messages" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/util" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // BoostCreate processes the boost/reblog of target | // BoostCreate processes the boost/reblog of target | ||||||
|  | @ -138,6 +139,23 @@ func (p *Processor) BoostCreate( | ||||||
| 		Target:         target.Account, | 		Target:         target.Account, | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
|  | 	// If the boost target status replies to a status | ||||||
|  | 	// that we own, and has a pending interaction | ||||||
|  | 	// request, use the boost as an implicit accept. | ||||||
|  | 	implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx, | ||||||
|  | 		requester, target, | ||||||
|  | 	) | ||||||
|  | 	if errWithCode != nil { | ||||||
|  | 		return nil, errWithCode | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// If we ended up implicitly accepting, mark the | ||||||
|  | 	// target status as no longer pending approval so | ||||||
|  | 	// it's serialized properly via the API. | ||||||
|  | 	if implicitlyAccepted { | ||||||
|  | 		target.PendingApproval = util.Ptr(false) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	return p.c.GetAPIStatus(ctx, requester, boost) | 	return p.c.GetAPIStatus(ctx, requester, boost) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -164,6 +164,23 @@ func (p *Processor) Create( | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// If the new status replies to a status that | ||||||
|  | 	// replies to us, use our reply as an implicit | ||||||
|  | 	// accept of any pending interaction. | ||||||
|  | 	implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx, | ||||||
|  | 		requester, status, | ||||||
|  | 	) | ||||||
|  | 	if errWithCode != nil { | ||||||
|  | 		return nil, errWithCode | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// If we ended up implicitly accepting, mark the | ||||||
|  | 	// replied-to status as no longer pending approval | ||||||
|  | 	// so it's serialized properly via the API. | ||||||
|  | 	if implicitlyAccepted { | ||||||
|  | 		status.InReplyTo.PendingApproval = util.Ptr(false) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	return p.c.GetAPIStatus(ctx, requester, status) | 	return p.c.GetAPIStatus(ctx, requester, status) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -31,6 +31,7 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/messages" | 	"github.com/superseriousbusiness/gotosocial/internal/messages" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/uris" | 	"github.com/superseriousbusiness/gotosocial/internal/uris" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/util" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func (p *Processor) getFaveableStatus( | func (p *Processor) getFaveableStatus( | ||||||
|  | @ -138,8 +139,6 @@ func (p *Processor) FaveCreate( | ||||||
| 		pendingApproval = false | 		pendingApproval = false | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	status.PendingApproval = &pendingApproval |  | ||||||
| 
 |  | ||||||
| 	// Create a new fave, marking it | 	// Create a new fave, marking it | ||||||
| 	// as pending approval if necessary. | 	// as pending approval if necessary. | ||||||
| 	faveID := id.NewULID() | 	faveID := id.NewULID() | ||||||
|  | @ -157,7 +156,7 @@ func (p *Processor) FaveCreate( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := p.state.DB.PutStatusFave(ctx, gtsFave); err != nil { | 	if err := p.state.DB.PutStatusFave(ctx, gtsFave); err != nil { | ||||||
| 		err = fmt.Errorf("FaveCreate: error putting fave in database: %w", err) | 		err = gtserror.Newf("db error putting fave: %w", err) | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -170,6 +169,23 @@ func (p *Processor) FaveCreate( | ||||||
| 		Target:         status.Account, | 		Target:         status.Account, | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
|  | 	// If the fave target status replies to a status | ||||||
|  | 	// that we own, and has a pending interaction | ||||||
|  | 	// request, use the fave as an implicit accept. | ||||||
|  | 	implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx, | ||||||
|  | 		requester, status, | ||||||
|  | 	) | ||||||
|  | 	if errWithCode != nil { | ||||||
|  | 		return nil, errWithCode | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// If we ended up implicitly accepting, mark the | ||||||
|  | 	// target status as no longer pending approval so | ||||||
|  | 	// it's serialized properly via the API. | ||||||
|  | 	if implicitlyAccepted { | ||||||
|  | 		status.PendingApproval = util.Ptr(false) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	return p.c.GetAPIStatus(ctx, requester, status) | 	return p.c.GetAPIStatus(ctx, requester, status) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -23,6 +23,7 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/filter/visibility" | 	"github.com/superseriousbusiness/gotosocial/internal/filter/visibility" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/processing/common" | 	"github.com/superseriousbusiness/gotosocial/internal/processing/common" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/processing/polls" | 	"github.com/superseriousbusiness/gotosocial/internal/processing/polls" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/state" | 	"github.com/superseriousbusiness/gotosocial/internal/state" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/text" | 	"github.com/superseriousbusiness/gotosocial/internal/text" | ||||||
|  | @ -43,6 +44,7 @@ type Processor struct { | ||||||
| 
 | 
 | ||||||
| 	// other processors | 	// other processors | ||||||
| 	polls   *polls.Processor | 	polls   *polls.Processor | ||||||
|  | 	intReqs *interactionrequests.Processor | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // New returns a new status processor. | // New returns a new status processor. | ||||||
|  | @ -50,6 +52,7 @@ func New( | ||||||
| 	state *state.State, | 	state *state.State, | ||||||
| 	common *common.Processor, | 	common *common.Processor, | ||||||
| 	polls *polls.Processor, | 	polls *polls.Processor, | ||||||
|  | 	intReqs *interactionrequests.Processor, | ||||||
| 	federator *federation.Federator, | 	federator *federation.Federator, | ||||||
| 	converter *typeutils.Converter, | 	converter *typeutils.Converter, | ||||||
| 	visFilter *visibility.Filter, | 	visFilter *visibility.Filter, | ||||||
|  | @ -66,5 +69,6 @@ func New( | ||||||
| 		formatter:    text.NewFormatter(state.DB), | 		formatter:    text.NewFormatter(state.DB), | ||||||
| 		parseMention: parseMention, | 		parseMention: parseMention, | ||||||
| 		polls:        polls, | 		polls:        polls, | ||||||
|  | 		intReqs:      intReqs, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -27,6 +27,7 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/media" | 	"github.com/superseriousbusiness/gotosocial/internal/media" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/processing" | 	"github.com/superseriousbusiness/gotosocial/internal/processing" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/processing/common" | 	"github.com/superseriousbusiness/gotosocial/internal/processing/common" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/processing/polls" | 	"github.com/superseriousbusiness/gotosocial/internal/processing/polls" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/processing/status" | 	"github.com/superseriousbusiness/gotosocial/internal/processing/status" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/state" | 	"github.com/superseriousbusiness/gotosocial/internal/state" | ||||||
|  | @ -100,11 +101,13 @@ func (suite *StatusStandardTestSuite) SetupTest() { | ||||||
| 
 | 
 | ||||||
| 	common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter) | 	common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter) | ||||||
| 	polls := polls.New(&common, &suite.state, suite.typeConverter) | 	polls := polls.New(&common, &suite.state, suite.typeConverter) | ||||||
|  | 	intReqs := interactionrequests.New(&common, &suite.state, suite.typeConverter) | ||||||
| 
 | 
 | ||||||
| 	suite.status = status.New( | 	suite.status = status.New( | ||||||
| 		&suite.state, | 		&suite.state, | ||||||
| 		&common, | 		&common, | ||||||
| 		&polls, | 		&polls, | ||||||
|  | 		&intReqs, | ||||||
| 		suite.federator, | 		suite.federator, | ||||||
| 		suite.typeConverter, | 		suite.typeConverter, | ||||||
| 		visFilter, | 		visFilter, | ||||||
|  |  | ||||||
							
								
								
									
										72
									
								
								internal/processing/status/util.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								internal/processing/status/util.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,72 @@ | ||||||
|  | // 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" | ||||||
|  | 
 | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/util" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func (p *Processor) implicitlyAccept( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	requester *gtsmodel.Account, | ||||||
|  | 	status *gtsmodel.Status, | ||||||
|  | ) (bool, gtserror.WithCode) { | ||||||
|  | 	if status.InReplyToAccountID != requester.ID { | ||||||
|  | 		// Status doesn't reply to us, | ||||||
|  | 		// we can't accept on behalf | ||||||
|  | 		// of someone else. | ||||||
|  | 		return false, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	targetPendingApproval := util.PtrOrValue(status.PendingApproval, false) | ||||||
|  | 	if !targetPendingApproval { | ||||||
|  | 		// Status isn't pending approval, | ||||||
|  | 		// nothing to implicitly accept. | ||||||
|  | 		return false, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Status is pending approval, | ||||||
|  | 	// check for an interaction request. | ||||||
|  | 	intReq, err := p.state.DB.GetInteractionRequestByInteractionURI(ctx, status.URI) | ||||||
|  | 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||||
|  | 		// Something's gone wrong. | ||||||
|  | 		err := gtserror.Newf("db error getting interaction request for %s: %w", status.URI, err) | ||||||
|  | 		return false, gtserror.NewErrorInternalError(err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// No interaction request present | ||||||
|  | 	// for this status. Race condition? | ||||||
|  | 	if intReq == nil { | ||||||
|  | 		return false, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Accept the interaction. | ||||||
|  | 	if _, errWithCode := p.intReqs.Accept(ctx, | ||||||
|  | 		requester, intReq.ID, | ||||||
|  | 	); errWithCode != nil { | ||||||
|  | 		return false, errWithCode | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return true, nil | ||||||
|  | } | ||||||
|  | @ -800,26 +800,55 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // StatusToAPIStatus converts a gts model status into its api | // StatusToAPIStatus converts a gts model | ||||||
| // (frontend) representation for serialization on the API. | // status into its api (frontend) representation | ||||||
|  | // for serialization on the API. | ||||||
| // | // | ||||||
| // Requesting account can be nil. | // Requesting account can be nil. | ||||||
| // | // | ||||||
| // Filter context can be the empty string if these statuses are not being filtered. | // filterContext can be the empty string | ||||||
|  | // if these statuses are not being filtered. | ||||||
| // | // | ||||||
| // If there is a matching "hide" filter, the returned status will be nil with a ErrHideStatus error; | // If there is a matching "hide" filter, the returned | ||||||
| // callers need to handle that case by excluding it from results. | // status will be nil with a ErrHideStatus error; callers | ||||||
|  | // need to handle that case by excluding it from results. | ||||||
| func (c *Converter) StatusToAPIStatus( | func (c *Converter) StatusToAPIStatus( | ||||||
| 	ctx context.Context, | 	ctx context.Context, | ||||||
| 	s *gtsmodel.Status, | 	status *gtsmodel.Status, | ||||||
| 	requestingAccount *gtsmodel.Account, | 	requestingAccount *gtsmodel.Account, | ||||||
| 	filterContext statusfilter.FilterContext, | 	filterContext statusfilter.FilterContext, | ||||||
| 	filters []*gtsmodel.Filter, | 	filters []*gtsmodel.Filter, | ||||||
| 	mutes *usermute.CompiledUserMuteList, | 	mutes *usermute.CompiledUserMuteList, | ||||||
|  | ) (*apimodel.Status, error) { | ||||||
|  | 	return c.statusToAPIStatus( | ||||||
|  | 		ctx, | ||||||
|  | 		status, | ||||||
|  | 		requestingAccount, | ||||||
|  | 		filterContext, | ||||||
|  | 		filters, | ||||||
|  | 		mutes, | ||||||
|  | 		true, | ||||||
|  | 		true, | ||||||
|  | 	) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // statusToAPIStatus is the package-internal implementation | ||||||
|  | // of StatusToAPIStatus that lets the caller customize whether | ||||||
|  | // to placehold unknown attachment types, and/or add a note | ||||||
|  | // about the status being pending and requiring approval. | ||||||
|  | func (c *Converter) statusToAPIStatus( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	status *gtsmodel.Status, | ||||||
|  | 	requestingAccount *gtsmodel.Account, | ||||||
|  | 	filterContext statusfilter.FilterContext, | ||||||
|  | 	filters []*gtsmodel.Filter, | ||||||
|  | 	mutes *usermute.CompiledUserMuteList, | ||||||
|  | 	placeholdAttachments bool, | ||||||
|  | 	addPendingNote bool, | ||||||
| ) (*apimodel.Status, error) { | ) (*apimodel.Status, error) { | ||||||
| 	apiStatus, err := c.statusToFrontend( | 	apiStatus, err := c.statusToFrontend( | ||||||
| 		ctx, | 		ctx, | ||||||
| 		s, | 		status, | ||||||
| 		requestingAccount, // Can be nil. | 		requestingAccount, // Can be nil. | ||||||
| 		filterContext,     // Can be empty. | 		filterContext,     // Can be empty. | ||||||
| 		filters, | 		filters, | ||||||
|  | @ -830,7 +859,7 @@ func (c *Converter) StatusToAPIStatus( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Convert author to API model. | 	// Convert author to API model. | ||||||
| 	acct, err := c.AccountToAPIAccountPublic(ctx, s.Account) | 	acct, err := c.AccountToAPIAccountPublic(ctx, status.Account) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.Newf("error converting status acct: %w", err) | 		return nil, gtserror.Newf("error converting status acct: %w", err) | ||||||
| 	} | 	} | ||||||
|  | @ -839,23 +868,43 @@ func (c *Converter) StatusToAPIStatus( | ||||||
| 	// Convert author of boosted | 	// Convert author of boosted | ||||||
| 	// status (if set) to API model. | 	// status (if set) to API model. | ||||||
| 	if apiStatus.Reblog != nil { | 	if apiStatus.Reblog != nil { | ||||||
| 		boostAcct, err := c.AccountToAPIAccountPublic(ctx, s.BoostOfAccount) | 		boostAcct, err := c.AccountToAPIAccountPublic(ctx, status.BoostOfAccount) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, gtserror.Newf("error converting boost acct: %w", err) | 			return nil, gtserror.Newf("error converting boost acct: %w", err) | ||||||
| 		} | 		} | ||||||
| 		apiStatus.Reblog.Account = boostAcct | 		apiStatus.Reblog.Account = boostAcct | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Normalize status for API by pruning | 	if placeholdAttachments { | ||||||
| 	// attachments that were not locally | 		// Normalize status for API by pruning attachments | ||||||
| 	// stored, replacing them with a helpful | 		// that were not able to be locally stored, and replacing | ||||||
| 	// message + links to remote. | 		// them with a helpful message + links to remote. | ||||||
| 	var aside string | 		var attachNote string | ||||||
| 	aside, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments) | 		attachNote, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments) | ||||||
| 	apiStatus.Content += aside | 		apiStatus.Content += attachNote | ||||||
|  | 
 | ||||||
|  | 		// Do the same for the reblogged status. | ||||||
| 		if apiStatus.Reblog != nil { | 		if apiStatus.Reblog != nil { | ||||||
| 		aside, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments) | 			attachNote, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments) | ||||||
| 		apiStatus.Reblog.Content += aside | 			apiStatus.Reblog.Content += attachNote | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if addPendingNote { | ||||||
|  | 		// If this status is pending approval and | ||||||
|  | 		// replies to the requester, add a note | ||||||
|  | 		// about how to approve or reject the reply. | ||||||
|  | 		pendingApproval := util.PtrOrValue(status.PendingApproval, false) | ||||||
|  | 		if pendingApproval && | ||||||
|  | 			requestingAccount != nil && | ||||||
|  | 			requestingAccount.ID == status.InReplyToAccountID { | ||||||
|  | 			pendingNote, err := c.pendingReplyNote(ctx, status) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return nil, gtserror.Newf("error deriving 'pending reply' note: %w", err) | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			apiStatus.Content += pendingNote | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return apiStatus, nil | 	return apiStatus, nil | ||||||
|  | @ -1972,7 +2021,20 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	for _, s := range r.Statuses { | 	for _, s := range r.Statuses { | ||||||
| 		status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil) | 		status, err := c.statusToAPIStatus( | ||||||
|  | 			ctx, | ||||||
|  | 			s, | ||||||
|  | 			requestingAccount, | ||||||
|  | 			statusfilter.FilterContextNone, | ||||||
|  | 			nil,  // No filters. | ||||||
|  | 			nil,  // No mutes. | ||||||
|  | 			true, // Placehold unknown attachments. | ||||||
|  | 
 | ||||||
|  | 			// Don't add note about | ||||||
|  | 			// pending, it's not | ||||||
|  | 			// relevant here. | ||||||
|  | 			false, | ||||||
|  | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err) | 			return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err) | ||||||
| 		} | 		} | ||||||
|  | @ -2609,8 +2671,8 @@ func (c *Converter) InteractionReqToAPIInteractionReq( | ||||||
| 		req.Status, | 		req.Status, | ||||||
| 		requestingAcct, | 		requestingAcct, | ||||||
| 		statusfilter.FilterContextNone, | 		statusfilter.FilterContextNone, | ||||||
| 		nil, | 		nil, // No filters. | ||||||
| 		nil, | 		nil, // No mutes. | ||||||
| 	) | 	) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		err := gtserror.Newf("error converting interacted status: %w", err) | 		err := gtserror.Newf("error converting interacted status: %w", err) | ||||||
|  | @ -2619,13 +2681,20 @@ func (c *Converter) InteractionReqToAPIInteractionReq( | ||||||
| 
 | 
 | ||||||
| 	var reply *apimodel.Status | 	var reply *apimodel.Status | ||||||
| 	if req.InteractionType == gtsmodel.InteractionReply { | 	if req.InteractionType == gtsmodel.InteractionReply { | ||||||
| 		reply, err = c.StatusToAPIStatus( | 		reply, err = c.statusToAPIStatus( | ||||||
| 			ctx, | 			ctx, | ||||||
| 			req.Reply, | 			req.Status, | ||||||
| 			requestingAcct, | 			requestingAcct, | ||||||
| 			statusfilter.FilterContextNone, | 			statusfilter.FilterContextNone, | ||||||
| 			nil, | 			nil,  // No filters. | ||||||
| 			nil, | 			nil,  // No mutes. | ||||||
|  | 			true, // Placehold unknown attachments. | ||||||
|  | 
 | ||||||
|  | 			// Don't add note about pending; | ||||||
|  | 			// requester already knows it's | ||||||
|  | 			// pending because they're looking | ||||||
|  | 			// at the request right now. | ||||||
|  | 			false, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			err := gtserror.Newf("error converting reply: %w", err) | 			err := gtserror.Newf("error converting reply: %w", err) | ||||||
|  |  | ||||||
|  | @ -18,6 +18,7 @@ | ||||||
| package typeutils_test | package typeutils_test | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"bytes" | ||||||
| 	"context" | 	"context" | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"testing" | 	"testing" | ||||||
|  | @ -1708,6 +1709,130 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction | ||||||
| }`, string(b)) | }`, string(b)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval() { | ||||||
|  | 	var ( | ||||||
|  | 		testStatus        = suite.testStatuses["admin_account_status_5"] | ||||||
|  | 		requestingAccount = suite.testAccounts["local_account_2"] | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	apiStatus, err := suite.typeconverter.StatusToAPIStatus( | ||||||
|  | 		context.Background(), | ||||||
|  | 		testStatus, | ||||||
|  | 		requestingAccount, | ||||||
|  | 		statusfilter.FilterContextNone, | ||||||
|  | 		nil, | ||||||
|  | 		nil, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// We want to see the HTML in | ||||||
|  | 	// the status so don't escape it. | ||||||
|  | 	out := new(bytes.Buffer) | ||||||
|  | 	enc := json.NewEncoder(out) | ||||||
|  | 	enc.SetIndent("", "  ") | ||||||
|  | 	enc.SetEscapeHTML(false) | ||||||
|  | 	if err := enc.Encode(apiStatus); err != nil { | ||||||
|  | 		suite.FailNow(err.Error()) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	suite.Equal(`{ | ||||||
|  |   "id": "01J5QVB9VC76NPPRQ207GG4DRZ", | ||||||
|  |   "created_at": "2024-02-20T10:41:37.000Z", | ||||||
|  |   "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5", | ||||||
|  |   "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  |   "sensitive": false, | ||||||
|  |   "spoiler_text": "", | ||||||
|  |   "visibility": "unlisted", | ||||||
|  |   "language": null, | ||||||
|  |   "uri": "http://localhost:8080/users/admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ", | ||||||
|  |   "url": "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ", | ||||||
|  |   "replies_count": 0, | ||||||
|  |   "reblogs_count": 0, | ||||||
|  |   "favourites_count": 0, | ||||||
|  |   "favourited": false, | ||||||
|  |   "reblogged": false, | ||||||
|  |   "muted": false, | ||||||
|  |   "bookmarked": false, | ||||||
|  |   "pinned": false, | ||||||
|  |   "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><hr><p><i lang=\"en\">ℹ️ Note from localhost:8080: This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR</a>.</i></p>", | ||||||
|  |   "reblog": null, | ||||||
|  |   "application": { | ||||||
|  |     "name": "superseriousbusiness", | ||||||
|  |     "website": "https://superserious.business" | ||||||
|  |   }, | ||||||
|  |   "account": { | ||||||
|  |     "id": "01F8MH17FWEB39HZJ76B6VXSKF", | ||||||
|  |     "username": "admin", | ||||||
|  |     "acct": "admin", | ||||||
|  |     "display_name": "", | ||||||
|  |     "locked": false, | ||||||
|  |     "discoverable": true, | ||||||
|  |     "bot": false, | ||||||
|  |     "created_at": "2022-05-17T13:10:59.000Z", | ||||||
|  |     "note": "", | ||||||
|  |     "url": "http://localhost:8080/@admin", | ||||||
|  |     "avatar": "", | ||||||
|  |     "avatar_static": "", | ||||||
|  |     "header": "http://localhost:8080/assets/default_header.webp", | ||||||
|  |     "header_static": "http://localhost:8080/assets/default_header.webp", | ||||||
|  |     "followers_count": 1, | ||||||
|  |     "following_count": 1, | ||||||
|  |     "statuses_count": 4, | ||||||
|  |     "last_status_at": "2021-10-20T10:41:37.000Z", | ||||||
|  |     "emojis": [], | ||||||
|  |     "fields": [], | ||||||
|  |     "enable_rss": true, | ||||||
|  |     "roles": [ | ||||||
|  |       { | ||||||
|  |         "id": "admin", | ||||||
|  |         "name": "admin", | ||||||
|  |         "color": "" | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   "media_attachments": [], | ||||||
|  |   "mentions": [ | ||||||
|  |     { | ||||||
|  |       "id": "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  |       "username": "1happyturtle", | ||||||
|  |       "url": "http://localhost:8080/@1happyturtle", | ||||||
|  |       "acct": "1happyturtle" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "tags": [], | ||||||
|  |   "emojis": [], | ||||||
|  |   "card": null, | ||||||
|  |   "poll": null, | ||||||
|  |   "text": "Hi @1happyturtle, can I reply?", | ||||||
|  |   "interaction_policy": { | ||||||
|  |     "can_favourite": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reply": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     }, | ||||||
|  |     "can_reblog": { | ||||||
|  |       "always": [ | ||||||
|  |         "public", | ||||||
|  |         "me" | ||||||
|  |       ], | ||||||
|  |       "with_approval": [] | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | `, out.String()) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() { | func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() { | ||||||
| 	testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"] | 	testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"] | ||||||
| 	apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment) | 	apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment) | ||||||
|  |  | ||||||
|  | @ -19,6 +19,7 @@ package typeutils | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"math" | 	"math" | ||||||
| 	"net/url" | 	"net/url" | ||||||
|  | @ -30,6 +31,8 @@ import ( | ||||||
| 	"github.com/k3a/html2text" | 	"github.com/k3a/html2text" | ||||||
| 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" | 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/config" | 	"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/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/language" | 	"github.com/superseriousbusiness/gotosocial/internal/language" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
|  | @ -187,6 +190,47 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att | ||||||
| 	return text.SanitizeToHTML(note.String()), arr | 	return text.SanitizeToHTML(note.String()), arr | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c *Converter) pendingReplyNote( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	s *gtsmodel.Status, | ||||||
|  | ) (string, error) { | ||||||
|  | 	intReq, err := c.state.DB.GetInteractionRequestByInteractionURI(ctx, s.URI) | ||||||
|  | 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||||
|  | 		// Something's gone wrong. | ||||||
|  | 		err := gtserror.Newf("db error getting interaction request for %s: %w", s.URI, err) | ||||||
|  | 		return "", err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// No interaction request present | ||||||
|  | 	// for this status. Race condition? | ||||||
|  | 	if intReq == nil { | ||||||
|  | 		return "", nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var ( | ||||||
|  | 		proto = config.GetProtocol() | ||||||
|  | 		host  = config.GetHost() | ||||||
|  | 
 | ||||||
|  | 		// Build the settings panel URL at which the user | ||||||
|  | 		// can view + approve/reject the interaction request. | ||||||
|  | 		// | ||||||
|  | 		// Eg., https://example.org/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR | ||||||
|  | 		settingsURL = proto + "://" + host + "/settings/user/interaction_requests/" + intReq.ID | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	var note strings.Builder | ||||||
|  | 	note.WriteString(`<hr>`) | ||||||
|  | 	note.WriteString(`<p><i lang="en">ℹ️ Note from ` + host + `: `) | ||||||
|  | 	note.WriteString(`This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: `) | ||||||
|  | 	note.WriteString(`<a href="` + settingsURL + `" `) | ||||||
|  | 	note.WriteString(`rel="noreferrer noopener" target="_blank">`) | ||||||
|  | 	note.WriteString(settingsURL) | ||||||
|  | 	note.WriteString(`</a>.`) | ||||||
|  | 	note.WriteString(`</i></p>`) | ||||||
|  | 
 | ||||||
|  | 	return text.SanitizeToHTML(note.String()), nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // ContentToContentLanguage tries to | // ContentToContentLanguage tries to | ||||||
| // extract a content string and language | // extract a content string and language | ||||||
| // tag string from the given intermediary | // tag string from the given intermediary | ||||||
|  |  | ||||||
|  | @ -52,10 +52,10 @@ export default function UserRouter() { | ||||||
| 						<Route path="/emailpassword" component={EmailPassword} /> | 						<Route path="/emailpassword" component={EmailPassword} /> | ||||||
| 						<Route path="/migration" component={UserMigration} /> | 						<Route path="/migration" component={UserMigration} /> | ||||||
| 						<Route path="/export-import" component={ExportImport} /> | 						<Route path="/export-import" component={ExportImport} /> | ||||||
|  | 						<InteractionRequestsRouter /> | ||||||
| 						<Route><Redirect to="/profile" /></Route> | 						<Route><Redirect to="/profile" /></Route> | ||||||
| 					</Switch> | 					</Switch> | ||||||
| 				</ErrorBoundary> | 				</ErrorBoundary> | ||||||
| 				<InteractionRequestsRouter /> |  | ||||||
| 			</Router> | 			</Router> | ||||||
| 		</BaseUrlContext.Provider> | 		</BaseUrlContext.Provider> | ||||||
| 	); | 	); | ||||||
|  | @ -73,13 +73,11 @@ function InteractionRequestsRouter() { | ||||||
| 	return ( | 	return ( | ||||||
| 		<BaseUrlContext.Provider value={absBase}> | 		<BaseUrlContext.Provider value={absBase}> | ||||||
| 			<Router base={thisBase}> | 			<Router base={thisBase}> | ||||||
| 				<ErrorBoundary> |  | ||||||
| 				<Switch> | 				<Switch> | ||||||
| 					<Route path="/search" component={InteractionRequests} /> | 					<Route path="/search" component={InteractionRequests} /> | ||||||
| 					<Route path="/:reqId" component={InteractionRequestDetail} /> | 					<Route path="/:reqId" component={InteractionRequestDetail} /> | ||||||
| 					<Route><Redirect to="/search"/></Route> | 					<Route><Redirect to="/search"/></Route> | ||||||
| 				</Switch> | 				</Switch> | ||||||
| 				</ErrorBoundary> |  | ||||||
| 			</Router> | 			</Router> | ||||||
| 		</BaseUrlContext.Provider> | 		</BaseUrlContext.Provider> | ||||||
| 	); | 	); | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue