Skip to content
Snippets Groups Projects
Commit 7eddbc03 authored by Dmytro Bogatov's avatar Dmytro Bogatov :two_hearts:
Browse files

Merge branch '32-show-discrepancies-in-ui' into 'master'

Resolve "Show discrepancies in UI"

Closes #32

See merge request !17
parents bd3ab2f7 1c5da3d3
No related branches found
No related tags found
No related merge requests found
Pipeline #
Showing
with 745 additions and 91 deletions
...@@ -6,17 +6,52 @@ import "bootstrap-select" ...@@ -6,17 +6,52 @@ import "bootstrap-select"
import * as Waves from "Waves" import * as Waves from "Waves"
import "bootstrap" import "bootstrap"
declare global {
interface JQuery {
jsonViewer(any): void;
}
}
$(async () => { $(async () => {
$('.selectpicker').selectpicker(); $('.selectpicker').selectpicker();
Utility.fixUtcTime(); Utility.fixUtcTime();
setupDiscrepancyViewer();
document.dispatchEvent(new Event("page-ready")); document.dispatchEvent(new Event("page-ready"));
}); });
/**
* Controls "load more" and "load less" buttons for
* resolved discrepancies list
*
*/
function setupDiscrepancyViewer() : void {
var resolvedLoaded = 10;
$("#load-more-resolved-btn").click(() => {
for (var index = resolvedLoaded + 1; index <= resolvedLoaded + 10; index++) {
$(`.discrepancy-card[data-number="${index}"]`).show();
}
resolvedLoaded += 10;
$("#load-less-resolved-btn").show();
if (resolvedLoaded >= $(".discrepancy-resolved").length) {
$("#load-more-resolved-btn").hide();
}
});
$("#load-less-resolved-btn").click(() => {
for (var index = resolvedLoaded; index > resolvedLoaded - 10; index--) {
$(`.discrepancy-card[data-number="${index}"]`).hide();
}
resolvedLoaded -= 10;
$("#load-more-resolved-btn").show();
if (resolvedLoaded <= 10) {
$("#load-less-resolved-btn").hide();
}
});
}
...@@ -161,6 +161,14 @@ export class Utility { ...@@ -161,6 +161,14 @@ export class Utility {
); );
}); });
$(".utc-date").each(function () {
$(this).text(
Utility.toLocalTimezone(
new Date($(this).text())
).toString()
);
});
} }
public static toUtcDate(date: Date): number { public static toUtcDate(date: Date): number {
... ...
......
...@@ -43,6 +43,26 @@ namespace StatusMonitor.Shared.Models.Entities ...@@ -43,6 +43,26 @@ namespace StatusMonitor.Shared.Models.Entities
} }
public override string ToString() => ToStringWithTimeZone(); public override string ToString() => ToStringWithTimeZone();
/// <summary>
/// Human readable description of the discrepancy.
/// </summary>
/// <returns>Human readable description of the discrepancy</returns>
public string Description() {
switch (Type)
{
case DiscrepancyType.GapInData:
return "Occurs if difference in timestamps between two consecutive data points exceeds certain value.";
case DiscrepancyType.HighLoad:
return "Occurs if CPU load exceeds certain value for some number of consecutive data points.";
case DiscrepancyType.LowHealth:
return "Occurs if system health value drops below certain value for some number of consecutive health reports.";
case DiscrepancyType.PingFailedNTimes:
return "Occurs if ping fails certain number of consecutive readings.";
default:
return "Unknown type";
}
}
} }
public enum DiscrepancyType public enum DiscrepancyType
... ...
......
...@@ -154,7 +154,7 @@ namespace StatusMonitor.Shared.Services ...@@ -154,7 +154,7 @@ namespace StatusMonitor.Shared.Services
return return
unresolved == 0 ? unresolved == 0 ?
"There are no outstanding issues. Well done." : "There are no outstanding issues. Well done." :
$"There {(unresolved == 1 ? "is" : "are")} still outstanding {unresolved} issue{(unresolved == 1 ? "" : "s")}." $"There {(unresolved == 1 ? "is" : "are")} still outstanding {unresolved} issue{(unresolved == 1 ? "" : "s")}. See admin panel."
; ;
} }
... ...
......
...@@ -33,6 +33,9 @@ namespace StatusMonitor.Web.Controllers.View ...@@ -33,6 +33,9 @@ namespace StatusMonitor.Web.Controllers.View
private readonly IMetricService _metricService; private readonly IMetricService _metricService;
private readonly IServiceProvider _provider; private readonly IServiceProvider _provider;
private readonly ICleanService _cleanService; private readonly ICleanService _cleanService;
private readonly IDataContext _context;
private readonly INotificationService _notification;
private readonly IConfiguration _config;
public AdminController( public AdminController(
...@@ -40,7 +43,10 @@ namespace StatusMonitor.Web.Controllers.View ...@@ -40,7 +43,10 @@ namespace StatusMonitor.Web.Controllers.View
ILogger<AdminController> logger, ILogger<AdminController> logger,
IMetricService metricService, IMetricService metricService,
IServiceProvider provider, IServiceProvider provider,
ICleanService cleanService ICleanService cleanService,
IDataContext context,
INotificationService notification,
IConfiguration congig
) )
{ {
_metricService = metricService; _metricService = metricService;
...@@ -48,6 +54,9 @@ namespace StatusMonitor.Web.Controllers.View ...@@ -48,6 +54,9 @@ namespace StatusMonitor.Web.Controllers.View
_loggingService = loggingService; _loggingService = loggingService;
_provider = provider; _provider = provider;
_cleanService = cleanService; _cleanService = cleanService;
_context = context;
_notification = notification;
_config = congig;
} }
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
...@@ -55,6 +64,11 @@ namespace StatusMonitor.Web.Controllers.View ...@@ -55,6 +64,11 @@ namespace StatusMonitor.Web.Controllers.View
ViewBag.Metrics = await _metricService ViewBag.Metrics = await _metricService
.GetMetricsAsync(); .GetMetricsAsync();
ViewBag.Discrepancies = await _context
.Discrepancies
.OrderByDescending(d => d.DateFirstOffense)
.ToListAsync();
return View(); return View();
} }
...@@ -76,6 +90,65 @@ namespace StatusMonitor.Web.Controllers.View ...@@ -76,6 +90,65 @@ namespace StatusMonitor.Web.Controllers.View
} }
} }
[HttpPost]
[ServiceFilter(typeof(ModelValidation))]
[AutoValidateAntiforgeryToken]
public async Task<IActionResult> ResolveDiscrepancy(DiscrepancyResolutionViewModel model)
{
if (
await _context
.Discrepancies
.AnyAsync(
d =>
d.DateFirstOffense == model.DateFirstOffense &&
d.MetricSource == model.Source &&
d.MetricType == model.EnumMetricType &&
d.Type == model.EnumDiscrepancyType
)
)
{
var discrepancy =
await _context
.Discrepancies
.SingleAsync(
d =>
d.DateFirstOffense == model.DateFirstOffense &&
d.MetricSource == model.Source &&
d.MetricType == model.EnumMetricType &&
d.Type == model.EnumDiscrepancyType
);
if (discrepancy.Resolved)
{
TempData["MessageSeverity"] = "warning";
TempData["MessageContent"] = $"Discrepancy {model.EnumDiscrepancyType} from {model.EnumMetricType} of {model.Source} at {model.DateFirstOffense} (UTC) has been already resolved.";
}
else
{
discrepancy.Resolved = true;
discrepancy.DateResolved = DateTime.UtcNow;
await _notification.ScheduleNotificationAsync(
$"Discrepancy \"{discrepancy.ToStringWithTimeZone(_config["ServiceManager:NotificationService:TimeZone"])}\" has been resolved!",
NotificationSeverity.Medium
);
await _context.SaveChangesAsync();
TempData["MessageSeverity"] = "success";
TempData["MessageContent"] = $"Discrepancy {model.EnumDiscrepancyType} from {model.EnumMetricType} of {model.Source} at {model.DateFirstOffense} (UTC) has been resolved.";
}
return RedirectToAction("Index", "Admin");
}
else
{
return NotFound($"Discrepancy for the following request not found. {model}");
}
}
public IActionResult Metric([FromQuery] string metric) public IActionResult Metric([FromQuery] string metric)
{ {
if (metric == null) if (metric == null)
... ...
......
...@@ -12,6 +12,12 @@ namespace StatusMonitor.Web.TagHelpers ...@@ -12,6 +12,12 @@ namespace StatusMonitor.Web.TagHelpers
/// </summary> /// </summary>
public class UtcTimeTagHelper : TagHelper public class UtcTimeTagHelper : TagHelper
{ {
/// <summary>
/// If true, the full date should be rendered
/// Otherwise, only time part should be rendered
/// </summary>
public bool ShowDate { get; set; } = false;
public DateTime Time { get; set; } public DateTime Time { get; set; }
[ViewContext] [ViewContext]
...@@ -26,7 +32,8 @@ namespace StatusMonitor.Web.TagHelpers ...@@ -26,7 +32,8 @@ namespace StatusMonitor.Web.TagHelpers
output.TagMode = TagMode.StartTagAndEndTag; output.TagMode = TagMode.StartTagAndEndTag;
output.Attributes.Clear(); output.Attributes.Clear();
output.Attributes.Add("class", "utc-time");
output.Attributes.Add("class", ShowDate ? "utc-date" : "utc-time");
output.Content.SetHtmlContent(Time.ToString()); output.Content.SetHtmlContent(Time.ToString());
... ...
......
using System;
using System.ComponentModel.DataAnnotations;
using StatusMonitor.Shared.Extensions;
using StatusMonitor.Shared.Models.Entities;
namespace StatusMonitor.Web.ViewModels
{
public class DiscrepancyResolutionViewModel
{
/// <summary>
/// Timestamp when the discrepancy starts.
/// </summary>
public DateTime DateFirstOffense { get; set; }
/// <summary>
/// Alias for DateFirstOffense.
/// Should represent the number of ticks (eq. DateTime.UtcNow.Ticks)
/// </summary>
[Required]
public string Date
{
get
{
return DateFirstOffense.ToString();
}
set
{
try
{
DateFirstOffense = new DateTime(Convert.ToInt64(value));
}
catch (System.Exception)
{
throw new ArgumentException("Invalid Date parameter.");
}
}
}
public DiscrepancyType EnumDiscrepancyType { get; set; }
/// <summary>
/// Alias for EnumDiscrepancyType.
/// </summary>
[Required]
public string DiscrepancyType
{
get
{
return EnumDiscrepancyType.ToString();
}
set
{
try
{
EnumDiscrepancyType = value.ToEnum<DiscrepancyType>();
}
catch (System.Exception)
{
throw new ArgumentException("Invalid DiscrepancyType parameter.");
}
}
}
public Metrics EnumMetricType { get; set; }
/// <summary>
/// Alias for EnumMetricType.
/// </summary>
[Required]
public string MetricType
{
get
{
return EnumMetricType.ToString();
}
set
{
try
{
EnumMetricType = value.ToEnum<Metrics>();
}
catch (System.Exception)
{
throw new ArgumentException("Invalid MetricType parameter.");
}
}
}
/// <summary>
/// Source identifier. May be server id or website URL.
/// </summary>
[Required]
[StringLength(32)]
[RegularExpression("[a-z0-9\\.\\-]+")]
public string Source { get; set; }
public override string ToString()
{
return $"Discrepancy removal model: type {DiscrepancyType} from {MetricType} of {Source} at {DateFirstOffense}.";
}
}
}
@using StatusMonitor.Shared.Models.Entities @using StatusMonitor.Shared.Models.Entities
@using Microsoft.Extensions.Configuration
@inject IConfiguration Config
@{ @{
ViewData["Title"] = "Admin panel"; ViewData["Title"] = "Admin panel";
...@@ -11,7 +14,12 @@ ...@@ -11,7 +14,12 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-3">
<div class="col-md-6">
<div class="row">
<div class="col-md-6">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2> <h2>
...@@ -57,7 +65,7 @@ ...@@ -57,7 +65,7 @@
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-6">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2> <h2>
...@@ -96,6 +104,124 @@ ...@@ -96,6 +104,124 @@
</div> </div>
</div> </div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h2>Discrepancies
<small>
@{
var discrepancyDataStartDate = DateTime.UtcNow - new TimeSpan(0, 0, Convert.ToInt32(Config["ServiceManager:CleanService:MaxAge"]));
}
View the list of resolved and unresolved dicrepancies from <utc-time time="@discrepancyDataStartDate" show-date=true /> until now.
</small>
</h2>
</div>
<div class="card-body card-padding">
<div role="tabpanel">
<ul class="tab-nav" role="tablist">
<li class="active">
<a
href="#unresolved"
aria-controls="unresolved"
role="tab"
data-toggle="tab"
aria-expanded="true"
>
Unresolved ( @(((IEnumerable<Discrepancy>)ViewBag.Discrepancies).Count(d => !d.Resolved)) )
</a>
</li>
<li class="">
<a
href="#resovled"
aria-controls="resovled"
role="tab"
data-toggle="tab"
aria-expanded="false"
>
Resolved ( @(((IEnumerable<Discrepancy>)ViewBag.Discrepancies).Count(d => d.Resolved)) )
</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="unresolved">
@if ( ((IEnumerable<Discrepancy>)ViewBag.Discrepancies).Any(d => !d.Resolved) )
{
@foreach (var dicrepancy in ((IEnumerable<Discrepancy>)ViewBag.Discrepancies).Where(d => !d.Resolved))
{
<vc:discrepancy-card
model=dicrepancy
number=0
hidden=false
></vc:discrepancy-card>
}
}
else
{
@: <h3>No outstanding issues! Well done!</h3>
}
</div>
<div role="tabpanel" class="tab-pane" id="resovled">
@if ( ((IEnumerable<Discrepancy>)ViewBag.Discrepancies).Any(d => d.Resolved) )
{
var number = 1;
@foreach (var dicrepancy in ((IEnumerable<Discrepancy>)ViewBag.Discrepancies).Where(d => d.Resolved))
{
var hidden = number > 10;
<vc:discrepancy-card
model=dicrepancy
number=number
hidden=hidden
></vc:discrepancy-card>
number++;
}
<div class="w-100 text-center p-t-10">
<button
type="type"
id="load-more-resolved-btn"
class="btn btn-primary btn-md waves-effect"
style="@( ((IEnumerable<Discrepancy>)ViewBag.Discrepancies).Count(d => d.Resolved) <= 10 ? "display: none;" : "")"
>
Show another 10
</button>
<button
type="type"
id="load-less-resolved-btn"
class="btn btn-warning btn-md waves-effect"
style="display: none;"
>
Hide another 10
</button>
</div>
}
else
{
@: <h3>No discrepancies have been noticed.</h3>
}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <div class="card">
... ...
......
<div
class="list-group-item media discrepancy-card discrepancy-@(Model.Resolved ? "resolved" : "unresolved")"
data-number="@ViewBag.Number"
style="@(ViewBag.Hidden ? "display: none;" : "")"
>
<div class="pull-left">
<i
class="zmdi zmdi-@(Model.Resolved ? "check-circle" : "alert-circle-o") zmdi-hc-3x"
style="color: @(Model.Resolved ? "green" : "red");"
>
</i>
</div>
@if (!Model.Resolved)
{
<div class="pull-right">
<div class="actions">
<form asp-controller="Admin" asp-action="ResolveDiscrepancy" asp-anti-forgery="true">
<input type="text" hidden name="Date" value="@Model.DateFirstOffense.Ticks">
<input type="text" hidden name="DiscrepancyType" value="@Model.Type">
<input type="text" hidden name="MetricType" value="@Model.MetricType">
<input type="text" hidden name="Source" value="@Model.MetricSource">
<button type="submit" class="btn btn-success btn-md waves-effect">
Resolve
</button>
</form>
</div>
</div>
}
else
{
<div class="pull-right">
<p>#@ViewBag.Number
</div>
}
<div class="media-body">
<div class="lgi-heading">
Discrepancy of type <strong>@Model.Type</strong> from
<a
asp-controller="Home"
asp-action="Metric"
asp-route-type="@Model.MetricType"
asp-route-source="@Model.MetricSource"
>
<em>@Model.MetricType</em> of <em>@Model.MetricSource</em>
</a>
.
</div>
<small class="lgi-text">@Model.Description()</small>
<ul class="lgi-attrs">
<li>Date started: <utc-time time="@Model.DateFirstOffense" /></li>
@if (Model.Resolved)
{
<li>Date resolved: <utc-time time="@Model.DateResolved" /></li>
<li>Duration: @( Math.Round((@Model.DateResolved - @Model.DateFirstOffense).TotalSeconds) ) seconds</li>
}
</ul>
</div>
</div>
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using StatusMonitor.Shared.Extensions;
using StatusMonitor.Shared.Models.Entities;
namespace StatusMonitor.Web.Views.ViewComponents
{
/// <summary>
/// View component responsible for rendering discrepancy card.
/// </summary>
[ViewComponent(Name = "DiscrepancyCard")]
public class DiscrepancyCardViewComponent : ViewComponent
{
public async Task<IViewComponentResult> InvokeAsync(Discrepancy model, int number = 0, bool hidden = false)
{
await Task.CompletedTask;
ViewBag.Number = number;
ViewBag.Hidden = hidden;
return View(model);
}
}
}
...@@ -27,12 +27,30 @@ namespace StatusMonitor.Tests.ControllerTests ...@@ -27,12 +27,30 @@ namespace StatusMonitor.Tests.ControllerTests
private readonly Mock<IMetricService> _mockMetricService = new Mock<IMetricService>(); private readonly Mock<IMetricService> _mockMetricService = new Mock<IMetricService>();
private readonly Mock<IApiController> _mockApiController = new Mock<IApiController>(); private readonly Mock<IApiController> _mockApiController = new Mock<IApiController>();
private readonly Mock<ICleanService> _mockCleanService = new Mock<ICleanService>(); private readonly Mock<ICleanService> _mockCleanService = new Mock<ICleanService>();
private readonly Mock<INotificationService> _mockNotificationService = new Mock<INotificationService>();
private readonly Mock<IConfiguration> _mockConfig = new Mock<IConfiguration>();
private readonly IDataContext _context;
private readonly AdminController _controller; private readonly AdminController _controller;
public AdminControllerTest() public AdminControllerTest()
{ {
var services = new ServiceCollection();
var mockEnv = new Mock<IHostingEnvironment>();
mockEnv
.SetupGet(environment => environment.EnvironmentName)
.Returns("Testing");
var env = mockEnv.Object;
services.RegisterSharedServices(env, new Mock<IConfiguration>().Object);
_context = services
.BuildServiceProvider()
.GetRequiredService<IDataContext>();
var mockServiceProvider = new Mock<IServiceProvider>(); var mockServiceProvider = new Mock<IServiceProvider>();
mockServiceProvider mockServiceProvider
.Setup(provider => provider.GetService(typeof(IApiController))) .Setup(provider => provider.GetService(typeof(IApiController)))
...@@ -43,8 +61,12 @@ namespace StatusMonitor.Tests.ControllerTests ...@@ -43,8 +61,12 @@ namespace StatusMonitor.Tests.ControllerTests
new Mock<ILogger<AdminController>>().Object, new Mock<ILogger<AdminController>>().Object,
_mockMetricService.Object, _mockMetricService.Object,
mockServiceProvider.Object, mockServiceProvider.Object,
_mockCleanService.Object _mockCleanService.Object,
_context,
_mockNotificationService.Object,
_mockConfig.Object
); );
_controller.ControllerContext.HttpContext = new DefaultHttpContext(); _controller.ControllerContext.HttpContext = new DefaultHttpContext();
_controller.TempData = new Mock<ITempDataDictionary>().Object; _controller.TempData = new Mock<ITempDataDictionary>().Object;
} }
... ...
......
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using StatusMonitor.Web.Controllers.View;
using StatusMonitor.Shared.Extensions;
using StatusMonitor.Shared.Models.Entities;
using StatusMonitor.Shared.Services;
using Xunit;
using Microsoft.AspNetCore.Http;
using Moq;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StatusMonitor.Shared.Models;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using StatusMonitor.Web.Services;
using System.Linq;
using StatusMonitor.Web.ViewModels;
using Microsoft.EntityFrameworkCore;
namespace StatusMonitor.Tests.ControllerTests
{
public partial class AdminControllerTest
{
[Fact]
public async Task ResolveDiscrepancySuccess()
{
// Arrange
var now = DateTime.UtcNow;
await _context.Discrepancies.AddAsync(
new Discrepancy
{
Type = DiscrepancyType.GapInData,
MetricType = Metrics.CpuLoad,
MetricSource = "the-source",
DateFirstOffense = now,
Resolved = false
}
);
await _context.SaveChangesAsync();
var input = new DiscrepancyResolutionViewModel
{
DateFirstOffense = now,
EnumDiscrepancyType = DiscrepancyType.GapInData,
EnumMetricType = Metrics.CpuLoad,
Source = "the-source"
};
// Act
var result = await _controller.ResolveDiscrepancy(input);
// Assert
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirectResult.ActionName);
Assert.Equal("Admin", redirectResult.ControllerName);
Assert.True(
(await _context
.Discrepancies
.SingleAsync(d =>
d.DateFirstOffense == now &&
d.MetricSource == "the-source" &&
d.MetricType == Metrics.CpuLoad &&
d.Type == DiscrepancyType.GapInData
)
).Resolved
);
Assert.InRange(
(await _context
.Discrepancies
.SingleAsync(d =>
d.DateFirstOffense == now &&
d.MetricSource == "the-source" &&
d.MetricType == Metrics.CpuLoad &&
d.Type == DiscrepancyType.GapInData
)
).DateResolved,
now,
now.AddMinutes(1)
);
_mockNotificationService
.Verify(
n => n.ScheduleNotificationAsync(
It.Is<string>(s => s.Contains("the-source")),
NotificationSeverity.Medium
)
);
_mockConfig.Verify(conf => conf["ServiceManager:NotificationService:TimeZone"]);
}
[Fact]
public async Task ResolveDiscrepancyWarning()
{
// Arrange
var now = DateTime.UtcNow;
await _context.Discrepancies.AddAsync(
new Discrepancy
{
Type = DiscrepancyType.GapInData,
MetricType = Metrics.CpuLoad,
MetricSource = "the-source",
DateFirstOffense = now,
Resolved = true
}
);
await _context.SaveChangesAsync();
var input = new DiscrepancyResolutionViewModel
{
DateFirstOffense = now,
EnumDiscrepancyType = DiscrepancyType.GapInData,
EnumMetricType = Metrics.CpuLoad,
Source = "the-source"
};
// Act
var result = await _controller.ResolveDiscrepancy(input);
// Assert
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirectResult.ActionName);
Assert.Equal("Admin", redirectResult.ControllerName);
_mockNotificationService
.Verify(
n => n.ScheduleNotificationAsync(
It.Is<string>(s => s.Contains("the-source")),
NotificationSeverity.Medium
),
Times.Never()
);
}
[Fact]
public async Task ResolveDiscrepancyNotFound()
{
// Arrange
var input = new DiscrepancyResolutionViewModel
{
DateFirstOffense = DateTime.UtcNow,
EnumDiscrepancyType = DiscrepancyType.GapInData,
EnumMetricType = Metrics.CpuLoad,
Source = "the-source"
};
// Act
var result = await _controller.ResolveDiscrepancy(input);
// Assert
var notFoundResult = Assert.IsType<NotFoundObjectResult>(result);
_mockNotificationService
.Verify(
n => n.ScheduleNotificationAsync(
It.Is<string>(s => s.Contains("the-source")),
NotificationSeverity.Medium
),
Times.Never()
);
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment