Flexibele kluissoorten: soorten in de database, beheer op de website

- Tabellen vault_categories en vault_category_fields (db/21), startrijen handmatig (db/handmatig/21b)
- VaultCategoryService met regels voor zichtbaarheid, gebruik en eigen velden
- Beheer > Kluissoorten (/beheer/soorten) met gebruikstellers; soort in gebruik niet te verwijderen
- Kluis en Nalatenschap (web en app) tonen soorten en eigen velden uit de database
- Socketprotocol: vault.categories; GRANTs voor mijnbesluit_app in db/02
- db/20 herhaalbaar gemaakt
- App versie 1.1 (versiecode 3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
This commit is contained in:
jf
2026-10-20 08:15:40 +00:00
co-authored by Claude Sonnet 5.5
parent a3a0bcfc05
commit af13d0e533
36 changed files with 2540 additions and 94 deletions
@@ -31,6 +31,7 @@ public static class ClientChecks
Accounts = Wereld.Accounts,
Families = Wereld.Families,
Vault = Wereld.Vault,
Soorten = Wereld.Soorten,
Messages = Wereld.Messages,
Legacy = Wereld.Legacy,
Overzicht = Wereld.Overzicht,
@@ -64,6 +65,67 @@ public static class ClientChecks
public static void Register(Runner runner)
{
runner.TestAsync("App: de soortenlijst komt over de lijn en de ingebouwde lijst in de app klopt ermee", async () =>
{
await using var proef = new Proef();
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var lijst = await app.KluisSoortenAsync();
Assert.True(lijst.Gelukt, $"de lijst komt binnen ({lijst.Melding})");
var server = lijst.Waarde!;
var ingebouwd = IngebouwdeSoorten.Lijst;
Assert.True(server.Select(s => (s.Id, s.Naam, s.BesprekenMet)).SequenceEqual(
ingebouwd.Select(s => (s.Id, s.Naam, s.BesprekenMet))),
"de ingebouwde lijst in de app is gelijk aan wat de server als startlijst heeft");
Assert.True(IngebouwdeSoorten.Naam(11) == "Paspoort" && IngebouwdeSoorten.Naam(4242) == "Overig",
"een onbekend nummer heet Overig");
});
runner.TestAsync("App: een eigen soort met velden, en de naam reist mee met het item", async () =>
{
await using var proef = new Proef();
var w = proef.Wereld;
await using var app = await proef.AangemeldeAppAsync("jan@example.nl");
var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Beheer");
w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true;
var soortId = (await w.Soorten.MaakAsync(beheerder.Id, "Niet reanimeren", "Alleen een papieren verklaring is leidend.", false)).Value;
await w.Soorten.VeldToevoegenAsync(beheerder.Id, soortId, "Wil je gereanimeerd worden?", "keuze",
"Ja\nNee\nAlleen na overleg", "Kies wat het beste past.", true);
await w.Soorten.VeldToevoegenAsync(beheerder.Id, soortId, "Datum verklaring", "datum", null, null, false);
var lijst = (await app.KluisSoortenAsync()).Waarde!;
var eigen = lijst.Single(s => s.Id == soortId);
Assert.True(!eigen.Ingebouwd && eigen.Uitleg!.Contains("papieren"), "de soort komt met zijn uitleg binnen");
Assert.True(eigen.Velden.Count == 2, "met twee velden");
Assert.True(eigen.Velden[0].Soort == KluisVeld.Keuze && eigen.Velden[0].Verplicht
&& eigen.Velden[0].Opties.SequenceEqual(new[] { "Ja", "Nee", "Alleen na overleg" }),
"het keuzeveld heeft zijn opties en is verplicht");
Assert.True(eigen.Velden[1].Soort == KluisVeld.Datum && !eigen.Velden[1].Verplicht, "het datumveld is niet verplicht");
var inhoud = new KluisInhoud
{
Velden = new() { ["wil_je_gereanimeerd_worden"] = "Nee", ["datum_verklaring"] = "2026-10-12" }
};
var opgeslagen = await app.KluisOpslaanAsync(soortId, "Mijn verklaring", inhoud, 0);
Assert.True(opgeslagen.Gelukt, $"het item wordt bewaard ({opgeslagen.Melding})");
var items = (await app.KluisAsync()).Waarde!;
var item = items.Single(i => i.Title == "Mijn verklaring");
Assert.True(item.Category == soortId, "het item staat in de nieuwe soort");
Assert.True(item.CategoryName == "Niet reanimeren", "en de naam reist met het item mee");
Assert.True(item.SoortNaam(IngebouwdeSoorten.Naam) == "Niet reanimeren", "ook zonder de lijst van de app");
var open = (await app.KluisOpenenAsync(item.Id)).Waarde!;
Assert.True(open.Velden["wil_je_gereanimeerd_worden"] == "Nee" && open.Velden["datum_verklaring"] == "2026-10-12",
"de velden komen er precies zo uit als ze erin gingen");
// Een ingebouwd item krijgt zijn ingebouwde naam, ook zonder CategoryName van de server.
var polis = new KluisItem(1, "Polis", 2, 0, null, DateTime.UtcNow);
Assert.True(polis.SoortNaam(IngebouwdeSoorten.Naam) == "Polis", "terugval op de ingebouwde naam");
});
runner.TestAsync("App: registreren met een code en de sessie bewaren", async () =>
{
await using var proef = new Proef();
+65
View File
@@ -25,6 +25,8 @@ public class Store
public List<Adverteerder> Adverteerders = new();
public List<Campagne> Campagnes = new();
public List<CampagneUur> CampagneUren = new();
public List<VaultCategoryDefinition> Soorten = VaultCategorySaatgoed.Maak();
public List<VaultCategoryField> SoortVelden = new();
private long _seq;
public long NextId() => ++_seq;
@@ -528,3 +530,66 @@ public class TestPush : IPushSender
return Task.FromResult(true);
}
}
/// <summary>De startrijen uit db/21_kluissoorten.sql, zodat de tests met dezelfde soorten beginnen als de echte database.</summary>
public static class VaultCategorySaatgoed
{
public static List<VaultCategoryDefinition> Maak()
{
VaultCategoryDefinition S(int id, string naam, int volgorde, bool bespreken = false) => new()
{
Id = id, Naam = naam, Actief = true, Volgorde = volgorde, BesprekenMet = bespreken, Ingebouwd = true
};
return new List<VaultCategoryDefinition>
{
S(3, "Wachtwoord", 10), S(8, "Sociale media", 20), S(4, "Apparaat", 30), S(5, "Pincode", 40),
S(2, "Polis", 50), S(1, "Uitvaartwens", 60, true), S(9, "Donorcodicil", 70, true),
S(10, "Rijbewijs", 80), S(11, "Paspoort", 90), S(6, "Document", 100), S(7, "Notitie", 110),
S(99, "Overig", 120)
};
}
}
public class VaultCategoryRepo : IVaultCategoryRepository
{
private readonly Store _s; public VaultCategoryRepo(Store s) => _s = s;
private VaultCategoryDefinition Met(VaultCategoryDefinition d)
{
d.Velden = _s.SoortVelden.Where(v => v.CategoryId == d.Id).ToList();
return d;
}
public Task<IReadOnlyList<VaultCategoryDefinition>> ListAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<VaultCategoryDefinition>>(
_s.Soorten.OrderBy(c => c.Volgorde).ThenBy(c => c.Naam).Select(Met).ToList());
public Task<VaultCategoryDefinition?> FindAsync(int id, CancellationToken ct = default) =>
Task.FromResult(_s.Soorten.Where(c => c.Id == id).Select(Met).FirstOrDefault());
public Task<VaultCategoryField?> FindFieldAsync(long fieldId, CancellationToken ct = default) =>
Task.FromResult(_s.SoortVelden.FirstOrDefault(f => f.Id == fieldId));
public Task<IReadOnlyList<MijnBesluit.Application.Models.SoortGebruik>> GebruikAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<MijnBesluit.Application.Models.SoortGebruik>>(
_s.VaultItems.Where(v => v.DeletedAt == null).GroupBy(v => v.Category)
.Select(g => new MijnBesluit.Application.Models.SoortGebruik(
(int)g.Key, g.Count(), g.Select(v => v.OwnerPersonId).Distinct().Count())).ToList());
public Task<IReadOnlySet<int>> InGebruikDoorAsync(long personId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlySet<int>>(
_s.VaultItems.Where(v => v.OwnerPersonId == personId && v.DeletedAt == null)
.Select(v => (int)v.Category).ToHashSet());
public Task<int> AantalItemsAsync(int categoryId, CancellationToken ct = default) =>
Task.FromResult(_s.VaultItems.Count(v => (int)v.Category == categoryId && v.DeletedAt == null));
private int _volgende = VaultCategoryDefinition.EersteEigenNummer;
public void Add(VaultCategoryDefinition soort) { if (soort.Id == 0) soort.Id = _volgende++; _s.Soorten.Add(soort); }
public void Remove(VaultCategoryDefinition soort) { _s.Soorten.Remove(soort); _s.SoortVelden.RemoveAll(v => v.CategoryId == soort.Id); }
public void Add(VaultCategoryField veld) { if (veld.Id == 0) veld.Id = _s.NextId(); _s.SoortVelden.Add(veld); }
public void Remove(VaultCategoryField veld) => _s.SoortVelden.Remove(veld);
}
+1
View File
@@ -1942,5 +1942,6 @@ ClientChecks.Register(runner);
ServiceChecks.Register(runner);
AdvertentieChecks.Register(runner);
UitrolChecks.Register(runner);
SoortenChecks.Register(runner);
return runner.Summary();
+3 -1
View File
@@ -25,6 +25,7 @@ public class World
public BeheerService Beheer { get; }
public AdvertentieService Adverteren { get; }
public AccountOpheffenService Opheffen { get; }
public VaultCategoryService Soorten { get; }
/// <summary>Maakt in één stap een account aan; scheelt in de scenario's het hele codegedoe.</summary>
public async Task<MijnBesluit.Domain.Entities.User> MaakGebruikerAsync(string email, string voornaam, string achternaam = "")
@@ -106,7 +107,8 @@ public class World
Accounts = new AccountService(users, people, families, codes, keys, Email, audit, uow, Clock, vault);
Families = new FamilyService(families, people, users, invitations, messages, legacy,
Email, audit, uow, Clock);
Vault = new VaultService(vault, people, messages, users, keys, files, audit, uow, Clock, Verkleiner, families, legacy);
Soorten = new VaultCategoryService(new VaultCategoryRepo(Store), users, audit, uow, Clock);
Vault = new VaultService(vault, people, messages, users, keys, files, audit, uow, Clock, Verkleiner, families, legacy, Soorten);
Meldingen = new MeldingService(new ToestelRepo(Store), users, Push, uow, Clock);
Messages = new MessageService(messages, people, users, vault, keys, files, Email, audit, uow, Clock, Meldingen);
Legacy = new LegacyService(legacy, people, users, vault, messages, Email, audit, uow, Clock, Vault, families);
@@ -0,0 +1,446 @@
using MijnBesluit.Application.Models;
using MijnBesluit.Application.Services;
using MijnBesluit.Domain.Entities;
using MijnBesluit.Domain.Enums;
namespace MijnBesluit.SelfCheck;
/// <summary>
/// De soorten kluisitems uit de database: wat de beheerder kan instellen en welke regels daarbij
/// vastliggen.
///
/// Het hart is één afspraak: wat in gebruik is, verdwijnt nooit. Een soort waar een item mee is
/// gemaakt kan niet worden verwijderd, en niet-actief zetten haalt hem alleen weg bij wie hem nog
/// nooit gebruikte. De rest hier is daar de omlijsting van: wie het mag instellen, hoe de velden
/// van een eigen soort worden gecontroleerd, en dat "Bespreken met" een instelling per soort is.
/// </summary>
public static class SoortenChecks
{
private static async Task<User> BeheerderAsync(World w, string email = "beheer@example.nl")
{
var u = await w.MaakGebruikerAsync(email, "Beheer", "Er");
w.Store.Users.Single(x => x.Id == u.Id).IsBeheerder = true;
return u;
}
private static async Task<int> MaakAsync(World w, User beheerder, string naam, bool bespreken = false)
{
var r = await w.Soorten.MaakAsync(beheerder.Id, naam, null, bespreken);
if (!r.Succeeded) throw new AssertionException($"soort maken mislukte: {r.Error}");
return r.Value;
}
public static void Register(Runner runner)
{
runner.TestAsync("Soorten: de startlijst is precies wat er voorheen vast in de code stond", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var lijst = await w.Soorten.ZichtbaarVoorAsync(jan.PersonId);
Assert.True(lijst.Count == 12, "twaalf soorten: elf ingebouwde en Overig");
Assert.True(lijst.First().Naam == "Wachtwoord" && lijst.Last().Naam == "Overig",
"de volgorde is die van de oude keuzelijst");
var bespreekbaar = lijst.Where(s => s.BesprekenMet).Select(s => s.Naam).OrderBy(n => n).ToArray();
Assert.True(bespreekbaar.SequenceEqual(new[] { "Donorcodicil", "Uitvaartwens" }),
"alleen uitvaartwens en donorcodicil kennen Bespreken met, zoals voorheen");
Assert.True(lijst.All(s => s.Ingebouwd && s.Velden.Count == 0), "ingebouwde soorten hebben geen eigen velden");
});
runner.TestAsync("Soorten: alleen een beheerder maakt of wijzigt soorten", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var beheer = await BeheerderAsync(w);
Assert.True(!(await w.Soorten.MaakAsync(jan.Id, "Dementie", null, false)).Succeeded,
"een gewone gebruiker maakt geen soort");
Assert.True(!(await w.Soorten.OverzichtAsync(jan.Id)).Succeeded, "en ziet het beheeroverzicht niet");
Assert.True(!(await w.Soorten.WijzigAsync(jan.Id, 3, "Wachtwoord", null, false, 10, false)).Succeeded,
"en zet geen soort uit");
var id = await MaakAsync(w, beheer, "Dementie");
Assert.True(id >= VaultCategoryDefinition.EersteEigenNummer, "een eigen soort begint bij 100");
Assert.True(!(await w.Soorten.VerwijderAsync(jan.Id, id)).Succeeded, "een gewone gebruiker verwijdert er geen");
});
runner.TestAsync("Soorten: een naam is verplicht en kan niet dubbel", async () =>
{
var w = new World();
var beheer = await BeheerderAsync(w);
Assert.True(!(await w.Soorten.MaakAsync(beheer.Id, " ", null, false)).Succeeded, "zonder naam kan het niet");
Assert.True(!(await w.Soorten.MaakAsync(beheer.Id, "polis", null, false)).Succeeded,
"Polis bestaat al, ook in kleine letters");
await MaakAsync(w, beheer, "Dementie");
Assert.True(!(await w.Soorten.MaakAsync(beheer.Id, "DEMENTIE", null, false)).Succeeded, "een tweede Dementie kan niet");
});
runner.TestAsync("Soorten: een nieuwe soort staat meteen in de lijst van iedereen", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var beheer = await BeheerderAsync(w);
var id = await MaakAsync(w, beheer, "Niet reanimeren");
var lijst = await w.Soorten.ZichtbaarVoorAsync(jan.PersonId);
Assert.True(lijst.Any(s => s.Id == id && s.Naam == "Niet reanimeren" && !s.Ingebouwd),
"de nieuwe soort staat erbij");
Assert.True(lijst.Last().Naam == "Niet reanimeren", "hij staat onderaan");
});
runner.TestAsync("Soorten: het beheeroverzicht telt items en eigenaren, niet meer", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet");
var beheer = await BeheerderAsync(w);
await w.Vault.CreateAsync(jan.Id, VaultCategory.Polis, "A", new VaultPayload());
await w.Vault.CreateAsync(jan.Id, VaultCategory.Polis, "B", new VaultPayload());
await w.Vault.CreateAsync(piet.Id, VaultCategory.Polis, "C", new VaultPayload());
var weg = (await w.Vault.CreateAsync(piet.Id, VaultCategory.Polis, "D", new VaultPayload())).Value!;
await w.Vault.DeleteAsync(weg.Id, piet.Id);
var overzicht = (await w.Soorten.OverzichtAsync(beheer.Id)).Value!;
var polis = overzicht.Single(s => s.Naam == "Polis");
Assert.True(polis.Items == 3, "drie items: het verwijderde telt niet mee");
Assert.True(polis.Eigenaren == 2, "van twee mensen");
Assert.True(!polis.KanVerwijderen, "een ingebouwde soort in gebruik is niet te verwijderen");
Assert.True(overzicht.Single(s => s.Naam == "Paspoort").Items == 0, "wat niemand gebruikt staat op nul");
});
runner.TestAsync("Soorten: wat in gebruik is kan niet worden verwijderd, wat ongebruikt is wel", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var beheer = await BeheerderAsync(w);
var gebruikt = await MaakAsync(w, beheer, "Dementie");
var ongebruikt = await MaakAsync(w, beheer, "Proef");
var item = (await w.Vault.CreateAsync(jan.Id, (VaultCategory)gebruikt, "Mijn wens",
new VaultPayload { Velden = new() })).Value!;
Assert.True(item.Id != 0, "Jan maakt een item in de nieuwe soort");
var geweigerd = await w.Soorten.VerwijderAsync(beheer.Id, gebruikt);
Assert.True(!geweigerd.Succeeded && geweigerd.Error!.Contains("niet-actief"),
"een soort in gebruik wordt geweigerd, met de uitleg wat wel kan");
Assert.True(await w.Soorten.VindAsync(gebruikt) is not null, "hij staat er nog");
Assert.True((await w.Soorten.VerwijderAsync(beheer.Id, ongebruikt)).Succeeded, "een ongebruikte kan weg");
Assert.True(await w.Soorten.VindAsync(ongebruikt) is null, "en is weg");
Assert.True(!(await w.Soorten.VerwijderAsync(beheer.Id, 3)).Succeeded,
"een ingebouwde soort verwijder je nooit, ook niet zonder items");
// En zodra het laatste item weg is, mag het weer.
await w.Vault.DeleteAsync(item.Id, jan.Id);
Assert.True((await w.Soorten.VerwijderAsync(beheer.Id, gebruikt)).Succeeded,
"zonder items is hij weer te verwijderen");
});
runner.TestAsync("Soorten: niet-actief verdwijnt alleen bij wie hem nooit gebruikte", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet");
var beheer = await BeheerderAsync(w);
var id = await MaakAsync(w, beheer, "Dementie");
var item = (await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Mijn wens",
new VaultPayload { Velden = new() })).Value!;
Assert.True((await w.Soorten.WijzigAsync(beheer.Id, id, "Dementie", null, false, 1000, false)).Succeeded,
"de beheerder zet hem op niet-actief");
Assert.True((await w.Soorten.ZichtbaarVoorAsync(jan.PersonId)).Any(s => s.Id == id),
"Jan heeft er een item van en blijft de soort zien");
Assert.True(!(await w.Soorten.ZichtbaarVoorAsync(piet.PersonId)).Any(s => s.Id == id),
"Piet heeft hem nooit gebruikt en ziet hem niet meer");
Assert.True(!(await w.Vault.CreateAsync(piet.Id, (VaultCategory)id, "Nieuw", new VaultPayload { Velden = new() })).Succeeded,
"Piet kan er ook geen nieuw item meer in maken");
var tweede = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Nog een", new VaultPayload { Velden = new() });
Assert.True(tweede.Succeeded, "Jan kan er nog wel nieuwe items in maken: het is een soort die hij kent");
var wijzigen = await w.Vault.UpdateAsync(item.Id, jan.Id, (VaultCategory)id, "Mijn wens (nieuw)",
new VaultPayload { Velden = new() }, ReleasePolicy.AlleenIkzelf);
Assert.True(wijzigen.Succeeded, "en zijn bestaande item kan hij gewoon wijzigen");
Assert.True((await w.Vault.OpenAsync(item.Id, jan.Id)).Succeeded, "en openen");
// Weer aanzetten: iedereen ziet hem weer.
await w.Soorten.WijzigAsync(beheer.Id, id, "Dementie", null, true, 1000, false);
Assert.True((await w.Soorten.ZichtbaarVoorAsync(piet.PersonId)).Any(s => s.Id == id),
"na weer aanzetten ziet Piet hem weer");
});
runner.TestAsync("Soorten: ook een ingebouwde soort kan op niet-actief, met dezelfde regel", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var piet = await w.MaakGebruikerAsync("piet@example.nl", "Piet");
var beheer = await BeheerderAsync(w);
await w.Vault.CreateAsync(jan.Id, VaultCategory.Paspoort, "Mijn paspoort", new VaultPayload());
await w.Soorten.WijzigAsync(beheer.Id, (int)VaultCategory.Paspoort, "Paspoort", null, false, 90, false);
Assert.True((await w.Soorten.ZichtbaarVoorAsync(jan.PersonId)).Any(s => s.Naam == "Paspoort"),
"Jan houdt Paspoort");
Assert.True(!(await w.Soorten.ZichtbaarVoorAsync(piet.PersonId)).Any(s => s.Naam == "Paspoort"),
"Piet ziet het niet meer");
});
runner.TestAsync("Soorten: Bespreken met is een instelling per soort", async () =>
{
var w = new World();
var opa = await w.MaakGebruikerAsync("opa@example.nl", "Klaas", "de Vries");
var zoon = await w.MaakFamilielidAsync(opa, "Peter", "de Vries", "zoon@example.nl");
await w.BeheerderAsync(opa, zoon);
var beheer = await BeheerderAsync(w);
// Standaard: een uitvaartwens kan het, een polis niet - zoals voorheen.
var wens = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Uitvaartwens, "Uitvaart", new VaultPayload(),
ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
Assert.True((await w.Vault.OpenAsync(wens.Id, zoon.Id)).Succeeded, "de gekozen beheerder leest de uitvaartwens mee");
var polis = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Polis, "Polis", new VaultPayload(),
ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
Assert.True(polis.SharedManagerPersonId is null, "bij een polis wordt de keuze genegeerd");
Assert.True(!(await w.Vault.OpenAsync(polis.Id, zoon.Id)).Succeeded, "en de beheerder ziet hem niet");
// De beheerder zet het bij Polis aan: nu werkt het ook daar.
await w.Soorten.WijzigAsync(beheer.Id, (int)VaultCategory.Polis, "Polis", null, true, 50, true);
var polis2 = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Polis, "Polis 2", new VaultPayload(),
ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
Assert.True(polis2.SharedManagerPersonId == zoon.PersonId, "bij Polis wordt de keuze nu bewaard");
Assert.True((await w.Vault.OpenAsync(polis2.Id, zoon.Id)).Succeeded, "en de beheerder leest hem mee");
// En bij Uitvaartwens uit: dan niet meer.
await w.Soorten.WijzigAsync(beheer.Id, (int)VaultCategory.Uitvaartwens, "Uitvaartwens", null, true, 60, false);
var wens2 = (await w.Vault.CreateAsync(opa.Id, VaultCategory.Uitvaartwens, "Uitvaart 2", new VaultPayload(),
ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
Assert.True(wens2.SharedManagerPersonId is null, "met de schakelaar uit wordt de keuze genegeerd");
});
runner.TestAsync("Soorten: Bespreken met werkt ook bij een soort die de beheerder zelf maakte", async () =>
{
var w = new World();
var opa = await w.MaakGebruikerAsync("opa@example.nl", "Klaas", "de Vries");
var zoon = await w.MaakFamilielidAsync(opa, "Peter", "de Vries", "zoon@example.nl");
await w.BeheerderAsync(opa, zoon);
var beheer = await BeheerderAsync(w);
var met = await MaakAsync(w, beheer, "Niet reanimeren", bespreken: true);
var zonder = await MaakAsync(w, beheer, "Dementie", bespreken: false);
var a = (await w.Vault.CreateAsync(opa.Id, (VaultCategory)met, "NR", new VaultPayload { Velden = new() },
ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
var b = (await w.Vault.CreateAsync(opa.Id, (VaultCategory)zonder, "DM", new VaultPayload { Velden = new() },
ReleasePolicy.AlleenIkzelf, null, zoon.PersonId)).Value!;
Assert.True((await w.Vault.OpenAsync(a.Id, zoon.Id)).Succeeded, "met de schakelaar aan leest hij mee");
Assert.True(!(await w.Vault.OpenAsync(b.Id, zoon.Id)).Succeeded, "met de schakelaar uit niet");
});
runner.TestAsync("Soorten: een soort die niet bestaat is geen soort", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var r = await w.Vault.CreateAsync(jan.Id, (VaultCategory)4242, "Onzin", new VaultPayload());
Assert.True(!r.Succeeded, "een onbekend soortnummer wordt geweigerd");
});
runner.TestAsync("Soorten: de sleutel van een veld komt uit het label en is uniek", () =>
{
Assert.True(VaultCategoryService.NieuweSleutel("Wil je gereanimeerd worden?", Array.Empty<string>())
== "wil_je_gereanimeerd_worden", "een vraag wordt een leesbare sleutel");
Assert.True(VaultCategoryService.NieuweSleutel("Café — één", Array.Empty<string>()) == "cafe_een",
"accenten en streepjes verdwijnen");
Assert.True(VaultCategoryService.NieuweSleutel("???", Array.Empty<string>()) == "veld",
"zonder bruikbare tekens blijft 'veld' over");
Assert.True(VaultCategoryService.NieuweSleutel("Arts", new[] { "arts" }) == "arts_2",
"een bestaande sleutel krijgt een nummer");
Assert.True(VaultCategoryService.NieuweSleutel(new string('x', 80), Array.Empty<string>()).Length <= 30,
"en een lang label wordt bijgesneden");
return Task.CompletedTask;
});
runner.TestAsync("Velden: een eigen soort bewaart zijn velden versleuteld en geeft ze terug", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var beheer = await BeheerderAsync(w);
var id = await MaakAsync(w, beheer, "Niet reanimeren");
Assert.True((await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Wil je gereanimeerd worden?", VeldSoort.Keuze,
"Ja\nNee\nAlleen na overleg", null, true)).Succeeded, "keuzeveld toevoegen");
Assert.True((await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Datum verklaring", VeldSoort.Datum,
null, null, false)).Succeeded, "datumveld toevoegen");
Assert.True((await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Toelichting voor de arts", VeldSoort.Lang,
null, "Zo precies mogelijk.", false)).Succeeded, "lang veld toevoegen");
var soort = (await w.Soorten.ZichtbaarVoorAsync(jan.PersonId)).Single(s => s.Id == id);
Assert.True(soort.Velden.Select(v => v.Sleutel).SequenceEqual(
new[] { "wil_je_gereanimeerd_worden", "datum_verklaring", "toelichting_voor_de_arts" }),
"de velden staan in de volgorde van toevoegen");
Assert.True(soort.Velden[0].Opties.SequenceEqual(new[] { "Ja", "Nee", "Alleen na overleg" }), "de opties kloppen");
var item = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Mijn verklaring", new VaultPayload
{
Velden = new()
{
["wil_je_gereanimeerd_worden"] = "Nee",
["datum_verklaring"] = "2026-10-12",
["onbekend_veld"] = "hoort er niet"
}
});
Assert.True(item.Succeeded, "het item wordt bewaard");
var open = (await w.Vault.OpenAsync(item.Value!.Id, jan.Id)).Value!;
Assert.True(open.Velden is not null && open.Velden["wil_je_gereanimeerd_worden"] == "Nee", "de waarde komt terug");
Assert.True(open.Velden!["datum_verklaring"] == "2026-10-12", "ook de datum");
Assert.True(!open.Velden.ContainsKey("onbekend_veld"), "een veld dat niet bestaat wordt weggelaten");
// En het staat echt versleuteld in de opslag, niet leesbaar.
var opgeslagen = w.Store.VaultItems.Single(v => v.Id == item.Value.Id);
Assert.True(!System.Text.Encoding.UTF8.GetString(opgeslagen.Ciphertext).Contains("Nee"),
"in de opslag staat niets leesbaars");
});
runner.TestAsync("Velden: verplicht, datum en lengte worden op de server gecontroleerd", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var beheer = await BeheerderAsync(w);
var id = await MaakAsync(w, beheer, "Niet reanimeren");
await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Keuze", VeldSoort.Keuze, "Ja\nNee", null, true);
await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Datum", VeldSoort.Datum, null, null, false);
var leeg = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "X", new VaultPayload { Velden = new() });
Assert.True(!leeg.Succeeded && leeg.Error!.Contains("Keuze"), "een leeg verplicht veld wordt geweigerd, met de naam erbij");
var slechteDatum = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "X", new VaultPayload
{ Velden = new() { ["keuze"] = "Ja", ["datum"] = "morgen" } });
Assert.True(!slechteDatum.Succeeded && slechteDatum.Error!.Contains("datum"), "een datum die geen datum is wordt geweigerd");
var teLang = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "X", new VaultPayload
{ Velden = new() { ["keuze"] = new string('a', VaultCategoryService.MaxWaardelengte + 1) } });
Assert.True(!teLang.Succeeded, "een te lange waarde wordt geweigerd");
var goed = await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "X", new VaultPayload
{ Velden = new() { ["keuze"] = "Ja", ["datum"] = "2026-02-28" } });
Assert.True(goed.Succeeded, "met alles goed ingevuld lukt het");
});
runner.TestAsync("Velden: een app die de soort niet kent kan geen gegevens overschrijven met niets", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var beheer = await BeheerderAsync(w);
var id = await MaakAsync(w, beheer, "Dementie");
await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Wens", VeldSoort.Lang, null, null, false);
var item = (await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Mijn wens", new VaultPayload
{ Velden = new() { ["wens"] = "Thuis blijven wonen." } })).Value!;
// Een oudere app stuurt de inhoud zonder het onderdeel Velden mee.
var oud = await w.Vault.UpdateAsync(item.Id, jan.Id, (VaultCategory)id, "Mijn wens",
new VaultPayload { Notes = "alleen een notitie" }, ReleasePolicy.AlleenIkzelf);
Assert.True(!oud.Succeeded && oud.Error!.Contains("app"), "het wijzigen wordt geweigerd met het advies de app bij te werken");
var open = (await w.Vault.OpenAsync(item.Id, jan.Id)).Value!;
Assert.True(open.Velden!["wens"] == "Thuis blijven wonen.", "wat er stond is niet overschreven");
});
runner.TestAsync("Velden: een ingebouwde soort bewaart geen eigen velden", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var item = (await w.Vault.CreateAsync(jan.Id, VaultCategory.Polis, "Polis", new VaultPayload
{ Insurer = "Zilveren Kruis", Velden = new() { ["iets"] = "weg ermee" } })).Value!;
var open = (await w.Vault.OpenAsync(item.Id, jan.Id)).Value!;
Assert.True(open.Velden is null, "bij een ingebouwde soort wordt Velden leeggemaakt");
Assert.True(open.Insurer == "Zilveren Kruis", "de eigen velden van de soort blijven gewoon");
});
runner.TestAsync("Velden: ingebouwde soorten hebben geen velden om te wijzigen", async () =>
{
var w = new World();
var beheer = await BeheerderAsync(w);
var r = await w.Soorten.VeldToevoegenAsync(beheer.Id, (int)VaultCategory.Polis, "Extra", VeldSoort.Tekst, null, null, false);
Assert.True(!r.Succeeded, "een veld toevoegen aan een ingebouwde soort kan niet");
});
runner.TestAsync("Velden: een veld met ingevulde gegevens wordt niet verwijderd maar niet-actief", async () =>
{
var w = new World();
var jan = await w.MaakGebruikerAsync("jan@example.nl", "Jan");
var beheer = await BeheerderAsync(w);
var id = await MaakAsync(w, beheer, "Dementie");
await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Wens", VeldSoort.Lang, null, null, false);
await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Arts", VeldSoort.Tekst, null, null, false);
var velden = (await w.Soorten.OverzichtAsync(beheer.Id)).Value!.Single(s => s.Id == id).Velden;
var wens = velden.Single(v => v.Sleutel == "wens");
var arts = velden.Single(v => v.Sleutel == "arts");
// Zonder items mag een veld gewoon weg.
Assert.True((await w.Soorten.VeldVerwijderenAsync(beheer.Id, arts.Id)).Succeeded, "zonder items kan een veld weg");
await w.Vault.CreateAsync(jan.Id, (VaultCategory)id, "Mijn wens", new VaultPayload
{ Velden = new() { ["wens"] = "Thuis blijven wonen." } });
var geweigerd = await w.Soorten.VeldVerwijderenAsync(beheer.Id, wens.Id);
Assert.True(!geweigerd.Succeeded && geweigerd.Error!.Contains("niet-actief"),
"met items erbij wordt verwijderen geweigerd, met de uitleg");
// Niet-actief mag. De waarde blijft bewaard en bij het wijzigen nog bruikbaar.
Assert.True((await w.Soorten.VeldWijzigenAsync(beheer.Id, wens.Id, "Wens", null, null, false, false, 10)).Succeeded,
"op niet-actief zetten kan");
var item = w.Store.VaultItems.Single(v => v.Title == "Mijn wens");
var wijzig = await w.Vault.UpdateAsync(item.Id, jan.Id, (VaultCategory)id, "Mijn wens",
new VaultPayload { Velden = new() { ["wens"] = "Thuis blijven wonen, met hulp." } }, ReleasePolicy.AlleenIkzelf);
Assert.True(wijzig.Succeeded, "het item is nog te wijzigen");
var open = (await w.Vault.OpenAsync(item.Id, jan.Id)).Value!;
Assert.True(open.Velden!["wens"] == "Thuis blijven wonen, met hulp.",
"en de waarde van het niet-actieve veld blijft bewaard");
});
runner.TestAsync("Velden: een keuzelijst heeft minstens twee opties, een uitleg heeft tekst", async () =>
{
var w = new World();
var beheer = await BeheerderAsync(w);
var id = await MaakAsync(w, beheer, "Dementie");
Assert.True(!(await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Keuze", VeldSoort.Keuze, "Alleen deze", null, false)).Succeeded,
"één optie is geen keuze");
Assert.True(!(await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Uitleg", VeldSoort.Uitleg, null, null, false)).Succeeded,
"een uitleg zonder tekst kan niet");
Assert.True((await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Uitleg", VeldSoort.Uitleg, null,
"Een papieren verklaring is leidend.", true)).Succeeded, "met tekst kan het wel");
var uitleg = (await w.Soorten.OverzichtAsync(beheer.Id)).Value!.Single(s => s.Id == id).Velden.Single();
Assert.True(!uitleg.Verplicht, "een uitlegtekst kan nooit verplicht zijn");
Assert.True(!(await w.Soorten.VeldToevoegenAsync(beheer.Id, id, "Raar", "zweef", null, null, false)).Succeeded,
"een soort veld die we niet kennen wordt geweigerd");
});
runner.TestAsync("Soorten: wijzigingen door de beheerder staan in het logboek", async () =>
{
var w = new World();
var beheer = await BeheerderAsync(w);
var id = await MaakAsync(w, beheer, "Dementie");
await w.Soorten.WijzigAsync(beheer.Id, id, "Dementie", null, false, 1000, false);
await w.Soorten.VerwijderAsync(beheer.Id, id);
var regels = w.Store.Audit.Where(a => a.Action == AuditAction.KluisSoortBeheerd).ToList();
Assert.True(regels.Count == 3, "gemaakt, gewijzigd en verwijderd staan er alle drie in");
Assert.True(regels.All(a => a.UserId == beheer.Id), "met de beheerder erbij");
});
}
}