Media are reserved for one post, atomically

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 10:34:16 +02:00
1 parent bb680e8cb8
commit 65938bb2a0
4 files changed
+117 -14

No files matched your search

@@ -99,5 +99,60 @@ namespace PrivaPub.Tests.Http
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);
}
}
}