Browse Source

Profiler

pull/282/head
Sebastian 8 years ago
parent
commit
d69db26143
  1. 1
      Squidex.ruleset
  2. 40
      src/Squidex.Domain.Apps.Entities/AppProvider.cs
  3. 1
      src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  4. 2
      src/Squidex.Infrastructure/CachingProviderBase.cs
  5. 3
      src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs
  6. 9
      src/Squidex.Infrastructure/Log/Adapter/SemanticLogLogger.cs
  7. 14
      src/Squidex.Infrastructure/Log/ILogProfilerSessionProvider.cs
  8. 24
      src/Squidex.Infrastructure/Log/NoopDisposable.cs
  9. 67
      src/Squidex.Infrastructure/Log/Profile.cs
  10. 58
      src/Squidex.Infrastructure/Log/ProfilerSession.cs
  11. 20
      src/Squidex.Infrastructure/Orleans/J{T}.cs
  12. 2
      src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs
  13. 56
      src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs
  14. 4
      src/Squidex/Config/Domain/InfrastructureServices.cs
  15. 4
      src/Squidex/Config/Domain/LoggingServices.cs
  16. 7
      src/Squidex/Config/Logging.cs
  17. 2
      src/Squidex/Config/Orleans/SiloWrapper.cs
  18. 2
      src/Squidex/Config/Web/WebExtensions.cs
  19. 10
      src/Squidex/Config/Web/WebServices.cs
  20. 4
      src/Squidex/Pipeline/ApiCostsFilter.cs
  21. 24
      src/Squidex/Pipeline/RequestLogPerformanceMiddleware.cs
  22. 40
      src/Squidex/Pipeline/RequestLogProfilerSessionProvider.cs
  23. 2
      src/Squidex/Program.cs
  24. 62
      tests/Squidex.Infrastructure.Tests/UsageTracking/ThreadingUsageTrackerTests.cs

1
Squidex.ruleset

@ -81,6 +81,7 @@
<Rule Id="AD0001" Action="None" />
</Rules>
<Rules AnalyzerId="Roslyn.Core" RuleNamespace="Microsoft.CodeAnalysis.Diagnostics">
<Rule Id="IDE0032" Action="None" />
<Rule Id="IDE0042" Action="None" />
</Rules>
</RuleSet>

40
src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities
@ -46,6 +47,8 @@ namespace Squidex.Domain.Apps.Entities
}
public async Task<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(Guid appId, Guid id)
{
using (Profile.Method<AppProvider>())
{
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync();
@ -63,8 +66,11 @@ namespace Squidex.Domain.Apps.Entities
return (app.Value, schema.Value);
}
}
public async Task<IAppEntity> GetAppAsync(string appName)
{
using (Profile.Method<AppProvider>())
{
var appId = await GetAppIdAsync(appName);
@ -82,8 +88,11 @@ namespace Squidex.Domain.Apps.Entities
return app.Value;
}
}
public async Task<ISchemaEntity> GetSchemaAsync(Guid appId, string name)
{
using (Profile.Method<AppProvider>())
{
var schemaId = await GetSchemaIdAsync(appId, name);
@ -94,8 +103,11 @@ namespace Squidex.Domain.Apps.Entities
return await GetSchemaAsync(appId, schemaId, false);
}
}
public async Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false)
{
using (Profile.Method<AppProvider>())
{
var schema = await grainFactory.GetGrain<ISchemaGrain>(id).GetStateAsync();
@ -106,8 +118,11 @@ namespace Squidex.Domain.Apps.Entities
return schema.Value;
}
}
public async Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId)
{
using (Profile.Method<AppProvider>())
{
var ids = await schemaRepository.QuerySchemaIdsAsync(appId);
@ -117,8 +132,11 @@ namespace Squidex.Domain.Apps.Entities
return schemas.Where(s => IsFound(s.Value)).Select(s => s.Value).ToList();
}
}
public async Task<List<IRuleEntity>> GetRulesAsync(Guid appId)
{
using (Profile.Method<AppProvider>())
{
var ids = await ruleRepository.QueryRuleIdsAsync(appId);
@ -128,8 +146,11 @@ namespace Squidex.Domain.Apps.Entities
return rules.Where(r => IsFound(r.Value)).Select(r => r.Value).ToList();
}
}
public async Task<List<IAppEntity>> GetUserApps(string userId)
{
using (Profile.Method<AppProvider>())
{
var ids = await appRepository.QueryUserAppIdsAsync(userId);
@ -139,30 +160,37 @@ namespace Squidex.Domain.Apps.Entities
return apps.Where(a => IsFound(a.Value)).Select(a => a.Value).ToList();
}
}
private Task<Guid> GetAppIdAsync(string name)
private async Task<Guid> GetAppIdAsync(string name)
{
using (Profile.Method<AppProvider>())
{
return appRepository.FindAppIdByNameAsync(name);
return await appRepository.FindAppIdByNameAsync(name);
}
}
private async Task<Guid> GetSchemaIdAsync(Guid appId, string name)
{
using (Profile.Method<AppProvider>())
{
return await schemaRepository.FindSchemaIdAsync(appId, name);
}
}
private static bool IsFound(IEntityWithVersion entity)
{
return entity.Version > EtagVersion.Empty;
}
private static bool IsExisting(J<ISchemaEntity> schema, bool allowDeleted)
private static bool IsExisting(J<IAppEntity> app)
{
return IsFound(schema.Value) && (!schema.Value.IsDeleted || allowDeleted);
return IsFound(app.Value) && !app.Value.IsArchived;
}
private static bool IsExisting(J<IAppEntity> app)
private static bool IsExisting(J<ISchemaEntity> schema, bool allowDeleted)
{
return IsFound(app.Value) && !app.Value.IsArchived;
return IsFound(schema.Value) && (!schema.Value.IsDeleted || allowDeleted);
}
}
}

