Skip to content

Commit 7fe51f7

Browse files
committed
Added BackgroundService and Validation
1 parent e514560 commit 7fe51f7

File tree

9 files changed

+239
-6
lines changed

9 files changed

+239
-6
lines changed

src/LinkDotNet.Blog.Domain/BlogPost.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ private BlogPost()
2222

2323
public DateTime UpdatedDate { get; private set; }
2424

25+
public DateTime? ScheduledPublishDate { get; private set; }
26+
2527
public virtual ICollection<Tag> Tags { get; private set; }
2628

27-
public bool IsPublished { get; set; }
29+
public bool IsPublished { get; private set; }
2830

2931
public int Likes { get; set; }
3032

@@ -35,15 +37,22 @@ public static BlogPost Create(
3537
string previewImageUrl,
3638
bool isPublished,
3739
DateTime? updatedDate = null,
40+
DateTime? scheduledPublishDate = null,
3841
IEnumerable<string> tags = null,
3942
string previewImageUrlFallback = null)
4043
{
44+
if (scheduledPublishDate is not null && isPublished)
45+
{
46+
throw new InvalidOperationException("Can't schedule publish date if the blog post is already published.");
47+
}
48+
4149
var blogPost = new BlogPost
4250
{
4351
Title = title,
4452
ShortDescription = shortDescription,
4553
Content = content,
4654
UpdatedDate = updatedDate ?? DateTime.Now,
55+
ScheduledPublishDate = scheduledPublishDate,
4756
PreviewImageUrl = previewImageUrl,
4857
PreviewImageUrlFallback = previewImageUrlFallback,
4958
IsPublished = isPublished,
@@ -53,6 +62,17 @@ public static BlogPost Create(
5362
return blogPost;
5463
}
5564

65+
public void Publish()
66+
{
67+
if (ScheduledPublishDate is not null)
68+
{
69+
UpdatedDate = ScheduledPublishDate.Value;
70+
ScheduledPublishDate = null;
71+
}
72+
73+
IsPublished = true;
74+
}
75+
5676
public void Update(BlogPost from)
5777
{
5878
if (from == this)
@@ -64,6 +84,7 @@ public void Update(BlogPost from)
6484
ShortDescription = from.ShortDescription;
6585
Content = from.Content;
6686
UpdatedDate = from.UpdatedDate;
87+
ScheduledPublishDate = from.ScheduledPublishDate;
6788
PreviewImageUrl = from.PreviewImageUrl;
6889
PreviewImageUrlFallback = from.PreviewImageUrlFallback;
6990
IsPublished = from.IsPublished;
@@ -85,4 +106,4 @@ private void ReplaceTags(IEnumerable<Tag> tags)
85106
}
86107
}
87108
}
88-
}
109+
}

src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewModel.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class CreateNewModel
1717
private bool shouldUpdateDate;
1818
private string tags;
1919
private string previewImageUrlFallback;
20+
private DateTime? scheduledPublishDate;
2021

2122
[Required]
2223
[MaxLength(256)]
@@ -65,6 +66,7 @@ public string PreviewImageUrl
6566
}
6667

6768
[Required]
69+
[PublishedWithScheduledDateValidation]
6870
public bool IsPublished
6971
{
7072
get => isPublished;
@@ -86,6 +88,16 @@ public bool ShouldUpdateDate
8688
}
8789
}
8890

91+
public DateTime? ScheduledPublishDate
92+
{
93+
get => scheduledPublishDate;
94+
set
95+
{
96+
scheduledPublishDate = value;
97+
IsDirty = true;
98+
}
99+
}
100+
89101
public string Tags
90102
{
91103
get => tags;
@@ -123,6 +135,7 @@ public static CreateNewModel FromBlogPost(BlogPost blogPost)
123135
PreviewImageUrl = blogPost.PreviewImageUrl,
124136
originalUpdatedDate = blogPost.UpdatedDate,
125137
PreviewImageUrlFallback = blogPost.PreviewImageUrlFallback,
138+
ScheduledPublishDate = blogPost.ScheduledPublishDate,
126139
IsDirty = false,
127140
};
128141
}
@@ -134,7 +147,16 @@ public BlogPost ToBlogPost()
134147
? null
135148
: originalUpdatedDate;
136149

