Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using PatternKit.Examples.Chain;
using PatternKit.Examples.Chain.ConfigDriven;
using TinyBDD;
using TinyBDD.Xunit;
using Xunit.Abstractions;
Expand Down Expand Up @@ -281,6 +282,171 @@ public void ComputeDelta_Rounds_To_Nearest_Nickel()
}
}

public sealed class MediatedTransactionPipelineCoverageTests
{
[Scenario("TransactionPipelineBuilder FluentHooks Cover CustomStagesRulesAndCouponDiscounts")]
[Fact]
public void TransactionPipelineBuilder_FluentHooks_Cover_CustomStagesRulesAndCouponDiscounts()
{
var couponContext = new TransactionContext
{
Customer = new Customer(null, 30),
Items =
[
new LineItem("MFG", 10m, Qty: 2, ManufacturerCoupon: 1m),
new LineItem("HOUSE", 5m, Qty: 1, InHouseCoupon: 0.50m),
]
};
var couponPipeline = TransactionPipelineBuilder.New()
.WithRoundingRules()
.AddDiscountsAndTax()
.Build();

var couponResult = couponPipeline.Run(couponContext);

ScenarioExpect.True(couponResult.Result.Ok);
ScenarioExpect.Contains(couponResult.Ctx.Log, entry => entry.StartsWith("discount: manufacturer coupons", StringComparison.Ordinal));
ScenarioExpect.Contains(couponResult.Ctx.Log, entry => entry.StartsWith("discount: in-house coupons", StringComparison.Ordinal));

var customContext = new TransactionContext
{
Customer = new Customer(null, 30),
Items = [new LineItem("STOP", 1m)]
};
var customPipeline = TransactionPipelineBuilder.New()
.AddStage(ctx =>
{
ctx.Result = TxResult.Fail("custom", "custom stop");
return false;
})
.Build();

var customResult = customPipeline.Run(customContext);

ScenarioExpect.False(customResult.Result.Ok);
ScenarioExpect.Equal("custom", customResult.Result.Code);
}

[Scenario("TransactionPipelineBuilder Preauth Blocks EmptyBaskets")]
[Fact]
public void TransactionPipelineBuilder_Preauth_Blocks_EmptyBaskets()
{
var context = new TransactionContext
{
Customer = new Customer(null, 30),
Items = []
};
var pipeline = TransactionPipelineBuilder.New()
.AddPreauth()
.Build();

var result = pipeline.Run(context);

ScenarioExpect.False(result.Result.Ok);
ScenarioExpect.Equal("empty", result.Result.Code);
ScenarioExpect.True(result.Ctx.Log.Contains("preauth: empty basket"));
}

[Scenario("CardTenderHandlers Surface AuthorizationAndCaptureFailures")]
[Fact]
public void CardTenderHandlers_Surface_AuthorizationAndCaptureFailures()
{
var authProcessor = new ConfigurableProcessor(
TxResult.Fail("declined", "declined"),
TxResult.Success("capture"));
var captureProcessor = new ConfigurableProcessor(
TxResult.Success("auth"),
TxResult.Fail("capture-failed", "capture failed"));
var authContext = CreateContext(20m);
var captureContext = CreateContext(20m);

var configAuthTender = new CardTender(new CardProcessors(new()
{
[CardVendor.Unknown] = authProcessor
}));
var configCaptureTender = new CardTender(new CardProcessors(new()
{
[CardVendor.Unknown] = captureProcessor
}));
var strategyAuthTender = new CardTenderStrategy(new CardProcessors(new()
{
[CardVendor.Unknown] = authProcessor
}));
var strategyCaptureTender = new CardTenderStrategy(new CardProcessors(new()
{
[CardVendor.Unknown] = captureProcessor
}));

var tender = new Tender(PaymentKind.Card, Vendor: CardVendor.Unknown);
var configAuthResult = configAuthTender.Handle(authContext, tender);
var configCaptureResult = configCaptureTender.Handle(captureContext, tender);
var strategyAuthResult = strategyAuthTender.TryApply(CreateContext(20m), tender);
var strategyCaptureResult = strategyCaptureTender.TryApply(CreateContext(20m), tender);

ScenarioExpect.Equal("tender:card", configAuthTender.Key);
ScenarioExpect.False(configAuthResult.Ok);
ScenarioExpect.Equal("declined", configAuthResult.Code);
ScenarioExpect.True(authContext.Log.Contains("auth: declined (declined)"));
ScenarioExpect.False(configCaptureResult.Ok);
ScenarioExpect.Equal("capture-failed", configCaptureResult.Code);
ScenarioExpect.True(captureContext.Log.Contains("auth: capture failed"));
ScenarioExpect.NotNull(strategyAuthResult);
ScenarioExpect.Equal("declined", strategyAuthResult!.Value.Code);
ScenarioExpect.NotNull(strategyCaptureResult);
ScenarioExpect.Equal("capture-failed", strategyCaptureResult!.Value.Code);
}

[Scenario("CharityRoundUpRule NotifiesTrackerWhenApplied")]
[Fact]
public void CharityRoundUpRule_NotifiesTracker_WhenApplied()
{
var tracker = new RecordingCharityTracker();
var rule = new CharityRoundUpRule(tracker);
var context = new TransactionContext
{
Customer = new Customer(null, 30),
Items = [new LineItem("CHARITY:KidsFund", 10.25m)]
};
context.RecomputeSubtotal();

RoundingPipeline.Apply(context, [rule]);

ScenarioExpect.Equal("KidsFund", tracker.Charity);
ScenarioExpect.Equal(0.75m, tracker.Delta);
ScenarioExpect.Contains(context.Log, entry => entry.StartsWith("charity: KidsFund notified", StringComparison.Ordinal));
}

private static TransactionContext CreateContext(decimal price)
{
var context = new TransactionContext
{
Customer = new Customer(null, 30),
Items = [new LineItem("CARD", price)]
};
context.RecomputeSubtotal();
return context;
}

private sealed class ConfigurableProcessor(TxResult authorization, TxResult capture) : ICardProcessor
{
public TxResult Authorize(TransactionContext ctx) => authorization;

public TxResult Capture(TransactionContext ctx) => capture;
}

private sealed class RecordingCharityTracker : ICharityTracker
{
public string? Charity { get; private set; }
public decimal Delta { get; private set; }

public void Track(string charity, Guid transactionId, decimal delta, decimal newTotal)
{
Charity = charity;
Delta = delta;
}
}
}


