[feature] Implement backfilling statuses thru scheduled_at (#3685)

* Implement backfilling statuses thru scheduled_at

* Forbid mentioning others in backfills

* Update error messages & codes

* Add new tests for backfilled statuses

* Test that backfilling doesn't timeline or notify

* Fix check for absence of notification

* Test that backfills do not cause federation

* Fix type of apimodel.StatusCreateRequest.ScheduledAt in tests

* Add config file switch and min date check
This commit is contained in:
Vyr Cossont 2025-02-12 09:49:33 -08:00 committed by GitHub
commit fccb0bc102
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 515 additions and 42 deletions

View file

@ -179,11 +179,15 @@ import (
// x-go-name: ScheduledAt
// description: |-
// ISO 8601 Datetime at which to schedule a status.
// Providing this parameter will cause ScheduledStatus to be returned instead of Status.
// Must be at least 5 minutes in the future.
//
// This feature isn't implemented yet; attemping to set it will return 501 Not Implemented.
// Providing this parameter with a *future* time will cause ScheduledStatus to be returned instead of Status.
// Must be at least 5 minutes in the future.
// This feature isn't implemented yet.
//
// Providing this parameter with a *past* time will cause the status to be backdated,
// and will not push it to the user's followers. This is intended for importing old statuses.
// type: string
// format: date-time
// in: formData
// -
// name: language
@ -384,12 +388,6 @@ func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, gtser
return nil, gtserror.NewErrorNotAcceptable(errors.New(text), text)
}
// Check not scheduled status.
if form.ScheduledAt != "" {
const text = "scheduled_at is not yet implemented"
return nil, gtserror.NewErrorNotImplemented(errors.New(text), text)
}
// Check if the deprecated "federated" field was
// set in lieu of "local_only", and use it if so.
if form.LocalOnly == nil && form.Federated != nil { // nolint:staticcheck

View file

@ -20,14 +20,18 @@ package statuses_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@ -41,10 +45,11 @@ const (
statusMarkdown = "# Title\n\n## Smaller title\n\nThis is a post written in [markdown](https://www.markdownguide.org/)\n\n<img src=\"https://d33wubrfki0l68.cloudfront.net/f1f475a6fda1c2c4be4cac04033db5c3293032b4/513a4/assets/images/markdown-mark-white.svg\"/>"
)
func (suite *StatusCreateTestSuite) postStatus(
// Post a status.
func (suite *StatusCreateTestSuite) postStatusCore(
formData map[string][]string,
jsonData string,
) (string, *httptest.ResponseRecorder) {
) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
@ -77,9 +82,42 @@ func (suite *StatusCreateTestSuite) postStatus(
// Trigger handler.
suite.statusModule.StatusCreatePOSTHandler(ctx)
return recorder
}
// Post a status and return the result as deterministic JSON.
func (suite *StatusCreateTestSuite) postStatus(
formData map[string][]string,
jsonData string,
) (string, *httptest.ResponseRecorder) {
recorder := suite.postStatusCore(formData, jsonData)
return suite.parseStatusResponse(recorder)
}
// Post a status and return the result as a non-deterministic API structure.
func (suite *StatusCreateTestSuite) postStatusStruct(
formData map[string][]string,
jsonData string,
) (*apimodel.Status, *httptest.ResponseRecorder) {
recorder := suite.postStatusCore(formData, jsonData)
result := recorder.Result()
defer result.Body.Close()
data, err := io.ReadAll(result.Body)
if err != nil {
suite.FailNow(err.Error())
}
apiStatus := apimodel.Status{}
if err := json.Unmarshal(data, &apiStatus); err != nil {
suite.FailNow(err.Error())
}
return &apiStatus, recorder
}
// Post a new status with some custom visibility settings
func (suite *StatusCreateTestSuite) TestPostNewStatus() {
out, recorder := suite.postStatus(map[string][]string{
@ -383,10 +421,98 @@ func (suite *StatusCreateTestSuite) TestPostNewScheduledStatus() {
// We should have a helpful error message.
suite.Equal(`{
"error": "Not Implemented: scheduled_at is not yet implemented"
"error": "Not Implemented: scheduled statuses are not yet supported"
}`, out)
}
func (suite *StatusCreateTestSuite) TestPostNewBackfilledStatus() {
// A time in the past.
scheduledAtStr := "2020-10-04T15:32:02.018Z"
scheduledAt, err := time.Parse(time.RFC3339Nano, scheduledAtStr)
if err != nil {
suite.FailNow(err.Error())
}
status, recorder := suite.postStatusStruct(map[string][]string{
"status": {"this is a recycled status from the past!"},
"scheduled_at": {scheduledAtStr},
}, "")
// Creating a status in the past should succeed.
suite.Equal(http.StatusOK, recorder.Code)
// The status should be backdated.
createdAt, err := time.Parse(time.RFC3339Nano, status.CreatedAt)
if err != nil {
suite.FailNow(err.Error())
return
}
suite.Equal(scheduledAt, createdAt.UTC())
// The status's ULID should be backdated.
timeFromULID, err := id.TimeFromULID(status.ID)
if err != nil {
suite.FailNow(err.Error())
return
}
suite.Equal(scheduledAt, timeFromULID.UTC())
}
func (suite *StatusCreateTestSuite) TestPostNewBackfilledStatusWithSelfMention() {
_, recorder := suite.postStatus(map[string][]string{
"status": {"@the_mighty_zork this is a recycled mention from the past!"},
"scheduled_at": {"2020-10-04T15:32:02.018Z"},
}, "")
// Mentioning yourself is allowed in backfilled statuses.
suite.Equal(http.StatusOK, recorder.Code)
}
func (suite *StatusCreateTestSuite) TestPostNewBackfilledStatusWithMention() {
_, recorder := suite.postStatus(map[string][]string{
"status": {"@admin this is a recycled mention from the past!"},
"scheduled_at": {"2020-10-04T15:32:02.018Z"},
}, "")
// Mentioning others is forbidden in backfilled statuses.
suite.Equal(http.StatusForbidden, recorder.Code)
}
func (suite *StatusCreateTestSuite) TestPostNewBackfilledStatusWithSelfReply() {
_, recorder := suite.postStatus(map[string][]string{
"status": {"this is a recycled reply from the past!"},
"scheduled_at": {"2020-10-04T15:32:02.018Z"},
"in_reply_to_id": {suite.testStatuses["local_account_1_status_1"].ID},
}, "")
// Replying to yourself is allowed in backfilled statuses.
suite.Equal(http.StatusOK, recorder.Code)
}
func (suite *StatusCreateTestSuite) TestPostNewBackfilledStatusWithReply() {
_, recorder := suite.postStatus(map[string][]string{
"status": {"this is a recycled reply from the past!"},
"scheduled_at": {"2020-10-04T15:32:02.018Z"},
"in_reply_to_id": {suite.testStatuses["admin_account_status_1"].ID},
}, "")
// Replying to others is forbidden in backfilled statuses.
suite.Equal(http.StatusForbidden, recorder.Code)
}
func (suite *StatusCreateTestSuite) TestPostNewBackfilledStatusWithPoll() {
_, recorder := suite.postStatus(map[string][]string{
"status": {"this is a recycled poll from the past!"},
"scheduled_at": {"2020-10-04T15:32:02.018Z"},
"poll[options][]": {"first option", "second option"},
"poll[expires_in]": {"3600"},
"poll[multiple]": {"true"},
}, "")
// Polls are forbidden in backfilled statuses.
suite.Equal(http.StatusForbidden, recorder.Code)
}
func (suite *StatusCreateTestSuite) TestPostNewStatusMarkdown() {
out, recorder := suite.postStatus(map[string][]string{
"status": {statusMarkdown},