137-
var blogPost = BlogPost.Create(Title, ShortDescription, Content, PreviewImageUrl, IsPublished, updatedDate, tagList, PreviewImageUrlFallback);
150+
var blogPost = BlogPost.Create(
151+
Title,
152+
ShortDescription,
153+
Content,
154+
PreviewImageUrl,
155+
IsPublished,
156+
updatedDate,
157+
scheduledPublishDate,
158+
tagList,
159+
PreviewImageUrlFallback);
138160
blogPost.Id = id;
139161
return blogPost;
140162
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System;
2+
using System.ComponentModel.DataAnnotations;
3+
4+
namespace LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components;
5+
6+
[AttributeUsage(AttributeTargets.Property)]
7+
public sealed class PublishedWithScheduledDateValidationAttribute : ValidationAttribute
8+
{
9+
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
10+
{
11+
if (validationContext.ObjectInstance is CreateNewModel { IsPublished: true, ScheduledPublishDate: { } })
12+
{
13+
return new ValidationResult("Cannot have a scheduled publish date when the post is already published.");
14+
}
15+
16+
return ValidationResult.Success;
17+
}
18+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using LinkDotNet.Blog.Domain;
5+
using LinkDotNet.Blog.Infrastructure.Persistence;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.Extensions.Hosting;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace LinkDotNet.Blog.Web.Features;
11+
12+
public class BlogPostPublisher : BackgroundService
13+
{
14+
private readonly IServiceProvider serviceProvider;
15+
private readonly ILogger<BlogPostPublisher> logger;
16+
17+
public BlogPostPublisher(IServiceProvider serviceProvider, ILogger<BlogPostPublisher> logger)
18+
{
19+
this.serviceProvider = serviceProvider;
20+
this.logger = logger;
21+
}
22+
23+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
24+
{
25+
// await Task.Yield();
26+
logger.LogInformation("BlogPostPublisher is starting.");
27+
28+
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
29+
30+
while (!stoppingToken.IsCancellationRequested)
31+
{
32+
await PublishScheduledBlogPosts();
33+
34+
await timer.WaitForNextTickAsync(stoppingToken);
35+
}
36+
37+
logger.LogInformation("BlogPostPublisher is stopping.");
38+
}
39+
40+
private async Task PublishScheduledBlogPosts()
41+
{
42+
logger.LogInformation("Checking for scheduled blog posts.");
43+
44+
using var scope = serviceProvider.CreateScope();
45+
var repository = scope.ServiceProvider.GetRequiredService<IRepository<BlogPost>>();
46+
47+
var now = DateTime.UtcNow;
48+
var scheduledBlogPosts = await repository.GetAllAsync(
49+
filter: b => b.ScheduledPublishDate != null && b.ScheduledPublishDate <= now);
50+
51+
foreach (var blogPost in scheduledBlogPosts)
52+
{
53+
blogPost.Publish();
54+
await repository.StoreAsync(blogPost);
55+
logger.LogInformation("Published blog post with ID {BlogPostId}", blogPost.Id);
56+
}
57+
}
58+
}

src/LinkDotNet.Blog.Web/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Blazored.Toast;
22
using LinkDotNet.Blog.Web.Authentication.Auth0;
33
using LinkDotNet.Blog.Web.Authentication.Dummy;
4+
using LinkDotNet.Blog.Web.Features;
45
using LinkDotNet.Blog.Web.RegistrationExtensions;
56
using Microsoft.AspNetCore.Builder;
67
using Microsoft.Extensions.DependencyInjection;
@@ -34,6 +35,7 @@ private static void RegisterServices(WebApplicationBuilder builder)
3435
builder.Services.RegisterServices();
3536
builder.Services.AddStorageProvider(builder.Configuration);
3637
builder.Services.AddResponseCompression();
38+
builder.Services.AddHostedService<BlogPostPublisher>();
3739

3840
if (builder.Environment.IsDevelopment())
3941
{
@@ -73,4 +75,4 @@ private static void ConfigureApp(WebApplication app)
7375
app.MapFallbackToPage("/searchByTag/{tag}", "/_Host");
7476
app.MapFallbackToPage("/search/{searchTerm}", "/_Host");
7577
}
76-
}
78+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using LinkDotNet.Blog.Domain;
5+
using LinkDotNet.Blog.IntegrationTests;
6+
using LinkDotNet.Blog.TestUtilities;
7+
using LinkDotNet.Blog.Web.Features;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Logging;
10+
11+
namespace LinkDotNet.Blog.UnitTests.Web.Features;
12+
13+
public sealed class BlogPostPublisherTests : SqlDatabaseTestBase<BlogPost>, IDisposable
14+
{
15+
private readonly BlogPostPublisher sut;
16+
17+
public BlogPostPublisherTests()
18+
{
19+
var serviceProvider = new ServiceCollection()
20+
.AddScoped(_ => Repository)
21+
.BuildServiceProvider();
22+
23+
sut = new BlogPostPublisher(serviceProvider, Mock.Of<ILogger<BlogPostPublisher>>());
24+
}
25+
26+
[Fact]
27+
public async Task ShouldPublishScheduledBlogPosts()
28+
{
29+
var now = DateTime.UtcNow;
30+
var bp1 = new BlogPostBuilder().WithScheduledPublishDate(now.AddHours(-2)).IsPublished(false).Build();
31+
var bp2 = new BlogPostBuilder().WithScheduledPublishDate(now.AddHours(-1)).IsPublished(false).Build();
32+
var bp3 = new BlogPostBuilder().WithScheduledPublishDate(now.AddHours(1)).IsPublished(false).Build();
33+
await Repository.StoreAsync(bp1);
34+
await Repository.StoreAsync(bp2);
35+
await Repository.StoreAsync(bp3);
36+
37+
await sut.StartAsync(CancellationToken.None);
38+
39+
(await Repository.GetByIdAsync(bp1.Id)).IsPublished.Should().BeTrue();
40+
(await Repository.GetByIdAsync(bp2.Id)).IsPublished.Should().BeTrue();
41+
(await Repository.GetByIdAsync(bp3.Id)).IsPublished.Should().BeFalse();
42+
}
43+
44+
public void Dispose() => sut?.Dispose();
45+
}

