using MijnBesluit.Application.Abstractions; using MijnBesluit.Domain.Entities; using MijnBesluit.Domain.Enums; namespace MijnBesluit.SelfCheck; /// Eenvoudige opslag in het geheugen, zodat de regels zonder database te controleren zijn. public class Store { public List Users = new(); public List People = new(); public List Families = new(); public List Members = new(); public List Invitations = new(); public List VaultItems = new(); public List VaultFiles = new(); public List Grants = new(); public List Messages = new(); public List Declarations = new(); public List Managers = new(); public List Audit = new(); public List Codes = new(); public List Toestellen = new(); public List Samenvoegingen = new(); public List Adverteerders = new(); public List Campagnes = new(); public List CampagneUren = new(); private long _seq; public long NextId() => ++_seq; } public class TestClock : IClock { public DateTime UtcNow { get; set; } = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); public void Advance(TimeSpan by) => UtcNow = UtcNow.Add(by); } /// Mastersleutel in het geheugen. In productie komt deze uit een sleutelbestand of sleutelkluis. /// /// Een verkleiner die je per test aanzet. Standaard uit, zodat de andere controles blijven /// werken zoals ze waren: zonder verkleiner is er geen kleine versie en wordt er niets omgezet, /// precies zoals op een server waar het verkleinen niet beschikbaar is. /// public class TestVerkleiner : IAfbeeldingVerkleiner { public bool Aan { get; set; } /// Wat er teruggegeven wordt als hij aanstaat. Klein, zodat het verschil duidelijk is. public byte[] Uitkomst { get; set; } = { 1, 2, 3, 4, 5, 6, 7, 8 }; /// Waarmee hij is aangeroepen; zo kan een test nakijken of de maat klopt. public List<(string Soort, int MaxZijde)> Aanroepen { get; } = new(); public byte[]? Verklein(byte[] inhoud, string soort, int maxZijde) { if (!Aan) return null; if (!soort.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return null; Aanroepen.Add((soort, maxZijde)); return Uitkomst; } } public class TestKeyProtector : IMasterKeyProtector { private readonly byte[] _master = MijnBesluit.Application.Security.EnvelopeCrypto.NewKey(); public int CurrentKeyVersion => 1; public byte[] Protect(byte[] dataKey) => MijnBesluit.Application.Security.EnvelopeCrypto.WrapKey(dataKey, _master); public byte[] Unprotect(byte[] p) => MijnBesluit.Application.Security.EnvelopeCrypto.UnwrapKey(p, _master); } public class TestEmail : IEmailSender { public List<(string To, string Subject, string Body)> Sent = new(); /// Zet dit aan om te doen alsof de mailserver onbereikbaar is. public bool Mislukt { get; set; } public Task SendAsync(string to, string subject, string body, CancellationToken ct = default) { if (Mislukt) throw new InvalidOperationException("Failure sending mail."); Sent.Add((to, subject, body)); return Task.CompletedTask; } } public class TestFiles : IFileStore { private readonly Dictionary _files = new(); public Task WriteAsync(byte[] bytes, string name, CancellationToken ct = default) { var key = Guid.NewGuid().ToString("N"); _files[key] = bytes; return Task.FromResult(key); } public Task ReadAsync(string key, CancellationToken ct = default) => Task.FromResult(_files[key]); public Task DeleteAsync(string key, CancellationToken ct = default) { _files.Remove(key); return Task.CompletedTask; } } public class Uow : IUnitOfWork { private readonly Store _s; public Uow(Store s) => _s = s; /// Deelt id's uit aan nieuwe records, net zoals de database dat bij opslaan doet. public Task SaveChangesAsync(CancellationToken ct = default) { foreach (var p in _s.People.Where(x => x.Id == 0)) p.Id = _s.NextId(); foreach (var u in _s.Users.Where(x => x.Id == 0)) { u.Id = _s.NextId(); if (u.PersonId == 0 && u.Person is not null) u.PersonId = u.Person.Id; } foreach (var f in _s.Families.Where(x => x.Id == 0)) f.Id = _s.NextId(); foreach (var m in _s.Members.Where(x => x.Id == 0)) m.Id = _s.NextId(); foreach (var i in _s.Invitations.Where(x => x.Id == 0)) i.Id = _s.NextId(); foreach (var v in _s.VaultItems.Where(x => x.Id == 0)) v.Id = _s.NextId(); foreach (var v in _s.VaultFiles.Where(x => x.Id == 0)) v.Id = _s.NextId(); foreach (var g in _s.Grants.Where(x => x.Id == 0)) g.Id = _s.NextId(); foreach (var m in _s.Messages.Where(x => x.Id == 0)) m.Id = _s.NextId(); foreach (var d in _s.Declarations.Where(x => x.Id == 0)) d.Id = _s.NextId(); foreach (var m in _s.Managers.Where(x => x.Id == 0)) m.Id = _s.NextId(); foreach (var a in _s.Audit.Where(x => x.Id == 0)) a.Id = _s.NextId(); foreach (var c in _s.Codes.Where(x => x.Id == 0)) c.Id = _s.NextId(); foreach (var v in _s.Samenvoegingen.Where(x => x.Id == 0)) v.Id = _s.NextId(); foreach (var a in _s.Adverteerders.Where(x => x.Id == 0)) a.Id = _s.NextId(); foreach (var c in _s.Campagnes.Where(x => x.Id == 0)) c.Id = _s.NextId(); foreach (var u in _s.CampagneUren.Where(x => x.Id == 0)) u.Id = _s.NextId(); return Task.FromResult(0); } } public class UserRepo : IUserRepository { private readonly Store _s; public UserRepo(Store s) => _s = s; /// /// Vult de persoon in, net als de echte opslag met Include doet. Zonder dit gedraagt de /// zelftest zich anders dan de server en merk je een ontbrekende Include hier niet. /// private User? MetPersoon(User? u) { if (u is not null) u.Person ??= _s.People.FirstOrDefault(p => p.Id == u.PersonId); return u; } public Task FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(MetPersoon(_s.Users.FirstOrDefault(u => u.Id == id))); public Task FindByEmailAsync(string e, CancellationToken ct = default) => Task.FromResult(MetPersoon(_s.Users.FirstOrDefault(u => u.Email == e))); public Task FindByPersonIdAsync(long pid, CancellationToken ct = default) => Task.FromResult(MetPersoon(_s.Users.FirstOrDefault(u => u.PersonId == pid))); public Task EmailExistsAsync(string e, CancellationToken ct = default) => Task.FromResult(_s.Users.Any(u => u.Email == e)); public Task> ListBeheerdersAsync(CancellationToken ct = default) => Task.FromResult>(_s.Users.Where(u => u.IsBeheerder).OrderBy(u => u.Id).ToList()); public void Add(User u) => _s.Users.Add(u); public void Remove(User u) => _s.Users.Remove(u); } public class LoginCodeRepo : ILoginCodeRepository { private readonly Store _s; public LoginCodeRepo(Store s) => _s = s; public Task FindOpenAsync(string email, LoginCodePurpose doel, DateTime nu, CancellationToken ct = default) => Task.FromResult(_s.Codes.Where(c => c.Email == email && c.Purpose == doel && c.IsOpen(nu)) .OrderByDescending(c => c.CreatedAt).FirstOrDefault()); public Task> ListOpenAsync(string email, LoginCodePurpose doel, DateTime nu, CancellationToken ct = default) => Task.FromResult>( _s.Codes.Where(c => c.Email == email && c.Purpose == doel && c.IsOpen(nu)).ToList()); public Task CountSinceAsync(string email, DateTime sinds, CancellationToken ct = default) => Task.FromResult(_s.Codes.Count(c => c.Email == email && c.CreatedAt >= sinds && c.SignedInAt is null)); public Task RemoveExpiredAsync(DateTime ouderDan, CancellationToken ct = default) { var aantal = _s.Codes.RemoveAll(c => c.ExpiresAt < ouderDan); return Task.FromResult(aantal); } public Task RemoveForEmailAsync(string email, CancellationToken ct = default) { var aantal = _s.Codes.RemoveAll(c => c.Email == email); return Task.FromResult(aantal); } public void Add(LoginCode code) => _s.Codes.Add(code); } public class PersonRepo : IPersonRepository { private readonly Store _s; public PersonRepo(Store s) => _s = s; public Task FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.People.FirstOrDefault(p => p.Id == id)); // Dezelfde volgorde als in de database: geclaimde profielen eerst. Er kunnen er meerdere // zijn met hetzelfde adres; zie docs/Identiteit.md. public Task FindByEmailAsync(string e, CancellationToken ct = default) => Task.FromResult(_s.People.Where(p => p.Email == e) .OrderByDescending(p => p.ClaimedByUserId != null).ThenBy(p => p.Id) .FirstOrDefault()); public Task> ListByEmailAsync(string e, CancellationToken ct = default) => Task.FromResult>( _s.People.Where(p => p.Email == e) .OrderByDescending(p => p.ClaimedByUserId != null).ThenBy(p => p.Id).ToList()); public Task> ListAantekeningenOverAdresAsync(string e, long gebruikerId, CancellationToken ct = default) => Task.FromResult>( _s.People.Where(p => p.Email == e && p.ClaimedByUserId == null && p.MergedIntoPersonId == null && p.ManagedByUserId != null && p.ManagedByUserId != gebruikerId && p.LinkDeclinedByUserId != gebruikerId) .OrderBy(p => p.CreatedAt).ToList()); public Task> ListByIdsAsync(IEnumerable ids, CancellationToken ct = default) { var set = ids.ToHashSet(); return Task.FromResult>(_s.People.Where(p => set.Contains(p.Id)).ToList()); } public Task FindByObjectionTokenAsync(byte[] hash, CancellationToken ct = default) => Task.FromResult(_s.People.FirstOrDefault( p => p.ObjectionTokenHash is not null && p.ObjectionTokenHash.SequenceEqual(hash))); public void Add(Person p) => _s.People.Add(p); public void Remove(Person p) => _s.People.Remove(p); } public class FamilyRepo : IFamilyRepository { private readonly Store _s; public FamilyRepo(Store s) => _s = s; public Task FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.Families.FirstOrDefault(f => f.Id == id)); public Task> ListOwnedByPersonAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Families.Where(f => f.OwnerPersonId == pid).ToList()); public Task> ListMembersAsync(long fid, CancellationToken ct = default) => Task.FromResult>(_s.Members.Where(m => m.FamilyId == fid).ToList()); public Task FindMemberAsync(long fid, long pid, CancellationToken ct = default) => Task.FromResult(_s.Members.FirstOrDefault(m => m.FamilyId == fid && m.PersonId == pid)); public Task> ListFamiliesContainingAsync(long pid, CancellationToken ct = default) { var ids = _s.Members.Where(m => m.PersonId == pid).Select(m => m.FamilyId).ToHashSet(); return Task.FromResult>(_s.Families.Where(f => ids.Contains(f.Id)).ToList()); } public Task> ListCircleAsync(long pid, CancellationToken ct = default) { var families = _s.Members.Where(m => m.PersonId == pid).Select(m => m.FamilyId) .Concat(_s.Families.Where(f => f.OwnerPersonId == pid).Select(f => f.Id)) .ToHashSet(); var kring = _s.Members.Where(m => families.Contains(m.FamilyId)).Select(m => m.PersonId).ToHashSet(); kring.Add(pid); return Task.FromResult>(kring.ToList()); } public Task> ListMembershipsOfPersonAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Members.Where(m => m.PersonId == pid).ToList()); public Task> ListOwnedAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Families.Where(f => f.OwnerPersonId == pid).ToList()); public void Add(Family f) => _s.Families.Add(f); public void Add(FamilyMember m) => _s.Members.Add(m); public void Remove(FamilyMember m) => _s.Members.Remove(m); public void Remove(Family f) => _s.Families.Remove(f); } public class InvitationRepo : IInvitationRepository { private readonly Store _s; public InvitationRepo(Store s) => _s = s; public Task FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.Invitations.FirstOrDefault(i => i.Id == id)); public Task FindByTokenHashAsync(byte[] h, CancellationToken ct = default) => Task.FromResult(_s.Invitations.FirstOrDefault(i => i.TokenHash.SequenceEqual(h))); public Task> ListOpenForPersonAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Invitations.Where(i => i.PersonId == pid).ToList()); public Task HasOpenInvitationAsync(long pid, DateTime now, CancellationToken ct = default) => Task.FromResult(_s.Invitations.Any(i => i.PersonId == pid && i.IsOpen(now))); public Task> ListLatestForPeopleAsync(IEnumerable personIds, CancellationToken ct = default) { var ids = personIds.Distinct().ToHashSet(); return Task.FromResult>( _s.Invitations.Where(i => ids.Contains(i.PersonId)) .GroupBy(i => i.PersonId) .Select(g => g.OrderByDescending(i => i.SentAt ?? i.CreatedAt).First()) .ToList()); } public Task> ListAllForPersonAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Invitations.Where(i => i.PersonId == pid).ToList()); public Task> ListSentByUserAsync(long userId, CancellationToken ct = default) => Task.FromResult>(_s.Invitations.Where(i => i.CreatedByUserId == userId).ToList()); public void Add(Invitation i) => _s.Invitations.Add(i); public void Remove(Invitation i) => _s.Invitations.Remove(i); } public class VaultRepo : IVaultRepository { private readonly Store _s; public VaultRepo(Store s) => _s = s; public Task FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.VaultItems.FirstOrDefault(v => v.Id == id)); public Task> ListForPersonAsync(long pid, VaultCategory? cat = null, CancellationToken ct = default) => Task.FromResult>(_s.VaultItems.Where(v => v.OwnerPersonId == pid && v.DeletedAt == null && (cat == null || v.Category == cat)).ToList()); public Task FindFileAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.VaultFiles.FirstOrDefault(f => f.Id == id)); public Task> ListFilesForItemAsync(long itemId, CancellationToken ct = default) => Task.FromResult>(_s.VaultFiles .Where(f => f.VaultItemId == itemId && f.DeletedAt == null).OrderBy(f => f.CreatedAt).ToList()); public Task> ListFilesForPersonAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.VaultFiles .Where(f => f.SubjectPersonId == pid && f.DeletedAt == null).OrderBy(f => f.CreatedAt).ToList()); public Task> ListFilesForMessageAsync(long mid, CancellationToken ct = default) => Task.FromResult>(_s.VaultFiles .Where(f => f.MessageId == mid && f.DeletedAt == null).OrderBy(f => f.CreatedAt).ToList()); public Task> ListProfilePhotosAsync(IEnumerable pids, CancellationToken ct = default) { var ids = pids.Distinct().ToHashSet(); return Task.FromResult>(_s.VaultFiles .Where(f => f.SubjectPersonId is { } p && ids.Contains(p) && f.IsProfilePhoto && f.DeletedAt == null) .ToList()); } public Task FindGrantAsync(string type, long rid, long uid, CancellationToken ct = default) => Task.FromResult(_s.Grants.FirstOrDefault(g => g.ResourceType == type && g.ResourceId == rid && g.UserId == uid)); public Task> ListSharedWithAsync(long userId, CancellationToken ct = default) { var ids = _s.Grants.Where(g => g.ResourceType == nameof(VaultItem) && g.UserId == userId && g.RevokedAt == null) .Select(g => g.ResourceId).ToHashSet(); return Task.FromResult>( _s.VaultItems.Where(v => ids.Contains(v.Id) && v.DeletedAt == null).ToList()); } public Task> ListFilesOwnedByAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.VaultFiles.Where(f => f.OwnerPersonId == pid).ToList()); public void Add(VaultItem i) => _s.VaultItems.Add(i); public void Add(VaultFile f) => _s.VaultFiles.Add(f); public void Add(ContentGrant g) => _s.Grants.Add(g); public void Remove(VaultItem i) => _s.VaultItems.Remove(i); public void Remove(VaultFile f) => _s.VaultFiles.Remove(f); public Task RemoveGrantsForUserAsync(long userId, CancellationToken ct = default) { _s.Grants.RemoveAll(g => g.UserId == userId); return Task.CompletedTask; } public Task RevokeGrantsForOwnerAsync(long ownerPersonId, string reason, DateTime nowUtc, CancellationToken ct = default) { var items = _s.VaultItems.Where(i => i.OwnerPersonId == ownerPersonId).Select(i => i.Id).ToHashSet(); var verleend = _s.Grants.Where(g => g.ResourceType == nameof(VaultItem) && items.Contains(g.ResourceId) && g.Reason == reason && g.RevokedAt == null).ToList(); foreach (var t in verleend) t.RevokedAt = nowUtc; return Task.FromResult(verleend.Count); } public Task RemoveGrantsAsync(long itemId, CancellationToken ct = default) { _s.Grants.RemoveAll(g => g.ResourceType == nameof(VaultItem) && g.ResourceId == itemId); return Task.CompletedTask; } } public class MessageRepo : IMessageRepository { private readonly Store _s; public MessageRepo(Store s) => _s = s; public Task FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.Messages.FirstOrDefault(m => m.Id == id)); public Task> ListOutboxAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Messages.Where(m => m.FromPersonId == pid).ToList()); public Task> ListInboxAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Messages.Where(m => m.ToPersonId == pid && m.Status >= MessageStatus.Bezorgd).ToList()); public Task> ListDueAsync(DateTime now, int max, CancellationToken ct = default) => Task.FromResult>(_s.Messages.Where(m => m.Status == MessageStatus.Gepland).Take(max).ToList()); public Task> ListPlannedForAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Messages .Where(m => m.ToPersonId == pid && m.Status == MessageStatus.Gepland).ToList()); public Task> ListAwaitingDeathOfAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Messages.Where(m => m.FromPersonId == pid && m.DeliverAfterDeath).ToList()); public Task> ListAllTouchingAsync(long pid, CancellationToken ct = default) => Task.FromResult>( _s.Messages.Where(m => m.FromPersonId == pid || m.ToPersonId == pid).ToList()); public Task FindByViewTokenHashAsync(byte[] tokenHash, CancellationToken ct = default) => Task.FromResult(_s.Messages.FirstOrDefault(m => m.ViewTokenHash is not null && m.ViewTokenHash.SequenceEqual(tokenHash))); public void Add(Message m) => _s.Messages.Add(m); public void Remove(Message m) => _s.Messages.Remove(m); } public class LegacyRepo : ILegacyRepository { private readonly Store _s; public LegacyRepo(Store s) => _s = s; public Task> ListDeclarationsAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Declarations.Where(d => d.PersonId == pid).ToList()); public Task FindDeclarationByUserAsync(long pid, long uid, CancellationToken ct = default) => Task.FromResult(_s.Declarations.FirstOrDefault(d => d.PersonId == pid && d.DeclaredByUserId == uid)); public Task> ListManagersForAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Managers.Where(m => m.PersonId == pid).ToList()); public Task> ListAssignmentsOfAsync(long mpid, CancellationToken ct = default) => Task.FromResult>(_s.Managers.Where(m => m.ManagerPersonId == mpid).ToList()); public Task> ListAllDeclarationsAsync(long pid, CancellationToken ct = default) => Task.FromResult>(_s.Declarations.Where(d => d.PersonId == pid).ToList()); public Task> ListDeclarationsByUserAsync(long userId, CancellationToken ct = default) => Task.FromResult>( _s.Declarations.Where(d => d.DeclaredByUserId == userId).ToList()); public void Remove(DeathDeclaration d) => _s.Declarations.Remove(d); public void Add(DeathDeclaration d) => _s.Declarations.Add(d); public void Add(LegacyManager m) => _s.Managers.Add(m); public void Remove(LegacyManager m) => _s.Managers.Remove(m); } public class SamenvoegRepo : ISamenvoegRepository { private readonly Store _s; public SamenvoegRepo(Store s) => _s = s; public Task FindByIdAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.Samenvoegingen.FirstOrDefault(x => x.Id == id)); public Task> ListVoorDuplicaatAsync(long pid, CancellationToken ct = default) => Task.FromResult>( _s.Samenvoegingen.Where(x => x.DuplicaatPersonId == pid) .OrderByDescending(x => x.SamengevoegdOp).ToList()); public Task> ListVoorDoelAsync(long pid, CancellationToken ct = default) => Task.FromResult>( _s.Samenvoegingen.Where(x => x.DoelPersonId == pid) .OrderByDescending(x => x.SamengevoegdOp).ToList()); public void Add(Samenvoeging s) => _s.Samenvoegingen.Add(s); } /// /// Adverteerders, campagnes en tellers. Vult de adverteerder in bij een campagne, net als de /// echte opslag met Include doet - zonder dat gedraagt de zelftest zich anders dan de server. /// public class AdvertentieRepo : IAdvertentieRepository { private readonly Store _s; public AdvertentieRepo(Store s) => _s = s; private Campagne? Met(Campagne? c) { if (c is not null) c.Adverteerder ??= _s.Adverteerders.FirstOrDefault(a => a.Id == c.AdverteerderId); return c; } public Task FindAdverteerderAsync(long id, CancellationToken ct = default) => Task.FromResult(_s.Adverteerders.FirstOrDefault(a => a.Id == id)); public Task FindAdverteerderByEmailAsync(string email, CancellationToken ct = default) => Task.FromResult(_s.Adverteerders.FirstOrDefault(a => a.Email == email)); public Task> ListAdverteerdersAsync(CancellationToken ct = default) => Task.FromResult>(_s.Adverteerders.OrderBy(a => a.Bedrijfsnaam).ToList()); public void Add(Adverteerder a) => _s.Adverteerders.Add(a); public Task FindCampagneAsync(long id, CancellationToken ct = default) => Task.FromResult(Met(_s.Campagnes.FirstOrDefault(c => c.Id == id))); public Task> ListCampagnesAsync(long adverteerderId, CancellationToken ct = default) => Task.FromResult>( _s.Campagnes.Where(c => c.AdverteerderId == adverteerderId) .OrderByDescending(c => c.AangemaaktOp).Select(c => Met(c)!).ToList()); public Task> ListLopendAsync(DateOnly dag, CancellationToken ct = default) => Task.FromResult>( _s.Campagnes.Where(c => c.DraaitOp(dag)).Select(c => Met(c)!).ToList()); public Task> ListWachtendAsync(CancellationToken ct = default) => Task.FromResult>( _s.Campagnes.Where(c => c.Stand == CampagneStand.WachtOpBetaling || c.Stand == CampagneStand.WachtOpGoedkeuring) .OrderBy(c => c.AangemaaktOp).Select(c => Met(c)!).ToList()); public void Add(Campagne c) => _s.Campagnes.Add(c); public Task FindUurAsync(long campagneId, DateTime uurUtc, CancellationToken ct = default) => Task.FromResult(_s.CampagneUren.FirstOrDefault(u => u.CampagneId == campagneId && u.UurUtc == uurUtc)); public Task> ListUrenAsync(long campagneId, CancellationToken ct = default) => Task.FromResult>( _s.CampagneUren.Where(u => u.CampagneId == campagneId).OrderBy(u => u.UurUtc).ToList()); public Task> VertoningenInUurAsync( DateTime uurUtc, IEnumerable campagneIds, CancellationToken ct = default) { var lijst = campagneIds.ToHashSet(); return Task.FromResult>( _s.CampagneUren.Where(u => u.UurUtc == uurUtc && lijst.Contains(u.CampagneId)) .ToDictionary(u => u.CampagneId, u => u.Vertoningen)); } /// Doet hetzelfde als de INSERT ... ON DUPLICATE KEY UPDATE van de echte opslag. public Task TelAsync(long campagneId, DateTime uurUtc, int vertoningen, int kliks, CancellationToken ct = default) { var regel = _s.CampagneUren.FirstOrDefault(u => u.CampagneId == campagneId && u.UurUtc == uurUtc); if (regel is null) { regel = new CampagneUur { Id = _s.NextId(), CampagneId = campagneId, UurUtc = uurUtc }; _s.CampagneUren.Add(regel); } regel.Vertoningen += vertoningen; regel.Kliks += kliks; return Task.CompletedTask; } public Task<(int Vertoningen, int Kliks)> TotalenAsync(long campagneId, CancellationToken ct = default) { var uren = _s.CampagneUren.Where(u => u.CampagneId == campagneId).ToList(); return Task.FromResult((uren.Sum(u => u.Vertoningen), uren.Sum(u => u.Kliks))); } public void Add(CampagneUur u) => _s.CampagneUren.Add(u); } public class Audit : IAuditLog { private readonly Store _s; private readonly IClock _c; public Audit(Store s, IClock c) { _s = s; _c = c; } public Task WriteAsync(AuditAction a, long? uid, string? et = null, long? eid = null, string? d = null, CancellationToken ct = default) { _s.Audit.Add(new AuditLogEntry { Action = a, UserId = uid, EntityType = et, EntityId = eid, Details = d, CreatedAt = _c.UtcNow }); return Task.CompletedTask; } } public class ToestelRepo : IToestelRepository { private readonly Store _s; public ToestelRepo(Store s) => _s = s; public Task FindByKenmerkAsync(string kenmerk, CancellationToken ct = default) => Task.FromResult(_s.Toestellen.FirstOrDefault(t => t.Kenmerk == kenmerk)); public Task> ListForUserAsync(long userId, CancellationToken ct = default) => Task.FromResult>(_s.Toestellen .Where(t => t.UserId == userId && t.AfgemeldOp == null).ToList()); public void Add(Toestel t) => _s.Toestellen.Add(t); public void Remove(Toestel t) => _s.Toestellen.Remove(t); } /// Onthoudt wat er verstuurd zou zijn, en kan doen alsof een toestel niet meer bestaat. public class TestPush : IPushSender { public List<(string Kenmerk, string Titel, string Tekst)> Verstuurd = new(); /// Kenmerken die Google niet meer kent. public HashSet Onbekend = new(); public bool IsIngesteld { get; set; } = true; public Task StuurAsync(string kenmerk, string titel, string tekst, IReadOnlyDictionary? gegevens = null, CancellationToken ct = default) { if (Onbekend.Contains(kenmerk)) return Task.FromResult(false); Verstuurd.Add((kenmerk, titel, tekst)); return Task.FromResult(true); } }