Reports, and who liked or boosted a post

Another's profile and posts can be reported from the client (/api/v1/reports): a category, words, and for a remote
account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. Who liked or boosted
a post opens from its card's menu (favourited_by, reblogged_by). e2e: a report from a Mastodon account's profile
reaches PrivaPub's moderators forwarded, and a post's likes and boosts list the persona who gave them; the admin
helper the tests share moves to Support.

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-06 16:47:19 +02:00
1 parent bb68ff8d5e
commit a0f07abf8c
17 files changed
+410 -16

No files matched your search

+3
View File
@@ -239,6 +239,9 @@ supplies:
persona pins and unpins its own public and unlisted posts from the card's menu. persona pins and unpins its own public and unlisted posts from the card's menu.
An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one), An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one),
linking the new account's profile. linking the new account's profile.
Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words,
and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona.
Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`).
- **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking - **Walls** (Smithereen's, FEP-400e): a post written on someone's wall says whose (`privapub.wall`, `data-wall`, linking
that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which that profile); one written on the persona's own wall is taken off from the card's menu (`DELETE` on the status, which
PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there. PrivaPub turns into a `Remove` that Smithereen takes for a deletion). Only the persona's followers may write there.
+29
View File
@@ -302,6 +302,20 @@
<span>@Localizer["Open original"]</span> <span>@Localizer["Open original"]</span>
</a> </a>
} }
@if (status.FavouritesCount > 0)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="@(() => showInteractedBy = false)" data-liked-by>
<i aria-hidden="true" class="ion-md-heart text-base"></i>
<span>@Localizer["Liked by"]</span>
</DButton>
}
@if (status.ReblogsCount > 0)
{
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" OnClick="@(() => showInteractedBy = true)" data-boosted-by>
<i aria-hidden="true" class="ion-md-repeat text-base"></i>
<span>@Localizer["Boosted by"]</span>
</DButton>
}
@if (CanQuote(status)) @if (CanQuote(status))
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleQuote" data-quote> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="ToggleQuote" data-quote>
@@ -318,6 +332,10 @@
} }
@if (!IsOwn) @if (!IsOwn)
{ {
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
<i aria-hidden="true" class="ion-md-flag text-base text-warning"></i>
<span>@Localizer["Report"]</span>
</DButton>
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))"> <DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Disabled="IsDefaultDisabled || isActing" OnClick="@(() => AccountAction("mute"))">
<i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i> <i aria-hidden="true" class="ion-md-eye-off text-base text-warning"></i>
<span>@Localizer["Mute"]</span> <span>@Localizer["Mute"]</span>
@@ -370,6 +388,14 @@
{ {
<Composer Editing="status" OnPublished="Edited" /> <Composer Editing="status" OnPublished="Edited" />
} }
@if (isReporting && status.Account is { } reported)
{
<ReportDialog Account="reported" Post="status" OnClosed="@(() => isReporting = false)" />
}
@if (showInteractedBy is { } boosts)
{
<InteractedByDialog StatusId="@status.Id" Boosts="boosts" OnClosed="@(() => showInteractedBy = null)" />
}
@if (isHistoryOpen) @if (isHistoryOpen)
{ {
<DModal IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list> <DModal IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
@@ -452,6 +478,9 @@
bool isReadingProvenance; bool isReadingProvenance;
bool isReadingServer; bool isReadingServer;
bool isHistoryOpen; bool isHistoryOpen;
bool isReporting;
// who liked it (false) or boosted it (true), when that list is open
bool? showInteractedBy;
List<StatusEdit> history = []; List<StatusEdit> history = [];
readonly HashSet<string> revealedMedia = []; readonly HashSet<string> revealedMedia = [];
+59
View File
@@ -0,0 +1,59 @@
@inherits LocalizableComponentBase
@* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to
have done so, newest first, each opening its profile. *@
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
<Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading>
<ChildContent>
@if (IsLoading)
{
<p class="flex items-center gap-2 mt-3"><DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />@Localizer["loading"]</p>
}
else if (accounts.Count == 0)
{
<p class="text-sm opacity-70 mt-3">@Localizer["Nobody this server knows of."]</p>
}
<div class="flex flex-col gap-2 mt-3">
@foreach (var account in accounts)
{
<a class="flex items-center gap-3 p-2 rounded-xl neo-raised neo-2xs min-w-0" href="@($"account/{Uri.EscapeDataString(account.Id)}")" data-account="@account.Acct">
@* alt="": the name is next to it. *@
<img alt="" class="h-9 w-9 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
}
</div>
</ChildContent>
</DModal>
@code {
[Inject] StatusService StatusService { get; set; }
[Parameter, EditorRequired] public string StatusId { get; set; }
/// <summary>Who boosted it, rather than who liked it.</summary>
[Parameter] public bool Boosts { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
List<Account> accounts = [];
protected override async Task OnInitializedAsync()
{
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData() => accounts = await StatusService.InteractedBy(StatusId, Boosts);
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+62
View File
@@ -0,0 +1,62 @@
@inherits LocalizableComponentBase
@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports).
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
<Heading>@Localizer["Report {0}", Account.Name]</Heading>
<ChildContent>
<EditForm Model="form" OnValidSubmit="Send" class="flex flex-col gap-3 mt-3">
<DataAnnotationsValidator />
<ErrorViewer AdditionalError="@error" />
<DSelect TValue="ReportCategory" @bind-Value="form.Category" data-report-category>
<option value="@ReportCategory.Spam">@Localizer[nameof(ReportCategory.Spam)]</option>
<option value="@ReportCategory.Legal">@Localizer[nameof(ReportCategory.Legal)]</option>
<option value="@ReportCategory.Other">@Localizer[nameof(ReportCategory.Other)]</option>
</DSelect>
<DTextarea TValue="string" @bind-Value="form.Comment" Immediate="true" rows="3" autocomplete="off" maxlength="@ReportForm.CommentLength"
placeholder="@Localizer["What is wrong with it?"]" data-report-comment />
@if (Host is { } host)
{
<DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward />
<p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p>
}
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
</EditForm>
</ChildContent>
</DModal>
@code {
[Inject] AccountService AccountService { get; set; }
[Parameter, EditorRequired] public Account Account { get; set; }
/// <summary>The post it was opened from, sent with the report.</summary>
[Parameter] public Status Post { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
ReportForm form = new();
bool isSending;
string error;
// the account's own server, for a remote account
string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null;
protected override void OnInitialized() => IsLoading = false;
async Task Send()
{
isSending = true;
error = null;
var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, Host is not null && form.Forward);
isSending = false;
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Report sent to the moderators."]);
await OnClosed.InvokeAsync();
}
Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync();
}
+1
View File
@@ -37,6 +37,7 @@ namespace decePubClient;
[JsonSerializable(typeof(StatusEditForm))] [JsonSerializable(typeof(StatusEditForm))]
[JsonSerializable(typeof(StatusSource))] [JsonSerializable(typeof(StatusSource))]
[JsonSerializable(typeof(List<StatusEdit>))] [JsonSerializable(typeof(List<StatusEdit>))]
[JsonSerializable(typeof(ReportRequest))]
[JsonSerializable(typeof(Poll))] [JsonSerializable(typeof(Poll))]
[JsonSerializable(typeof(PollVoteForm))] [JsonSerializable(typeof(PollVoteForm))]
[JsonSerializable(typeof(MastodonList))] [JsonSerializable(typeof(MastodonList))]
+3
View File
@@ -101,6 +101,9 @@ public static class APIs
public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses"; public const string GET_ScheduledStatuses = "api/v1/scheduled_statuses";
public static string GET_M_StatusSource(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/source"; public static string GET_M_StatusSource(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/source";
public static string GET_M_StatusHistory(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/history"; public static string GET_M_StatusHistory(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/history";
public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by";
public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by";
public const string POST_Reports = "api/v1/reports";
public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}"; public static string PUT_M_Status(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}";
public const string GET_FollowedTags = "api/v1/followed_tags"; public const string GET_FollowedTags = "api/v1/followed_tags";
public const string GET_GroupedNotifications = "api/v2/notifications"; public const string GET_GroupedNotifications = "api/v2/notifications";
+12
View File
@@ -206,6 +206,18 @@ public class StatusSource
public string SpoilerText { get; set; } public string SpoilerText { get; set; }
} }
/// <summary>/api/v1/reports: an account, and some of its posts, reported to the persona's server's moderators; forwarded,
/// a remote account's server gets it too, from the server's own actor.</summary>
public class ReportRequest
{
public string AccountId { get; set; }
public List<string> StatusIds { get; set; } = [];
public string Comment { get; set; }
/// <summary>spam, legal or other.</summary>
public string Category { get; set; }
public bool Forward { get; set; }
}
/// <summary>/api/v1/statuses/:id/history: one version of a post, the oldest first and the one showing last.</summary> /// <summary>/api/v1/statuses/:id/history: one version of a post, the oldest first and the one showing last.</summary>
public class StatusEdit public class StatusEdit
{ {
+18
View File
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
using decePubClient.Models.Types;
using decePubClient.Resources;
namespace decePubClient.Models;
/// <summary>A report of an account, and of the post it was made from, as the report dialog edits it.</summary>
public class ReportForm
{
public const int CommentLength = 1000;
public ReportCategory Category { get; set; } = ReportCategory.Other;
[StringLength(CommentLength, ErrorMessageResourceName = ValidationNames.MaxLength, ErrorMessageResourceType = typeof(ErrorMessages))]
public string Comment { get; set; }
/// <summary>A copy to a remote account's own server, from PrivaPub's actor.</summary>
public bool Forward { get; set; } = true;
}
+10
View File
@@ -0,0 +1,10 @@
namespace decePubClient.Models.Types
{
/// <summary>Why an account is reported (Mastodon's categories, without its server rules, which PrivaPub has none of).</summary>
public enum ReportCategory
{
Other = 0,
Spam = 1,
Legal = 2
}
}
+9
View File
@@ -98,7 +98,15 @@
<i class="ion-md-remove-circle text-error"></i> <i class="ion-md-remove-circle text-error"></i>
<span>@(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"])</span> <span>@(relationship?.Blocking == true ? Localizer["Unblock"] : Localizer["Block"])</span>
</DButton> </DButton>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="@(() => isReporting = true)" data-report>
<i class="ion-md-flag text-warning"></i>
<span>@Localizer["Report"]</span>
</DButton>
</div> </div>
@if (isReporting)
{
<ReportDialog Account="account" OnClosed="@(() => isReporting = false)" />
}
} }
</div> </div>
@@ -201,6 +209,7 @@
bool isActing; bool isActing;
bool isLoadingMore; bool isLoadingMore;
bool isListMenuOpen; bool isListMenuOpen;
bool isReporting;
List<MastodonList> allLists = []; List<MastodonList> allLists = [];
HashSet<string> inLists = []; HashSet<string> inLists = [];
+30
View File
@@ -1745,4 +1745,34 @@
<data name="edited" xml:space="preserve"> <data name="edited" xml:space="preserve">
<value>modificato</value> <value>modificato</value>
</data> </data>
<data name="Report" xml:space="preserve">
<value>Segnala</value>
</data>
<data name="Report {0}" xml:space="preserve">
<value>Segnala {0}</value>
</data>
<data name="Legal" xml:space="preserve">
<value>Contro la legge</value>
</data>
<data name="What is wrong with it?" xml:space="preserve">
<value>Che cosa c'è che non va?</value>
</data>
<data name="Send a copy to {0}" xml:space="preserve">
<value>Invia una copia a {0}</value>
</data>
<data name="The copy comes from the server, never from you." xml:space="preserve">
<value>La copia parte dal server, mai da te.</value>
</data>
<data name="Report sent to the moderators." xml:space="preserve">
<value>Segnalazione inviata ai moderatori.</value>
</data>
<data name="Liked by" xml:space="preserve">
<value>Piace a</value>
</data>
<data name="Boosted by" xml:space="preserve">
<value>Condiviso da</value>
</data>
<data name="Nobody this server knows of." xml:space="preserve">
<value>Nessuno che questo server conosca.</value>
</data>
</root> </root>
+30
View File
@@ -1745,4 +1745,34 @@
<data name="edited" xml:space="preserve"> <data name="edited" xml:space="preserve">
<value>edited</value> <value>edited</value>
</data> </data>
<data name="Report" xml:space="preserve">
<value>Report</value>
</data>
<data name="Report {0}" xml:space="preserve">
<value>Report {0}</value>
</data>
<data name="Legal" xml:space="preserve">
<value>Against the law</value>
</data>
<data name="What is wrong with it?" xml:space="preserve">
<value>What is wrong with it?</value>
</data>
<data name="Send a copy to {0}" xml:space="preserve">
<value>Send a copy to {0}</value>
</data>
<data name="The copy comes from the server, never from you." xml:space="preserve">
<value>The copy comes from the server, never from you.</value>
</data>
<data name="Report sent to the moderators." xml:space="preserve">
<value>Report sent to the moderators.</value>
</data>
<data name="Liked by" xml:space="preserve">
<value>Liked by</value>
</data>
<data name="Boosted by" xml:space="preserve">
<value>Boosted by</value>
</data>
<data name="Nobody this server knows of." xml:space="preserve">
<value>Nobody this server knows of.</value>
</data>
</root> </root>
+17
View File
@@ -5,6 +5,7 @@ using collAnon.Client.Services;
using decePubClient.Extensions; using decePubClient.Extensions;
using decePubClient.Models; using decePubClient.Models;
using decePubClient.Models.Mastodon; using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
@@ -250,6 +251,22 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
} }
/// <summary>Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server
/// gets the report too, from PrivaPub's actor and never naming the persona.</summary>
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, bool forward,
CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
{
AccountId = accountId,
StatusIds = statusIds,
Comment = comment?.Trim(),
Category = category.ToString().ToLowerInvariant(),
Forward = forward
}, cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
public async Task<WebResult> UnblockDomain(string domain, CancellationToken cancellationToken = default) public async Task<WebResult> UnblockDomain(string domain, CancellationToken cancellationToken = default)
{ {
using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"], using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"],
+14
View File
@@ -90,6 +90,20 @@ namespace decePubClient.Services
return await response.MastodonReadFromJsonAsync<List<StatusEdit>>(cancellationToken) ?? []; return await response.MastodonReadFromJsonAsync<List<StatusEdit>>(cancellationToken) ?? [];
} }
/// <summary>Who liked a post, or boosted it, as far as PrivaPub knows; [] when it cannot be read.</summary>
public async Task<List<Account>> InteractedBy(string statusId, bool boosts, CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(boosts ? APIs.Mastodon.GET_M_RebloggedBy(statusId) : APIs.Mastodon.GET_M_FavouritedBy(statusId),
["limit=80"], cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(InteractedBy)}", cancellationToken);
return [];
}
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
}
/// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary> /// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary>
public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default) public async Task<WebResult> Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default)
{ {
+3 -16
View File
@@ -1,4 +1,3 @@
using System.Net.Http.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using decePubClient.E2E.Support; using decePubClient.E2E.Support;
@@ -11,22 +10,10 @@ namespace decePubClient.E2E.Actions
// and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi // and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi
public sealed class AdministrationTests(Browser browser) public sealed class AdministrationTests(Browser browser)
{ {
const string Root = "pastureroot"; const string Root = PastureAdmin.Root;
const string Password = "Pasture-Pass-1!"; static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
static readonly Pasture.Persona Admin = new("alice", "alice", Root, Password, null, null, null);
static async Task<JsonNode> AsAdmin(string path) static Task<JsonNode> AsAdmin(string path) => PastureAdmin.Get(path);
{
using var http = Pasture.Http();
var login = await http.PostAsJsonAsync($"{Pasture.Api}/clientapi/user/login", new { userName = Root, password = Password });
login.EnsureSuccessStatusCode();
var jwt = (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + path);
request.Headers.Authorization = new("Bearer", jwt);
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
}
static async Task<ILocator> Open(IPage page, string section) static async Task<ILocator> Open(IPage page, string section)
{ {
+84
View File
@@ -0,0 +1,84 @@
using System.Text.Json.Nodes;
using decePubClient.E2E.Support;
using Microsoft.Playwright;
namespace decePubClient.E2E.Actions
{
// A persona reports a remote account from its profile, with a category, its words and a copy to the account's server:
// PrivaPub's moderators see it, forwarded (/clientapi/moderator/reports). And who liked or boosted a post is listed
// from its card
public sealed class ReportTests(Browser browser)
{
[Fact]
public async Task A_remote_account_is_reported_from_its_profile()
{
var persona = Pasture.Personas().First();
var target = (await PersonaApi.Call(persona, "/api/v2/search?q=mastouser%40mastodon.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
Assert.SkipWhen(target is null, "no Mastodon: run tools/pasture/run.sh add mastodon");
var targetId = target!["id"]!.GetValue<string>();
var words = $"reported by the e2e tests {Guid.NewGuid():N}"[..40];
await using var session = await SignIn.As(browser, persona);
var page = session.Page;
await Results.Cell("client.action.report", "mastodon", persona.UserName, async () =>
{
await page.GotoAsync($"{Pasture.Site}/account/{Uri.EscapeDataString(targetId)}");
// (the profile's own button: each of its posts' cards has one too)
await page.Locator($"article[data-account-id='{targetId}'] button[data-report]").ClickAsync(new() { Timeout = 30000 });
var dialog = page.Locator("[data-report-dialog]");
await dialog.Locator("[data-report-category]").SelectOptionAsync("Spam");
await dialog.Locator("[data-report-comment]").FillAsync(words);
await Assertions.Expect(dialog.Locator("[data-report-forward]")).ToBeCheckedAsync();
await dialog.Locator("[data-report-send]").ClickAsync();
await Assertions.Expect(dialog).ToHaveCountAsync(0, new() { Timeout = 15000 });
var report = (await PastureAdmin.Get("/clientapi/moderator/reports?resolved=false"))!.AsArray()
.FirstOrDefault(r => r!["comment"]?.GetValue<string>() == words);
Assert.NotNull(report);
Assert.Equal((targetId, "spam", true), (report!["targetAccountId"]!.GetValue<string>(), report["category"]!.GetValue<string>(),
report["forwarded"]!.GetValue<bool>()));
await session.AssertGuards();
}, page);
}
[Fact]
public async Task Who_liked_and_boosted_a_post_is_listed_from_its_card()
{
var personas = Pasture.Personas();
Assert.SkipWhen(personas.Count < 2, "the town has one persona");
var (author, fan) = (personas[0], personas[1]);
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
new JsonObject { ["status"] = $"like and boost me {Guid.NewGuid():N}"[..28], ["visibility"] = "public" }))!;
var id = post["id"]!.GetValue<string>();
var fanAcct = (await PersonaApi.Call(fan, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/reblog", HttpMethod.Post);
await using var session = await SignIn.As(browser, author);
var page = session.Page;
try
{
await Results.Cell("client.render.interacted-by", "privapub", author.UserName, async () =>
{
await page.GotoAsync($"{Pasture.Site}/expand/{Uri.EscapeDataString(id)}");
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
await card.WaitForAsync(new() { Timeout = 30000 });
foreach (var (button, list) in new[] { ("data-liked-by", "likes"), ("data-boosted-by", "boosts") })
{
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
await card.Locator($"button[{button}]").ClickAsync();
var dialog = card.Locator($"[data-interacted-by='{list}']");
await Assertions.Expect(dialog.Locator($"[data-account='{fanAcct}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
await page.Keyboard.PressAsync("Escape");
await Assertions.Expect(dialog).ToHaveCountAsync(0);
}
await session.AssertGuards();
}, page);
}
finally
{
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Net.Http.Json;
using System.Text.Json.Nodes;
namespace decePubClient.E2E.Support
{
// PrivaPub's /clientapi as the pasture's administrator (pastureroot, whom the scenarios make): what its moderators
// and administrators see
public static class PastureAdmin
{
public const string Root = "pastureroot";
public const string Password = "Pasture-Pass-1!";
public static async Task<JsonNode> Get(string path)
{
using var http = Pasture.Http();
var login = await http.PostAsJsonAsync($"{Pasture.Api}/clientapi/user/login", new { userName = Root, password = Password });
login.EnsureSuccessStatusCode();
var jwt = (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + path);
request.Headers.Authorization = new("Bearer", jwt);
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
}
}
}