[Collection("Culture")]
[Feature("Mediated Transaction pipeline – cash + loyalty + cigarettes")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,26 @@ public Task Commerce_Context_Map_Imports_Through_IServiceCollection()
services.AddCommerceContextMapDemo();
return services.BuildServiceProvider();
})
.When("summarizing the context map", sp => sp.GetRequiredService<CommerceContextMapDemo.CommerceContextMapReporter>().Summarize())
.Then("the summary reflects the production relationships", summary =>
.When("summarizing the context map and translating catalog products", sp =>
{
ScenarioExpect.Equal(2, summary.RelationshipCount);
ScenarioExpect.True(summary.HasPublishedLanguage);
ScenarioExpect.True(summary.HasCustomerSupplier);
var summary = sp.GetRequiredService<CommerceContextMapDemo.CommerceContextMapReporter>().Summarize();
var translator = sp.GetRequiredService<CommerceContextMapDemo.CatalogToFulfillmentTranslator>();
var product = translator.Translate(new CommerceContextMapDemo.CatalogProduct("SKU-42", "Trail Jacket"));
var shipment = new CommerceContextMapDemo.BillingShipment("SHIP-42", 9.95m);
return new { Summary = summary, Product = product, Shipment = shipment };
})
.Then("the summary reflects the production relationships", result =>
{
ScenarioExpect.Equal(2, result.Summary.RelationshipCount);
ScenarioExpect.True(result.Summary.HasPublishedLanguage);
ScenarioExpect.True(result.Summary.HasCustomerSupplier);
})
.And("the translator maps catalog language into fulfillment language", result =>
{
ScenarioExpect.Equal("SKU-42", result.Product.Sku);
ScenarioExpect.Equal("Trail Jacket", result.Product.Description);
ScenarioExpect.Equal("SHIP-42", result.Shipment.ShipmentId);
ScenarioExpect.Equal(9.95m, result.Shipment.Charge);
})
.AssertPassed();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications()
var productCacheAside = provider.GetRequiredService<ProductCatalogCacheAsideExample>();
var productRateLimit = provider.GetRequiredService<ProductSearchRateLimitingExample>();
var externalConfiguration = provider.GetRequiredService<TenantExternalConfigurationStoreExample>();
var identity = provider.GetRequiredService<IIdentityService>();
var presence = provider.GetRequiredService<IPresenceService>();
var rateLimiter = provider.GetRequiredService<IRateLimiter>();
var preferences = provider.GetRequiredService<IPreferenceService>();
var email = provider.GetRequiredService<IEmailSender>();
var sms = provider.GetRequiredService<ISmsSender>();
var push = provider.GetRequiredService<IPushSender>();
var im = provider.GetRequiredService<IImSender>();

auth.Chain.Execute(new PatternKit.Examples.Chain.HttpRequest("GET", "/admin/metrics", new Dictionary<string, string>()));

Expand All @@ -157,6 +165,24 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications()
var send = notifications.Strategy.ExecuteAsync(new SendContext(Guid.NewGuid(), "hello", false), CancellationToken.None)
.GetAwaiter()
.GetResult();
var userId = Guid.NewGuid();
var directEmail = email.SendAsync(new SendContext(userId, "email", false), CancellationToken.None).GetAwaiter().GetResult();
var directSms = sms.SendAsync(new SendContext(userId, "sms", true), CancellationToken.None).GetAwaiter().GetResult();
var directPush = push.SendAsync(new SendContext(userId, "push", false), CancellationToken.None).GetAwaiter().GetResult();
var directIm = im.SendAsync(new SendContext(userId, "im", false), CancellationToken.None).GetAwaiter().GetResult();
var preferredChannels = preferences.GetPreferredOrderAsync(userId, CancellationToken.None).GetAwaiter().GetResult();
var identityReady =
identity.HasVerifiedEmailAsync(userId, CancellationToken.None).GetAwaiter().GetResult()
&& identity.HasSmsOptInAsync(userId, CancellationToken.None).GetAwaiter().GetResult()
&& identity.HasPushTokenAsync(userId, CancellationToken.None).GetAwaiter().GetResult();
var presenceReady =
presence.IsOnlineInImAsync(userId, CancellationToken.None).GetAwaiter().GetResult()
&& !presence.IsDoNotDisturbAsync(userId, CancellationToken.None).GetAwaiter().GetResult();
var allChannelsRateLimited =
rateLimiter.CanSendAsync(Channel.Email, userId, CancellationToken.None).GetAwaiter().GetResult()
&& rateLimiter.CanSendAsync(Channel.Sms, userId, CancellationToken.None).GetAwaiter().GetResult()
&& rateLimiter.CanSendAsync(Channel.Push, userId, CancellationToken.None).GetAwaiter().GetResult()
&& rateLimiter.CanSendAsync(Channel.Im, userId, CancellationToken.None).GetAwaiter().GetResult();
var state = asyncState.RunAsync(["connect", "ok"]).GetAwaiter().GetResult();
var asyncResult = asyncTemplate.Pipeline.ExecuteAsync(7, CancellationToken.None).GetAwaiter().GetResult();
var generatedRecipientList = generatedRecipients.Runner.RunGenerated();
Expand Down Expand Up @@ -223,7 +249,15 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications()
("generated queue load leveling accepts fulfillment work", queueLoadLeveling.Service.EnqueueAsync(new FulfillmentWorkItem("ORDER-QL", "central")).GetAwaiter().GetResult().Accepted),
("generated cache-aside reuses product catalog reads", CacheAsideHits(productCacheAside.Service)),
("generated rate limit rejects product search overflow", RateLimitRejects(productRateLimit.Service)),
("generated external configuration store loads tenant settings", externalConfiguration.Service.LoadAsync().AsTask().GetAwaiter().GetResult().Loaded)
("generated external configuration store loads tenant settings", externalConfiguration.Service.LoadAsync().AsTask().GetAwaiter().GetResult().Loaded),
("notification identity service approves reachable users", identityReady),
("notification presence service permits delivery", presenceReady),
("notification rate limiter permits every channel", allChannelsRateLimited),
("notification preferences include every registered channel", preferredChannels.SequenceEqual([Channel.Push, Channel.Email, Channel.Sms])),
("notification email sender accepts messages", directEmail is { Channel: Channel.Email, Success: true }),
("notification sms sender accepts messages", directSms is { Channel: Channel.Sms, Success: true }),
("notification push sender accepts messages", directPush is { Channel: Channel.Push, Success: true }),
("notification im sender accepts messages", directIm is { Channel: Channel.Im, Success: true })
];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,41 @@ public Task Order_Domain_Event_Demo_Is_Importable_Through_IServiceCollection()
ScenarioExpect.Equal("placed:order-300:50", ScenarioExpect.Single(summary.AuditEntries));
})
.AssertPassed();

