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().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().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().MatchID(id).Modify(s => s.ScheduledAt, DateTime.UtcNow.AddSeconds(-1)).ExecuteAsync(Token); await DB.Default.Update().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().MatchID(mediaId).ExecuteFirstAsync(Token); Assert.Equal(published.Text("id"), media.PostId); Assert.Null(media.ScheduledStatusId); } } }