An upload's owner checked that it was free, then attached it in a second step, so two posts asking for the same upload at once could both get it; a post now, or an edit, could take media a scheduled post held, which then failed when its time came (only logged) and was deleted; and a persona deleted or banned after scheduling a post still published it. Now a post claims its media in one conditional update before anything is written (its id is minted first), and a post that loses the race is refused with 422, the media it took put back. Scheduling reserves media the same way. Media held by a scheduled post are that post's alone, and its job publishes only for a persona still there whose root is neither deleted nor banned; otherwise what it held is trashed. MastodonScheduledStatusesTests: a post now and an edit can't take scheduled media, two racing posts never both get an upload, a gone persona's scheduled post never publishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
159 lines
8.9 KiB
C#
159 lines
8.9 KiB
C#
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);
|
|
}
|
|
|
|
async Task<string> Picture(Mastodon account)
|
|
{
|
|
var upload = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(16, 16), "image/jpeg", "a.jpg"));
|
|
return (await account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = upload })).Ok().Body.Text("id");
|
|
}
|
|
|
|
// a scheduled post's media are its alone: a post now, or an edit, can't take them
|
|
[Fact]
|
|
public async Task A_scheduled_posts_media_cannot_be_taken_by_a_post_now()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var mediaId = await Picture(alice);
|
|
(await alice.Client.Post("/api/v1/statuses", ("status", "later"), ("media_ids[]", mediaId), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok();
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "now"), ("media_ids[]", mediaId))).Status);
|
|
var plain = (await alice.Client.Post("/api/v1/statuses", ("status", "plain"))).Ok().Body.Text("id");
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put($"/api/v1/statuses/{plain}", ("status", "edited"), ("media_ids[]", mediaId))).Status);
|
|
var media = await DB.Default.Find<MediaAttachment>().OneAsync(mediaId, Token);
|
|
Assert.Null(media.PostId);
|
|
Assert.NotNull(media.ScheduledStatusId);
|
|
}
|
|
|
|
// two posts asking for the same upload at once: exactly one gets it
|
|
[Fact]
|
|
public async Task Two_posts_racing_for_one_upload_never_both_get_it()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
for (var round = 0; round < 5; round++)
|
|
{
|
|
var mediaId = await Picture(alice);
|
|
var answers = await Task.WhenAll(Enumerable.Range(0, 2).Select(i =>
|
|
alice.Client.Post("/api/v1/statuses", ("status", $"race {round} {i}"), ("media_ids[]", mediaId))));
|
|
Assert.Single(answers, a => a.Status == HttpStatusCode.OK);
|
|
Assert.Single(answers, a => a.Status == HttpStatusCode.UnprocessableEntity);
|
|
}
|
|
}
|
|
|
|
// a persona deleted since it scheduled a post publishes nothing, and what the post held is trashed
|
|
[Fact]
|
|
public async Task A_gone_personas_scheduled_post_never_publishes()
|
|
{
|
|
var gone = await _host.Mastodon($"gone{Guid.NewGuid():N}"[..12]);
|
|
var mediaId = await Picture(gone);
|
|
var id = (await gone.Client.Post("/api/v1/statuses", ("status", "from beyond"), ("media_ids[]", mediaId), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok().Object.Text("id");
|
|
await DB.Default.Update<Models.User.Avatar>().MatchID(gone.Persona.Id).Modify(a => a.DeletionAt, DateTime.UtcNow).ExecuteAsync(Token);
|
|
|
|
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.RunAt, DateTime.UtcNow).ExecuteAsync(Token);
|
|
await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token);
|
|
|
|
Assert.False(await DB.Default.Find<Models.Post.Post>().Match(p => p.GroupUserId == gone.Persona.Id && p.Text == "from beyond").ExecuteAnyAsync(Token));
|
|
Assert.False(await DB.Default.Find<ScheduledStatus>().Match(s => s.ID == id).ExecuteAnyAsync(Token));
|
|
Assert.NotNull((await DB.Default.Find<MediaAttachment>().OneAsync(mediaId, Token)).TrashedAt);
|
|
}
|
|
}
|
|
}
|