Bifrost/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs

600 lines
25 KiB
C#

using System.Net;
using System.Reflection;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Options;
using Npgsql;
namespace Bifrost.Web.Tests;
public sealed class ConceptAppraisalServiceTests
{
private const string Collection = "aetheria-concept-mill";
private static readonly IPAddress Client = IPAddress.Parse("203.0.113.7");
[Fact]
public async Task Anonymous_device_voter_can_cast_and_is_counted()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
Assert.Equal(CastResult.Recorded, result);
var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"];
Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tally);
}
[Fact]
public async Task Anonymous_without_device_id_gets_no_identity_and_cast_is_refused()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, null));
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, "has:colon"));
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, string.Empty));
Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", null, ConceptStance.Up));
Assert.Empty(f.Db.ConceptAppraisals);
}
[Theory]
[InlineData("abc\n")]
[InlineData("abc\r\n")]
[InlineData("abc ")]
[InlineData("abc\t")]
[InlineData("abc\0")]
public async Task A_device_id_with_trailing_whitespace_or_control_char_cannot_cast_beside_its_bare_form(string forged)
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", f.Anonymous("abc"), ConceptStance.Up));
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, forged));
Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", VoterIdentity.For(CurrentBifrostActor.Anonymous, forged), ConceptStance.Down));
Assert.Single(f.Db.ConceptAppraisals);
}
[Fact]
public async Task A_changed_stance_is_saved_and_read_back_through_a_fresh_context()
{
var dbName = $"concept-{Guid.NewGuid():N}";
await using var first = new Fixture(dbName);
await first.AddSubject("ship-a");
Assert.Equal(CastResult.Recorded, await first.Cast("ship-a", first.Anonymous("device-one"), ConceptStance.Up));
var other = IPAddress.Parse("198.51.100.9");
// Production scopes one DbContext per request: the recast gets a context that has never tracked the row.
await using (var second = new Fixture(dbName))
{
second.Clock.Advance(TimeSpan.FromMinutes(5));
Assert.Equal(
CastResult.Changed,
await second.Cast("ship-a", second.Anonymous("device-one"), ConceptStance.Down, other));
}
await using var reader = new Fixture(dbName);
var row = Assert.Single(reader.Db.ConceptAppraisals);
Assert.Equal(ConceptStance.Down, row.Stance);
Assert.Equal(NetworkPrefix.Tag(other, 32, 56, Fixture.Secret), row.NetworkTag);
Assert.Equal(new DateTimeOffset(2026, 10, 10, 12, 5, 0, TimeSpan.Zero), row.CastAtUtc);
Assert.Equal(new ConceptTally(0, 1, new StanceCounts(0, 0)), (await reader.Service.GetTalliesAsync(Collection, default))["ship-a"]);
}
[Fact]
public async Task Voter_class_is_stamped_at_cast()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var account = f.Account(MembershipStatus.Authenticated);
var member = f.Account(MembershipStatus.Active);
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up);
var rows = f.Db.ConceptAppraisals.ToList();
Assert.Equal(VoterClass.Anonymous, rows.Single(x => x.VoterKey == "device:device-one").VoterClass);
Assert.Null(rows.Single(x => x.VoterKey == "device:device-one").UserAccountId);
var accountRow = rows.Single(x => x.UserAccountId == account.UserAccount!.Id);
var memberRow = rows.Single(x => x.UserAccountId == member.UserAccount!.Id);
Assert.Equal(VoterClass.Account, accountRow.VoterClass);
Assert.Equal(VoterClass.Member, memberRow.VoterClass);
// Membership ends; the same account votes again with the opposite stance. The row keeps its stamp.
member.UserAccount!.Membership!.Status = MembershipStatus.Authenticated;
var recast = await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down);
Assert.Equal(CastResult.Changed, recast);
Assert.Equal(VoterClass.Member, f.Db.ConceptAppraisals.Single(x => x.Id == memberRow.Id).VoterClass);
}
[Fact]
public async Task Second_cast_by_the_same_key_changes_stance_without_a_new_row()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", voter, ConceptStance.Up));
Assert.Equal(CastResult.Changed, await f.Cast("ship-a", voter, ConceptStance.Down));
Assert.Equal(CastResult.Unchanged, await f.Cast("ship-a", voter, ConceptStance.Down));
var row = Assert.Single(f.Db.ConceptAppraisals);
Assert.Equal(ConceptStance.Down, row.Stance);
}
[Fact]
public async Task Member_weight_does_not_weight_counts()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var member = f.Account(MembershipStatus.Active, weight: 5m);
Assert.Equal(5m, member.EffectiveVotingWeight);
await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up);
var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"];
Assert.Equal(1, tally.Up);
Assert.Equal(1, tally.Members.Up);
}
[Fact]
public async Task Member_tally_is_a_filter_over_the_same_rows()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Down);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Down);
var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"];
var rows = f.Db.ConceptAppraisals.ToList();
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up), tally.Up);
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down), tally.Down);
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up && x.VoterClass == VoterClass.Member), tally.Members.Up);
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down && x.VoterClass == VoterClass.Member), tally.Members.Down);
Assert.Equal(new ConceptTally(3, 2, new StanceCounts(1, 1)), tally);
}
[Fact]
public async Task Tally_carries_no_voter_identity()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var account = f.Account(MembershipStatus.Authenticated);
var member = f.Account(MembershipStatus.Active);
await f.Cast("ship-a", f.Anonymous("device-secret-id"), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down);
Assert.Equal(
new[] { "Down", "Members", "Up" },
typeof(ConceptTally).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name).Order().ToArray());
Assert.Equal(
new[] { "Down", "Up" },
typeof(StanceCounts).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name).Order().ToArray());
var json = JsonSerializer.Serialize(await f.Service.GetTalliesAsync(Collection, default));
var view = JsonSerializer.Serialize(
await f.Service.GetSubjectAsync(Collection, "ship-a", VoterIdentity.For(account, null), default));
foreach (var text in new[] { json, view })
{
Assert.DoesNotContain("device:", text);
Assert.DoesNotContain("account:", text);
Assert.DoesNotContain("device-secret-id", text);
foreach (var id in new[] { account.UserAccount!.Id, member.UserAccount!.Id })
{
Assert.DoesNotContain(id.ToString("D"), text);
Assert.DoesNotContain(id.ToString("N"), text);
}
foreach (var tag in f.Db.ConceptAppraisals.Select(x => x.NetworkTag).Distinct())
{
Assert.DoesNotContain(tag, text);
}
}
}
[Fact]
public async Task Votes_on_unknown_or_retired_keys_are_refused_and_create_nothing()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.AddSubject("ship-old", retired: true);
var subjectsBefore = f.Db.ConceptSubjects.Count();
var voter = f.Anonymous("device-one");
Assert.Equal(CastResult.UnknownSubject, await f.Cast("ship-missing", voter, ConceptStance.Up));
Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up));
Assert.Equal(CastResult.RetiredSubject, await f.Cast("ship-old", voter, ConceptStance.Up));
Assert.Equal(subjectsBefore, f.Db.ConceptSubjects.Count());
Assert.Empty(f.Db.ConceptAppraisals);
Assert.DoesNotContain("ship-old", (await f.Service.GetTalliesAsync(Collection, default)).Keys);
Assert.Contains("ship-a", (await f.Service.GetTalliesAsync(Collection, default)).Keys);
}
[Fact]
public async Task Retract_removes_the_row_and_the_count()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
await f.Cast("ship-a", voter, ConceptStance.Up);
await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Up);
Assert.True(await f.Service.RetractAsync(Collection, "ship-a", voter, default));
Assert.False(await f.Service.RetractAsync(Collection, "ship-a", voter, default));
Assert.Equal(1, (await f.Service.GetTalliesAsync(Collection, default))["ship-a"].Up);
Assert.Single(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Network_tag_is_an_hmac_never_a_raw_address()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
var tag = f.Db.ConceptAppraisals.Single().NetworkTag;
Assert.Matches("^[0-9a-f]{64}$", tag);
Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), tag);
}
[Fact]
public async Task Unique_violation_at_insert_becomes_a_change()
{
var dbName = $"race-{Guid.NewGuid():N}";
var racer = new RacingInterceptor(dbName);
await using var f = new Fixture(dbName, racer);
await f.AddSubject("ship-a");
racer.Subject = f.Db.ConceptSubjects.Single();
var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Down);
Assert.Equal(CastResult.Changed, result);
Assert.Equal(1, racer.Fired);
var row = Assert.Single(f.Db.ConceptAppraisals);
Assert.Equal(ConceptStance.Down, row.Stance);
}
[Fact]
public void Unique_indexes_are_declared_in_model_and_migration()
{
var options = new DbContextOptionsBuilder<BifrostDbContext>().UseNpgsql("Host=unused").Options;
using var db = new BifrostDbContext(options);
Assert.Contains(
db.Model.FindEntityType(typeof(ConceptAppraisal))!.GetIndexes(),
i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["SubjectId", "VoterKey"]));
Assert.Contains(
db.Model.FindEntityType(typeof(ConceptSubject))!.GetIndexes(),
i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["Collection", "Key"]));
var script = db.Database.GenerateCreateScript();
Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptAppraisals_SubjectId_VoterKey\"", script);
Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptSubjects_Collection_Key\"", script);
}
[Fact]
public async Task Purge_removes_only_rows_in_tag_and_window_and_audits()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var admin = f.Admin();
var t0 = f.Clock.GetUtcNow();
var other = IPAddress.Parse("198.51.100.9");
await f.Cast("ship-a", f.Anonymous("device-in"), ConceptStance.Up);
await f.Cast("ship-a", f.Anonymous("device-other-net"), ConceptStance.Up, other);
f.Clock.Advance(TimeSpan.FromHours(2));
await f.Cast("ship-a", f.Anonymous("device-late"), ConceptStance.Up);
var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret);
var removed = await f.Service.PurgeAsync(admin, tag, t0, t0.AddHours(1), default);
Assert.Equal(1, removed);
Assert.Equal(
["device:device-late", "device:device-other-net"],
f.Db.ConceptAppraisals.Select(x => x.VoterKey).ToList().Order().ToArray());
var audit = Assert.Single(f.Db.AuditEvents);
Assert.Equal("ConceptAppraisal", audit.EntityType);
Assert.Equal("Purge", audit.Action);
Assert.DoesNotContain(tag, audit.Detail);
Assert.DoesNotContain("tag", audit.Detail);
Assert.Equal(admin.UserAccount!.Id, audit.ActorUserAccountId);
Assert.Contains("from=" + t0.ToString("O"), audit.Detail);
Assert.Contains("count=1", audit.Detail);
Assert.DoesNotContain("device", audit.Detail);
var refused = await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => f.Service.PurgeAsync(f.Account(MembershipStatus.Active), tag, t0, t0.AddDays(1), default));
Assert.DoesNotContain(tag, refused.Message);
Assert.Equal("Only a platform administrator can purge appraisals.", refused.Message);
Assert.Equal(2, f.Db.ConceptAppraisals.Count());
Assert.Single(f.Db.AuditEvents);
}
[Fact]
public async Task Unique_violation_with_no_surviving_row_is_not_swallowed()
{
var dbName = $"race-{Guid.NewGuid():N}";
var racer = new RacingInterceptor(dbName) { InsertRival = false };
await using var f = new Fixture(dbName, racer);
await f.AddSubject("ship-a");
racer.Subject = f.Db.ConceptSubjects.Single();
await Assert.ThrowsAsync<DbUpdateException>(
() => f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up));
}
[Fact]
public async Task A_changed_cast_restamps_tag_and_time()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
await f.Cast("ship-a", voter, ConceptStance.Up, IPAddress.Parse("198.51.100.9"));
var first = f.Db.ConceptAppraisals.Single().CastAtUtc;
f.Clock.Advance(TimeSpan.FromMinutes(5));
await f.Cast("ship-a", voter, ConceptStance.Down);
var row = f.Db.ConceptAppraisals.Single();
Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), row.NetworkTag);
Assert.Equal(first.AddMinutes(5), row.CastAtUtc);
}
[Fact]
public async Task Unknown_client_address_leaves_no_tag_and_purge_never_matches_the_empty_tag()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.Service.CastAsync(Collection, "ship-a", f.Anonymous("device-one"), ConceptStance.Up, null, default);
Assert.Equal(string.Empty, f.Db.ConceptAppraisals.Single().NetworkTag);
var t0 = f.Clock.GetUtcNow();
Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), string.Empty, t0.AddDays(-1), t0.AddDays(1), default));
Assert.Single(f.Db.ConceptAppraisals);
Assert.Empty(f.Db.AuditEvents);
}
[Fact]
public async Task Purge_window_excludes_its_end_and_includes_its_start()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var t0 = f.Clock.GetUtcNow();
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret);
Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), tag, t0.AddDays(-1), t0, default));
Assert.Equal(1, await f.Service.PurgeAsync(f.Admin(), tag, t0, t0.AddSeconds(1), default));
}
[Fact]
public async Task Retract_by_no_voter_or_on_unknown_subject_is_a_no_op()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
await f.Cast("ship-a", voter, ConceptStance.Up);
Assert.False(await f.Service.RetractAsync(Collection, "ship-a", null, default));
Assert.False(await f.Service.RetractAsync(Collection, "ship-missing", voter, default));
Assert.False(await f.Service.RetractAsync("other-collection", "ship-a", voter, default));
Assert.Single(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Subject_page_view_counts_only_its_own_subject_and_shows_the_voters_own_stance()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.AddSubject("ship-b");
await f.AddSubject("ship-old", retired: true);
var mine = f.Anonymous("device-one");
await f.Cast("ship-a", mine, ConceptStance.Down);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up);
await f.Cast("ship-b", f.Anonymous("device-two"), ConceptStance.Up);
var view = await f.Service.GetSubjectAsync(Collection, "ship-a", mine, default);
var stranger = await f.Service.GetSubjectAsync(Collection, "ship-a", f.Anonymous("device-nine"), default);
var anon = await f.Service.GetSubjectAsync(Collection, "ship-a", null, default);
Assert.Equal(new ConceptTally(2, 1, new StanceCounts(1, 0)), view!.Tally);
Assert.Equal(ConceptStance.Down, view.YourStance);
Assert.Null(stranger!.YourStance);
Assert.Null(anon!.YourStance);
Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-old", mine, default));
Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-missing", mine, default));
}
[Fact]
public async Task Collection_and_key_must_both_be_slugs_even_when_a_row_exists()
{
await using var f = new Fixture();
await f.AddSubject("Ship_A");
f.Db.ConceptSubjects.Add(new ConceptSubject
{
Collection = "Bad_Collection",
Key = "ship-a",
Title = "t",
RegisteredAtUtc = f.Clock.GetUtcNow()
});
await f.Db.SaveChangesAsync();
var voter = f.Anonymous("device-one");
Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up));
Assert.Equal(
CastResult.UnknownSubject,
await f.Service.CastAsync("Bad_Collection", "ship-a", voter, ConceptStance.Up, Client, default));
Assert.Empty(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Voter_keys_have_their_documented_shape()
{
await using var f = new Fixture();
var account = f.Account(MembershipStatus.Authenticated);
Assert.Equal("device:abc-DEF_123", VoterIdentity.For(CurrentBifrostActor.Anonymous, "abc-DEF_123")!.Key);
Assert.Equal("account:" + account.UserAccount!.Id.ToString("N"), VoterIdentity.For(account, "ignored")!.Key);
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 101)));
Assert.NotNull(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 100)));
}
private sealed class RacingInterceptor(string dbName) : SaveChangesInterceptor
{
public bool InsertRival { get; init; } = true;
public ConceptSubject? Subject { get; set; }
public int Fired { get; private set; }
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var adding = eventData.Context!.ChangeTracker.Entries<ConceptAppraisal>()
.Any(e => e.State == EntityState.Added);
if (Subject is null || !adding || Fired > 0)
{
return result;
}
Fired++;
if (!InsertRival)
{
throw new DbUpdateException("conflict", new PostgresException("duplicate", "ERROR", "ERROR", "23505"));
}
var options = new DbContextOptionsBuilder<BifrostDbContext>().UseInMemoryDatabase(dbName).Options;
await using (var other = new BifrostDbContext(options))
{
other.ConceptAppraisals.Add(new ConceptAppraisal
{
SubjectId = Subject.Id,
VoterKey = "device:device-one",
Stance = ConceptStance.Up,
VoterClass = VoterClass.Anonymous,
CastAtUtc = DateTimeOffset.UtcNow
});
await other.SaveChangesAsync(cancellationToken);
}
throw new DbUpdateException(
"conflict",
new PostgresException("duplicate", "ERROR", "ERROR", "23505"));
}
}
private sealed class Fixture : IAsyncDisposable
{
public const string Secret = "test-tag-secret";
public Fixture(string? dbName = null, IInterceptor? interceptor = null)
{
var builder = new DbContextOptionsBuilder<BifrostDbContext>()
.UseInMemoryDatabase(dbName ?? $"concept-{Guid.NewGuid():N}");
if (interceptor is not null)
{
builder.AddInterceptors(interceptor);
}
Db = new BifrostDbContext(builder.Options);
Clock = new ManualClock(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero));
Service = new ConceptAppraisalService(
Db,
new AuditTrailService(Db, Clock),
Clock,
Options.Create(new ConceptVoteOptions { NetworkTagSecret = Secret }));
}
public BifrostDbContext Db { get; }
public ManualClock Clock { get; }
public ConceptAppraisalService Service { get; }
public async Task AddSubject(string key, bool retired = false)
{
Db.ConceptSubjects.Add(new ConceptSubject
{
Collection = Collection,
Key = key,
Title = key,
RegisteredAtUtc = Clock.GetUtcNow(),
RetiredAtUtc = retired ? Clock.GetUtcNow() : null
});
await Db.SaveChangesAsync();
}
public Task<CastResult> Cast(string key, VoterIdentity? voter, ConceptStance stance, IPAddress? client = null) =>
Service.CastAsync(Collection, key, voter, stance, client ?? Client, default);
public VoterIdentity Anonymous(string deviceId) =>
VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!;
public CurrentBifrostActor Account(MembershipStatus status, decimal weight = 0m, bool admin = false)
{
var user = new UserAccount
{
DisplayName = "voter",
GitHubLogin = "voter",
NormalizedGitHubLogin = "voter",
CreatedAtUtc = Clock.GetUtcNow(),
LastSeenAtUtc = Clock.GetUtcNow()
};
user.Membership = new Membership
{
UserAccountId = user.Id,
Status = status,
IsPlatformAdmin = admin,
CreatedAtUtc = Clock.GetUtcNow()
};
if (weight > 0m)
{
user.Membership.TierSnapshots.Add(new TierSnapshot { Weight = weight, IsCurrent = true });
}
Db.UserAccounts.Add(user);
Db.SaveChanges();
return new CurrentBifrostActor(true, user);
}
public CurrentBifrostActor Admin()
{
return Account(MembershipStatus.Active, admin: true);
}
public ValueTask DisposeAsync() => Db.DisposeAsync();
}
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}