Files
SocialPub/PrivaPub.Tests/Statistics/RollupTests.cs
T

152 lines
8.2 KiB
C#
Raw Permalink Normal View History

2026-10-03 11:30:14 +02:00
using MongoDB.Entities;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
using PrivaPub.Tests.Support;
namespace PrivaPub.Tests.Statistics
{
public class RollupFoldTests
{
static IEnumerable<InteractionEvent> Day(string host) => new[]
{
new InteractionEvent { Host = host, Channel = "recv", Activity = "Create", Status = 202, Reason = "queued", Signature = "cavage:rsa-sha256", LatencyMs = 40, Bytes = 900 },
new InteractionEvent { Host = host, Channel = "recv", Activity = "Create", Status = 401, Reason = "no-signature" },
new InteractionEvent { Host = host, Channel = "in", Activity = "Create", Object = "Note", Outcome = "accepted", Reason = "stored", Audience = "public", LocalKind = "person", ActorHash = "a", Features = new() { "fep-044f-quote", "source:text/markdown" }, WaitMs = 1500 },
new InteractionEvent { Host = host, Channel = "in", Activity = "Create", Object = "Note", Outcome = "accepted", Reason = "stored", Audience = "private", ActorHash = "b", Features = new() { "interaction-policy" } },
new InteractionEvent { Host = host, Channel = "in", Activity = "Flag", Outcome = "accepted", Reason = "reported", Audience = "none", ActorHash = "a" },
new InteractionEvent { Host = host, Channel = "in", Activity = "Like", Object = "Note", Outcome = "dropped", Reason = "unknown-object", Audience = "public" },
new InteractionEvent { Host = host, Channel = "out", Activity = "Create", Object = "Note", Outcome = "ok", Status = 202, Audience = "public", LocalKind = "person", LatencyMs = 120, Bytes = 2000 },
new InteractionEvent { Host = host, Channel = "out", Activity = "Block", Outcome = "ok", Status = 202, Audience = "none" },
new InteractionEvent { Host = host, Channel = "out", Activity = "Create", Object = "Note", Outcome = "retry", Reason = "503", Audience = "private" },
new InteractionEvent { Host = host, Channel = "http", Purpose = "actor", Trigger = "verify", Outcome = "ok", LatencyMs = 300, Bytes = 1400 },
new InteractionEvent { Host = host, Channel = "http", Purpose = "object", Trigger = "fetchancestors", Outcome = "refused", Reason = "410" }
};
[Fact]
public void Every_event_counts_for_the_admin_with_its_reason()
{
var fold = Rollup.Fold(Day("social.example")).Hosts["social.example"];
Assert.Equal(1, fold.Counters["recv:Create:202:queued"]);
Assert.Equal(1, fold.Counters["recv:Create:401:no-signature"]);
Assert.Equal(1, fold.Counters["sig:cavage:rsa-sha256"]);
Assert.Equal(2, fold.Counters["in:Create:Note:accepted:stored"]);
Assert.Equal(1, fold.Counters["in:Like:Note:dropped:unknown-object"]);
Assert.Equal(1, fold.Counters["out:Create:Note:retry:503"]);
Assert.Equal(1, fold.Counters["out:Block:-:ok"]);
Assert.Equal(1, fold.Counters["http:object:refused:410"]);
Assert.Equal(1, fold.Counters["aud:in:private"]);
Assert.Equal(1, fold.Counters["kind:out:person"]);
Assert.Equal(1, fold.Counters["feat:source:text/markdown"]);
Assert.Equal(1, fold.Latency["out:le250ms"]);
Assert.Equal(1, fold.Latency["http:actor:le500ms"]);
Assert.Equal(1, fold.Waits["in:le10s"]);
Assert.Equal(2000, fold.Bytes["out"]);
Assert.Equal(1400, fold.Bytes["http:actor"]);
Assert.Equal(2, fold.Accounts.Count);
}
[Fact]
public void The_public_counters_keep_only_public_kinds_of_traffic_without_reasons()
{
var fold = Rollup.Fold(Day("social.example")).Hosts["social.example"];
Assert.Equal(new Dictionary<string, long>
{
["in:Create:Note:accepted"] = 1,
["in:Like:Note:dropped"] = 1,
["out:Create:Note:delivered"] = 1,
["feat:fep-044f-quote"] = 1,
["feat:source:text/markdown"] = 1,
["health:ok"] = 4,
["health:failed"] = 1
}, fold.PublicCounters);
Assert.DoesNotContain(fold.PublicCounters.Keys, k => k.Contains("Flag") || k.Contains("Block") || k.Contains("recv") || k.Contains("interaction-policy"));
}
[Fact]
public void Keys_are_safe_field_names_and_folding_is_repeatable()
{
Assert.Equal("in:-:Note:accepted", Rollup.Key("in", null, "Note", "accepted", null));
Assert.Equal("feat:content:text/x_mfm", Rollup.Key("feat", "content:text/x.mfm"));
var first = Rollup.Fold(Day("a.example").Concat(Day("b.example")));
var second = Rollup.Fold(Day("a.example").Concat(Day("b.example")));
Assert.Equal(first.Hosts["a.example"].Counters, second.Hosts["a.example"].Counters);
Assert.Equal(2, first.Accounts.Count);
Assert.Equal(2, first.Hosts.Count);
}
}
[Trait("Category", "Integration")]
public sealed class RollupJobTests : IAsyncLifetime
{
static readonly DateTime Day = new(2001, 2, 3, 0, 0, 0, DateTimeKind.Utc);
readonly string _host = $"rollup{Guid.NewGuid():N}.example";
public ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task A_past_day_folds_into_server_rows_replaces_itself_keeps_reads_and_forgets_its_salt()
{
var token = TestContext.Current.CancellationToken;
await DB.Default.SaveAsync(new[]
{
new InteractionEvent { At = Day.AddHours(3), Host = _host, Channel = "in", Activity = "Create", Object = "Note", Outcome = "accepted", Audience = "public", ActorHash = "x" },
new InteractionEvent { At = Day.AddHours(5), Host = _host, Channel = "out", Activity = "Create", Object = "Note", Outcome = "ok", Audience = "public" },
new InteractionEvent { At = Day.AddDays(1).AddMinutes(1), Host = _host, Channel = "in", Activity = "Like", Outcome = "accepted" }
}, token);
await DB.Default.SaveAsync(new InstanceDay { Day = Day, Host = _host, Reads = new() { ["media:hit"] = 7 } }, token);
await DB.Default.SaveAsync(new InteractionSalt { Day = Day, Key = "AAAA", ExpiresAt = DateTime.UtcNow.AddHours(1) }, token);
var job = new RollupJob(new JobQueue(j => j.DedupeKey == "never"));
Assert.Equal(JobResult.Done, (await job.Handle(RollupJob.For(Day, $"test|{Guid.NewGuid():N}"), token)).Result);
var first = await DB.Default.Find<InstanceDay>().Match(d => d.Day == Day && d.Host == _host).ExecuteSingleAsync(token);
Assert.Equal(JobResult.Done, (await job.Handle(RollupJob.For(Day, $"test|{Guid.NewGuid():N}"), token)).Result);
var second = await DB.Default.Find<InstanceDay>().Match(d => d.Day == Day && d.Host == _host).ExecuteSingleAsync(token);
Assert.Equal(1, first.Counters["in:Create:Note:accepted"]);
Assert.False(first.Counters.ContainsKey("in:Like:-:accepted"));
Assert.Equal(1, first.PublicCounters["out:Create:Note:delivered"]);
Assert.Equal(1, first.Accounts);
Assert.Equal(7, first.Reads["media:hit"]);
Assert.Equal(first.Counters, second.Counters);
Assert.Equal(7, second.Reads["media:hit"]);
Assert.NotNull(second.RolledUpAt);
Assert.False(await DB.Default.Find<InteractionSalt>().Match(s => s.Day == Day).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.DedupeKey == "rollup|2001-02-04" && j.Kind == JobKind.RollupDay).ExecuteAnyAsync(token));
}
[Fact]
public async Task Today_is_not_folded_before_it_ends()
{
var outcome = await new RollupJob(new JobQueue(j => j.DedupeKey == "never")).Handle(RollupJob.For(DateTime.UtcNow.Date, "x"), TestContext.Current.CancellationToken);
Assert.Equal(JobResult.Defer, outcome.Result);
Assert.Equal(DateTime.UtcNow.Date.AddDays(1) + RollupJob.After, outcome.RetryAt);
}
[Fact]
public async Task The_schedule_plans_today_and_catches_up_a_missed_day()
{
var token = TestContext.Current.CancellationToken;
var missed = DateTime.UtcNow.Date.AddDays(-3);
await DB.Default.SaveAsync(new InteractionEvent { At = missed.AddHours(12), Host = _host, Channel = "in", Activity = "Create", Outcome = "accepted" }, token);
await new StatisticsSchedule(new JobQueue(j => j.DedupeKey == "never"), Microsoft.Extensions.Logging.Abstractions.NullLogger<StatisticsSchedule>.Instance).Plan(token);
var today = await DB.Default.Find<Job>().Match(j => j.DedupeKey == $"rollup|{RollupJob.DayKey(DateTime.UtcNow.Date)}").ExecuteFirstAsync(token);
Assert.Equal(DateTime.UtcNow.Date.AddDays(1) + RollupJob.After, today.RunAt);
Assert.True(await DB.Default.Find<Job>().Match(j => j.DedupeKey == $"rollup|{RollupJob.DayKey(missed)}").ExecuteAnyAsync(token));
}
}
}