1
src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -15,6 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Orleans.OrleansCodeGenerator.Build" Version="2.0.0" />
<PackageReference Include="Microsoft.Orleans.OrleansRuntime" Version="2.0.0" />
<PackageReference Include="NodaTime" Version="2.2.5" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.0.2" />

2
src/Squidex.Domain.Apps.Entities/CachingProviderBase.cs → src/Squidex.Infrastructure/CachingProviderBase.cs

@ -8,7 +8,7 @@
using Microsoft.Extensions.Caching.Memory;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities
namespace Squidex.Infrastructure
{
public abstract class CachingProviderBase
{

3
src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs

@ -162,7 +162,8 @@ namespace Squidex.Infrastructure.Commands
throw new DomainObjectNotFoundException(id.ToString(), GetType());
}
else if (!isUpdate && Version >= 0)
if (!isUpdate && Version >= 0)
{
throw new DomainException("Object has already been created.");
}

9
src/Squidex.Infrastructure/Log/Adapter/SemanticLogLogger.cs

@ -86,14 +86,5 @@ namespace Squidex.Infrastructure.Log.Adapter
{
return NoopDisposable.Instance;
}
private class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new NoopDisposable();
public void Dispose()
{
}
}
}
}

14
src/Squidex.Infrastructure/Log/ILogProfilerSessionProvider.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure.Log
{
public interface ILogProfilerSessionProvider
{
ProfilerSession GetSession();
}
}

24
src/Squidex.Infrastructure/Log/NoopDisposable.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Infrastructure.Log
{
public sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new NoopDisposable();
private NoopDisposable()
{
}
public void Dispose()
{
}
}
}

67
src/Squidex.Infrastructure/Log/Profile.cs

@ -0,0 +1,67 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace Squidex.Infrastructure.Log
{
public static class Profile
{
private static ILogProfilerSessionProvider sessionProvider;
private sealed class Timer : IDisposable
{
private readonly Stopwatch watch = Stopwatch.StartNew();
private readonly ProfilerSession session;
private readonly string key;
public Timer(ProfilerSession session, string key)
{
this.session = session;
this.key = key;
}
public void Dispose()
{
watch.Stop();
session.Measured(key, watch.ElapsedMilliseconds);
}
}
public static void Init(ILogProfilerSessionProvider provider)
{
sessionProvider = provider;
}
public static IDisposable Method<T>([CallerMemberName] string memberName = null)
{
return Key($"{typeof(T).Name}/{memberName}");
}
public static IDisposable Method(string objectName, [CallerMemberName] string memberName = null)
{
return Key($"{objectName}/{memberName}");
}
public static IDisposable Key(string key)
{
Guard.NotNull(key, nameof(key));
var session = sessionProvider?.GetSession();
if (session == null)
{
return NoopDisposable.Instance;
}
return new Timer(session, key);
}
}
}

