Scheduled posts: written now, published at their time
POST /api/v1/statuses with scheduled_at no longer posts at once: the post is kept as asked (at least five minutes ahead; 300 waiting, 25 a day, as Mastodon allows), its media kept from the janitor, and a PublishScheduled job publishes it at its time as the persona. scheduled_statuses lists, moves and drops them; a moved post's old job finds it not due. Idempotency-Key holds for scheduling too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
73c362de96
commit
cafd2e6db3
13 files changed
+418
-26
No files matched your search
@@ -84,7 +84,6 @@ namespace PrivaPub.Tests.Http
|
||||
[InlineData("/api/v1/followed_tags")]
|
||||
[InlineData("/api/v1/endorsements")]
|
||||
[InlineData("/api/v1/featured_tags")]
|
||||
[InlineData("/api/v1/scheduled_statuses")]
|
||||
public async Task Signed_in_stub_routes_answer_empty_lists(string path)
|
||||
{
|
||||
var reader = await _host.Mastodon("reader");
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Net;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
// Mastodon's scheduled statuses: a post given a scheduled_at waits as asked, can be moved or dropped, and is
|
||||
// published by its job when its time comes
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class MastodonScheduledStatusesTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
|
||||
static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
static string In(TimeSpan from) => DateTime.UtcNow.Add(from).ToString("o");
|
||||
|
||||
[Fact]
|
||||
public async Task A_scheduled_post_waits_is_moved_and_is_dropped_by_its_owner_only()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var mallory = await _host.Mastodon("mallory");
|
||||
|
||||
var scheduled = (await alice.Client.Post("/api/v1/statuses", ("status", "later, friends"), ("visibility", "unlisted"),
|
||||
("spoiler_text", "soon"), ("scheduled_at", In(TimeSpan.FromMinutes(30))))).Ok().Object;
|
||||
var id = scheduled.Text("id");
|
||||
Assert.Null(scheduled["uri"]);
|
||||
Assert.Equal("later, friends", scheduled["params"].Text("text"));
|
||||
Assert.Equal("unlisted", scheduled["params"].Text("visibility"));
|
||||
Assert.Equal("soon", scheduled["params"].Text("spoiler_text"));
|
||||
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array);
|
||||
Assert.Equal(new[] { id }, (await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Ids);
|
||||
|
||||
var later = DateTime.UtcNow.AddHours(2);
|
||||
var moved = (await alice.Client.Put($"/api/v1/scheduled_statuses/{id}", ("scheduled_at", later.ToString("o")))).Ok().Object;
|
||||
Assert.Equal(later.ToString("yyyy-MM-ddTHH:mm"), moved.Text("scheduled_at")[..16]);
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity,
|
||||
(await alice.Client.Put($"/api/v1/scheduled_statuses/{id}", ("scheduled_at", In(TimeSpan.FromMinutes(2))))).Status);
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity,
|
||||
(await alice.Client.Post("/api/v1/statuses", ("status", "too soon"), ("scheduled_at", In(TimeSpan.FromMinutes(1))))).Status);
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity,
|
||||
(await alice.Client.Post("/api/v1/statuses", ("status", " "), ("scheduled_at", In(TimeSpan.FromHours(1))))).Status);
|
||||
Assert.Empty((await mallory.Client.Get("/api/v1/scheduled_statuses")).Ok().Array);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/scheduled_statuses/{id}")).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v1/scheduled_statuses/{id}")).Status);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/scheduled_statuses")).Status);
|
||||
|
||||
(await alice.Client.Delete($"/api/v1/scheduled_statuses/{id}")).Ok();
|
||||
Assert.Empty((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array);
|
||||
await DB.Default.Update<Job>().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id)
|
||||
.Modify(j => j.RunAt, DateTime.UtcNow).ExecuteAsync(Token);
|
||||
await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token);
|
||||
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scheduled_post_is_published_with_its_media_when_its_time_comes()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var upload = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(16, 16), "image/jpeg", "a.jpg"));
|
||||
var mediaId = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = upload })).Ok().Body.Text("id");
|
||||
|
||||
var id = (await alice.Client.Post("/api/v1/statuses", ("status", "on time"), ("media_ids[]", mediaId), ("poll[options][]", "yes"),
|
||||
("poll[options][]", "no"), ("poll[expires_in]", "3600"), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok().Object.Text("id");
|
||||
Assert.Equal(id, (await DB.Default.Find<MediaAttachment>().MatchID(mediaId).ExecuteFirstAsync(Token)).ScheduledStatusId);
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "same media"), ("media_ids[]", mediaId),
|
||||
("scheduled_at", In(TimeSpan.FromMinutes(20))))).Status);
|
||||
|
||||
// not due yet: its job leaves it waiting
|
||||
Assert.Equal(1, await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token));
|
||||
Assert.Single((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array);
|
||||
|
||||
// its time comes (moved into the past, as the clock would have it)
|
||||
await DB.Default.Update<ScheduledStatus>().MatchID(id).Modify(s => s.ScheduledAt, DateTime.UtcNow.AddSeconds(-1)).ExecuteAsync(Token);
|
||||
await DB.Default.Update<Job>().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id).Modify(j => j.State, JobState.Pending).ExecuteAsync(Token);
|
||||
await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token);
|
||||
|
||||
Assert.Empty((await alice.Client.Get("/api/v1/scheduled_statuses")).Ok().Array);
|
||||
var published = Assert.Single((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array)!;
|
||||
Assert.Contains("on time", published.Text("content"));
|
||||
Assert.Equal(mediaId, Assert.Single(published["media_attachments"]!.AsArray()).Text("id"));
|
||||
Assert.Equal(2, published["poll"]!["options"]!.AsArray().Count);
|
||||
var media = await DB.Default.Find<MediaAttachment>().MatchID(mediaId).ExecuteFirstAsync(Token);
|
||||
Assert.Equal(published.Text("id"), media.PostId);
|
||||
Assert.Null(media.ScheduledStatusId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user