Skip to content

Commit f6e8f71

Browse files
authored
Merge pull request #23 from linkdotnet/feature/analytics
Feature/analytics
2 parents f6ce4c7 + 7df13e9 commit f6e8f71

25 files changed

+675
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using System;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using FluentAssertions;
5+
using LinkDotNet.Blog.Web.Pages.Admin;
6+
using LinkDotNet.Domain;
7+
using Xunit;
8+
9+
namespace LinkDotNet.Blog.IntegrationTests.Web.Pages.Admin.Dashboard
10+
{
11+
public class DashboardServiceTests : SqlDatabaseTestBase<UserRecord>
12+
{
13+
private readonly DashboardService sut;
14+
15+
public DashboardServiceTests()
16+
{
17+
sut = new DashboardService(Repository);
18+
}
19+
20+
[Fact]
21+
public async Task ShouldGetTotalUsers()
22+
{
23+
var record1 = new UserRecord
24+
{
25+
UserIdentifierHash = 2,
26+
DateTimeUtcClicked = DateTime.UtcNow,
27+
UrlClicked = string.Empty,
28+
};
29+
var record2 = new UserRecord
30+
{
31+
UserIdentifierHash = 1,
32+
UrlClicked = string.Empty,
33+
};
34+
var record3 = new UserRecord
35+
{
36+
UserIdentifierHash = 2,
37+
UrlClicked = string.Empty,
38+
};
39+
await Repository.StoreAsync(record1);
40+
await Repository.StoreAsync(record2);
41+
await Repository.StoreAsync(record3);
42+
43+
var data = await sut.GetDashboardDataAsync();
44+
45+
data.TotalAmountOfUsers.Should().Be(2);
46+
data.AmountOfUsersLast30Days.Should().Be(1);
47+
}
48+
49+
[Fact]
50+
public async Task ShouldGetTotalClicks()
51+
{
52+
var record1 = new UserRecord
53+
{
54+
DateTimeUtcClicked = DateTime.UtcNow,
55+
UrlClicked = "index",
56+
};
57+
var record2 = new UserRecord
58+
{
59+
UrlClicked = string.Empty,
60+
};
61+
var record3 = new UserRecord
62+
{
63+
UrlClicked = string.Empty,
64+
};
65+
await Repository.StoreAsync(record1);
66+
await Repository.StoreAsync(record2);
67+
await Repository.StoreAsync(record3);
68+
69+
var data = await sut.GetDashboardDataAsync();
70+
71+
data.TotalPageClicks.Should().Be(3);
72+
data.PageClicksLast30Days.Should().Be(1);
73+
}
74+
75+
[Fact]
76+
public async Task ShouldGetAboutMeClicks()
77+
{
78+
var record1 = new UserRecord
79+
{
80+
DateTimeUtcClicked = DateTime.UtcNow,
81+
UrlClicked = "AboutMe",
82+
};
83+
var record2 = new UserRecord
84+
{
85+
UrlClicked = string.Empty,
86+
};
87+
var record3 = new UserRecord
88+
{
89+
UrlClicked = "AboutMe",
90+
};
91+
await Repository.StoreAsync(record1);
92+
await Repository.StoreAsync(record2);
93+
await Repository.StoreAsync(record3);
94+
95+
var data = await sut.GetDashboardDataAsync();
96+
97+
data.TotalAboutMeClicks.Should().Be(2);
98+
data.AboutMeClicksLast30Days.Should().Be(1);
99+
}
100+
101+
[Fact]
102+
public async Task ShouldGetBlogPostClicks()
103+
{
104+
var record1 = new UserRecord
105+
{
106+
UrlClicked = "blogPost/1",
107+
};
108+
var record2 = new UserRecord
109+
{
110+
UrlClicked = "blogPost/2",
111+
};
112+
var record3 = new UserRecord
113+
{
114+
UrlClicked = "blogPost/1",
115+
};
116+
var record4 = new UserRecord
117+
{
118+
UrlClicked = "unrelated",
119+
};
120+
await Repository.StoreAsync(record1);
121+
await Repository.StoreAsync(record2);
122+
await Repository.StoreAsync(record3);
123+
await Repository.StoreAsync(record4);
124+
125+
var data = (await sut.GetDashboardDataAsync()).BlogPostVisitCount.ToList();
126+
127+
data.Count.Should().Be(2);
128+
data[0].Key.Should().Be("blogPost/1");
129+
data[0].Value.Should().Be(2);
130+
data[1].Key.Should().Be("blogPost/2");
131+
data[1].Value.Should().Be(1);
132+
}
133+
}
134+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using Bunit;
5+
using FluentAssertions;
6+
using LinkDotNet.Blog.TestUtilities;
7+
using LinkDotNet.Blog.Web.Shared.Admin.Dashboard;
8+
using LinkDotNet.Domain;
9+
using LinkDotNet.Infrastructure.Persistence;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Xunit;
12+
13+
namespace LinkDotNet.Blog.IntegrationTests.Web.Pages.Admin.Dashboard
14+
{
15+
public class VisitCountPerPageTests : SqlDatabaseTestBase<BlogPost>
16+
{
17+
[Fact]
18+
public async Task ShouldShowCounts()
19+
{
20+
var blogPost = new BlogPostBuilder().WithTitle("I was clicked").Build();
21+
await Repository.StoreAsync(blogPost);
22+
using var ctx = new TestContext();
23+
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
24+
var visits = new List<KeyValuePair<string, int>>();
25+
visits.Add(new KeyValuePair<string, int>($"blogPost/{blogPost.Id}", 5));
26+
var pageVisitCounts = visits.OrderByDescending(s => s.Value);
27+
28+
var cut = ctx.RenderComponent<VisitCountPerPage>(p => p.Add(
29+
s => s.PageVisitCount, pageVisitCounts));
30+
31+
cut.WaitForState(() => cut.FindAll("td").Any());
32+
var elements = cut.FindAll("td").Select(t => t.InnerHtml).ToList();
33+
elements.Should().Contain("I was clicked");
34+
elements.Should().Contain("5");
35+
}
36+
}
37+
}

LinkDotNet.Blog.IntegrationTests/Web/Pages/BlogPostPageTests.cs

+1
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ private void RegisterComponents(TestContextBase ctx, ILocalStorageService localS
8585
ctx.Services.AddScoped(_ => localStorageService ?? new Mock<ILocalStorageService>().Object);
8686
ctx.Services.AddScoped(_ => new Mock<IToastService>().Object);
8787
ctx.Services.AddScoped(_ => new Mock<IHeadElementHelper>().Object);
88+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
8889
}
8990
}
9091
}

