using System.Globalization; using MijnBesluit.Application.Services; using MijnBesluit.Domain.Enums; namespace MijnBesluit.SelfCheck; /// /// Adverteren: de rekensom, de weg van indienen naar draaien, en wie er wat mag. /// /// Zie docs/Adverteren.md. De twee dingen die hier echt bewaakt worden: dat er niets draait /// waarvoor niet is betaald én naar gekeken, en dat alleen een beheerder die knoppen heeft. /// public static class AdvertentieChecks { public static void Register(Runner runner) { runner.TestAsync("Adverteren: een code voor adverteerders is geen inlogcode", async () => { // De belangrijkste controle van het hele adverteerdersgedeelte. Een adverteerder is // geen gebruiker van MijnBesluit: zijn code hoort nooit toegang tot een kluis te // geven, en de inlogcode van een gebruiker hoort niet in het adverteerdersgedeelte // te werken. Ze staan in dezelfde tabel, dus het verschil zit in het doel - en // precies zo'n verschil is er een om vast te leggen. var w = new World(); await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen"); // Een code voor adverteren, aangevraagd op het adres van een echte gebruiker. Assert.True((await w.Accounts.VraagAdverteerderscodeAanAsync("jan@example.nl", "Kwik-Bit", "Jan")).Succeeded, "de code voor adverteren gaat de deur uit"); var reclamecode = w.LaatsteCodeUitMail("jan@example.nl"); var binnen = await w.Accounts.LogInMetCodeAsync("jan@example.nl", reclamecode); Assert.True(!binnen.Succeeded, "met een adverteerderscode kom je niet in de kluis van die gebruiker"); // En andersom. w.Clock.Advance(TimeSpan.FromSeconds(31)); Assert.True((await w.Accounts.VraagInlogcodeAanAsync("jan@example.nl")).Succeeded, "nu een gewone inlogcode"); var inlogcode = w.LaatsteCodeUitMail("jan@example.nl"); var reclame = await w.Accounts.ControleerAdverteerderscodeAsync("jan@example.nl", inlogcode); Assert.True(!reclame.Succeeded, "en met een inlogcode kom je niet in het adverteerdersgedeelte"); }); runner.TestAsync("Adverteren: de bedrijfsnaam reist mee met de code", async () => { // Wie nieuw is vult zijn bedrijfsnaam in vóórdat hij de code krijgt. Die naam wordt // bij de code bewaard en pas gebruikt zodra de code klopt - zo kan niemand zich een // bedrijfsnaam aanmeten zonder eerst bij dat e-mailadres te kunnen. var w = new World(); Assert.True((await w.Accounts.VraagAdverteerderscodeAanAsync( "info@kwik-bit.nl", "Kwik-Bit", "Jonathan")).Succeeded, "de code gaat de deur uit"); var code = w.LaatsteCodeUitMail("info@kwik-bit.nl"); var controle = await w.Accounts.ControleerAdverteerderscodeAsync("info@kwik-bit.nl", code); Assert.True(controle.Succeeded, $"de code klopt ({controle.Error})"); Assert.True(controle.Value!.Bedrijfsnaam == "Kwik-Bit", "met de bedrijfsnaam erbij"); Assert.True(controle.Value.Contactpersoon == "Jonathan", "en de contactpersoon"); var account = await w.Adverteren.MeldAanAsync(controle.Value.Bedrijfsnaam, controle.Value.Contactpersoon, "info@kwik-bit.nl"); Assert.True(account.Succeeded, $"en het account wordt aangemaakt ({account.Error})"); // Dezelfde code werkt geen tweede keer. var nogmaals = await w.Accounts.ControleerAdverteerderscodeAsync("info@kwik-bit.nl", code); Assert.True(!nogmaals.Succeeded, "een code werkt maar één keer"); }); runner.Test("Adverteren: de betaallink klopt, ook op een Nederlandse server", () => { var w = new World(); w.Adverteren.BunqMe = "https://bunq.me/KwikBit"; var vorige = CultureInfo.CurrentCulture; try { // Precies de server waar dit op draait: een taalinstelling die 30,00 met een // komma schrijft. In de link hoort een punt te staan - bunq leest in een komma // een ander bedrag, of helemaal geen. // // Een zelfgemaakte instelling en niet new CultureInfo("nl-NL"): niet elke // omgeving heeft de landgegevens bij zich (globalization-invariant mode), en dan // valt deze controle om op iets wat er niet toe doet. Waar het om gaat is de // komma, en die zetten we hier zelf. var metKomma = (CultureInfo)CultureInfo.InvariantCulture.Clone(); metKomma.NumberFormat.NumberDecimalSeparator = ","; CultureInfo.CurrentCulture = metKomma; Assert.True((30.00m).ToString("0.00") == "30,00", "de instelling van deze controle schrijft zelf wél een komma"); var link = w.Adverteren.Betaallink(3000, "MB-00012"); Assert.True(link == "https://bunq.me/KwikBit?amount=30.00&description=MB-00012", $"bedrag met een punt en het kenmerk erin ({link})"); } finally { CultureInfo.CurrentCulture = vorige; } // Een afsluitende schuine streep in de instelling hoort geen dubbele op te leveren. w.Adverteren.BunqMe = "https://bunq.me/KwikBit/"; var netjes = w.Adverteren.Betaallink(1000, "MB-00001"); Assert.True(netjes == "https://bunq.me/KwikBit?amount=10.00&description=MB-00001", $"een streep te veel wordt opgeruimd ({netjes})"); // Op het scherm hoort juist een komma te staan; daar leest een mens het. Er stond // "€ 10.00" op de adverteerderspagina, en dat is niet hoe je hier een bedrag schrijft. Assert.True(AdvertentieService.Euro(3000) == "\u20ac 30,00", $"een bedrag op het scherm heeft een komma ({AdvertentieService.Euro(3000)})"); Assert.True(AdvertentieService.Euro(1000) == "\u20ac 10,00", "ook een rond bedrag"); // Niets ingesteld: dan liever geen knop dan een knop naar de verkeerde rekening. w.Adverteren.BunqMe = ""; Assert.True(w.Adverteren.Betaallink(1000, "MB-00001") is null, "zonder ingesteld adres komt er geen betaallink"); }); runner.Test("Adverteren: de prijs is dagen maal vertoningen per uur maal tarief", () => { var w = new World(); // Vaste getallen, zodat deze controle blijft kloppen als het tarief in de instellingen // wijzigt. Dat is immers de bedoeling van dat tarief. w.Adverteren.Tarieven.TariefCenten = 20; w.Adverteren.Tarieven.MinimumCenten = 1000; // Dertig dagen, vijf per uur: 150 eenheden, 3000 cent. Assert.True(w.Adverteren.BerekenBedrag(30, 5) == 3000, "dertig dagen, vijf per uur is dertig euro"); // Begin- en einddag tellen allebei mee: van de 1e tot en met de 30e is dertig dagen. var eerste = new DateOnly(2026, 10, 1); Assert.True(w.Adverteren.BerekenBedrag(eerste, eerste.AddDays(29), 5) == 3000, "met datums gerekend komt er hetzelfde uit"); Assert.True(w.Adverteren.BerekenBedrag(eerste, eerste, 1) == w.Adverteren.BerekenBedrag(1, 1), "van de eerste tot en met de eerste is één dag"); // Onder de bodem betaal je de bodem: een kleine boeking kost aan kijken, afvinken en // terugmailen meer dan hij opbrengt. Assert.True(w.Adverteren.BerekenBedrag(1, 1) == 1000, "het kleinste bedrag is de bodem"); Assert.True(w.Adverteren.BerekenBedrag(7, 2) == 1000, "zeven dagen, twee per uur haalt de bodem niet"); Assert.True(w.Adverteren.BerekenBedrag(0, 5) == 0 && w.Adverteren.BerekenBedrag(30, 0) == 0, "een campagne zonder dagen of zonder vertoningen kost niets"); }); runner.TestAsync("Adverteren: een campagne wordt pas getoond na betalen én goedkeuren", async () => { var w = new World(); var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Bea", "Beheer"); w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true; var bedrijf = (await w.Adverteren.MeldAanAsync("Uitvaartzorg Noord", "Piet", "piet@uitvaart.nl")).Value!; var vanaf = DateOnly.FromDateTime(w.Clock.UtcNow); var tot = vanaf.AddDays(29); var ingediend = await w.Adverteren.DienInAsync(bedrijf.Id, "Persoonlijk afscheid, dichtbij", "https://uitvaartnoord.nl", vanaf, tot, 5); Assert.True(ingediend.Succeeded, $"indienen lukt ({ingediend.Error})"); var campagne = ingediend.Value!; Assert.True(campagne.Stand == CampagneStand.WachtOpBetaling, "hij wacht op de betaling"); Assert.True(campagne.BedragCenten == w.Adverteren.BerekenBedrag(30, 5), "met het berekende bedrag"); Assert.True(campagne.Betaalkenmerk is { Length: > 0 }, "en een kenmerk om de betaling mee terug te vinden"); // De beheerder hoort het te weten, want vanaf nu ligt het bij hem. Assert.True(w.Email.Sent.Any(m => m.To == "beheer@example.nl"), "de beheerder krijgt bericht"); // Nog niets te zien: niet betaald, niet gekeurd. Assert.True((await w.Adverteren.WachtOpBeheerderAsync()).Count == 1, "hij staat op de lijst van de beheerder"); Assert.True(!campagne.DraaitOp(vanaf), "en draait nog niet"); // Goedkeuren vóór betalen mag niet. Anders draait er iets waarvoor niet betaald is, // en dat merk je pas als het voorbij is. var tevroeg = await w.Adverteren.GoedkeurenAsync(campagne.Id, beheerder.Id); Assert.True(!tevroeg.Succeeded, "goedkeuren kan niet zolang er niet betaald is"); Assert.True((await w.Adverteren.BetaaldAsync(campagne.Id, beheerder.Id)).Succeeded, "betaling afvinken"); Assert.True(campagne.Stand == CampagneStand.WachtOpGoedkeuring, "nu wacht hij op de inhoudelijke blik"); Assert.True(!campagne.DraaitOp(vanaf), "betaald is nog niet goedgekeurd"); Assert.True((await w.Adverteren.GoedkeurenAsync(campagne.Id, beheerder.Id)).Succeeded, "goedkeuren"); Assert.True(campagne.Stand == CampagneStand.Loopt, "hij loopt"); Assert.True(campagne.DraaitOp(vanaf) && campagne.DraaitOp(tot), "op de eerste en de laatste dag"); Assert.True(!campagne.DraaitOp(tot.AddDays(1)), "en een dag later niet meer"); Assert.True((await w.Adverteren.WachtOpBeheerderAsync()).Count == 0, "er wacht niets meer"); Assert.True(w.Email.Sent.Any(m => m.To == "piet@uitvaart.nl"), "de adverteerder hoort dat hij is goedgekeurd"); }); runner.TestAsync("Adverteren: wie het verst achterloopt is aan de beurt", async () => { // Twee campagnes tegelijk, met een verschillend aantal per uur. Wie meer boekt hoort // meer te krijgen, zonder dat de een de ander verdringt en zonder dat iemand over // zijn ingekochte aantal heen gaat. var w = new World(); var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Bea", "Beheer"); w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true; var groot = await LopendAsync(w, beheerder, "Groot BV", "groot@example.nl", perUur: 3); var klein = await LopendAsync(w, beheerder, "Klein BV", "klein@example.nl", perUur: 1); // Vier keer kiezen binnen hetzelfde klokuur. Groot heeft er drie gekocht, Klein één, // dus de verdeling hoort 3 om 1 te zijn. var nu = w.Clock.UtcNow; var beurten = new List(); for (var i = 0; i < 4; i++) { var gekozen = await w.Adverteren.KiesAsync(nu); Assert.True(gekozen is not null, $"er is een advertentie bij beurt {i + 1}"); beurten.Add(gekozen!.Id); await w.Adverteren.TelVertoningAsync(gekozen.Id, nu); } Assert.True(beurten.Count(id => id == groot.Id) == 3, "Groot komt drie keer aan bod"); Assert.True(beurten.Count(id => id == klein.Id) == 1, "Klein één keer"); // Allebei op hun aantal: dan verschijnt er niets. Geen opvulling, geen herhaling. Assert.True(await w.Adverteren.KiesAsync(nu) is null, "daarna is het uur op"); // Het volgende klokuur begint het opnieuw. var volgendUur = nu.AddHours(1); Assert.True(await w.Adverteren.KiesAsync(volgendUur) is not null, "een uur later mag het weer"); // En wat er werkelijk is getoond staat klaar voor de adverteerder. var totalen = await w.Adverteren.TotalenAsync(groot.Id); Assert.True(totalen.Vertoningen == 3 && totalen.Kliks == 0, "de tellers kloppen"); await w.Adverteren.TelKlikAsync(groot.Id, nu); Assert.True((await w.Adverteren.TotalenAsync(groot.Id)).Kliks == 1, "en een klik telt apart"); }); runner.TestAsync("Adverteren: buiten de periode of zonder goedkeuring komt er niets", async () => { var w = new World(); var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Bea", "Beheer"); w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true; var nu = w.Clock.UtcNow; var vandaag = DateOnly.FromDateTime(nu); // Betaald en goedgekeurd, maar hij begint pas morgen. var straks = (await w.Adverteren.MeldAanAsync("Later BV", "L", "later@example.nl")).Value!; var morgen = (await w.Adverteren.DienInAsync(straks.Id, "Binnenkort", "https://later.nl", vandaag.AddDays(1), vandaag.AddDays(8), 5)).Value!; await w.Adverteren.BetaaldAsync(morgen.Id, beheerder.Id); await w.Adverteren.GoedkeurenAsync(morgen.Id, beheerder.Id); Assert.True(await w.Adverteren.KiesAsync(nu) is null, "een campagne die morgen begint draait vandaag niet"); Assert.True(await w.Adverteren.KiesAsync(nu.AddDays(1)) is not null, "morgen wel"); // Betaald maar niet goedgekeurd: dat hoort niets te tonen. Dit is de belangrijkste // van allemaal - hier zou een niet-gekeurde advertentie langs de beheerder glippen. var wacht = (await w.Adverteren.MeldAanAsync("Wacht BV", "W", "wacht@example.nl")).Value!; var ongekeurd = (await w.Adverteren.DienInAsync(wacht.Id, "Nog niet gezien", "https://wacht.nl", vandaag, vandaag.AddDays(8), 5)).Value!; await w.Adverteren.BetaaldAsync(ongekeurd.Id, beheerder.Id); Assert.True(await w.Adverteren.KiesAsync(nu) is null, "betaald zonder goedkeuring komt er niet in"); // En een stopgezette campagne verdwijnt meteen. await w.Adverteren.GoedkeurenAsync(ongekeurd.Id, beheerder.Id); Assert.True(await w.Adverteren.KiesAsync(nu) is not null, "na goedkeuren wel"); await w.Adverteren.StopzettenAsync(ongekeurd.Id, beheerder.Id); Assert.True(await w.Adverteren.KiesAsync(nu) is null, "stopzetten haalt hem er meteen uit"); }); runner.TestAsync("Adverteren: alleen een beheerder mag afvinken en goedkeuren", async () => { var w = new World(); var gewoon = await w.MaakGebruikerAsync("jan@example.nl", "Jan", "Jansen"); var bedrijf = (await w.Adverteren.MeldAanAsync("Bloemen Zo", "Riet", "riet@bloemen.nl")).Value!; var vanaf = DateOnly.FromDateTime(w.Clock.UtcNow); var campagne = (await w.Adverteren.DienInAsync(bedrijf.Id, "Rouwbloemen", "https://bloemenzo.nl", vanaf, vanaf.AddDays(6), 2)).Value!; var betalen = await w.Adverteren.BetaaldAsync(campagne.Id, gewoon.Id); Assert.True(!betalen.Succeeded, "een gewone gebruiker vinkt niets af"); // Dezelfde melding als "bestaat niet": wie geen beheerder is, hoort niet te weten dat // hier iets te beheren valt. Assert.True(betalen.Error == "Niet gevonden.", "en krijgt niet te horen dat het aan zijn rechten ligt"); Assert.True(!(await w.Adverteren.GoedkeurenAsync(campagne.Id, gewoon.Id)).Succeeded, "en keurt niets goed"); Assert.True(campagne.Stand == CampagneStand.WachtOpBetaling, "er is niets veranderd"); }); runner.TestAsync("Adverteren: afwijzen kan niet zonder reden", async () => { var w = new World(); var beheerder = await w.MaakGebruikerAsync("beheer@example.nl", "Bea", "Beheer"); w.Store.Users.Single(u => u.Id == beheerder.Id).IsBeheerder = true; var bedrijf = (await w.Adverteren.MeldAanAsync("Iets Anders", "Kees", "kees@anders.nl")).Value!; var vanaf = DateOnly.FromDateTime(w.Clock.UtcNow); var campagne = (await w.Adverteren.DienInAsync(bedrijf.Id, "Koop nu", "https://anders.nl", vanaf, vanaf.AddDays(6), 2)).Value!; Assert.True(!(await w.Adverteren.AfwijzenAsync(campagne.Id, beheerder.Id, " ")).Succeeded, "zonder reden wijs je niets af"); Assert.True((await w.Adverteren.AfwijzenAsync(campagne.Id, beheerder.Id, "Past niet bij dit product")).Succeeded, "met een reden wel"); Assert.True(campagne.Stand == CampagneStand.Afgewezen, "hij staat als afgewezen"); Assert.True(campagne.AfwijzingReden == "Past niet bij dit product", "met de reden erbij"); // De adverteerder hoort te weten waaróm. Anders dient hij hetzelfde nog een keer in. var bericht = w.Email.Sent.Last(m => m.To == "kees@anders.nl"); Assert.True(bericht.Body.Contains("Past niet bij dit product"), "en krijgt die reden te lezen"); }); runner.TestAsync("Adverteren: wat er niet in mag", async () => { var w = new World(); var bedrijf = (await w.Adverteren.MeldAanAsync("Test BV", "Tester", "test@example.nl")).Value!; var vanaf = new DateOnly(2026, 10, 1); // Een link die code uitvoert bij de lezer. Dit is de reden dat er op het schema wordt // gecontroleerd en niet alleen op "is het gevuld". var script = await w.Adverteren.DienInAsync(bedrijf.Id, "Klik hier", "javascript:alert(1)", vanaf, vanaf.AddDays(6), 2); Assert.True(!script.Succeeded, "een javascript-link komt er niet in"); var geenAdres = await w.Adverteren.DienInAsync(bedrijf.Id, "Klik hier", "www.voorbeeld.nl", vanaf, vanaf.AddDays(6), 2); Assert.True(!geenAdres.Succeeded, "en een adres zonder http:// ook niet"); var omgekeerd = await w.Adverteren.DienInAsync(bedrijf.Id, "Klik hier", "https://voorbeeld.nl", vanaf.AddDays(6), vanaf, 2); Assert.True(!omgekeerd.Succeeded, "de einddatum mag niet voor de begindatum liggen"); var teveel = await w.Adverteren.DienInAsync(bedrijf.Id, "Klik hier", "https://voorbeeld.nl", vanaf, vanaf.AddDays(6), w.Adverteren.Tarieven.MaxPerUur + 1); Assert.True(!teveel.Succeeded, "en niemand koopt alle ruimte van een uur op"); Assert.True(w.Store.Campagnes.Count == 0, "er is niets van dit alles opgeslagen"); // Hetzelfde e-mailadres kan geen tweede account: daarmee logt hij in. var nogeens = await w.Adverteren.MeldAanAsync("Test BV 2", "Tester", "test@example.nl"); Assert.True(!nogeens.Succeeded, "eenzelfde e-mailadres kan geen tweede account"); }); } /// Een adverteerder met één campagne die vanaf vandaag draait: betaald en goedgekeurd. private static async Task LopendAsync( World w, MijnBesluit.Domain.Entities.User beheerder, string bedrijf, string email, int perUur) { var adverteerder = (await w.Adverteren.MeldAanAsync(bedrijf, "Contact", email)).Value!; var vandaag = DateOnly.FromDateTime(w.Clock.UtcNow); var campagne = (await w.Adverteren.DienInAsync(adverteerder.Id, $"Advertentie van {bedrijf}", "https://voorbeeld.nl", vandaag, vandaag.AddDays(30), perUur)).Value!; await w.Adverteren.BetaaldAsync(campagne.Id, beheerder.Id); await w.Adverteren.GoedkeurenAsync(campagne.Id, beheerder.Id); return campagne; } }