194 lines
13 KiB
C#
194 lines
13 KiB
C#
using ElectionSystem.Api.Infrastructure.Auditing;
|
|
using ElectionSystem.Api.Infrastructure.Persistence;
|
|
using ElectionSystem.Api.Common.Pagination;
|
|
using ElectionSystem.Api.Modules.Identity.Domain;
|
|
using ElectionSystem.Api.Modules.Organization;
|
|
using ElectionSystem.Api.Modules.Organization.Domain;
|
|
using ElectionSystem.Api.Modules.Voters.Domain;
|
|
using ElectionSystem.Api.Modules.Election.Domain;
|
|
using ElectionSystem.Api.Modules.OfflineCounting.Domain;
|
|
using ElectionSystem.Api.Modules.OfflineCounting;
|
|
using ElectionSystem.Api.Modules.Results;
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ElectionSystem.Api.Tests;
|
|
|
|
public sealed class PersistenceModelTests
|
|
{
|
|
[Fact]
|
|
public async Task UserName_must_be_unique()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ElectionDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
|
await using var context = new ElectionDbContext(options);
|
|
context.Users.Add(new ApplicationUser { UserName = "admin", DisplayName = "Administrator" });
|
|
await context.SaveChangesAsync();
|
|
var index = context.Model.FindEntityType(typeof(ApplicationUser))!.GetIndexes().Single(x => x.Properties.Single().Name == nameof(ApplicationUser.UserName));
|
|
Assert.True(index.IsUnique);
|
|
}
|
|
|
|
[Fact]
|
|
public void Audit_entry_does_not_contain_vote_content_field()
|
|
{
|
|
var properties = typeof(AuditEntry).GetProperties().Select(property => property.Name);
|
|
Assert.DoesNotContain(properties, property => property.Contains("Vote", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Provincial_administrator_scope_includes_all_descendant_regions()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ElectionDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
|
await using var context = new ElectionDbContext(options);
|
|
var province = new AdministrativeRegion { Code = "11", Name = "省", Level = AdministrativeRegionLevel.Province, Source = "test", SourceVersion = "test" };
|
|
var city = new AdministrativeRegion { Code = "1101", Name = "市", Level = AdministrativeRegionLevel.City, ParentId = province.Id, Source = "test", SourceVersion = "test" };
|
|
var village = new AdministrativeRegion { Code = "110101001001", Name = "村", Level = AdministrativeRegionLevel.Village, ParentId = city.Id, Source = "test", SourceVersion = "test" };
|
|
var admin = new ApplicationUser { UserName = "province-admin", DisplayName = "Province administrator" };
|
|
context.AddRange(province, city, village, admin);
|
|
context.UserRegionalRoleAssignments.Add(new UserRegionalRoleAssignment { UserId = admin.Id, RegionId = province.Id, Role = RegionalRole.ProvinceAdministrator });
|
|
await context.SaveChangesAsync();
|
|
|
|
var scope = await new RegionScopeService(context).GetManagedRegionIdsAsync(admin.Id, CancellationToken.None);
|
|
Assert.Equal(3, scope.Count);
|
|
Assert.Contains(village.Id, scope);
|
|
}
|
|
|
|
[Fact]
|
|
public void Voter_number_and_region_status_indexes_are_defined()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ElectionDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
|
using var context = new ElectionDbContext(options);
|
|
var entity = context.Model.FindEntityType(typeof(VoterProfile))!;
|
|
Assert.Contains(entity.GetIndexes(), x => x.IsUnique && x.Properties.Single().Name == nameof(VoterProfile.VoterNumber));
|
|
Assert.Contains(entity.GetIndexes(), x => x.Properties.Select(p => p.Name).SequenceEqual(new[] { nameof(VoterProfile.RegionId), nameof(VoterProfile.Status), nameof(VoterProfile.DisplayName) }));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AreaCity_import_preserves_parent_chain_and_four_levels()
|
|
{
|
|
const string csv = "id,pid,deep,name\n11,0,0,北京市\n1101,11,1,市辖区\n110101,1101,2,东城区\n110101001,110101,3,东华门街道\n";
|
|
var options = new DbContextOptionsBuilder<ElectionDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
|
await using var context = new ElectionDbContext(options);
|
|
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv));
|
|
|
|
var result = await new AreaCityCatalogImportService(context).ImportAsync(stream, "test-2026", CancellationToken.None);
|
|
|
|
Assert.Equal(4, result.CreatedCount);
|
|
var township = await context.AdministrativeRegions.SingleAsync(x => x.Code == "110101001");
|
|
var district = await context.AdministrativeRegions.SingleAsync(x => x.Code == "110101");
|
|
Assert.Equal(district.Id, township.ParentId);
|
|
Assert.Equal(AdministrativeRegionLevel.Township, township.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void Local_identity_password_hash_cannot_be_reused_as_plaintext()
|
|
{
|
|
var user = new ApplicationUser { UserName = "local-admin", DisplayName = "Local administrator" };
|
|
var hasher = new PasswordHasher<ApplicationUser>();
|
|
var hash = hasher.HashPassword(user, "Correct-Horse-7!Battery");
|
|
|
|
Assert.NotEqual("Correct-Horse-7!Battery", hash);
|
|
Assert.NotEqual(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "Correct-Horse-7!Battery"));
|
|
Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong-password"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Cursor_token_round_trips_and_rejects_malformed_values()
|
|
{
|
|
var cursor = new TestCursor("张三", Guid.NewGuid());
|
|
|
|
var encoded = CursorToken.Encode(cursor);
|
|
|
|
Assert.True(CursorToken.TryDecode<TestCursor>(encoded, out var decoded));
|
|
Assert.Equal(cursor, decoded);
|
|
Assert.False(CursorToken.TryDecode<TestCursor>("not-a-cursor", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void Offline_submissions_enforce_one_entry_per_clerk_and_do_not_store_voters()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ElectionDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
|
using var context = new ElectionDbContext(options);
|
|
var entity = context.Model.FindEntityType(typeof(OfflineCountSubmission))!;
|
|
Assert.Contains(entity.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual(new[] { nameof(OfflineCountSubmission.OfflineCountingSessionId), nameof(OfflineCountSubmission.EnteredByUserId) }));
|
|
Assert.DoesNotContain(typeof(OfflineCountSubmission).GetProperties(), x => x.Name.Contains("Voter", StringComparison.OrdinalIgnoreCase));
|
|
Assert.DoesNotContain(typeof(OfflineCountTally).GetProperties(), x => x.Name.Contains("Voter", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Result_service_counts_confirmed_versions_only_and_keeps_abstention_invalid_and_missing_separate()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ElectionDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
|
await using var context = new ElectionDbContext(options);
|
|
var region = Region("11", "省", AdministrativeRegionLevel.Province);
|
|
var election = new ElectionActivity { Name = "测试投票", RegionId = region.Id, Status = ElectionActivityStatus.Counting, VotingStartsAtUtc = DateTime.UtcNow.AddHours(-2), VotingEndsAtUtc = DateTime.UtcNow.AddHours(-1), CountingEndsAtUtc = DateTime.UtcNow.AddHours(1), CreatedByUserId = Guid.NewGuid() };
|
|
var contest = new ElectionContest { ElectionActivityId = election.Id, Title = "主任", Type = ElectionContestType.CandidateElection, SortOrder = 1, Seats = 1, MaxSelections = 1 };
|
|
var a = new ElectionOption { ElectionContestId = contest.Id, Label = "甲", SortOrder = 1 };
|
|
var b = new ElectionOption { ElectionContestId = contest.Id, Label = "乙", SortOrder = 2 };
|
|
var confirmed = new OfflineCountingSession { ElectionActivityId = election.Id, CountingStationId = Guid.NewGuid(), Status = OfflineCountingSessionStatus.Confirmed, OpenedByUserId = Guid.NewGuid() };
|
|
var unconfirmed = new OfflineCountingSession { ElectionActivityId = election.Id, CountingStationId = Guid.NewGuid(), Status = OfflineCountingSessionStatus.NeedsReview, OpenedByUserId = Guid.NewGuid() };
|
|
var good = new OfflineCountSubmission { OfflineCountingSessionId = confirmed.Id, EnteredByUserId = Guid.NewGuid() };
|
|
var duplicateVerifiedEntry = new OfflineCountSubmission { OfflineCountingSessionId = confirmed.Id, EnteredByUserId = Guid.NewGuid(), SubmittedAtUtc = good.SubmittedAtUtc.AddSeconds(1) };
|
|
var pending = new OfflineCountSubmission { OfflineCountingSessionId = unconfirmed.Id, EnteredByUserId = Guid.NewGuid() };
|
|
context.AddRange(region, election, contest, a, b, confirmed, unconfirmed, good, duplicateVerifiedEntry, pending);
|
|
context.AddRange(
|
|
new OfflineCountTally { OfflineCountSubmissionId = good.Id, ElectionContestId = contest.Id, ElectionOptionId = a.Id, Votes = 18 },
|
|
new OfflineCountTally { OfflineCountSubmissionId = good.Id, ElectionContestId = contest.Id, ElectionOptionId = b.Id, Votes = 12 },
|
|
new OfflineCountTally { OfflineCountSubmissionId = good.Id, ElectionContestId = contest.Id, Abstentions = 3, InvalidBallots = 2, MissingSelections = 1 },
|
|
new OfflineCountTally { OfflineCountSubmissionId = duplicateVerifiedEntry.Id, ElectionContestId = contest.Id, ElectionOptionId = a.Id, Votes = 18 },
|
|
new OfflineCountTally { OfflineCountSubmissionId = duplicateVerifiedEntry.Id, ElectionContestId = contest.Id, ElectionOptionId = b.Id, Votes = 12 },
|
|
new OfflineCountTally { OfflineCountSubmissionId = duplicateVerifiedEntry.Id, ElectionContestId = contest.Id, Abstentions = 3, InvalidBallots = 2, MissingSelections = 1 },
|
|
new OfflineCountTally { OfflineCountSubmissionId = pending.Id, ElectionContestId = contest.Id, ElectionOptionId = a.Id, Votes = 99 });
|
|
await context.SaveChangesAsync();
|
|
|
|
var result = await new ElectionResultService(context).BuildAsync(election.Id, CancellationToken.None);
|
|
|
|
var summary = Assert.Single(result.Contests);
|
|
Assert.Equal(18, summary.Options.Single(x => x.OptionId == a.Id).Votes);
|
|
Assert.Equal(30, summary.ValidVotes);
|
|
Assert.Equal(3, summary.Abstentions);
|
|
Assert.Equal(2, summary.InvalidBallots);
|
|
Assert.Equal(1, summary.MissingSelections);
|
|
Assert.Equal(new[] { a.Id }, summary.WinnerOptionIds);
|
|
}
|
|
|
|
[Fact]
|
|
public void Missing_selections_are_calculated_from_counted_ballots_and_required_choices()
|
|
{
|
|
var contest = new ElectionContest { ElectionActivityId = Guid.NewGuid(), Title = "两席岗位", Type = ElectionContestType.CandidateElection, SortOrder = 1, Seats = 2, MinSelections = 2, MaxSelections = 2 };
|
|
|
|
var missing = OfflineCountingRules.CalculateMissingSelections(contest, countedBallots: 20, optionVotes: 33, abstentions: 2, invalidBallots: 1);
|
|
|
|
Assert.Equal(4, missing);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Four_level_filter_returns_selected_subtree_and_rejects_cross_branch_selection()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ElectionDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
|
await using var context = new ElectionDbContext(options);
|
|
var province = Region("11", "省", AdministrativeRegionLevel.Province);
|
|
var city = Region("1101", "市", AdministrativeRegionLevel.City, province.Id);
|
|
var district = Region("110101", "区", AdministrativeRegionLevel.District, city.Id);
|
|
var township = Region("110101001", "镇", AdministrativeRegionLevel.Township, district.Id);
|
|
var village = Region("110101001001", "村", AdministrativeRegionLevel.Village, township.Id);
|
|
var otherCity = Region("1201", "另一市", AdministrativeRegionLevel.City, province.Id);
|
|
context.AddRange(province, city, district, township, village, otherCity);
|
|
await context.SaveChangesAsync();
|
|
var service = new RegionCascadeFilterService(context);
|
|
var scope = new HashSet<Guid> { province.Id, city.Id, district.Id, township.Id, village.Id, otherCity.Id };
|
|
|
|
var result = await service.ResolveAsync(scope, new AdministrativeRegionFilter(province.Id, city.Id, district.Id, township.Id), CancellationToken.None);
|
|
|
|
Assert.Equal(new[] { township.Id, village.Id }.Order(), result.Order());
|
|
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(scope, new AdministrativeRegionFilter(province.Id, otherCity.Id, district.Id, null), CancellationToken.None));
|
|
}
|
|
|
|
private static AdministrativeRegion Region(string code, string name, AdministrativeRegionLevel level, Guid? parentId = null) => new()
|
|
{
|
|
Code = code, Name = name, Level = level, ParentId = parentId, Source = "test", SourceVersion = "test"
|
|
};
|
|
|
|
private sealed record TestCursor(string Name, Guid Id);
|
|
}
|