[Scenario("Order Domain Event demo dispatches billed events")]
[Fact]
public Task Order_Domain_Event_Demo_Dispatches_Billed_Events()
=> Given("fluent and generated order domain event dispatchers", () =>
{
var fluentProjection = new OrderEventProjection();
var fluentAudit = new List<string>();
GeneratedOrderDomainEvents.Projection = new OrderEventProjection();
GeneratedOrderDomainEvents.Audit = [];

return new BilledEventDispatchers(
OrderDomainEventPolicies.CreateFluentDispatcher(fluentProjection, fluentAudit),
fluentAudit,
GeneratedOrderDomainEvents.CreateDispatcher());
})
.When("order billed events are dispatched", (Func<BilledEventDispatchers, ValueTask<(IReadOnlyList<string> FluentAudit, IReadOnlyList<string> GeneratedAudit)>>)(async dispatchers =>
{
var fluentEvent = new OrderBilled(Guid.NewGuid(), DateTimeOffset.UtcNow, "order-400", 25m);
var generatedEvent = new OrderBilled(Guid.NewGuid(), DateTimeOffset.UtcNow, "order-500", 30m);

await dispatchers.Fluent.DispatchAsync(fluentEvent);
await dispatchers.Generated.DispatchAsync(generatedEvent);

return (dispatchers.FluentAudit.ToArray(), GeneratedOrderDomainEvents.Audit.ToArray());
}))
.Then("both paths audit billed events", result =>
{
ScenarioExpect.Equal("billed:order-400:25", ScenarioExpect.Single(result.FluentAudit));
ScenarioExpect.Equal("billed:order-500:30", ScenarioExpect.Single(result.GeneratedAudit));
})
.AssertPassed();