LinkDotNet.Blog.IntegrationTests/Web/Pages/IndexTests.cs

+1
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ private void RegisterComponents(TestContextBase ctx)
159159
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
160160
ctx.Services.AddScoped(_ => CreateSampleAppConfiguration());
161161
ctx.Services.AddScoped(_ => new Mock<IHeadElementHelper>().Object);
162+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
162163
}
163164
}
164165
}

LinkDotNet.Blog.IntegrationTests/Web/Pages/SearchByTagTests.cs

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using FluentAssertions;
66
using LinkDotNet.Blog.TestUtilities;
77
using LinkDotNet.Blog.Web.Pages;
8+
using LinkDotNet.Blog.Web.Shared;
89
using LinkDotNet.Domain;
910
using LinkDotNet.Infrastructure.Persistence;
1011
using Microsoft.Extensions.DependencyInjection;
@@ -25,6 +26,7 @@ public async Task ShouldOnlyDisplayTagsGivenByParameter()
2526
await AddBlogPostWithTagAsync("Tag 2");
2627
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
2728
ctx.Services.AddScoped(_ => new Mock<IHeadElementHelper>().Object);
29+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
2830
var cut = ctx.RenderComponent<SearchByTag>(p => p.Add(s => s.Tag, "Tag 1"));
2931
cut.WaitForState(() => cut.FindAll(".blog-card").Any());
3032

