Add backend-authoritative invoice draft editing (ADR 0006/0007)
Move the invoice draft editor to a backend single source of truth: an in-memory InvoiceDraftSession (per-token, cached) holds the editable payload, server-computed sums/VAT and validation, plus an automatic change history. The browser posts single edits; the server recomputes and signals the editing session over a dedicated SignalR hub (DraftPreviewHub) to re-fetch. This reverses the previously-documented stateless editor (EVAL_live_invoice_editing, INVOICE_LIFECYCLE §10), by explicit product decision — captured in ADR 0006 and 0007 plus the live-draft-editing concept doc. Backend (this milestone): - InvoiceDraftSession + ChangeHistoryEntry data holders - InvoiceDraftCalculator: pure port of quantChange/invSumUpdate (§13b, VAT-by-rate) and consistency checks — fully unit-tested - IInvoiceDraftCache/InvoiceDraftCache: in-memory store with idle sliding TTL - IInvoiceDraftService/InvoiceDraftEditService: open (payload or DB reload), patch, build state, flush via existing RegisterInvoiceAsync (no new persistence), preview from cache, discard (DB reload), history - InvoiceDraftExpiryService: pre-expiry warning + eviction-with-reason - DraftPreviewHub + IDraftNotifier/DraftNotifier: targeted draftReady/draftExpiring/ draftClosed signals per draft token - inv/dopen|dstate|dpatch|dpreview|dsave|dhistory|ddiscard|dclose endpoints; save reports success/failure via the existing EventService - DI + hub mapping in Program.cs Frontend (additive foundation): $fis.draft SignalR client for /draftpreview. The editor DOM inversion (routing deltas, rendering from server state) is the next, separately-verified step; existing endpoints are unaffected. Tests: 30 new (calculator, cache, expiry, patch/history, flush); 306 total passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e53d8962ad
commit
af445c015e
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the in-memory draft cache (storage + idle sliding TTL) and the background
|
||||
/// expiry monitor that warns before eviction and closes the editor on eviction (ADR 0006).
|
||||
/// </summary>
|
||||
public class InvoiceDraftCacheTests
|
||||
{
|
||||
private static IConfiguration Config(int idle = 30, int warn = 5) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Fuchs:DraftEditing:IdleMinutes"] = idle.ToString(),
|
||||
["Fuchs:DraftEditing:ExpiryWarnMinutes"] = warn.ToString()
|
||||
}).Build();
|
||||
|
||||
private sealed class FakeNotifier : IDraftNotifier
|
||||
{
|
||||
public readonly List<(string token, int version)> Ready = new();
|
||||
public readonly List<(string token, int secondsLeft)> Expiring = new();
|
||||
public readonly List<(string token, string reason)> Closed = new();
|
||||
public Task SignalDraftReadyAsync(string token, int version, CancellationToken ct = default) { Ready.Add((token, version)); return Task.CompletedTask; }
|
||||
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken ct = default) { Expiring.Add((token, secondsLeft)); return Task.CompletedTask; }
|
||||
public Task SignalClosedAsync(string token, string reason, CancellationToken ct = default) { Closed.Add((token, reason)); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
// ── Cache storage ─────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void SetGet_RoundTrips_AndUnknownTokenIsNull()
|
||||
{
|
||||
var cache = new InvoiceDraftCache(Config());
|
||||
var s = new InvoiceDraftSession { Token = "abc" };
|
||||
cache.Set(s);
|
||||
Assert.Same(s, cache.Get("abc"));
|
||||
Assert.Null(cache.Get("nope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_EvictsSession()
|
||||
{
|
||||
var cache = new InvoiceDraftCache(Config());
|
||||
cache.Set(new InvoiceDraftSession { Token = "x" });
|
||||
Assert.NotNull(cache.Remove("x"));
|
||||
Assert.Null(cache.Get("x"));
|
||||
Assert.Null(cache.Remove("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ResetsExpiryWarningFlag_SoAFreshWarningIsDue()
|
||||
{
|
||||
var cache = new InvoiceDraftCache(Config());
|
||||
var s = new InvoiceDraftSession { Token = "x", ExpiryWarningSent = true };
|
||||
cache.Set(s);
|
||||
cache.Get("x");
|
||||
Assert.False(s.ExpiryWarningSent);
|
||||
}
|
||||
|
||||
// ── Expiry monitor ────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task Sweep_NearTtl_WarnsOnceThenEvictsWithReason()
|
||||
{
|
||||
var cfg = Config(idle: 30, warn: 5);
|
||||
var cache = new InvoiceDraftCache(cfg);
|
||||
var notifier = new FakeNotifier();
|
||||
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
|
||||
|
||||
var s = new InvoiceDraftSession { Token = "a" };
|
||||
cache.Set(s);
|
||||
|
||||
// Idle 26 min → inside the 5-min warning window (30-5=25) but not yet expired.
|
||||
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-26);
|
||||
await svc.SweepAsync(CancellationToken.None);
|
||||
Assert.Single(notifier.Expiring);
|
||||
Assert.Empty(notifier.Closed);
|
||||
Assert.True(s.ExpiryWarningSent);
|
||||
|
||||
// Another sweep while still idle must not spam a second warning.
|
||||
await svc.SweepAsync(CancellationToken.None);
|
||||
Assert.Single(notifier.Expiring);
|
||||
|
||||
// Past the TTL → evicted and the editor is told to close with a reason.
|
||||
s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-31);
|
||||
await svc.SweepAsync(CancellationToken.None);
|
||||
Assert.Single(notifier.Closed);
|
||||
Assert.Equal(("a", "expired"), notifier.Closed[0]);
|
||||
Assert.Null(cache.Get("a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_FreshSession_DoesNothing()
|
||||
{
|
||||
var cfg = Config(idle: 30, warn: 5);
|
||||
var cache = new InvoiceDraftCache(cfg);
|
||||
var notifier = new FakeNotifier();
|
||||
var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger<InvoiceDraftExpiryService>.Instance);
|
||||
cache.Set(new InvoiceDraftSession { Token = "fresh" });
|
||||
|
||||
await svc.SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(notifier.Expiring);
|
||||
Assert.Empty(notifier.Closed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the server-side port of the former client-side invoice math
|
||||
/// (<c>quantChange</c> + <c>invSumUpdate</c>). Because the truth now lives in the
|
||||
/// backend (ADR 0006), this logic is finally unit-testable directly.
|
||||
/// </summary>
|
||||
public class InvoiceDraftCalculatorTests
|
||||
{
|
||||
private static InvoiceDraftSession SessionWith(string reqJson, bool p13b = false)
|
||||
{
|
||||
var s = new InvoiceDraftSession { Token = "t" };
|
||||
s.Admin = new JObject { ["p13b"] = p13b };
|
||||
s.New = new JObject { ["invoiceemail"] = "kunde@example.de", ["invoiceaddress"] = "Weg 1" };
|
||||
s.Req = JArray.Parse(reqJson);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── RecomputeTotals ──────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
|
||||
{
|
||||
var s = SessionWith(@"[
|
||||
{ 'Id':'10','items':[
|
||||
{'net_val':100,'vat_val':19,'svcnet_val':0,'svcvat_val':0,'vat':'19%','Type':'material'},
|
||||
{'net_val':50,'vat_val':9.5,'svcnet_val':50,'svcvat_val':9.5,'vat':'19%','Type':'Service'} ] },
|
||||
{ 'Id':'11','items':[
|
||||
{'net_val':200,'vat_val':14,'svcnet_val':0,'svcvat_val':0,'vat':'7%','Type':'material'} ] }
|
||||
]");
|
||||
|
||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||
|
||||
Assert.Equal(350m, s.Sums.TotalNet);
|
||||
Assert.Equal(42.5m, s.Sums.TotalVat);
|
||||
Assert.Equal(392.5m, s.Sums.TotalGross);
|
||||
Assert.Equal(50m, s.Sums.ServiceNet);
|
||||
Assert.Equal(9.5m, s.Sums.ServiceVat);
|
||||
Assert.Equal(28.5m, s.Sums.VatByRate["19"]);
|
||||
Assert.Equal(14m, s.Sums.VatByRate["7"]);
|
||||
Assert.Equal(150m, s.Sums.NetByBlock["10"]);
|
||||
Assert.Equal(200m, s.Sums.NetByBlock["11"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecomputeTotals_ReverseCharge_SuppressesVatAndGrossEqualsNet()
|
||||
{
|
||||
var s = SessionWith(@"[{ 'Id':'1','items':[
|
||||
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]", p13b: true);
|
||||
|
||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||
|
||||
Assert.Equal(100m, s.Sums.TotalNet);
|
||||
Assert.Equal(100m, s.Sums.TotalGross);
|
||||
Assert.Equal(0m, s.Sums.TotalVat);
|
||||
Assert.Empty(s.Sums.VatByRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecomputeTotals_EmptyDraft_AllZero()
|
||||
{
|
||||
var s = SessionWith("[]");
|
||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||
Assert.Equal(0m, s.Sums.TotalNet);
|
||||
Assert.Equal(0m, s.Sums.TotalGross);
|
||||
Assert.Empty(s.Sums.VatByRate);
|
||||
}
|
||||
|
||||
// ── RecomputeItem (quantChange port) ─────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("Service", true)]
|
||||
[InlineData("material", false)]
|
||||
public void RecomputeItem_DerivesLineValuesFromQtyPriceVat(string type, bool isService)
|
||||
{
|
||||
var item = new JObject
|
||||
{
|
||||
["quantityhours"] = 5, ["net"] = "10", ["vat"] = "19", ["Type"] = type
|
||||
};
|
||||
|
||||
InvoiceDraftCalculator.RecomputeItem(item);
|
||||
|
||||
Assert.Equal(50m, item["net_val"]!.Value<decimal>());
|
||||
Assert.Equal(9.5m, item["vat_val"]!.Value<decimal>());
|
||||
if (isService)
|
||||
{
|
||||
Assert.Equal(50m, item["svcnet_val"]!.Value<decimal>());
|
||||
Assert.Equal(9.5m, item["svcvat_val"]!.Value<decimal>());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(item["svcnet_val"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecomputeItem_ZeroQuantity_LeavesValuesUntouched()
|
||||
{
|
||||
var item = new JObject { ["quantityhours"] = 0, ["net"] = "10", ["vat"] = "19", ["Type"] = "material" };
|
||||
InvoiceDraftCalculator.RecomputeItem(item);
|
||||
Assert.Null(item["net_val"]); // guard qty>0 && price>0 not met → no derivation
|
||||
}
|
||||
|
||||
// ── NormalizeRate ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("19,0%", "19")]
|
||||
[InlineData("7%", "7")]
|
||||
[InlineData("19", "19")]
|
||||
[InlineData("", "")]
|
||||
[InlineData("0", "")]
|
||||
[InlineData("7,5", "7.5")]
|
||||
public void NormalizeRate_CanonicalisesRateStrings(string raw, string expected)
|
||||
=> Assert.Equal(expected, InvoiceDraftCalculator.NormalizeRate(raw));
|
||||
|
||||
// ── Validate ─────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Validate_ValidDraft_NoErrors()
|
||||
{
|
||||
var s = SessionWith(@"[{ 'Id':'1','items':[
|
||||
{'net_val':100,'vat_val':19,'vat':'19%','Type':'material'} ] }]");
|
||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||
InvoiceDraftCalculator.Validate(s);
|
||||
Assert.DoesNotContain(s.ValidationMessages, m => m.Severity == "error");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "warning")] // missing email → advisory
|
||||
[InlineData("not-an-email", "error")]
|
||||
public void Validate_EmailProblems_AreFlagged(string email, string severity)
|
||||
{
|
||||
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat':'19%','Type':'material'}] }]");
|
||||
s.New["invoiceemail"] = email;
|
||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||
InvoiceDraftCalculator.Validate(s);
|
||||
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NoItems_IsError()
|
||||
{
|
||||
var s = SessionWith("[]");
|
||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||
InvoiceDraftCalculator.Validate(s);
|
||||
Assert.Contains(s.ValidationMessages, m => m.Field == "items" && m.Severity == "error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnknownVatRate_IsWarning()
|
||||
{
|
||||
var s = SessionWith(@"[{ 'Id':'1','items':[{'net_val':10,'vat_val':0.5,'vat':'5%','Type':'material'}] }]");
|
||||
InvoiceDraftCalculator.RecomputeTotals(s);
|
||||
InvoiceDraftCalculator.Validate(s);
|
||||
Assert.Contains(s.ValidationMessages, m => m.Field == "vat" && m.Severity == "warning");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.security;
|
||||
using Xunit;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the draft edit orchestrator's pure paths (open/patch/history/flush)
|
||||
/// without any database, proving the backend-authoritative model behaves correctly
|
||||
/// end-to-end at the service seam (ADR 0006).
|
||||
/// </summary>
|
||||
public class InvoiceDraftServiceTests
|
||||
{
|
||||
/// <summary>Captures the invoice handed to registration and returns it with a fake DB id — no SQL.</summary>
|
||||
private sealed class FakeInvoiceService : IInvoiceService
|
||||
{
|
||||
public FdsInvoiceData? Registered;
|
||||
public bool? LastChange;
|
||||
public Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
Registered = invoice;
|
||||
LastChange = change;
|
||||
invoice.InvoiceRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "INV42" });
|
||||
return Task.FromResult(invoice);
|
||||
}
|
||||
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
|
||||
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
|
||||
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
|
||||
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
|
||||
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException();
|
||||
}
|
||||
|
||||
private static (InvoiceDraftEditService svc, FakeInvoiceService inv, InvoiceDraftCache cache) NewService()
|
||||
{
|
||||
var cfg = new ConfigurationBuilder().Build();
|
||||
var cache = new InvoiceDraftCache(cfg);
|
||||
var inv = new FakeInvoiceService();
|
||||
var svc = new InvoiceDraftEditService(cache, inv, intranet: null!, NullLogger<InvoiceDraftEditService>.Instance);
|
||||
return (svc, inv, cache);
|
||||
}
|
||||
|
||||
private static JObject Payload() => JObject.Parse(@"{
|
||||
'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
|
||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
|
||||
'req':[{'Id':'1','items':[
|
||||
{'Id':'900','net_val':100,'vat_val':19,'vat':'19%','Type':'material','net':'10','quantityhours':10} ]}]
|
||||
}");
|
||||
|
||||
[Fact]
|
||||
public void OpenFromPayload_SeedsSessionAndComputesTotals()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
Assert.False(string.IsNullOrEmpty(s.Token));
|
||||
Assert.Equal(0, s.Version);
|
||||
Assert.Equal(100m, s.Sums.TotalNet);
|
||||
Assert.Equal(119m, s.Sums.TotalGross);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("neu@x.de") });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
Assert.Equal(1, s2!.Version);
|
||||
Assert.Equal("neu@x.de", s2.New["invoiceemail"]!.Value<string>());
|
||||
var h = Assert.Single(s2.History);
|
||||
Assert.Equal("email", h.Target);
|
||||
Assert.Equal("a@b.de", h.OldValue);
|
||||
Assert.Equal("neu@x.de", h.NewValue);
|
||||
Assert.Equal(1, h.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_ItemQty_RecomputesLineAndTotals()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.qty", Ref = "900", Value = JToken.FromObject(5) });
|
||||
|
||||
// qty 5 × price 10 = 50 net, 19% → 9.5 VAT.
|
||||
Assert.Equal(50m, s2!.Sums.TotalNet);
|
||||
Assert.Equal(9.5m, s2.Sums.VatByRate["19"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_P13bToggle_FlipsAndSuppressesVat()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b" }); // no value → toggle
|
||||
|
||||
Assert.Equal(100m, s2!.Sums.TotalGross); // reverse-charge → gross == net
|
||||
Assert.Empty(s2.Sums.VatByRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_UnknownToken_ReturnsNull()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
Assert.Null(svc.ApplyPatch("ghost", new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushToDbAsync_RegistersWithMappedTotals_AndSetsInvId()
|
||||
{
|
||||
var (svc, inv, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("INV42", result!.Id);
|
||||
Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
|
||||
Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
|
||||
|
||||
// The FdsInvoiceData handed to registration carries the session's server-computed totals.
|
||||
var prms = inv.Registered!.BuildInvoiceParams(change: false, invId: "");
|
||||
var balance = prms.First(p => p.ParameterName == "@InvoiceBalance");
|
||||
Assert.Equal("119", System.Convert.ToString(balance.Value, System.Globalization.CultureInfo.InvariantCulture));
|
||||
var vatRate = prms.First(p => p.ParameterName == "@InvoiceVAT_1");
|
||||
Assert.Equal("19", vatRate.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardAsync_NeverSaved_ReturnsSessionUnchanged()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
var back = await svc.DiscardAsync(s.Token, "user1", null!);
|
||||
Assert.Same(s, back); // no InvId → nothing to reload from the DB
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHistory_UnknownToken_IsEmpty()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
Assert.Empty(svc.GetHistory("ghost"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user