private sealed record BilledEventDispatchers(
PatternKit.Application.DomainEvents.DomainEventDispatcher<OrderDomainEvent> Fluent,
List<string> FluentAudit,
PatternKit.Application.DomainEvents.IDomainEventDispatcher<OrderDomainEvent> Generated);
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,21 @@ public void EUFraudDetector_Always_Passes()
ScenarioExpect.False(detector.IsFraudulent(order));
}

[Scenario("AsiaFraudDetector Always Passes")]
[Fact]
public void AsiaFraudDetector_Always_Passes()
{
var detector = new AsiaFraudDetector();
var order = new Order
{
Id = "test",
CustomerId = "cust",
Region = Region.Asia
};

ScenarioExpect.False(detector.IsFraudulent(order));
}

[Scenario("CreateOrderTemplates Creates Standard Order")]
[Fact]
public void CreateOrderTemplates_Creates_Standard_Order()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ public Task Fluent_Product_Gateway_Routes_Requests_By_Path()
.When("requests are routed", router => new
{
Inventory = router.Route(new ProductGatewayRequest("/inventory/SKU-100", "tenant-a")),
Pricing = router.Route(new ProductGatewayRequest("/pricing/SKU-100", "tenant-a")),
Fallback = router.Route(new ProductGatewayRequest("/unknown/SKU-100", "tenant-a"))
})
.Then("matching traffic uses downstream APIs and unknown traffic uses fallback", result =>
{
ScenarioExpect.Equal("inventory", result.Inventory.RouteName);
ScenarioExpect.Equal("inventory", result.Inventory.Response.Source);
ScenarioExpect.Equal("pricing", result.Pricing.RouteName);
ScenarioExpect.Equal("pricing", result.Pricing.Response.Source);
ScenarioExpect.True(result.Fallback.Fallback);
ScenarioExpect.Equal("fallback", result.Fallback.Response.Source);
})
Expand Down
33 changes: 33 additions & 0 deletions test/PatternKit.Examples.Tests/Generators/FacadeSpecsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ public void ShippingFacade_EstimatesDeliveryCorrectly()
ScenarioExpect.True(days > 0 && days <= 12);
}