tests/LinkDotNet.Blog.TestUtilities/BlogPostBuilder.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class BlogPostBuilder
1414
private string[] tags;
1515
private int likes;
1616
private DateTime? updateDate;
17+
private DateTime? scheduledPublishDate;
1718

1819
public BlogPostBuilder WithTitle(string title)
1920
{
@@ -69,9 +70,24 @@ public BlogPostBuilder WithUpdatedDate(DateTime updateDate)
6970
return this;
7071
}
7172

73+
public BlogPostBuilder WithScheduledPublishDate(DateTime scheduledPublishDate)
74+
{
75+
this.scheduledPublishDate = scheduledPublishDate;
76+
return this;
77+
}
78+
7279
public BlogPost Build()
7380
{
74-
var blogPost = BlogPost.Create(title, shortDescription, content, previewImageUrl, isPublished, updateDate, tags, previewImageUrlFallback);
81+
var blogPost = BlogPost.Create(
82+
title,
83+
shortDescription,
84+
content,
85+
previewImageUrl,
86+
isPublished,
87+
updateDate,
88+
scheduledPublishDate,
89+
tags,
90+
previewImageUrlFallback);
7591
blogPost.Likes = likes;
7692
return blogPost;
7793
}

tests/LinkDotNet.Blog.UnitTests/Domain/BlogPostTests.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,25 @@ public void ShouldNotDeleteTagsWhenSameReference()
6969
bp.Tags.Should().HaveCount(1);
7070
bp.Tags.Single().Content.Should().Be("tag 1");
7171
}
72-
}
72+
73+
[Fact]
74+
public void ShouldPublishBlogPost()
75+
{
76+
var date = new DateTime(2023, 3, 24);
77+
var bp = new BlogPostBuilder().IsPublished(false).WithScheduledPublishDate(date).Build();
78+
79+
bp.Publish();
80+
81+
bp.IsPublished.Should().BeTrue();
82+
bp.ScheduledPublishDate.Should().BeNull();
83+
bp.UpdatedDate.Should().Be(date);
84+
}
85+
86+
[Fact]
87+
public void ShouldThrowErrorWhenCreatingBlogPostThatIsPublishedAndHasScheduledPublishDate()
88+
{
89+
Action action = () => BlogPost.Create("1", "2", "3", "4", true, scheduledPublishDate: new DateTime(2023, 3, 24));
90+
91+
action.Should().Throw<InvalidOperationException>();
92+
}
93+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.ComponentModel.DataAnnotations;
4+
using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Components;
5+
6+
namespace LinkDotNet.Blog.UnitTests.Web.Features.Admin.BlogPostEditor.Components;
7+
8+
public class PublishedWithScheduledDateValidationAttributeTests
9+
{
10+
[Fact]
11+
public void GivenBlogPostIsPublishedAndHasScheduledDate_WhenValidating_ThenError()
12+
{
13+
var model = new CreateNewModel
14+
{
15+
Title = "Title",
16+
ShortDescription = "Desc",
17+
Content = "Content",
18+
IsPublished = true,
19+
ScheduledPublishDate = DateTime.Now,
20+
PreviewImageUrl = "https://steven-giesel.com",
21+
};
22+
var validationContext = new ValidationContext(model);
23+
var results = new List<ValidationResult>();
24+
25+
var result = Validator.TryValidateObject(model, validationContext, results, true);
26+
27+
result.Should().BeFalse();
28+
results.Count.Should().Be(1);
29+
}
30+
}

0 commit comments

Comments
 (0)