58
src/Squidex.Infrastructure/Log/ProfilerSession.cs

@ -0,0 +1,58 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Concurrent;
namespace Squidex.Infrastructure.Log
{
public sealed class ProfilerSession
{
private struct ProfilerItem
{
public long Total;
public long Count;
}
private readonly ConcurrentDictionary<string, ProfilerItem> traces = new ConcurrentDictionary<string, ProfilerItem>();
public void Measured(string name, long elapsed)
{
Guard.NotNullOrEmpty(name, nameof(name));
traces.AddOrUpdate(name, x =>
{
return new ProfilerItem { Total = elapsed, Count = 1 };
},
(x, result) =>
{
result.Total += elapsed;
result.Count++;
return result;
});
}
public void Write(IObjectWriter writer)
{
Guard.NotNull(writer, nameof(writer));
if (traces.Count > 0)
{
writer.WriteObject("profiler", p =>
{
foreach (var kvp in traces)
{
p.WriteObject(kvp.Key, k => k
.WriteProperty("elapsedMsTotal", kvp.Value.Total)
.WriteProperty("elapsedMsAvg", kvp.Value.Total / kvp.Value.Count)
.WriteProperty("count", kvp.Value.Count));
}
});
}
}
}
}

20
src/Squidex.Infrastructure/Orleans/J{T}.cs

@ -11,27 +11,23 @@ using System.Threading.Tasks;
using Newtonsoft.Json;
using Orleans.CodeGeneration;
using Orleans.Serialization;
using Squidex.Infrastructure.Log;
namespace Squidex.Infrastructure.Orleans
{
public struct J<T>
{
private readonly T value;
public T Value
{
get { return value; }
}
public T Value { get; }
[JsonConstructor]
public J(T value)
{
this.value = value;
Value = value;
}
public static implicit operator T(J<T> value)
{
return value.value;
return value.Value;
}
public static implicit operator J<T>(T d)
@ -41,7 +37,7 @@ namespace Squidex.Infrastructure.Orleans
public override string ToString()
{
return value?.ToString() ?? string.Empty;
return Value?.ToString() ?? string.Empty;
}
public static Task<J<T>> AsTask(T value)
@ -57,6 +53,8 @@ namespace Squidex.Infrastructure.Orleans
[SerializerMethod]
public static void Serialize(object input, ISerializationContext context, Type expected)
{
using (Profile.Method(nameof(J)))
{
var stream = new MemoryStream();
@ -72,9 +70,12 @@ namespace Squidex.Infrastructure.Orleans
context.StreamWriter.Write(outBytes.Length);
context.StreamWriter.Write(outBytes);
}
}
[DeserializerMethod]
public static object Deserialize(Type expected, IDeserializationContext context)
{
using (Profile.Method(nameof(J)))
{
var outLength = context.StreamReader.ReadInt();
var outBytes = context.StreamReader.ReadBytes(outLength);
@ -87,4 +88,5 @@ namespace Squidex.Infrastructure.Orleans
}
}
}
}
}

2
src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs

@ -110,6 +110,8 @@ namespace Squidex.Infrastructure.UsageTracking
public async Task<long> GetMonthlyCallsAsync(string key, DateTime date)
{
Guard.NotNull(key, nameof(key));
ThrowIfDisposed();
var dateFrom = new DateTime(date.Year, date.Month, 1);

56
src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs

@ -0,0 +1,56 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
namespace Squidex.Infrastructure.UsageTracking
{
public sealed class CachingUsageTracker : CachingProviderBase, IUsageTracker
{
private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10);
private readonly IUsageTracker inner;
public CachingUsageTracker(IUsageTracker inner, IMemoryCache cache)
: base(cache)
{
Guard.NotNull(inner, nameof(inner));
this.inner = inner;
}
public Task<IReadOnlyList<StoredUsage>> QueryAsync(string key, DateTime fromDate, DateTime toDate)
{
return inner.QueryAsync(key, fromDate, toDate);
}
public Task TrackAsync(string key, double weight, double elapsedMs)
{
return inner.TrackAsync(key, weight, elapsedMs);
}
public async Task<long> GetMonthlyCallsAsync(string key, DateTime date)
{
Guard.NotNull(key, nameof(key));
var cacheKey = string.Concat(key, date);
if (Cache.TryGetValue<long>(cacheKey, out var result))
{
return result;
}
result = await inner.GetMonthlyCallsAsync(key, date);
Cache.Set(cacheKey, result, CacheTime);
return result;
}
}
}