[Scenario("ShippingSubsystems CoverFallbackDestinationsAndSpeeds")]
[Fact]
public void ShippingSubsystems_CoverFallbackDestinationsAndSpeeds()
{
var rates = new RateCalculator();
var estimator = new DeliveryEstimator();
var facade = new ShippingFacade(estimator, rates, new ShippingValidator());

var quote = facade.GetQuote(destination: "international", weight: 8m, speed: "economy");
var invalidQuote = facade.GetQuote(destination: "", weight: 8m, speed: "economy");

ScenarioExpect.Equal(29.99m, rates.CalculateBaseRate("international"));
ScenarioExpect.Equal(1.50m, rates.CalculateWeightSurcharge(8m));
ScenarioExpect.Equal(12, estimator.EstimateDays("international", "economy"));
ScenarioExpect.Equal("$31.49 - Delivery in 12 business days", quote);
ScenarioExpect.Equal("Invalid shipment parameters", invalidQuote);
}

[Scenario("BillingFacade ProcessesPaymentCorrectly")]
[Fact]
public void BillingFacade_ProcessesPaymentCorrectly()
Expand Down Expand Up @@ -219,6 +237,21 @@ public void BillingSubsystems_CoverValidationAndMissingRecordPaths()
ScenarioExpect.Equal("Refund amount exceeds original payment", excessiveRefund.ErrorMessage);
}

[Scenario("BillingSubsystems CoverSuccessfulRefundPath")]
[Fact]
public void BillingSubsystems_CoverSuccessfulRefundPath()
{
var payments = new PaymentProcessor();

var paid = payments.ProcessPayment("CUST001", 25m, "CreditCard");
var refund = payments.RefundPayment(paid.ReceiptNumber!, 10m);

ScenarioExpect.True(refund.Success);
ScenarioExpect.NotNull(refund.RefundId);
ScenarioExpect.Equal(10m, refund.RefundedAmount);
ScenarioExpect.NotNull(refund.ProcessedDate);
}

[Scenario("BillingFacade HandlesPaymentAndRefundFailures")]
[Fact]
public void BillingFacade_HandlesPaymentAndRefundFailures()
Expand Down
Loading
Loading