@@ -40,6 +42,7 @@ public async Task ShouldHandleSpecialCharacters()
4042
await AddBlogPostWithTagAsync("C#");
4143
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
4244
ctx.Services.AddScoped(_ => new Mock<IHeadElementHelper>().Object);
45+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
4346
var cut = ctx.RenderComponent<SearchByTag>(p => p.Add(s => s.Tag, Uri.EscapeDataString("C#")));
4447
cut.WaitForState(() => cut.FindAll(".blog-card").Any());
4548

LinkDotNet.Blog.IntegrationTests/Web/Pages/SearchTests.cs

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using LinkDotNet.Domain;
99
using LinkDotNet.Infrastructure.Persistence;
1010
using Microsoft.Extensions.DependencyInjection;
11+
using Moq;
1112
using Xunit;
1213

1314
namespace LinkDotNet.Blog.IntegrationTests.Web.Pages
@@ -23,6 +24,7 @@ public async Task ShouldFindBlogPostWhenTitleMatches()
2324
await Repository.StoreAsync(blogPost2);
2425
using var ctx = new TestContext();
2526
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
27+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
2628

2729
var cut = ctx.RenderComponent<Search>(p => p.Add(s => s.SearchTerm, "Title 1"));
2830

@@ -41,6 +43,7 @@ public async Task ShouldFindBlogPostWhenTagMatches()
4143
await Repository.StoreAsync(blogPost2);
4244
using var ctx = new TestContext();
4345
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
46+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
4447

4548
var cut = ctx.RenderComponent<Search>(p => p.Add(s => s.SearchTerm, "Cat"));
4649

@@ -57,6 +60,7 @@ public async Task ShouldUnescapeQuery()
5760
await Repository.StoreAsync(blogPost1);
5861
using var ctx = new TestContext();
5962
ctx.Services.AddScoped<IRepository<BlogPost>>(_ => Repository);
63+
ctx.Services.AddScoped(_ => new Mock<IUserRecordService>().Object);
6064