4
src/Squidex/Config/Domain/InfrastructureServices.cs

@ -8,6 +8,7 @@
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using Squidex.Infrastructure;
@ -25,6 +26,9 @@ namespace Squidex.Config.Domain
.As<IClock>();
services.AddSingletonAs<BackgroundUsageTracker>()
.AsSelf();
services.AddSingletonAs(c => new CachingUsageTracker(c.GetRequiredService<BackgroundUsageTracker>(), c.GetRequiredService<IMemoryCache>()))
.As<IUsageTracker>();
services.AddSingletonAs<HttpContextAccessor>()

4
src/Squidex/Config/Domain/LoggingServices.cs

@ -59,6 +59,10 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<SemanticLog>()
.As<ISemanticLog>();
services.AddSingletonAs<RequestLogProfilerSessionProvider>()
.As<ILogProfilerSessionProvider>()
.AsSelf();
}
}
}

7
src/Squidex/Config/Logging.cs

@ -12,7 +12,7 @@ namespace Squidex.Config
{
public static class Logging
{
public static void AddOrleansFilter(this ILoggingBuilder builder)
public static void AddFilter(this ILoggingBuilder builder)
{
builder.AddFilter((category, level) =>
{
@ -26,6 +26,11 @@ namespace Squidex.Config
return level >= LogLevel.Warning;
}
if (category.StartsWith("Microsoft.AspNetCore.", StringComparison.OrdinalIgnoreCase))
{
return level > LogLevel.Information;
}
return level >= LogLevel.Information;
});
}

2
src/Squidex/Config/Orleans/SiloWrapper.cs

@ -75,7 +75,7 @@ namespace Squidex.Config.Orleans
{
builder.AddConfiguration(hostingContext.Configuration.GetSection("logging"));
builder.AddSemanticLog();
builder.AddOrleansFilter();
builder.AddFilter();
})
.ConfigureApplicationParts(builder =>
{

2
src/Squidex/Config/Web/WebExtensions.cs

@ -15,7 +15,7 @@ namespace Squidex.Config.Web
{
public static IApplicationBuilder UseMyTracking(this IApplicationBuilder app)
{
app.UseMiddleware<LogPerformanceMiddleware>();
app.UseMiddleware<RequestLogPerformanceMiddleware>();
return app;
}

10
src/Squidex/Config/Web/WebServices.cs

@ -15,10 +15,14 @@ namespace Squidex.Config.Web
{
public static void AddMyMvc(this IServiceCollection services)
{
services.AddSingletonAs<FileCallbackResultExecutor>();
services.AddSingletonAs<FileCallbackResultExecutor>()
.AsSelf();
services.AddSingletonAs<AppApiFilter>();
services.AddSingletonAs<ApiCostsFilter>();
services.AddSingletonAs<AppApiFilter>()
.AsSelf();
services.AddSingletonAs<ApiCostsFilter>()
.AsSelf();
services.AddMvc().AddMySerializers();
services.AddCors();

4
src/Squidex/Pipeline/ApiCostsFilter.cs

@ -11,6 +11,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Domain.Apps.Entities.Apps.Services;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Pipeline
@ -46,6 +47,8 @@ namespace Squidex.Pipeline
var appFeature = context.HttpContext.Features.Get<IAppFeature>();
if (appFeature?.App != null && FilterDefinition.Weight > 0)
{
using (Profile.Key("CheckUsage"))
{
var plan = appPlanProvider.GetPlanForApp(appFeature.App);
@ -56,6 +59,7 @@ namespace Squidex.Pipeline
context.Result = new StatusCodeResult(429);
return;
}
}
var stopWatch = Stopwatch.StartNew();

24
src/Squidex/Pipeline/LogPerformanceMiddleware.cs → src/Squidex/Pipeline/RequestLogPerformanceMiddleware.cs

@ -13,13 +13,15 @@ using Squidex.Infrastructure.Log;
namespace Squidex.Pipeline
{
public sealed class LogPerformanceMiddleware : ActionFilterAttribute
public sealed class RequestLogPerformanceMiddleware : ActionFilterAttribute
{
private readonly RequestLogProfilerSessionProvider requestSession;
private readonly RequestDelegate next;
private readonly ISemanticLog log;
public LogPerformanceMiddleware(RequestDelegate next, ISemanticLog log)
public RequestLogPerformanceMiddleware(RequestLogProfilerSessionProvider requestSession, RequestDelegate next, ISemanticLog log)
{
this.requestSession = requestSession;
this.next = next;
this.log = log;
}
@ -28,11 +30,25 @@ namespace Squidex.Pipeline
{
var stopWatch = Stopwatch.StartNew();
await next(context);
var session = new ProfilerSession();
try
{
requestSession.Start(context, session);
await next(context);
}
finally
{
stopWatch.Stop();
log.LogInformation(w => w.WriteProperty("elapsedRequestMs", stopWatch.ElapsedMilliseconds));
log.LogInformation(w =>
{
session.Write(w);
w.WriteProperty("elapsedRequestMs", stopWatch.ElapsedMilliseconds);
});
}
}
}
}

40
src/Squidex/Pipeline/RequestLogProfilerSessionProvider.cs

@ -0,0 +1,40 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
namespace Squidex.Pipeline
{
public sealed class RequestLogProfilerSessionProvider : ILogProfilerSessionProvider
{
private const string ItemKey = "ProfilerSesison";
private readonly IHttpContextAccessor httpContextAccessor;
public RequestLogProfilerSessionProvider(IHttpContextAccessor httpContextAccessor)
{
this.httpContextAccessor = httpContextAccessor;
Profile.Init(this);
}
public ProfilerSession GetSession()
{
var context = httpContextAccessor?.HttpContext?.Items[ItemKey] as ProfilerSession;
return context;
}
public void Start(HttpContext httpContext, ProfilerSession session)
{
Guard.NotNull(httpContext, nameof(httpContext));
httpContext.Items[ItemKey] = session;
}
}
}

2
src/Squidex/Program.cs

@ -30,7 +30,7 @@ namespace Squidex
{
builder.AddConfiguration(hostingContext.Configuration.GetSection("logging"));
builder.AddSemanticLog();
builder.AddOrleansFilter();
builder.AddFilter();
})
.ConfigureAppConfiguration((hostContext, builder) =>
{

62
tests/Squidex.Infrastructure.Tests/UsageTracking/ThreadingUsageTrackerTests.cs

@ -0,0 +1,62 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Xunit;
namespace Squidex.Infrastructure.UsageTracking
{
public sealed class ThreadingUsageTrackerTests
{
private readonly MemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly IUsageTracker inner = A.Fake<IUsageTracker>();
private readonly IUsageTracker sut;
public ThreadingUsageTrackerTests()
{
sut = new CachingUsageTracker(inner, cache);
}
[Fact]
public async Task Should_forward_track_call()
{
await sut.TrackAsync("MyKey", 123, 456);
A.CallTo(() => inner.TrackAsync("MyKey", 123, 456))
.MustHaveHappened();
}
[Fact]
public async Task Should_forward_query_call()
{
await sut.QueryAsync("MyKey", DateTime.MaxValue, DateTime.MinValue);
A.CallTo(() => inner.QueryAsync("MyKey", DateTime.MaxValue, DateTime.MinValue))
.MustHaveHappened();
}
[Fact]
public async Task Should_cache_monthly_usage()
{
A.CallTo(() => inner.GetMonthlyCallsAsync("MyKey", DateTime.Today))
.Returns(100);
var result1 = await sut.GetMonthlyCallsAsync("MyKey", DateTime.Today);
var result2 = await sut.GetMonthlyCallsAsync("MyKey", DateTime.Today);
Assert.Equal(100, result1);
Assert.Equal(100, result2);
A.CallTo(() => inner.GetMonthlyCallsAsync("MyKey", DateTime.Today))
.MustHaveHappened(Repeated.Exactly.Once);
}
}
}
Loading…
Cancel
Save