6165
var cut = ctx.RenderComponent<Search>(p => p.Add(s => s.SearchTerm, "Title%201"));
6266

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using Blazored.LocalStorage;
4+
using Bunit;
5+
using Bunit.TestDoubles;
6+
using FluentAssertions;
7+
using LinkDotNet.Blog.Web.Shared;
8+
using LinkDotNet.Domain;
9+
using LinkDotNet.Infrastructure.Persistence;
10+
using Moq;
11+
using Xunit;
12+
13+
namespace LinkDotNet.Blog.UnitTests.Web.Shared
14+
{
15+
public class UserRecordServiceTests : TestContext
16+
{
17+
private readonly Mock<IRepository<UserRecord>> repositoryMock;
18+
private readonly FakeNavigationManager fakeNavigationManager;
19+
private readonly FakeAuthenticationStateProvider fakeAuthenticationStateProvider;
20+
private readonly Mock<ILocalStorageService> localStorageService;
21+
private readonly UserRecordService sut;
22+
23+
public UserRecordServiceTests()
24+
{
25+
repositoryMock = new Mock<IRepository<UserRecord>>();
26+
fakeNavigationManager = new FakeNavigationManager(this.Renderer);
27+
fakeAuthenticationStateProvider = new FakeAuthenticationStateProvider();
28+
localStorageService = new Mock<ILocalStorageService>();
29+
sut = new UserRecordService(
30+
repositoryMock.Object,
31+
fakeNavigationManager,
32+
fakeAuthenticationStateProvider,
33+
localStorageService.Object);
34+
}
35+
36+
[Fact]
37+
public async Task ShouldStoreInformation()
38+
{
39+
const string url = "http://localhost/subpart";
40+
fakeNavigationManager.NavigateTo(url);
41+
UserRecord recordToDb = null;
42+
repositoryMock.Setup(r => r.StoreAsync(It.IsAny<UserRecord>()))
43+
.Callback<UserRecord>(u => recordToDb = u);
44+
45+
await sut.StoreUserRecordAsync();
46+
47+
recordToDb.Should().NotBeNull();
48+
recordToDb.UrlClicked.Should().Be("subpart");
49+
recordToDb.UserIdentifierHash.Should().NotBe(0);
50+
recordToDb.DateTimeUtcClicked.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
51+
}
52+
53+
[Fact]
54+
public async Task ShouldGetUserHashAgain()
55+
{
56+
UserRecord recordToDb = null;
57+
var guidForUser = Guid.NewGuid();
58+
localStorageService.Setup(l => l.ContainKeyAsync("user", default))
59+
.ReturnsAsync(true);
60+
localStorageService.Setup(l => l.GetItemAsync<Guid>("user", default))
61+
.ReturnsAsync(guidForUser);
62+
repositoryMock.Setup(r => r.StoreAsync(It.IsAny<UserRecord>()))
63+
.Callback<UserRecord>(u => recordToDb = u);
64+
65+
await sut.StoreUserRecordAsync();
66+
67+
recordToDb.Should().NotBeNull();
68+
var hashCode = guidForUser.GetHashCode();
69+
recordToDb.UserIdentifierHash.Should().Be(hashCode);
70+
}
71+
72+
[Fact]
73+
public async Task ShouldNotStoreForAdmin()
74+
{
75+
fakeAuthenticationStateProvider.TriggerAuthenticationStateChanged("Steven");
76+
77+
await sut.StoreUserRecordAsync();
78+
79+
repositoryMock.Verify(r => r.StoreAsync(It.IsAny<UserRecord>()), Times.Never);
80+
}
81+
82+
[Fact]
83+
public async Task ShouldNotThrowExceptionToOutsideWorld()
84+
{
85+
localStorageService.Setup(l => l.ContainKeyAsync("user", default)).Throws<Exception>();
86+
87+
Func<Task> act = () => sut.StoreUserRecordAsync();
88+
89+
await act.Should().NotThrowAsync<Exception>();
90+
}
91+
92+
[InlineData("http://localhost/blogPost/12?q=3", "blogPost/12")]
93+
[InlineData("http://localhost/?q=3", "")]
94+
[InlineData("", "")]
95+
[InlineData("http://localhost/someroute/subroute", "someroute/subroute")]
96+
[Theory]
97+
public async Task ShouldRemoveQueryStringIfPresent(string url, string expectedRecord)
98+
{
99+
fakeNavigationManager.NavigateTo(url);
100+
UserRecord recordToDb = null;
101+
repositoryMock.Setup(r => r.StoreAsync(It.IsAny<UserRecord>()))
102+
.Callback<UserRecord>(u => recordToDb = u);
103+
104+
await sut.StoreUserRecordAsync();
105+
106+
recordToDb.Should().NotBeNull();
107+
recordToDb.UrlClicked.Should().Be(expectedRecord);
108+
}
109+
}
110+
}

LinkDotNet.Blog.Web/Pages/AboutMe.razor

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
@using LinkDotNet.Blog.Web.Shared.Skills
33
@inject AppConfiguration appConfiguration
44
@inject AuthenticationStateProvider authenticationStateProvider
5+
@inject IUserRecordService userRecordService
56
<OgData Title="@("About Me - " + appConfiguration.ProfileInformation.Name)"
67
Description="@("About Me," + appConfiguration.ProfileInformation.Name)"
78
Keywords="@appConfiguration.ProfileInformation.Name"
@@ -40,4 +41,12 @@
4041
isAuthenticated = userIdentity.IsAuthenticated;
4142
}
4243
}
44+
45+
protected override async Task OnAfterRenderAsync(bool firstRender)
46+
{
47+
if (firstRender)
48+
{
49+
await userRecordService.StoreUserRecordAsync();
50+
}
51+
}
4352
}

0 commit comments

Comments
 (0)