├── tests ├── load-testing │ ├── load.js │ ├── soak.js │ ├── spike.js │ └── stress.js ├── Blogger.UnitTests │ ├── GlobalUsings.cs │ ├── Domain │ │ ├── SubscriberAggregateTests │ │ │ └── SubscriberIdTests.cs │ │ └── ArticleAggregateTests │ │ │ ├── TagTests.cs │ │ │ └── AuthorTests.cs │ ├── Blogger.UnitTests.csproj │ └── BuildingBlocks │ │ └── EntityTests.cs ├── Blogger.IntegrationTests │ ├── Fixtures │ │ ├── BloggerDbContextFixture.cs │ │ └── EfDatabaseBaseFixture.cs │ ├── Blogger.IntegrationTests.csproj │ ├── Articles │ │ ├── MakeDraftCommandHandlerTests.cs │ │ ├── CreateArticleCommandHandlerTests.cs │ │ ├── GetArchiveQueryHandlerTests.cs │ │ ├── GetArticleQueryHandlerTests.cs │ │ ├── GetTaggedArticlesQueryHandlerTests.cs │ │ ├── GetTagsQueryHandlerTests.cs │ │ ├── GetPopularTagsQueryHandlerTests.cs │ │ ├── PublishDraftCommandHandlerTests.cs │ │ └── GetPopularArticlesHandlerTests.cs │ ├── Subscribers │ │ └── SubscribeCommandHandlerTests.cs │ └── Comments │ │ ├── ApproveCommentCommandHandlerTests.cs │ │ ├── ApproveReplyCommandHandlerTests.cs │ │ ├── GetCommentsHandlerTests.cs │ │ ├── GetRepliesHandlerTests.cs │ │ ├── MakeCommentCommandHandlerTests.cs │ │ └── ReplyToCommentCommandHandlerTests.cs └── Blogger.FunctionalTests │ ├── Helper │ └── StringGenerator.cs │ └── Blogger.FunctionalTests.csproj ├── Directory.Build.targets ├── liara.json ├── src ├── Blogger.BuildingBlocks │ ├── Blogger.BuildingBlocks.csproj │ ├── Domain │ │ ├── IDomainEvent.cs │ │ ├── IAggregateRoot.cs │ │ ├── DomainException.cs │ │ ├── AggregateRoot.cs │ │ ├── Entity.cs │ │ └── ValueObject.cs │ └── Exceptions │ │ └── InvalidEmailAddressException.cs ├── Blogger.APIs │ ├── IAssemblyMarker.cs │ ├── Abstractions │ │ └── IEndpoint.cs │ ├── Endpoints │ │ ├── Articles │ │ │ ├── GetTags │ │ │ │ ├── GetTagsResponse.cs │ │ │ │ ├── GetTagsMappingProfile.cs │ │ │ │ └── GetTagsEndpoint.cs │ │ │ ├── MakeDraft │ │ │ │ ├── MakeDraftResponse.cs │ │ │ │ ├── MakeDraftRequest.cs │ │ │ │ ├── MakeDraftMappingProfile.cs │ │ │ │ ├── MakeDraftEndpoint.cs │ │ │ │ └── MakeDraftRequestValidator.cs │ │ │ ├── GetPopularTags │ │ │ │ ├── GetPopularTagsResponse.cs │ │ │ │ ├── GetPopularTagsMappingProfile.cs │ │ │ │ └── GetPopularTagsEndpoint.cs │ │ │ ├── CreateArticle │ │ │ │ ├── CreateArticleResponse.cs │ │ │ │ ├── CreateArticleRequest.cs │ │ │ │ ├── CreateArticleMappingProfile.cs │ │ │ │ ├── CreateArticleEndPoint.cs │ │ │ │ └── CreateArticleRequestValidator.cs │ │ │ ├── GetArticle │ │ │ │ ├── GetArticleRequest.cs │ │ │ │ ├── GetArticleRequestValidator.cs │ │ │ │ ├── GetArticleResponse.cs │ │ │ │ ├── GetArticleEndpoint.cs │ │ │ │ └── GetArticleMappingProfile.cs │ │ │ ├── GetTaggedArticles │ │ │ │ ├── GetTaggedArticlesRequest.cs │ │ │ │ ├── GetTaggedArticlesResponse.cs │ │ │ │ ├── GetTaggedArticlesMappingProfile.cs │ │ │ │ └── GetTaggedArticlesEndpoint.cs │ │ │ ├── PublishDraft │ │ │ │ ├── PublishDraftRequest.cs │ │ │ │ ├── PublishDraftRequestValidator.cs │ │ │ │ ├── PublishDraftMappingProfile.cs │ │ │ │ └── PublishDraftEndpoint.cs │ │ │ ├── GetPopularArticles │ │ │ │ ├── GetPopularArticlesResponse.cs │ │ │ │ ├── GetPopularArticlesMappingProfile.cs │ │ │ │ └── GetPopularArticlesEndpoint.cs │ │ │ ├── UpdateDraft │ │ │ │ ├── UpdateDraftRequest.cs │ │ │ │ ├── UpdateDraftMappingProfile.cs │ │ │ │ ├── UpdateDraftEndpoint.cs │ │ │ │ └── UpdateDraftRequestValidator.cs │ │ │ ├── GetArticles │ │ │ │ ├── GetArticlesRequest.cs │ │ │ │ ├── GetArticlesResponse.cs │ │ │ │ ├── GetArticlesEndpoint.cs │ │ │ │ └── GetArticlesMappingProfile.cs │ │ │ └── GetArchive │ │ │ │ ├── GetArchiveResponse.cs │ │ │ │ ├── GetArchiveMappingProfile.cs │ │ │ │ └── GetArchiveEndpoint.cs │ │ ├── Comments │ │ │ ├── MakeComment │ │ │ │ ├── MakeCommentResponse.cs │ │ │ │ ├── MakeCommentRequest.cs │ │ │ │ ├── MakeCommentMappingProfile.cs │ │ │ │ ├── MakeCommentRequestValidator.cs │ │ │ │ └── MakeCommentEndpoint.cs │ │ │ ├── ReplyToCommet │ │ │ │ ├── ReplyToCommentResponse.cs │ │ │ │ ├── ReplyToCommentRequestModel.cs │ │ │ │ ├── ReplyToCommentMappingProfile.cs │ │ │ │ ├── ReplyToCommentRequestValidator.cs │ │ │ │ └── ReplyToCommentEndpoint.cs │ │ │ ├── ApproveComment │ │ │ │ ├── ApproveCommentRequest.cs │ │ │ │ ├── ApproveCommentRequestValidator.cs │ │ │ │ ├── ApproveCommentMappingProfile.cs │ │ │ │ └── ApproveCommentEndpoint.cs │ │ │ ├── GetReplies │ │ │ │ ├── GetRepliesRequest.cs │ │ │ │ ├── GetRepliesResponse.cs │ │ │ │ ├── GetRepliesValidator.cs │ │ │ │ ├── GetRepliesMappingProfile.cs │ │ │ │ └── GetRepliesEndpoint.cs │ │ │ ├── GetComments │ │ │ │ ├── GetCommentsRequest.cs │ │ │ │ ├── GetCommentsResponse.cs │ │ │ │ ├── GetCommentsValidator.cs │ │ │ │ ├── GetCommentsMappingProfile.cs │ │ │ │ └── GetCommentsEndpoint.cs │ │ │ └── ApproveReply │ │ │ │ ├── ApproveReplyRequest.cs │ │ │ │ ├── ApproveReplyRequestValidator.cs │ │ │ │ ├── ApproveReplyMappingProfile.cs │ │ │ │ └── ApproveReplyEndpoint.cs │ │ ├── Subscribers │ │ │ └── Subscribe │ │ │ │ ├── SubscribeRequest.cs │ │ │ │ ├── SubscribeRequestValidator.cs │ │ │ │ ├── SubscribeMappingProfile.cs │ │ │ │ └── SubscribeEndpoint.cs │ │ └── EndpointSchema.cs │ ├── Blogger.APIs.http │ ├── appsettings.json │ ├── appsettings.Development.json │ ├── Blogger.APIs.csproj │ ├── Dockerfile │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Filters │ │ └── EndpointValidatorFilter.cs │ ├── ErrorHandling │ │ └── GlobalExceptionHandler.cs │ ├── GlobalUsings.cs │ └── DependencyInjection.cs ├── Blogger.Domain │ ├── IAssemblyMarker.cs │ ├── ArticleAggregate │ │ ├── Models │ │ │ ├── TagModel.cs │ │ │ └── ArchiveModel.cs │ │ ├── ArticleStatus.cs │ │ ├── DraftTagsMissingException.cs │ │ ├── IArticleService.cs │ │ ├── Tag.cs │ │ ├── ArticleId.cs │ │ ├── Like.cs │ │ ├── Author.cs │ │ └── IArticleRepository.cs │ ├── CommentAggregate │ │ ├── MakeCommentEvent.cs │ │ ├── UnapprovedCommentException.cs │ │ ├── InvalidReplyApprovalLinkException.cs │ │ ├── ReplyId.cs │ │ ├── CommentId.cs │ │ ├── Client.cs │ │ ├── ICommentRepository.cs │ │ ├── Reply.cs │ │ ├── ApproveLink.cs │ │ └── Comment.cs │ ├── GlobalUsings.cs │ ├── Blogger.Domain.csproj │ └── SubscriberAggregate │ │ ├── ISubscriberRepository.cs │ │ ├── SubscriberId.cs │ │ └── Subscriber.cs ├── Blogger.Application │ ├── IAssemblyMarker.cs │ ├── Articles │ │ ├── GetTags │ │ │ ├── GetTagsQueryResponse.cs │ │ │ ├── GetTagsQuery.cs │ │ │ └── GetTagsQueryHandler.cs │ │ ├── GetPopularTags │ │ │ ├── GetPopularTagsQueryResponse.cs │ │ │ ├── GetPopularTagsQuery.cs │ │ │ └── GetPopularTagsQueryHandler.cs │ │ ├── MakeDraft │ │ │ ├── MakingDraftCommandResponse.cs │ │ │ ├── MakingDraftCommand.cs │ │ │ ├── DraftAlreadyExistsException.cs │ │ │ └── MakingDraftCommandHandler.cs │ │ ├── PublishDraft │ │ │ ├── PublishDraftCommand.cs │ │ │ └── PublishDraftCommandHandler.cs │ │ ├── GetArchive │ │ │ ├── GetArchiveQuery.cs │ │ │ ├── GetArchiveQueryResponse.cs │ │ │ └── GetArchiveQueryHandler.cs │ │ ├── CreateArticle │ │ │ ├── CreateArticleCommandResponse.cs │ │ │ ├── CreateArticleCommand.cs │ │ │ ├── ArticleAlreadyExistsException.cs │ │ │ └── CreateArticleCommandHandler.cs │ │ ├── GetArticle │ │ │ ├── GetArticleQuery.cs │ │ │ ├── GetArticleQueryHandler.cs │ │ │ └── GetArticleQueryResponse.cs │ │ ├── GetPopularArticles │ │ │ ├── GetPopularArticlesQueryResponse.cs │ │ │ ├── GetPopularArticlesQuery.cs │ │ │ └── GetPopularArticlesHandler.cs │ │ ├── GetTaggedArticles │ │ │ ├── GetTaggedArticlesQuery.cs │ │ │ ├── GetTaggedArticlesQueryHandler.cs │ │ │ └── GetTaggedArticlesQueryResponse.cs │ │ ├── UpdateDraft │ │ │ ├── UpdateDraftCommand.cs │ │ │ ├── DraftNotFoundException.cs │ │ │ ├── DraftTitleDuplicatedException.cs │ │ │ └── UpdateDraftCommandHandler.cs │ │ ├── GetArticles │ │ │ ├── GetArticlesQuery.cs │ │ │ ├── GetArticlesQueryHandler.cs │ │ │ └── GetArticlesQueryResponse.cs │ │ └── ArticleService.cs │ ├── ApplicationServices │ │ ├── ILinkGenerator.cs │ │ └── IEmailService.cs │ ├── Subscribers │ │ ├── Subscribe │ │ │ ├── SubscribeCommand.cs │ │ │ ├── DuplicateSubscribtionException.cs │ │ │ └── SubscribeCommandHandler.cs │ │ ├── ISubscriberService.cs │ │ └── SubscriberService.cs │ ├── Comments │ │ ├── ApproveComment │ │ │ ├── ApproveCommentCommand.cs │ │ │ ├── ApproveCommentCommandResponse.cs │ │ │ ├── InvalidCommentApprovalLinkException.cs │ │ │ └── ApproveCommentCommandHandler.cs │ │ ├── GetComments │ │ │ ├── GetCommentsQuery.cs │ │ │ ├── GetCommentsQueryResponse.cs │ │ │ └── GetCommentsHandler.cs │ │ ├── MakeComment │ │ │ ├── MakeCommentCommandResponse.cs │ │ │ ├── MakeCommentCommand.cs │ │ │ ├── NotFoundArticleException.cs │ │ │ ├── NotValidClientException.cs │ │ │ └── MakeCommentCommandHandler.cs │ │ ├── ReplyToComment │ │ │ ├── ReplyToCommentCommandResponse.cs │ │ │ ├── ReplyToCommentCommand.cs │ │ │ ├── NotFoundArticleException.cs │ │ │ └── ReplyToCommentCommandHandler.cs │ │ ├── GetReplies │ │ │ ├── GetRepliesQuery.cs │ │ │ ├── GetRepliesQueryResponse.cs │ │ │ └── GetRepliesHandler.cs │ │ └── ApproveReply │ │ │ ├── ApproveReplyCommand.cs │ │ │ ├── ApproveReplyCommandResponse.cs │ │ │ ├── CommentNotFoundException.cs │ │ │ └── ApproveReplyCommandHandler.cs │ ├── GlobalUsings.cs │ ├── ApplicationSettings.cs │ ├── Blogger.Application.csproj │ └── DependencyInjection.cs └── Blogger.Infrastructure │ ├── IAssemblyMarker.cs │ ├── Services │ ├── Externals │ │ ├── EmailSettings.cs │ │ └── EmailService.cs │ └── LinkGenerator.cs │ ├── Persistence │ ├── BloggerDbContextFactory.cs │ ├── BloggerDbContext.cs │ ├── Repositories │ │ ├── SubscriberRepository.cs │ │ └── CommentRepository.cs │ ├── Configurations │ │ └── SubscriberConfiguration.cs │ └── BloggerDbContext.Schema.cs │ ├── GlobalUsings.cs │ ├── GlobalSuppressions.cs │ ├── DependencyInjection.cs │ └── Blogger.Infrastructure.csproj ├── global.json ├── .dockerignore ├── Directory.Build.props ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── thisisnabi.dev.apis.yml ├── SECURITY.md ├── LICENSE ├── README.md └── Directory.Packages.props /tests/load-testing/load.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/load-testing/soak.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/load-testing/spike.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/load-testing/stress.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /liara.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "thisisnabi-dev-apis", 3 | "port": 80 4 | } 5 | -------------------------------------------------------------------------------- /src/Blogger.BuildingBlocks/Blogger.BuildingBlocks.csproj: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "8.0.6", 4 | "rollForward": "minor" 5 | } 6 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/IAssemblyMarker.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs; 2 | public interface IAssemblyMarker 3 | { 4 | } 5 | -------------------------------------------------------------------------------- /src/Blogger.Domain/IAssemblyMarker.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain; 2 | public interface IAssemblyMarker 3 | { 4 | } 5 | -------------------------------------------------------------------------------- /src/Blogger.Application/IAssemblyMarker.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application; 2 | public interface IAssemblyMarker 3 | { 4 | } 5 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/IAssemblyMarker.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Infrastructure; 2 | public interface IAssemblyMarker 3 | { 4 | } 5 | -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/Models/TagModel.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.ArticleAggregate.Models; 2 | public sealed record TagModel(Tag Tag, int Count); -------------------------------------------------------------------------------- /src/Blogger.BuildingBlocks/Domain/IDomainEvent.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.BuildingBlocks.Domain; 2 | public interface IDomainEvent 3 | { 4 | DateTime OccurredOn { get; } 5 | } 6 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Abstractions/IEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Abstractions; 2 | 3 | public interface IEndpoint 4 | { 5 | void MapEndpoint(IEndpointRouteBuilder app); 6 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetTags/GetTagsResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetTags; 2 | 3 | public record GetTagsResponse(string Name, int Count); 4 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/MakeDraft/MakeDraftResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.MakeDraft; 2 | 3 | public record MakeDraftResponse(string DraftId); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetTags/GetTagsQueryResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetTags; 2 | 3 | public record GetTagsQueryResponse(Tag Tag, int Count); 4 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Blogger.APIs.http: -------------------------------------------------------------------------------- 1 | @Blogger.APIs_HostAddress = http://localhost:5138 2 | 3 | GET {{Blogger.APIs_HostAddress}}/weatherforecast/ 4 | Accept: application/json 5 | 6 | ### 7 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/MakeComment/MakeCommentResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.MakeComment; 2 | 3 | public record MakeCommentResponse(string CommentId); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/ApplicationServices/ILinkGenerator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.ApplicationServices; 2 | public interface ILinkGenerator 3 | { 4 | string Generate(); 5 | } 6 | -------------------------------------------------------------------------------- /tests/Blogger.UnitTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | // built-int 2 | 3 | 4 | // third-parties 5 | global using FluentAssertions; 6 | 7 | 8 | // solution 9 | global using Blogger.BuildingBlocks.Domain; -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetPopularTags/GetPopularTagsResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetPopularTags; 2 | 3 | public record GetPopularTagsResponse(string Name); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetPopularTags/GetPopularTagsQueryResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetPopularTags; 2 | 3 | public record GetPopularTagsQueryResponse(Tag Tag); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/MakeDraft/MakingDraftCommandResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.MakeDraft; 2 | 3 | public record MakeDraftCommandResponse(ArticleId DraftId); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/PublishDraft/PublishDraftCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.PublishDraft; 2 | 3 | public record PublishDraftCommand(ArticleId DraftId) : IRequest; -------------------------------------------------------------------------------- /src/Blogger.Application/Subscribers/Subscribe/SubscribeCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Subscribers.Subscribe; 2 | public record SubscribeCommand(SubscriberId SubscriberId) : IRequest; 3 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/CreateArticle/CreateArticleResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.CreateArticle; 2 | 3 | public record CreateArticleResponse(string ArticleId); 4 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ReplyToCommet/ReplyToCommentResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ReplyToCommet; 2 | 3 | public record ReplyToCommentResponse(string ReplyId); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetTags/GetTagsQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetTags; 2 | public record GetTagsQuery() 3 | : IRequest>; -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ApproveComment/ApproveCommentRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ApproveComment; 2 | 3 | public record ApproveCommentRequest([FromQuery] string Link); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetArchive/GetArchiveQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetArchive; 2 | public record GetArchiveQuery() : IRequest>; -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/ArticleStatus.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.ArticleAggregate; 2 | 3 | public enum ArticleStatus 4 | { 5 | Draft = 1, 6 | Published = 2, 7 | Deleted 8 | } 9 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/CreateArticle/CreateArticleCommandResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.CreateArticle; 2 | 3 | public record CreateArticleCommandResponse(ArticleId ArticleId); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetArticle/GetArticleQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetArticle; 2 | public record GetArticleQuery(ArticleId ArticleId) 3 | : IRequest; -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArticle/GetArticleRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArticle; 2 | 3 | public record GetArticleRequest([FromRoute(Name = "article-id")] string ArticleId); 4 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetReplies/GetRepliesRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetReplies; 2 | 3 | public record GetRepliesRequest([FromRoute(Name = "comment-id")] Guid CommentId); 4 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetTaggedArticles/GetTaggedArticlesRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetTaggedArticles; 2 | 3 | public record GetTaggedArticlesRequest([FromQuery] string Tag); 4 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/MakeDraft/MakeDraftRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.MakeDraft; 2 | 3 | public record MakeDraftRequest(string Title, string Body, string Summary, string[] Tags); 4 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/PublishDraft/PublishDraftRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.PublishDraft; 2 | 3 | public record PublishDraftRequest([FromRoute(Name = "draft-id")] string DraftId); 4 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetComments/GetCommentsRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetComments; 2 | 3 | public record GetCommentsRequest([FromRoute(Name = "article-id")] string ArticleId); 4 | -------------------------------------------------------------------------------- /src/Blogger.APIs/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetPopularArticles/GetPopularArticlesResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetPopularArticles; 2 | 3 | public record GetPopularArticlesResponse(string Title, string ArticleId); -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ApproveComment/ApproveCommentCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Comments.ApproveComment; 2 | 3 | public record ApproveCommentCommand(string Link) : IRequest; -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/MakeComment/MakeCommentRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.MakeComment; 2 | 3 | public record MakeCommetRequest(string ArticleId, string Content, string FullName, string Email); -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/CreateArticle/CreateArticleRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.CreateArticle; 2 | 3 | public record CreateArticleRequest(string Title, string Body, string Summary, string[] Tags); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetPopularTags/GetPopularTagsQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetPopularTags; 2 | public record GetPopularTagsQuery(int Size) 3 | : IRequest>; -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/GetComments/GetCommentsQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Comments.GetComments; 2 | public record GetCommentsQuery(ArticleId ArticleId) 3 | : IRequest>; -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetReplies/GetRepliesResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetReplies; 2 | 3 | public record GetRepliesResponse( 4 | string FullName, 5 | DateTime CreatedOnUtc, 6 | string Content); -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetPopularArticles/GetPopularArticlesQueryResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetPopularArticles; 2 | 3 | public record GetPopularArticlesQueryResponse(ArticleId ArticleId, string Title); 4 | -------------------------------------------------------------------------------- /src/Blogger.BuildingBlocks/Domain/IAggregateRoot.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.BuildingBlocks.Domain; 2 | 3 | public interface IAggregateRoot 4 | { 5 | IReadOnlyCollection Events { get; } 6 | 7 | void ClearEvents(); 8 | } 9 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/UpdateDraft/UpdateDraftRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.UpdateDraft; 2 | 3 | public record UpdateDraftRequest(string DraftId, string Title, string Body, string Summary, string[] Tags); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/MakeComment/MakeCommentCommandResponse.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.MakeComment; 4 | public record MakeCommentCommandResponse(CommentId CommentId); 5 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ApproveReply/ApproveReplyRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ApproveReply; 2 | 3 | public record ApproveReplyRequest([FromQuery] string Link, [FromQuery(Name = "comment-id")] Guid CommentId); 4 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ReplyToComment/ReplyToCommentCommandResponse.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.ReplyToComment; 4 | 5 | public record ReplyToCommentCommandResponse(ReplyId ReplyId); -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetTaggedArticles/GetTaggedArticlesQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetTaggedArticles; 2 | public record GetTaggedArticlesQuery(Tag Tag) 3 | : IRequest>; -------------------------------------------------------------------------------- /src/Blogger.Application/Subscribers/ISubscriberService.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Subscribers; 2 | 3 | public interface ISubscriberService 4 | { 5 | Task IsDuplicated(SubscriberId subscriberId, CancellationToken cancellationToken); 6 | } 7 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetComments/GetCommentsResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetComments; 2 | 3 | public record GetCommentsResponse( 4 | string Id, 5 | string FullName, 6 | DateTime CreatedOnUtc, 7 | string Content); -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Subscribers/Subscribe/SubscribeRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Blogger.APIs.Endpoints.Subscribers.Subscribe; 4 | 5 | public record SubscribeRequest([FromBody][EmailAddress] string Email); 6 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetPopularArticles/GetPopularArticlesQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetPopularArticles; 2 | public record GetPopularArticlesQuery(int Size) 3 | : IRequest>; -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/MakeDraft/MakingDraftCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.MakeDraft; 2 | 3 | public record MakeDraftCommand(string Title, string Body, string Summary, IReadOnlyList Tags) 4 | : IRequest; -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/UpdateDraft/UpdateDraftCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.UpdateDraft; 2 | 3 | public record UpdateDraftCommand(ArticleId DraftId, string Title, string Body, string Summary, IReadOnlyList Tags) 4 | : IRequest; -------------------------------------------------------------------------------- /src/Blogger.Application/ApplicationServices/IEmailService.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.ApplicationServices; 2 | public interface IEmailService 3 | { 4 | Task SendAsync(string email, string subject, string content, CancellationToken cancellationToken); 5 | } 6 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetArticles/GetArticlesQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetArticles; 2 | public record GetArticlesQuery(int PageNumber = 1, int PageSize = 10, string Title = "") 3 | : IRequest>; -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArticles/GetArticlesRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArticles; 2 | 3 | public record GetArticlesRequest( 4 | [FromQuery] int Page = 1, 5 | [FromQuery] int Size = 10, 6 | [FromQuery] string Title = ""); 7 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/GetReplies/GetRepliesQuery.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.GetReplies; 4 | public record GetRepliesQuery(CommentId CommentId) 5 | : IRequest>; -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/CreateArticle/CreateArticleCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.CreateArticle; 2 | 3 | public record CreateArticleCommand(string Title, string Body, string Summary, IReadOnlyList Tags) 4 | : IRequest; -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ApproveReply/ApproveReplyCommand.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.ApproveReply; 4 | 5 | public record ApproveReplyCommand(CommentId CommentId, string Link) : IRequest; -------------------------------------------------------------------------------- /src/Blogger.BuildingBlocks/Domain/DomainException.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.BuildingBlocks.Domain; 2 | 3 | public abstract class DomainException : Exception 4 | { 5 | protected DomainException() : base() { } 6 | 7 | protected DomainException(string? message) : base(message) { } 8 | } -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/MakeCommentEvent.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.CommentAggregate; 2 | 3 | public class MakeCommentEvent : IDomainEvent 4 | { 5 | public DateTime OccurredOn => DateTime.UtcNow; 6 | 7 | public CommentId CommentId { get; set; } = null!; 8 | } 9 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ApproveComment/ApproveCommentCommandResponse.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.ApproveComment; 4 | 5 | public record ApproveCommentCommandResponse(ArticleId ArticleId, CommentId CommentId) : IRequest; -------------------------------------------------------------------------------- /src/Blogger.Domain/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | // built-in 2 | global using System.Collections.Immutable; 3 | global using System.Net.Mail; 4 | 5 | // third-party 6 | 7 | 8 | // solution 9 | global using Blogger.BuildingBlocks.Domain; 10 | global using Blogger.Domain.CommentAggregate; 11 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ApproveReply/ApproveReplyCommandResponse.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.ApproveReply; 4 | 5 | public record ApproveReplyCommandResponse(ArticleId ArticleId, CommentId CommentId, ReplyId ReplyId) : IRequest; -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/EndpointSchema.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints; 2 | 3 | public static class EndpointSchema 4 | { 5 | public const string ArticleTag = "Articles"; 6 | public const string CommentTag = "Comments"; 7 | public const string SubscriberTag = "Subscribers"; 8 | } 9 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/MakeComment/MakeCommentCommand.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.MakeComment; 4 | 5 | public record MakeCommentCommand(ArticleId ArticleId, Client Client, string Content) : IRequest; -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/Models/ArchiveModel.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.ArticleAggregate.Models; 2 | 3 | public record ArchiveModel(int Year, int Month, IEnumerable Articles); 4 | public record ArticleArchiveModel(ArticleId ArticleId, string Title, int DayOfMonth); 5 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ReplyToComment/ReplyToCommentCommand.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.ReplyToComment; 4 | 5 | public record ReplyToCommentCommand(CommentId CommentId, Client Client, string Content) 6 | : IRequest; -------------------------------------------------------------------------------- /src/Blogger.Application/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | // built-in 2 | global using System.Collections.Immutable; 3 | 4 | // third-party 5 | global using MediatR; 6 | 7 | // solution 8 | global using Blogger.Domain.ArticleAggregate; 9 | global using Blogger.Domain.SubscriberAggregate; 10 | global using Blogger.BuildingBlocks.Exceptions; -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetArchive/GetArchiveQueryResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetArchive; 2 | 3 | public record GetArchiveQueryResponse(int Year, int Month, IReadOnlyCollection Articles); 4 | 5 | public record ArticleArchiveResponse(ArticleId ArticleId, string Title, int Day); -------------------------------------------------------------------------------- /src/Blogger.Domain/Blogger.Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArchive/GetArchiveResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArchive; 2 | 3 | public record GetArchiveResponse( 4 | int Year, 5 | int Month, 6 | IReadOnlyList Articles); 7 | 8 | public record GetArchiveItemResponse(string ArticleId, string Title, int Day); -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArticles/GetArticlesResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArticles; 2 | 3 | public record GetArticlesResponse( 4 | string ArticleId, 5 | string Title, 6 | string Body, 7 | string Summary, 8 | DateTime PublishedOnUtc, 9 | int ReadOnMinutes, 10 | string[] Tags); -------------------------------------------------------------------------------- /src/Blogger.Application/ApplicationSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application; 2 | public static class ApplicationSettings 3 | { 4 | public static class ApproveLink 5 | { 6 | public const int ExpirationOnHours = 10; 7 | public const string ConfirmEmailSubject = "Confirm Your Engagement - thisisnabi.dev"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/UnapprovedCommentException.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.CommentAggregate; 2 | 3 | public class UnapprovedCommentException : DomainException 4 | { 5 | private const string _messages = "Reply is not allowed for unapproved comments."; 6 | public UnapprovedCommentException() : base(_messages) 7 | { 8 | } 9 | } -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/DraftTagsMissingException.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.ArticleAggregate; 2 | 3 | public class DraftTagsMissingException : DomainException 4 | { 5 | private const string _messages = "Cannot publish draft without tags."; 6 | public DraftTagsMissingException() : base(_messages) 7 | { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetReplies/GetRepliesValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetReplies; 2 | 3 | public class GetRepliesValidator : AbstractValidator 4 | { 5 | public GetRepliesValidator() 6 | { 7 | RuleFor(x => x.CommentId) 8 | .NotEmpty() 9 | .NotNull(); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/InvalidReplyApprovalLinkException.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.CommentAggregate; 2 | 3 | public class InvalidReplyApprovalLinkException : DomainException 4 | { 5 | private const string _message = "Invalid Reply approved link."; 6 | public InvalidReplyApprovalLinkException() : base(_message) 7 | { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetTaggedArticles/GetTaggedArticlesResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetTaggedArticles; 2 | 3 | public record GetTaggedArticlesResponse( 4 | string ArticleId, 5 | string Title, 6 | string Body, 7 | string Summary, 8 | DateTime PublishedOnUtc, 9 | int ReadOnMinutes, 10 | string[] Tags); -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetComments/GetCommentsValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetComments; 2 | 3 | public class GetCommentsValidator : AbstractValidator 4 | { 5 | public GetCommentsValidator() 6 | { 7 | RuleFor(x => x.ArticleId) 8 | .NotEmpty() 9 | .NotNull(); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArticle/GetArticleRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArticle; 2 | 3 | public class GetArticleRequestValidator : AbstractValidator 4 | { 5 | public GetArticleRequestValidator() 6 | { 7 | RuleFor(x => x.ArticleId) 8 | .NotEmpty() 9 | .NotNull(); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Subscribers/Subscribe/SubscribeRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Subscribers.Subscribe; 2 | 3 | public class SubscribeRequestValidator : AbstractValidator 4 | { 5 | public SubscribeRequestValidator() 6 | { 7 | RuleFor(x => x.Email) 8 | .NotEmpty() 9 | .NotNull(); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/PublishDraft/PublishDraftRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.PublishDraft; 2 | 3 | public class PublishDraftRequestValidator : AbstractValidator 4 | { 5 | public PublishDraftRequestValidator() 6 | { 7 | RuleFor(x => x.DraftId) 8 | .NotEmpty() 9 | .NotNull(); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/UpdateDraft/DraftNotFoundException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | 4 | namespace Blogger.Application.Articles.UpdateDraft; 5 | public class DraftNotFoundException : DomainException 6 | { 7 | private const string _message = "Draft not found."; 8 | 9 | public DraftNotFoundException() : base(_message) 10 | { 11 | 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Blogger.Application/Blogger.Application.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/IArticleService.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.ArticleAggregate; 2 | 3 | namespace Blogger.Application.Articles; 4 | public interface IArticleService 5 | { 6 | Task IsArticleIdValidAsync(ArticleId articleId, CancellationToken cancellationToken); 7 | 8 | Task HasIdAsync(ArticleId articleId, CancellationToken cancellationToken); 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ApproveComment/ApproveCommentRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ApproveComment; 2 | 3 | public class ApproveCommentRequestValidator : AbstractValidator 4 | { 5 | public ApproveCommentRequestValidator() 6 | { 7 | RuleFor(x => x.Link) 8 | .NotEmpty() 9 | .NotNull(); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ApproveReply/CommentNotFoundException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | 4 | namespace Blogger.Application.Comments.ApproveReply; 5 | public class CommentNotFoundException : DomainException 6 | { 7 | private const string _message = "Comment not found."; 8 | 9 | public CommentNotFoundException() : base(_message) 10 | { 11 | 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/MakeComment/NotFoundArticleException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | 4 | namespace Blogger.Application.Comments.MakeComment; 5 | public class NotFoundArticleException : DomainException 6 | { 7 | private const string _message = "Article not found."; 8 | 9 | public NotFoundArticleException() : base(_message) 10 | { 11 | 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/MakeComment/NotValidClientException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | 4 | namespace Blogger.Application.Comments.MakeComment; 5 | 6 | public class NotValidClientException : DomainException 7 | { 8 | private const string _messages = "Invalid client id."; 9 | 10 | public NotValidClientException() : base(_messages) 11 | { 12 | 13 | } 14 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ReplyToComment/NotFoundArticleException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | 4 | namespace Blogger.Application.Comments.ReplyToComment; 5 | public class NotFoundCommentException : DomainException 6 | { 7 | private const string _message = "Invalid comment for Reply!"; 8 | 9 | public NotFoundCommentException() : base(_message) 10 | { 11 | 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArticle/GetArticleResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArticle; 2 | 3 | public record GetArticleResponse(string ArticleId, 4 | string Title, 5 | string Body, 6 | string Summary, 7 | int ReadOnMinutes, 8 | string AuthorFullName, 9 | string AuthorAvatar, 10 | string AuthorJobTitle, 11 | DateTime PublishedOnUtc, 12 | string[] Tags); -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ApproveComment/ApproveCommentMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ApproveComment; 2 | 3 | public class ApproveCommentMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.Link, src => src.Link); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Blogger.Application/Subscribers/Subscribe/DuplicateSubscribtionException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | 4 | namespace Blogger.Application.Subscribers.Subscribe; 5 | 6 | public class DuplicateSubscribtionException : DomainException 7 | { 8 | private const string _messages = "Duplicated registration!"; 9 | 10 | public DuplicateSubscribtionException() : base(_messages) 11 | { 12 | 13 | } 14 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/PublishDraft/PublishDraftMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.PublishDraft; 2 | 3 | public class PublishDraftMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.DraftId, src => ArticleId.Create(src.DraftId)); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetPopularTags/GetPopularTagsMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetPopularTags; 2 | 3 | public class GetPopularTagsMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.Name, src => src.Tag.ToString()); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Services/Externals/EmailSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Infrastructure.Services.Externals; 2 | 3 | public class EmailSettings 4 | { 5 | public string From { get; set; } = null!; 6 | 7 | public string SmtpHost { get; set; } = null!; 8 | 9 | public int SmtpPort { get; set; } 10 | 11 | public string UserName { get; set; } = null!; 12 | 13 | public string Password { get; set; } = null!; 14 | } 15 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ApproveComment/InvalidCommentApprovalLinkException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | 4 | namespace Blogger.Application.Comments.ApproveComment; 5 | 6 | public class InvalidCommentApprovalLinkException : DomainException 7 | { 8 | private const string _message = "Invalid comment approved link."; 9 | public InvalidCommentApprovalLinkException() : base(_message) 10 | { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetTags/GetTagsMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetTags; 2 | 3 | public class GetTagsMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.Count, src => src.Count) 9 | .Map(x => x.Name, src => src.Tag.ToString()); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/GetReplies/GetRepliesQueryResponse.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.GetReplies; 4 | 5 | public record GetRepliesQueryResponse(string FullName, DateTime CreatedOnUtc, string Content) 6 | { 7 | 8 | public static explicit operator GetRepliesQueryResponse(Reply Reply) 9 | => new GetRepliesQueryResponse(Reply.Client.FullName, Reply.CreatedOnUtc, Reply.Content); 10 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetPopularArticles/GetPopularArticlesMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetPopularArticles; 2 | 3 | public class GetPopularArticlesMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.ArticleId, src => src.ArticleId.ToString()); 9 | } 10 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ReplyToCommet/ReplyToCommentRequestModel.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ReplyToCommet; 2 | 3 | public record ReplyToCommentRequestModel([FromRoute(Name = "comment-id")] Guid CommentId, 4 | [FromBody] ReplyToCommentRequest body); 5 | 6 | public record ReplyToCommentRequest(string Content, 7 | string FullName, 8 | string Email); -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/MakeDraft/DraftAlreadyExistsException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | namespace Blogger.Application.Articles.MakeDraft; 4 | 5 | public sealed class DraftAlreadyExistsException : DomainException 6 | { 7 | private const string _messages = "Draft with Title `{0}` already exists."; 8 | public DraftAlreadyExistsException(string articleId) 9 | : base(string.Format(_messages, articleId)) 10 | { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Fixtures/BloggerDbContextFixture.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Infrastructure.Persistence; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | 5 | namespace Blogger.IntegrationTests.Fixtures; 6 | 7 | public class BloggerDbContextFixture : EfDatabaseBaseFixture 8 | { 9 | protected override BloggerDbContext BuildDbContext(DbContextOptions options) 10 | { 11 | return new BloggerDbContext(options); 12 | } 13 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Subscribers/Subscribe/SubscribeMappingProfile.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.SubscriberAggregate; 2 | 3 | namespace Blogger.APIs.Endpoints.Subscribers.Subscribe; 4 | 5 | public class SubscribeMappingProfile : IRegister 6 | { 7 | public void Register(TypeAdapterConfig config) 8 | { 9 | config.ForType() 10 | .Map(x => x.SubscriberId, src => SubscriberId.Create(src.Email)); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Blogger.APIs/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "ConnectionStrings": { 9 | "SvcDbContext": "data source=.;initial catalog=thisisnabi.blogger;TrustServerCertificate=True;Trusted_Connection=True;" 10 | }, 11 | "EmailSettings": { 12 | "From": "", 13 | "SmtpHost": "smtp.gmail.com", 14 | "SmtpPort": 587 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ApproveReply/ApproveReplyRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ApproveReply; 2 | 3 | public class ApproveReplyRequestValidator : AbstractValidator 4 | { 5 | public ApproveReplyRequestValidator() 6 | { 7 | RuleFor(x => x.CommentId) 8 | .NotEmpty() 9 | .NotNull(); 10 | 11 | RuleFor(x => x.Link) 12 | .NotEmpty() 13 | .NotNull(); 14 | } 15 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/UpdateDraft/DraftTitleDuplicatedException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | 4 | namespace Blogger.Application.Articles.UpdateDraft; 5 | public class DraftTitleDuplicatedException : DomainException 6 | { 7 | private const string _message = "A draft with the same title already exists. Draft title: {0}"; 8 | 9 | public DraftTitleDuplicatedException(string title) : base(string.Format(_message, title)) 10 | { 11 | 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/CreateArticle/ArticleAlreadyExistsException.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | 3 | namespace Blogger.Application.Articles.CreateArticle; 4 | 5 | public sealed class ArticleAlreadyExistsException : DomainException 6 | { 7 | private const string _messages = "Article with Title `{0}` already exists."; 8 | public ArticleAlreadyExistsException(string articleId) 9 | : base(string.Format(_messages, articleId)) 10 | { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Blogger.Domain/SubscriberAggregate/ISubscriberRepository.cs: -------------------------------------------------------------------------------- 1 | 2 | namespace Blogger.Domain.SubscriberAggregate; 3 | 4 | public interface ISubscriberRepository 5 | { 6 | Task CreateAsync(Subscriber subscriber, CancellationToken cancellationToken); 7 | Task FindByIdAsync(SubscriberId subscriberId); 8 | Task IsExistsAsync(SubscriberId subscriberId, CancellationToken cancellationToken); 9 | Task SavaChangesAsync(CancellationToken cancellationToken); 10 | } 11 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ApproveReply/ApproveReplyMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ApproveReply; 2 | 3 | public class ApproveReplyMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.CommentId, src => CommentId.Create(src.CommentId)) 9 | .Map(x => x.Link, src => src.Link); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetReplies/GetRepliesMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetReplies; 2 | 3 | public class GetRepliesMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.CommentId, src => CommentId.Create(src.CommentId)); 9 | 10 | config.ForType(); 11 | } 12 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/GetComments/GetCommentsQueryResponse.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.GetComments; 4 | 5 | public record GetCommentsQueryResponse(CommentId CommentId,string FullName, DateTime CreatedOnUtc, string Content) 6 | { 7 | 8 | public static explicit operator GetCommentsQueryResponse(Comment comment) 9 | => new GetCommentsQueryResponse(comment.Id,comment.Client.FullName, comment.CreatedOnUtc, comment.Content); 10 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md 26 | !**/.gitignore 27 | !.git/HEAD 28 | !.git/config 29 | !.git/packed-refs 30 | !.git/refs/heads/** -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | net8.0 4 | enable 5 | enable 6 | true 7 | 8 | latest 9 | latest-minimum 10 | minimum 11 | 12 | 0.1.0 13 | 14 | $(NoWarn);IDE0290 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Blogger.Application/Subscribers/SubscriberService.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Subscribers; 2 | 3 | public class SubscriberService(ISubscriberRepository subscriberRepository) : ISubscriberService 4 | { 5 | private readonly ISubscriberRepository _subscriberRepository = subscriberRepository; 6 | 7 | public async Task IsDuplicated(SubscriberId subscriberId, CancellationToken cancellationToken) 8 | { 9 | var exists = await _subscriberRepository.IsExistsAsync(subscriberId, cancellationToken); 10 | return exists; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetComments/GetCommentsMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetComments; 2 | 3 | public class GetCommentsMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.ArticleId, src => ArticleId.Create(src.ArticleId)); 9 | 10 | config.ForType() 11 | .Map(x => x.Id, src => src.CommentId.ToString()); 12 | } 13 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/UpdateDraft/UpdateDraftMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.UpdateDraft; 2 | 3 | public class UpdateDraftMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.DraftId, src => ArticleId.Create(src.DraftId)) 9 | .Map(x => x.Tags, src => src.Tags.Select(x => Tag.Create(x)) 10 | .ToImmutableList()); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/ArticleService.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles; 2 | public class ArticleService(IArticleRepository articleRepository) : IArticleService 3 | { 4 | public Task HasIdAsync(ArticleId articleId, CancellationToken cancellationToken) 5 | { 6 | return IsArticleIdValidAsync(articleId, cancellationToken); 7 | } 8 | 9 | public async Task IsArticleIdValidAsync(ArticleId articleId, CancellationToken cancellationToken) 10 | { 11 | return await articleRepository.GetArticleByIdAsync(articleId, cancellationToken) is not null; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/Tag.cs: -------------------------------------------------------------------------------- 1 | using Humanizer; 2 | 3 | namespace Blogger.Domain.ArticleAggregate; 4 | public class Tag : ValueObject 5 | { 6 | public string Value { get; init; } = null!; 7 | 8 | public override IEnumerable GetEqualityComponents() 9 | { 10 | yield return Value; 11 | } 12 | 13 | public static Tag Create(string tagValue) 14 | { 15 | return new Tag 16 | { 17 | Value = tagValue.Kebaberize() 18 | }; 19 | } 20 | 21 | public override string ToString() 22 | { 23 | return Value; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/ReplyId.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.CommentAggregate; 2 | 3 | public class ReplyId : ValueObject 4 | { 5 | public Guid Value { get; init; } 6 | 7 | public override IEnumerable GetEqualityComponents() 8 | { 9 | yield return Value; 10 | } 11 | 12 | public static ReplyId CreateUniqueId() => Create(Guid.NewGuid()); 13 | 14 | public static ReplyId Create(Guid value) => new ReplyId 15 | { 16 | Value = value 17 | }; 18 | 19 | public override string ToString() 20 | { 21 | return Value.ToString(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Blogger.BuildingBlocks/Domain/AggregateRoot.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.BuildingBlocks.Domain; 2 | public abstract class AggregateRoot : Entity, IAggregateRoot 3 | where TId : notnull 4 | { 5 | public IReadOnlyCollection Events => [.. _events]; 6 | 7 | private readonly List _events; 8 | 9 | protected AggregateRoot(TId id) : base(id) 10 | { 11 | _events = []; 12 | } 13 | 14 | public void ClearEvents() => _events.Clear(); 15 | 16 | protected void AddEvent(TDomainEvent @event) 17 | where TDomainEvent : IDomainEvent => _events.Add(@event); 18 | } 19 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Persistence/BloggerDbContextFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Design; 2 | 3 | namespace Blogger.Infrastructure.Persistence; 4 | public class BloggerDbContextFactory : IDesignTimeDbContextFactory 5 | { 6 | public BloggerDbContext CreateDbContext(string[] args) 7 | { 8 | var optionBuilder = new DbContextOptionsBuilder(); 9 | optionBuilder.UseSqlServer("data source=.;initial catalog=thisisnabi.blogger;TrustServerCertificate=True;Trusted_Connection=True;"); 10 | 11 | return new BloggerDbContext(optionBuilder.Options); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/ArticleId.cs: -------------------------------------------------------------------------------- 1 | using Humanizer; 2 | 3 | namespace Blogger.Domain.ArticleAggregate; 4 | 5 | public sealed class ArticleId : ValueObject 6 | { 7 | public required string Slug { get; init; } 8 | 9 | public override IEnumerable GetEqualityComponents() 10 | { 11 | yield return Slug; 12 | } 13 | 14 | public static ArticleId CreateUniqueId(string title) 15 | => Create(title.Kebaberize()); 16 | 17 | public static ArticleId Create(string value) 18 | => new ArticleId{ Slug = value }; 19 | 20 | public override string ToString() => Slug; 21 | } 22 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/MakeDraft/MakeDraftMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.MakeDraft; 2 | 3 | public class MakeDraftMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | 9 | .Map(x => x.Tags, src => src.Tags.Select(x => Tag.Create(x)) 10 | .ToImmutableList()); 11 | 12 | config.ForType() 13 | .Map(x => x.DraftId, src => src.DraftId.Slug); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/CommentId.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.CommentAggregate; 2 | 3 | public class CommentId : ValueObject 4 | { 5 | public Guid Value { get; init; } 6 | 7 | public override IEnumerable GetEqualityComponents() 8 | { 9 | yield return Value; 10 | } 11 | public static CommentId CreateUniqueId() => Create( 12 | Guid.NewGuid() 13 | ); 14 | 15 | public static CommentId Create(Guid value) => new CommentId 16 | { 17 | Value = value 18 | }; 19 | 20 | public override string ToString() 21 | { 22 | return Value.ToString(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Blogger.BuildingBlocks/Exceptions/InvalidEmailAddressException.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Net.Mail; 3 | 4 | using Blogger.BuildingBlocks.Domain; 5 | 6 | namespace Blogger.BuildingBlocks.Exceptions; 7 | 8 | public class InvalidEmailAddressException : DomainException 9 | { 10 | private const string _messages = "Invalid Email Address."; 11 | 12 | public InvalidEmailAddressException() : base(_messages) { } 13 | 14 | public static void Throw(string email) 15 | { 16 | if (!MailAddress.TryCreate(email, out _)) 17 | { 18 | throw new InvalidEmailAddressException(); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/CreateArticle/CreateArticleMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.CreateArticle; 2 | 3 | public class CreateArticleMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.Tags, src => src.Tags.Select(x => Tag.Create(x)) 9 | .ToImmutableList()); 10 | 11 | config.ForType() 12 | .Map(x => x.ArticleId, src => src.ArticleId.Slug); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetTags/GetTagsEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetTags; 2 | 3 | public class GetTagsEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapGet("/articles/tags/", async ( 8 | IMapper mapper, 9 | IMediator mediator, 10 | CancellationToken cancellationToken) => 11 | { 12 | var command = new GetTagsQuery(); 13 | var response = await mediator.Send(command, cancellationToken); 14 | 15 | return mapper.Map>(response); 16 | }).WithTags(EndpointSchema.ArticleTag); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArchive/GetArchiveMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArchive; 2 | 3 | public class GetArchiveMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.Articles, src => src.Articles); 9 | 10 | config.ForType() 11 | .Map(x => x.ArticleId, src => src.ArticleId.ToString()) 12 | .Map(x => x.Title, src => src.Title) 13 | .Map(x => x.Day, src => src.Day); 14 | 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetTags/GetTagsQueryHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetTags; 2 | 3 | public class GetTagsQueryHandler(IArticleRepository articleRepository) 4 | : IRequestHandler> 5 | { 6 | private readonly IArticleRepository _articleRepository = articleRepository; 7 | 8 | public async Task> Handle(GetTagsQuery request, CancellationToken cancellationToken) 9 | { 10 | var tags = await _articleRepository.GetTagsAsync(cancellationToken); 11 | 12 | return [.. tags.Select(x => new GetTagsQueryResponse(x.Tag, x.Count))]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/Blogger.FunctionalTests/Helper/StringGenerator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.FunctionalTests.Helper; 2 | 3 | public static class StringGenerator 4 | { 5 | private readonly static Random _random = new(); 6 | 7 | public static string GenerateRandomString(int length) 8 | { 9 | const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 10 | 11 | var stringBuilder = new System.Text.StringBuilder(length); 12 | 13 | for (int i = 0; i < length; i++) 14 | { 15 | int index = _random.Next(chars.Length); 16 | 17 | stringBuilder.Append(chars[index]); 18 | } 19 | 20 | return stringBuilder.ToString(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | // built-int 2 | global using Microsoft.EntityFrameworkCore; 3 | global using System.Collections.Immutable; 4 | global using System.Text; 5 | 6 | global using Microsoft.Extensions.Configuration; 7 | global using Microsoft.Extensions.DependencyInjection; 8 | 9 | // third-party 10 | 11 | // solution 12 | global using Blogger.Domain.ArticleAggregate; 13 | global using Blogger.Domain.CommentAggregate; 14 | global using Blogger.Domain.SubscriberAggregate; 15 | global using Blogger.Infrastructure.Persistence; 16 | global using Blogger.Infrastructure.Persistence.Repositories; 17 | global using Blogger.Infrastructure.Services; 18 | global using Blogger.Infrastructure.Services.Externals; 19 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArchive/GetArchiveEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArchive; 2 | 3 | public class GetArchiveEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapGet("/articles/archive", async ( 8 | IMapper mapper, 9 | IMediator mediator, 10 | CancellationToken cancellationToken) => 11 | { 12 | var command = new GetArchiveQuery(); 13 | var result = await mediator.Send(command, cancellationToken); 14 | 15 | return mapper.Map>(result); 16 | }).WithTags(EndpointSchema.ArticleTag); 17 | } 18 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/PublishDraft/PublishDraftCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles.UpdateDraft; 2 | 3 | namespace Blogger.Application.Articles.PublishDraft; 4 | 5 | public class PublishDraftCommandHandler(IArticleRepository articleRepository) 6 | : IRequestHandler 7 | { 8 | public async Task Handle(PublishDraftCommand request, CancellationToken cancellationToken) 9 | { 10 | var draft = await articleRepository.GetDraftByIdAsync(request.DraftId, cancellationToken); 11 | if (draft is null) throw new DraftNotFoundException(); 12 | 13 | draft.Publish(); 14 | 15 | await articleRepository.SaveChangesAsync(cancellationToken); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/MakeComment/MakeCommentMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.MakeComment; 2 | 3 | public class MakeCommentMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.ArticleId, src => ArticleId.Create(src.ArticleId)) 9 | .Map(x => x.Client, src => Client.Create(src.FullName, src.Email)) 10 | .Map(x => x.Content, src => src.Content); 11 | 12 | config.ForType() 13 | .Map(x => x.CommentId, src => src.CommentId.ToString()); 14 | } 15 | } -------------------------------------------------------------------------------- /src/Blogger.Domain/SubscriberAggregate/SubscriberId.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Exceptions; 2 | 3 | namespace Blogger.Domain.SubscriberAggregate; 4 | 5 | public class SubscriberId : ValueObject 6 | { 7 | public MailAddress Email { get; init; } = null!; 8 | 9 | public override IEnumerable GetEqualityComponents() 10 | { 11 | yield return Email; 12 | } 13 | 14 | public static SubscriberId CreateUniqueId(string email) 15 | { 16 | return Create(email); 17 | } 18 | 19 | public static SubscriberId Create(string value) 20 | { 21 | InvalidEmailAddressException.Throw(value); 22 | 23 | return new SubscriberId { Email = new MailAddress(value) }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Subscribers/Subscribe/SubscribeEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Subscribers.Subscribe; 2 | 3 | public class SubscribeEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapPost("/subscribe", async ( 8 | [FromBody] SubscribeRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | await mediator.Send(command, cancellationToken); 15 | }).Validator() 16 | .WithTags(EndpointSchema.SubscriberTag); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/Client.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Exceptions; 2 | 3 | namespace Blogger.Domain.CommentAggregate; 4 | 5 | public class Client : ValueObject 6 | { 7 | public string FullName { get; set; } = null!; 8 | 9 | public string Email { get; set; } = null!; 10 | 11 | public override IEnumerable GetEqualityComponents() 12 | { 13 | yield return FullName; 14 | yield return Email; 15 | } 16 | 17 | public static Client Create(string fullName, string email) 18 | { 19 | InvalidEmailAddressException.Throw(email); 20 | 21 | return new Client 22 | { 23 | Email = email, 24 | FullName = fullName, 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/UpdateDraft/UpdateDraftEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.UpdateDraft; 2 | 3 | public class UpdateDraftEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapPut("/articles/draft", async ( 8 | [FromBody] UpdateDraftRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | await mediator.Send(command, cancellationToken); 15 | }).Validator() 16 | .WithTags(EndpointSchema.ArticleTag); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetPopularTags/GetPopularTagsQueryHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetPopularTags; 2 | 3 | public class GetPopularTagsQueryHandler(IArticleRepository articleRepository) 4 | : IRequestHandler> 5 | { 6 | private readonly IArticleRepository _articleRepository = articleRepository; 7 | 8 | public async Task> 9 | Handle(GetPopularTagsQuery request, CancellationToken cancellationToken) 10 | { 11 | var tags = await _articleRepository.GetPopularTagsAsync(request.Size, cancellationToken); 12 | return [.. tags.Select(x => new GetPopularTagsQueryResponse(x))]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetTaggedArticles/GetTaggedArticlesMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetTaggedArticles; 2 | 3 | public class GetTaggedArticlesMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.Tag, src => Tag.Create(src.Tag)); 9 | 10 | config.ForType() 11 | .Map(x => x.ArticleId, src => src.ArticleId.Slug) 12 | .Map(x => x.Tags, src => src.Tags.Select(x => x.Value) 13 | .ToImmutableArray()); 14 | } 15 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ReplyToCommet/ReplyToCommentMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ReplyToCommet; 2 | 3 | public class ReplyToCommentMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.CommentId, src => CommentId.Create(src.CommentId)) 9 | .Map(x => x.Client, src => Client.Create(src.body.FullName, src.body.Email)) 10 | .Map(x => x.Content, src => src.body.Content); 11 | 12 | config.ForType() 13 | .Map(x => x.ReplyId, src => src.ReplyId.ToString()); 14 | } 15 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/GetReplies/GetRepliesHandler.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.GetReplies; 4 | 5 | public class GetRepliesHandler(ICommentRepository commentRepository) 6 | : IRequestHandler> 7 | { 8 | private readonly ICommentRepository _commentRepository = commentRepository; 9 | 10 | public async Task> Handle(GetRepliesQuery request, CancellationToken cancellationToken) 11 | { 12 | var comments = await _commentRepository.GetApprovedRepliesAsync(request.CommentId, cancellationToken); 13 | return [.. comments.Select(x => (GetRepliesQueryResponse)x)]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetArticles/GetArticlesQueryHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetArticles; 2 | 3 | public class GetArticlesQueryHandler(IArticleRepository articleRepository) 4 | : IRequestHandler> 5 | { 6 | private readonly IArticleRepository _articleRepository = articleRepository; 7 | 8 | public async Task> Handle(GetArticlesQuery request, CancellationToken cancellationToken) 9 | { 10 | var articles = await _articleRepository.GetLatestArticlesAsync(request.PageNumber, request.PageSize, request.Title,cancellationToken); 11 | 12 | return [.. articles.Select(x => (GetArticlesQueryResponse)x)]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/PublishDraft/PublishDraftEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.PublishDraft; 2 | 3 | public class PublishDraftEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapPatch("/articles/{draft-id}/publish", async ( 8 | [AsParameters] PublishDraftRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | await mediator.Send(command, cancellationToken); 15 | }).Validator() 16 | .WithTags(EndpointSchema.ArticleTag); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetArticles/GetArticlesQueryResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetArticles; 2 | 3 | public record GetArticlesQueryResponse( 4 | ArticleId ArticleId, 5 | string Title, 6 | string Summary, 7 | DateTime? PublishedOnUtc, 8 | int ReadOnMinutes, 9 | IReadOnlyCollection Tags) 10 | { 11 | public static explicit operator GetArticlesQueryResponse(Article article) 12 | => new GetArticlesQueryResponse(article.Id, 13 | article.Title, 14 | article.Summary, 15 | article.PublishedOnUtc, 16 | article.GetReadOnInMinutes, 17 | article.Tags); 18 | } 19 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/MakeComment/MakeCommentRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.MakeComment; 2 | 3 | public class MakeCommentRequestValidator : AbstractValidator 4 | { 5 | public MakeCommentRequestValidator() 6 | { 7 | RuleFor(x => x.ArticleId) 8 | .NotEmpty() 9 | .NotNull(); 10 | 11 | RuleFor(x => x.Content) 12 | .MaximumLength(500) 13 | .NotEmpty() 14 | .NotNull(); 15 | 16 | RuleFor(x => x.FullName) 17 | .MaximumLength(100) 18 | .NotEmpty() 19 | .NotNull(); 20 | 21 | RuleFor(x => x.Email) 22 | .MaximumLength(1044) 23 | .NotEmpty() 24 | .NotNull(); 25 | } 26 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/GetComments/GetCommentsHandler.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.GetComments; 4 | 5 | public class GetCommentsHandler(ICommentRepository commentRepository) 6 | : IRequestHandler> 7 | { 8 | private readonly ICommentRepository _commentRepository = commentRepository; 9 | 10 | public async Task> Handle(GetCommentsQuery request, CancellationToken cancellationToken) 11 | { 12 | var comments = await _commentRepository.GetApprovedArticleCommentsAsync(request.ArticleId, cancellationToken); 13 | return [.. comments.Select(x => (GetCommentsQueryResponse)x)]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetTaggedArticles/GetTaggedArticlesQueryHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetTaggedArticles; 2 | 3 | public class GetTaggedArticlesQueryHandler(IArticleRepository articleRepository) 4 | : IRequestHandler> 5 | { 6 | private readonly IArticleRepository _articleRepository = articleRepository; 7 | 8 | public async Task> Handle(GetTaggedArticlesQuery request, CancellationToken cancellationToken) 9 | { 10 | var articles = await _articleRepository.GetLatestArticlesAsync(request.Tag, cancellationToken); 11 | 12 | return [.. articles.Select(x => (GetTaggedArticlesQueryResponse)x)]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArticles/GetArticlesEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArticles; 2 | 3 | public class GetArticlesEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapGet("/articles/", async ( 8 | [AsParameters] GetArticlesRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | var result = await mediator.Send(command, cancellationToken); 15 | 16 | return mapper.Map>(result); 17 | }).WithTags(EndpointSchema.ArticleTag); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetPopularTags/GetPopularTagsEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetPopularTags; 2 | 3 | public class GetPopularTagsEndpoint : IEndpoint 4 | { 5 | private const int SizeOfTopPopular = 7; 6 | 7 | public void MapEndpoint(IEndpointRouteBuilder app) 8 | { 9 | app.MapGet("/articles/tags/populars", async ( 10 | IMapper mapper, 11 | IMediator mediator, 12 | CancellationToken cancellationToken) => 13 | { 14 | var command = new GetPopularTagsQuery(SizeOfTopPopular); 15 | var response = await mediator.Send(command, cancellationToken); 16 | 17 | return mapper.Map>(response); 18 | }).WithTags(EndpointSchema.ArticleTag); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetPopularArticles/GetPopularArticlesHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetPopularArticles; 2 | 3 | public class GetPopularArticlesHandler(IArticleRepository articleRepository) 4 | : IRequestHandler> 5 | { 6 | private readonly IArticleRepository _articleRepository = articleRepository; 7 | 8 | public async Task> Handle(GetPopularArticlesQuery request, CancellationToken cancellationToken) 9 | { 10 | var articles = await _articleRepository.GetPopularArticlesAsync(request.Size, cancellationToken); 11 | 12 | return [.. articles.Select(x => new GetPopularArticlesQueryResponse(x.Id, x.Title))]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetTaggedArticles/GetTaggedArticlesQueryResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetTaggedArticles; 2 | 3 | public record GetTaggedArticlesQueryResponse( 4 | ArticleId ArticleId, 5 | string Title, 6 | string Summary, 7 | DateTime? PublishedOnUtc, 8 | int ReadOnMinutes, 9 | IReadOnlyCollection Tags) 10 | { 11 | public static explicit operator GetTaggedArticlesQueryResponse(Article article) 12 | => new GetTaggedArticlesQueryResponse(article.Id, 13 | article.Title, 14 | article.Summary, 15 | article.PublishedOnUtc, 16 | article.GetReadOnInMinutes, 17 | article.Tags); 18 | } 19 | -------------------------------------------------------------------------------- /src/Blogger.BuildingBlocks/Domain/Entity.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.BuildingBlocks.Domain; 2 | 3 | public abstract class Entity where TId : notnull 4 | { 5 | public TId Id { get; protected set; } 6 | 7 | protected Entity(TId id) 8 | { 9 | Id = id; 10 | } 11 | 12 | public override bool Equals(object? obj) 13 | => obj is not null && 14 | obj is Entity entity && 15 | obj.GetType() == GetType() && 16 | Id.Equals(entity.Id); 17 | 18 | public static bool operator ==(Entity left, Entity right) 19 | => left.Equals(right); 20 | 21 | public static bool operator !=(Entity left, Entity right) 22 | => !left.Equals(right); 23 | 24 | public override int GetHashCode() 25 | => HashCode.Combine(GetType(), Id); 26 | } -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/ICommentRepository.cs: -------------------------------------------------------------------------------- 1 | 2 | using Blogger.Domain.ArticleAggregate; 3 | 4 | namespace Blogger.Domain.CommentAggregate; 5 | public interface ICommentRepository 6 | { 7 | Task GetCommentByApproveLinkAsync(string link, CancellationToken cancellationToken); 8 | Task> GetApprovedArticleCommentsAsync(ArticleId articleId, CancellationToken cancellationToken); 9 | Task> GetApprovedRepliesAsync(CommentId commentId, CancellationToken cancellationToken); 10 | 11 | 12 | Task CreateAsync(Comment comment, CancellationToken cancellationToken); 13 | 14 | Task GetCommentByIdAsync(CommentId commentId, CancellationToken cancellationToken); 15 | Task SaveChangesAsync(CancellationToken cancellationToken); 16 | } 17 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ReplyToCommet/ReplyToCommentRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ReplyToCommet; 2 | 3 | public class ReplyToCommentRequestValidator : AbstractValidator 4 | { 5 | public ReplyToCommentRequestValidator() 6 | { 7 | RuleFor(x => x.CommentId) 8 | .NotEmpty() 9 | .NotNull(); 10 | 11 | RuleFor(x => x.body.Content) 12 | .MaximumLength(500) 13 | .NotEmpty() 14 | .NotNull(); 15 | 16 | RuleFor(x => x.body.FullName) 17 | .MaximumLength(100) 18 | .NotEmpty() 19 | .NotNull(); 20 | 21 | RuleFor(x => x.body.Email) 22 | .MaximumLength(1044) 23 | .NotEmpty() 24 | .NotNull(); 25 | } 26 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/MakeDraft/MakeDraftEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.MakeDraft; 2 | 3 | public class MakeCommentEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapPost("/articles/draft", async ( 8 | [FromBody] MakeDraftRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | var response = await mediator.Send(command, cancellationToken); 15 | 16 | return mapper.Map(response); 17 | }).Validator() 18 | .WithTags(EndpointSchema.ArticleTag); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArticles/GetArticlesMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArticles; 2 | 3 | public class GetArticlesMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.PageNumber, src => src.Page) 9 | .Map(x => x.PageSize, src => src.Size) 10 | .Map(x => x.Title, src => src.Title); 11 | 12 | config.ForType() 13 | .Map(x => x.ArticleId, src => src.ArticleId.ToString()) 14 | .Map(x => x.Tags, src => src.Tags.Select(x => x.Value) 15 | .ToImmutableArray()); 16 | } 17 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/MakeComment/MakeCommentEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.MakeComment; 2 | 3 | public class MakeCommentEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapPost("comments/", async ( 8 | [FromBody] MakeCommetRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | var response = await mediator.Send(command, cancellationToken); 15 | 16 | return mapper.Map(response); 17 | }).Validator() 18 | .WithTags(EndpointSchema.CommentTag); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/Like.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | 3 | namespace Blogger.Domain.ArticleAggregate; 4 | public class Like : ValueObject 5 | { 6 | public string ClientIP { get; init; } = null!; 7 | 8 | public DateTime LikedOn { get; set; } 9 | 10 | public override IEnumerable GetEqualityComponents() 11 | { 12 | yield return ClientIP; 13 | yield return LikedOn; 14 | } 15 | 16 | public static Like Create(string clientIp, DateTime likedOn) 17 | { 18 | if (!IPAddress.TryParse(clientIp, out IPAddress? ipAddress)) 19 | { 20 | throw new ArgumentOutOfRangeException(nameof(clientIp)); 21 | } 22 | 23 | return new Like 24 | { 25 | ClientIP = clientIp, 26 | LikedOn = likedOn 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Blogger.Domain/SubscriberAggregate/Subscriber.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.ArticleAggregate; 2 | using Blogger.Domain.CommentAggregate; 3 | 4 | namespace Blogger.Domain.SubscriberAggregate; 5 | 6 | public sealed class Subscriber : AggregateRoot 7 | { 8 | public DateTime JoinedOnUtc { get; init; } 9 | 10 | private readonly IList _articleIds; 11 | public IReadOnlyCollection ArticleIds => _articleIds.ToImmutableList(); 12 | 13 | public static Subscriber Create(SubscriberId subscriberId) 14 | => new Subscriber(subscriberId) 15 | { 16 | JoinedOnUtc = DateTime.UtcNow 17 | }; 18 | 19 | private Subscriber(SubscriberId id) : base(id) 20 | { 21 | _articleIds = []; 22 | } 23 | 24 | private Subscriber() : this(null!) { } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetPopularArticles/GetPopularArticlesEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetPopularArticles; 2 | 3 | public class GetPopularArticlesEndpoint : IEndpoint 4 | { 5 | private const int SizeOfTopPopular = 5; 6 | 7 | public void MapEndpoint(IEndpointRouteBuilder app) 8 | { 9 | app.MapGet("/articles/populars", async ( 10 | IMapper mapper, 11 | IMediator mediator, 12 | CancellationToken cancellationToken) => 13 | { 14 | var command = new GetPopularArticlesQuery(SizeOfTopPopular); 15 | var result = await mediator.Send(command, cancellationToken); 16 | 17 | return mapper.Map>(result); 18 | }).WithTags(EndpointSchema.ArticleTag); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetArticle/GetArticleQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Comments.MakeComment; 2 | 3 | namespace Blogger.Application.Articles.GetArticle; 4 | 5 | public class GetArticleQueryHandler( 6 | IArticleRepository articleRepository) : IRequestHandler 7 | { 8 | private readonly IArticleRepository _articleRepository = articleRepository; 9 | 10 | public async Task Handle(GetArticleQuery request, CancellationToken cancellationToken) 11 | { 12 | var article = await _articleRepository.GetArticleByIdAsync(request.ArticleId, cancellationToken); 13 | if (article is null) 14 | { 15 | throw new NotFoundArticleException(); 16 | } 17 | 18 | return (GetArticleQueryResponse)article; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetTaggedArticles/GetTaggedArticlesEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetTaggedArticles; 2 | 3 | public class GetTaggedArticlesEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapGet("/articles/tagged", async ( 8 | [AsParameters] GetTaggedArticlesRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | var result = await mediator.Send(command, cancellationToken); 15 | 16 | return mapper.Map>(result); 17 | }).WithTags(EndpointSchema.ArticleTag); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetComments/GetCommentsEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetComments; 2 | 3 | public class GetCommentsEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapGet("/comments/{article-id}", async ( 8 | [AsParameters] GetCommentsRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | var result = await mediator.Send(command, cancellationToken); 15 | 16 | return mapper.Map>(result); 17 | }).Validator() 18 | .WithTags(EndpointSchema.CommentTag); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/GetReplies/GetRepliesEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.GetReplies; 2 | 3 | public class GetRepliesEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapGet("/comments/replies/{comment-id}", async ( 8 | [AsParameters] GetRepliesRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | var result = await mediator.Send(command, cancellationToken); 15 | 16 | return mapper.Map>(result); 17 | }).Validator() 18 | .WithTags(EndpointSchema.ArticleTag); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.Application/DependencyInjection.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles; 2 | using Blogger.Application.Subscribers; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace Blogger.Application; 7 | public static class DependencyInjection 8 | { 9 | public static IServiceCollection ConfigureApplicationLayer(this IServiceCollection services, IConfiguration configuration) 10 | { 11 | services.AddTransient(); 12 | services.AddTransient(); 13 | 14 | var application = typeof(IAssemblyMarker); 15 | 16 | services.AddMediatR(configure => 17 | { 18 | configure.RegisterServicesFromAssembly(application.Assembly); 19 | }); 20 | 21 | 22 | return services; 23 | } 24 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/MakeDraft/MakeDraftRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.MakeDraft; 2 | 3 | public class MakeDraftRequestValidator : AbstractValidator 4 | { 5 | private const string TagMaximumLengthMessage = "The tags must contain at most 10 elements."; 6 | 7 | public MakeDraftRequestValidator() 8 | { 9 | RuleFor(x => x.Title) 10 | .MaximumLength(70) 11 | .NotEmpty() 12 | .NotNull(); 13 | 14 | RuleFor(x => x.Summary) 15 | .MaximumLength(300) 16 | .NotEmpty() 17 | .NotNull(); 18 | 19 | RuleFor(x => x.Body) 20 | .NotEmpty() 21 | .NotNull(); 22 | 23 | RuleFor(x => x.Tags) 24 | .Must(x => x is null || x.Length <= 10).WithMessage(TagMaximumLengthMessage); 25 | } 26 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/CreateArticle/CreateArticleEndPoint.cs: -------------------------------------------------------------------------------- 1 | using Blogger.APIs.Endpoints; 2 | 3 | namespace Blogger.APIs.Endpoints.Articles.CreateArticle; 4 | 5 | public class CreateArticleEndpoint : IEndpoint 6 | { 7 | public void MapEndpoint(IEndpointRouteBuilder app) 8 | { 9 | app.MapPost("/articles/", async ( 10 | [FromBody] CreateArticleRequest request, 11 | IMapper mapper, 12 | IMediator mediator, 13 | CancellationToken cancellationToken) => 14 | { 15 | var command = mapper.Map(request); 16 | var response = await mediator.Send(command, cancellationToken); 17 | 18 | return mapper.Map(response); 19 | }).Validator() 20 | .WithTags(EndpointSchema.ArticleTag); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ReplyToCommet/ReplyToCommentEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ReplyToCommet; 2 | 3 | public class ReplyToCommentEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapPost("comments/{comment-id}/reply", async ( 8 | [AsParameters] ReplyToCommentRequestModel request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | var response = await mediator.Send(command, cancellationToken); 15 | 16 | return mapper.Map(response); 17 | }).Validator() 18 | .WithTags(EndpointSchema.CommentTag); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ApproveComment/ApproveCommentEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ApproveComment; 2 | 3 | public class ApproveCommentEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapGet("/comments/approve", async ( 8 | [AsParameters] ApproveCommentRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | var result = await mediator.Send(command, cancellationToken); 15 | 16 | return Results.LocalRedirect($"/articles/{result.ArticleId}?comment-id={result.CommentId}"); 17 | }).Validator() 18 | .WithTags(EndpointSchema.CommentTag); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Blogger.APIs.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Linux 5 | ..\.. 6 | ba328c9e-6323-4c3e-9432-e3a32d9b4f97 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArticle/GetArticleEndpoint.cs: -------------------------------------------------------------------------------- 1 | using Blogger.APIs.Endpoints; 2 | using Blogger.Application.Articles.GetArticle; 3 | 4 | namespace Blogger.APIs.Endpoints.Articles.GetArticle; 5 | 6 | public class GetArticleEndpoint : IEndpoint 7 | { 8 | public void MapEndpoint(IEndpointRouteBuilder app) 9 | { 10 | app.MapGet("/articles/{article-id}", async ( 11 | [AsParameters] GetArticleRequest request, 12 | IMapper mapper, 13 | IMediator mediator, 14 | CancellationToken cancellationToken) => 15 | { 16 | var command = mapper.Map(request); 17 | var result = await mediator.Send(command, cancellationToken); 18 | 19 | return mapper.Map(result); 20 | }).Validator() 21 | .WithTags(EndpointSchema.ArticleTag); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Comments/ApproveReply/ApproveReplyEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Comments.ApproveReply; 2 | 3 | public class ApproveReplyEndpoint : IEndpoint 4 | { 5 | public void MapEndpoint(IEndpointRouteBuilder app) 6 | { 7 | app.MapGet("/comments/replies/approve", async ( 8 | [AsParameters] ApproveReplyRequest request, 9 | IMapper mapper, 10 | IMediator mediator, 11 | CancellationToken cancellationToken) => 12 | { 13 | var command = mapper.Map(request); 14 | var result = await mediator.Send(command, cancellationToken); 15 | 16 | return Results.LocalRedirect($"/articles/{result.ArticleId}?comment-id={result.CommentId}&reply-id={result.ReplyId}"); 17 | }).Validator() 18 | .WithTags(EndpointSchema.CommentTag); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.BuildingBlocks/Domain/ValueObject.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.BuildingBlocks.Domain; 2 | 3 | public abstract class ValueObject where T : ValueObject 4 | { 5 | public abstract IEnumerable GetEqualityComponents(); 6 | 7 | public override bool Equals(object? obj) 8 | => obj is not null && 9 | obj is T valueObject && 10 | obj.GetType() == GetType() && 11 | GetEqualityComponents().SequenceEqual(valueObject.GetEqualityComponents()); 12 | 13 | public static bool operator ==(ValueObject left, ValueObject right) 14 | => left.Equals(right); 15 | 16 | public static bool operator !=(ValueObject left, ValueObject right) 17 | => !left.Equals(right); 18 | 19 | public override int GetHashCode() 20 | => GetEqualityComponents() 21 | .Select(x => x?.GetHashCode() ?? 0) 22 | .Aggregate((x, y) => x ^ y); 23 | } 24 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/CreateArticle/CreateArticleRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.CreateArticle; 2 | 3 | public class CreateArticleRequestValidator : AbstractValidator 4 | { 5 | private const string TagMaximumLengthMessage = "The tags must contain at most 10 elements."; 6 | 7 | public CreateArticleRequestValidator() 8 | { 9 | RuleFor(x => x.Title) 10 | .MaximumLength(70) 11 | .NotEmpty() 12 | .NotNull(); 13 | 14 | RuleFor(x => x.Summary) 15 | .MaximumLength(300) 16 | .NotEmpty() 17 | .NotNull(); 18 | 19 | RuleFor(x => x.Body) 20 | .NotEmpty() 21 | .NotNull(); 22 | 23 | RuleFor(x => x.Tags) 24 | .NotEmpty() 25 | .NotNull() 26 | .Must(x => x.Length <= 10).WithMessage(TagMaximumLengthMessage); 27 | } 28 | } -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/Reply.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.CommentAggregate; 2 | 3 | public class Reply(ReplyId id) : Entity(id) 4 | { 5 | public Reply() : this(null!) 6 | { 7 | 8 | } 9 | 10 | public Client Client { get; init; } = null!; 11 | public ApproveLink ApproveLink { get; init; } = null!; 12 | public DateTime CreatedOnUtc { get; init; } 13 | 14 | public string Content { get; init; } = null!; 15 | 16 | public bool IsApproved { get; private set; } 17 | 18 | public static Reply Create(Client client, string content, ApproveLink approveLink) => 19 | new Reply(ReplyId.CreateUniqueId()) 20 | { 21 | Content = content, 22 | CreatedOnUtc = DateTime.UtcNow, 23 | Client = client, 24 | IsApproved = false, 25 | ApproveLink = approveLink 26 | }; 27 | 28 | public void Approve() => IsApproved = true; 29 | } 30 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetArchive/GetArchiveQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | 3 | namespace Blogger.Application.Articles.GetArchive; 4 | 5 | public class GetArchiveQueryHandler(IArticleRepository articleRepository) 6 | : IRequestHandler> 7 | { 8 | private readonly IArticleRepository _articleRepository = articleRepository; 9 | 10 | public async Task> Handle(GetArchiveQuery request, CancellationToken cancellationToken) 11 | { 12 | var archives = await _articleRepository.GetArchivesAsync(cancellationToken); 13 | 14 | var results = archives.Select(x => new GetArchiveQueryResponse( 15 | x.Year, x.Month, 16 | [.. x.Articles.Select(d => new ArticleArchiveResponse(d.ArticleId, d.Title, d.DayOfMonth))])) 17 | .ToList(); 18 | 19 | return [.. results]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/GlobalSuppressions.cs: -------------------------------------------------------------------------------- 1 | // This file is used by Code Analysis to maintain SuppressMessage 2 | // attributes that are applied to this project. 3 | // Project-level suppressions either have no target or are given 4 | // a specific target and scoped to a namespace, type, member, etc. 5 | 6 | using System.Diagnostics.CodeAnalysis; 7 | 8 | [assembly: SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments", Justification = "", Scope = "namespaceanddescendants", Target = "~N:Blogger.Infrastructure.Persistence.Migrations")] 9 | [assembly: SuppressMessage("Style", "IDE0161:Convert to file-scoped namespace", Justification = "", Scope = "namespace", Target = "~N:Blogger.Infrastructure.Persistence.Migrations")] 10 | [assembly: SuppressMessage("Style", "IDE0300:Simplify collection initialization", Justification = "", Scope = "namespace", Target = "~N:Blogger.Infrastructure.Persistence.Migrations)")] 11 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/GetArticle/GetArticleMappingProfile.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.GetArticle; 2 | 3 | public class GetArticleMappingProfile : IRegister 4 | { 5 | public void Register(TypeAdapterConfig config) 6 | { 7 | config.ForType() 8 | .Map(x => x.ArticleId, src => ArticleId.Create(src.ArticleId)); 9 | 10 | config.ForType() 11 | .Map(x => x.ArticleId, src => src.ArticleId.ToString()) 12 | .Map(x => x.AuthorFullName, src => src.Author.FullName) 13 | .Map(x => x.AuthorAvatar, src => src.Author.Avatar) 14 | .Map(x => x.AuthorJobTitle, src => src.Author.JobTitle) 15 | .Map(x => x.Tags, src => src.Tags.Select(x => x.Value) 16 | .ToImmutableArray()); 17 | } 18 | } -------------------------------------------------------------------------------- /src/Blogger.APIs/Dockerfile: -------------------------------------------------------------------------------- 1 | #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. 2 | 3 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base 4 | USER app 5 | WORKDIR /app 6 | EXPOSE 8080 7 | 8 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 9 | ARG BUILD_CONFIGURATION=Release 10 | WORKDIR /src 11 | COPY ["src/Blogger.APIs/Blogger.APIs.csproj", "src/Blogger.APIs/"] 12 | RUN dotnet restore "./src/Blogger.APIs/Blogger.APIs.csproj" 13 | COPY . . 14 | WORKDIR "/src/src/Blogger.APIs" 15 | RUN dotnet build "./Blogger.APIs.csproj" -c $BUILD_CONFIGURATION -o /app/build 16 | 17 | FROM build AS publish 18 | ARG BUILD_CONFIGURATION=Release 19 | RUN dotnet publish "./Blogger.APIs.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false 20 | 21 | FROM base AS final 22 | WORKDIR /app 23 | COPY --from=publish /app/publish . 24 | ENTRYPOINT ["dotnet", "Blogger.APIs.dll"] -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/GetArticle/GetArticleQueryResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.GetArticle; 2 | 3 | public record GetArticleQueryResponse( 4 | ArticleId ArticleId, 5 | string Title, 6 | string Body, 7 | string Summary, 8 | int ReadOnMinutes, 9 | Author Author, 10 | DateTime? PublishedOnUtc, 11 | IReadOnlyCollection Tags) 12 | { 13 | 14 | public static explicit operator GetArticleQueryResponse(Article article) 15 | => new GetArticleQueryResponse(article.Id, 16 | article.Title, 17 | article.Body, 18 | article.Summary, 19 | article.GetReadOnInMinutes, 20 | article.Author, 21 | article.PublishedOnUtc, 22 | article.Tags); 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/ApproveLink.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.CommentAggregate; 2 | 3 | public class ApproveLink : ValueObject 4 | { 5 | public string ApproveId { get; set; } = null!; 6 | 7 | public DateTime ExpirationOnUtc { get; set; } 8 | 9 | public override IEnumerable GetEqualityComponents() 10 | { 11 | yield return ApproveId; 12 | yield return ExpirationOnUtc; 13 | } 14 | 15 | private ApproveLink(string approvedId, DateTime expairedOn) 16 | { 17 | ApproveId = approvedId; 18 | ExpirationOnUtc = expairedOn; 19 | } 20 | 21 | private ApproveLink() 22 | { 23 | 24 | } 25 | 26 | public static ApproveLink Create(string approvedId, DateTime expairedOn) => 27 | new ApproveLink(approvedId, expairedOn); 28 | 29 | public override string ToString() 30 | { 31 | return $"https://thisisnabi.dev/comments/approve?link={ApproveId}"; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/CreateArticle/CreateArticleCommandHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.CreateArticle; 2 | 3 | public class CreateArticleCommandHandler(IArticleRepository articleRepository) : IRequestHandler 4 | { 5 | public async Task Handle(CreateArticleCommand request, CancellationToken cancellationToken) 6 | { 7 | var articleId = ArticleId.CreateUniqueId(request.Title); 8 | if (await articleRepository.HasIdAsync(articleId, cancellationToken)) 9 | { 10 | throw new ArticleAlreadyExistsException(articleId.ToString()); 11 | } 12 | 13 | var article = Article.CreateArticle(request.Title, request.Body, request.Summary, request.Tags); 14 | 15 | articleRepository.Add(article); 16 | await articleRepository.SaveChangesAsync(cancellationToken); 17 | 18 | return new CreateArticleCommandResponse(article.Id); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ApproveComment/ApproveCommentCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.ApproveComment; 4 | public class ApproveCommentCommandHandler(ICommentRepository commentRepository) : IRequestHandler 5 | { 6 | private readonly ICommentRepository _commentRepository = commentRepository; 7 | 8 | public async Task Handle(ApproveCommentCommand request, CancellationToken cancellationToken) 9 | { 10 | var comment = await _commentRepository.GetCommentByApproveLinkAsync(request.Link, cancellationToken); 11 | if (comment is null) 12 | { 13 | throw new InvalidCommentApprovalLinkException(); 14 | } 15 | 16 | comment.Approve(); 17 | await _commentRepository.SaveChangesAsync(cancellationToken); 18 | 19 | return new ApproveCommentCommandResponse(comment.ArticleId, comment.Id); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Blogger.Application/Subscribers/Subscribe/SubscribeCommandHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Subscribers.Subscribe; 2 | public class SubscribeCommandHandler(ISubscriberRepository subscriberRepository, 3 | ISubscriberService subscriberService) : IRequestHandler 4 | { 5 | private readonly ISubscriberRepository _subscriberRepository = subscriberRepository; 6 | private readonly ISubscriberService _subscriberService = subscriberService; 7 | 8 | public async Task Handle(SubscribeCommand request, CancellationToken cancellationToken) 9 | { 10 | 11 | if (await _subscriberService.IsDuplicated(request.SubscriberId, cancellationToken)) 12 | { 13 | throw new DuplicateSubscribtionException(); 14 | } 15 | 16 | var subscriber = Subscriber.Create(request.SubscriberId); 17 | await _subscriberRepository.CreateAsync(subscriber, cancellationToken); 18 | 19 | await _subscriberRepository.SavaChangesAsync(cancellationToken); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Persistence/BloggerDbContext.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.ArticleAggregate; 2 | using Blogger.Domain.CommentAggregate; 3 | using Blogger.Domain.SubscriberAggregate; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace Blogger.Infrastructure.Persistence; 7 | public class BloggerDbContext : DbContext 8 | { 9 | public BloggerDbContext(DbContextOptions dbContextOptions) 10 | : base(dbContextOptions) 11 | { 12 | 13 | } 14 | 15 | public DbSet
Articles => Set
(); 16 | public DbSet Comments => Set(); 17 | public DbSet Subscribers => Set(); 18 | 19 | protected override void OnModelCreating(ModelBuilder modelBuilder) 20 | { 21 | modelBuilder.HasDefaultSchema(BloggerDbContextSchema.DefaultSchema); 22 | 23 | var infrastructureAssembly = typeof(IAssemblyMarker).Assembly; 24 | modelBuilder.ApplyConfigurationsFromAssembly(infrastructureAssembly); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = WebApplication.CreateBuilder(args); 2 | 3 | builder.Configuration.AddEnvironmentVariables(); 4 | 5 | builder.Services.ConfigureApplicationLayer(builder.Configuration); 6 | builder.Services.ConfigureInfrastructureLayer(builder.Configuration); 7 | builder.Services.ConfigureMapster(); 8 | builder.Services.ConfigureValidator(); 9 | builder.Services.ConfigureCors(); 10 | 11 | builder.Services.AddEndpoints(); 12 | 13 | builder.Services.AddEndpointsApiExplorer(); 14 | builder.Services.AddSwaggerGen(); 15 | 16 | builder.Services.AddExceptionHandler(); 17 | builder.Services.AddProblemDetails(); 18 | 19 | var app = builder.Build(); 20 | 21 | // TODO: if (app.Environment.IsDevelopment()) 22 | app.UseExceptionHandler(); 23 | app.UseCors("AllowOrigin"); 24 | 25 | app.UseSwagger(); 26 | app.UseSwaggerUI(); 27 | 28 | app.MapEndpoints(); 29 | 30 | app.Run(); 31 | 32 | 33 | // Make the implicit Program class public so test projects can access it 34 | public partial class Program() { } -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ApproveReply/ApproveReplyCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.CommentAggregate; 2 | 3 | namespace Blogger.Application.Comments.ApproveReply; 4 | public class ApproveReplyCommandHandler(ICommentRepository commentRepository) : IRequestHandler 5 | { 6 | private readonly ICommentRepository _commentRepository = commentRepository; 7 | 8 | public async Task Handle(ApproveReplyCommand request, CancellationToken cancellationToken) 9 | { 10 | var comment = await _commentRepository.GetCommentByIdAsync(request.CommentId, cancellationToken); 11 | if (comment is null) 12 | { 13 | throw new CommentNotFoundException(); 14 | } 15 | 16 | var ReplyId = comment.ApproveReply(request.Link); 17 | await _commentRepository.SaveChangesAsync(cancellationToken); 18 | 19 | return new ApproveReplyCommandResponse(comment.ArticleId, comment.Id, ReplyId); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/DependencyInjection.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.ApplicationServices; 2 | 3 | namespace Blogger.Infrastructure; 4 | public static class DependencyInjection 5 | { 6 | public static IServiceCollection ConfigureInfrastructureLayer(this IServiceCollection services, IConfiguration configuration) 7 | { 8 | services.Configure(configuration.GetSection(nameof(EmailSettings))); 9 | 10 | services.AddDbContext(options => 11 | { 12 | options.UseSqlServer(configuration.GetConnectionString(BloggerDbContextSchema.DefaultConnectionStringName)); 13 | }); 14 | 15 | services.AddTransient(); 16 | services.AddTransient(); 17 | services.AddTransient(); 18 | services.AddSingleton(); 19 | services.AddSingleton(); 20 | 21 | return services; 22 | } 23 | } -------------------------------------------------------------------------------- /tests/Blogger.UnitTests/Domain/SubscriberAggregateTests/SubscriberIdTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Exceptions; 2 | using Blogger.Domain.SubscriberAggregate; 3 | 4 | namespace Blogger.UnitTests.Domain.SubscriberAggregateTests; 5 | public class SubscriberIdTests 6 | { 7 | [Fact] 8 | public void CreateUniqueId_ShouldThrowInvalidEmailAddressException_WhenHaveIncorrectEmail() 9 | { 10 | // act 11 | var func = () => SubscriberId.CreateUniqueId("invalidData"); 12 | 13 | // assert 14 | func.Should().Throw(); 15 | } 16 | 17 | [Theory] 18 | [InlineData("thisisnabi.dev@gmail.com")] 19 | [InlineData("thisisnabi@outlook.com")] 20 | public void CreateUniqueId_ShouldReturnSubscriberId_WhenHaveCorrectEmail(string emailAddress) 21 | { 22 | // arrange 23 | var subId = SubscriberId.CreateUniqueId(emailAddress); 24 | 25 | // assert 26 | subId.Should().NotBeNull(); 27 | subId.Email.Address.Should().Be(emailAddress); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Blogger.Infrastructure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | all 7 | runtime; build; native; contentfiles; analyzers; buildtransitive 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/MakeDraft/MakingDraftCommandHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Application.Articles.MakeDraft; 2 | 3 | public class MakeDraftCommandHandler(IArticleRepository articleRepository) 4 | : IRequestHandler 5 | { 6 | 7 | public async Task Handle(MakeDraftCommand request, CancellationToken cancellationToken) 8 | { 9 | var draftId = ArticleId.CreateUniqueId(request.Title); 10 | if (await articleRepository.HasIdAsync(draftId, cancellationToken)) 11 | { 12 | throw new DraftAlreadyExistsException(draftId.ToString()); 13 | } 14 | 15 | var draft = Article.CreateDraft(request.Title, request.Body, request.Summary); 16 | 17 | if (request.Tags.Any()) 18 | { 19 | draft.AddTags(request.Tags); 20 | } 21 | 22 | articleRepository.Add(draft); 23 | await articleRepository.SaveChangesAsync(cancellationToken); 24 | 25 | return new MakeDraftCommandResponse(draft.Id); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Persistence/Repositories/SubscriberRepository.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Infrastructure.Persistence.Repositories; 2 | public class SubscriberRepository(BloggerDbContext bloggerDbContext) : ISubscriberRepository 3 | { 4 | 5 | public async Task CreateAsync(Subscriber subscriber, CancellationToken cancellationToken) 6 | { 7 | await bloggerDbContext.Subscribers.AddAsync(subscriber, cancellationToken); 8 | } 9 | 10 | public async Task FindByIdAsync(SubscriberId subscriberId) 11 | { 12 | var subscriber = await bloggerDbContext.Subscribers.FindAsync(subscriberId); 13 | return subscriber; 14 | } 15 | public Task IsExistsAsync(SubscriberId subscriberId, CancellationToken cancellationToken) 16 | { 17 | return bloggerDbContext.Subscribers.AnyAsync(s => s.Id.Equals(subscriberId), cancellationToken); 18 | } 19 | public async Task SavaChangesAsync(CancellationToken cancellationToken) 20 | { 21 | await bloggerDbContext.SaveChangesAsync(cancellationToken); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Endpoints/Articles/UpdateDraft/UpdateDraftRequestValidator.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Endpoints.Articles.UpdateDraft; 2 | 3 | public class UpdateDraftRequestValidator : AbstractValidator 4 | { 5 | private const string TagMaximumLengthMessage = "The tags must contain at most 10 elements."; 6 | 7 | public UpdateDraftRequestValidator() 8 | { 9 | RuleFor(x => x.DraftId) 10 | .NotEmpty() 11 | .NotNull(); 12 | 13 | RuleFor(x => x.Title) 14 | .MaximumLength(70) 15 | .NotEmpty() 16 | .NotNull(); 17 | 18 | RuleFor(x => x.Title) 19 | .MaximumLength(70) 20 | .NotEmpty() 21 | .NotNull(); 22 | 23 | RuleFor(x => x.Summary) 24 | .MaximumLength(300) 25 | .NotEmpty() 26 | .NotNull(); 27 | 28 | RuleFor(x => x.Body) 29 | .NotEmpty() 30 | .NotNull(); 31 | 32 | RuleFor(x => x.Tags) 33 | .Must(x => x is null || x.Length <= 10).WithMessage(TagMaximumLengthMessage); 34 | } 35 | } -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Fixtures/EfDatabaseBaseFixture.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace Blogger.IntegrationTests.Fixtures; 4 | public abstract class EfDatabaseBaseFixture 5 | : IDisposable where TDbContext : DbContext 6 | { 7 | public TDbContext BuildDbContext(string dbName) 8 | { 9 | try 10 | { 11 | var _options = new DbContextOptionsBuilder() 12 | .UseInMemoryDatabase(dbName) 13 | .EnableSensitiveDataLogging() 14 | .Options; 15 | 16 | var db = BuildDbContext(_options); 17 | db.Database.EnsureCreated(); 18 | 19 | return BuildDbContext(_options); 20 | } 21 | catch (Exception ex) 22 | { 23 | throw new Exception($"unable to connect to db.", ex); 24 | } 25 | } 26 | 27 | protected abstract TDbContext BuildDbContext(DbContextOptions options); 28 | 29 | public void Dispose() 30 | { 31 | GC.SuppressFinalize(this); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nabi Karampour 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/thisisnabi.dev.apis.yml: -------------------------------------------------------------------------------- 1 | name: thisisnabi.dev.apis 2 | 3 | env: 4 | DOTNET_VERSION: '8' 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: 🚚 Get latest code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up .NET 19 | uses: actions/setup-dotnet@v4 20 | with: 21 | dotnet-version: ${{ env.DOTNET_VERSION }} 22 | 23 | - name: Restore Nuget packages 24 | run: dotnet restore 25 | 26 | - name: Build 27 | run: dotnet build --no-restore 28 | 29 | - name: Test 30 | run: dotnet test --no-build --verbosity normal 31 | 32 | deploy: 33 | runs-on: ubuntu-latest 34 | needs: build 35 | steps: 36 | - name: 🚚 Get latest code 37 | uses: actions/checkout@v3 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: 20 41 | - name: update-liara 42 | env: 43 | LIARA_TOKEN: ${{ secrets.LIARA_API_TOKEN }} 44 | run: | 45 | npm i -g @liara/cli@5 46 | liara deploy --api-token="$LIARA_TOKEN" --no-app-logs 47 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "http": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "launchUrl": "swagger", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "dotnetRunMessages": true, 11 | "applicationUrl": "http://localhost:5138" 12 | }, 13 | "IIS Express": { 14 | "commandName": "IISExpress", 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "Container (Dockerfile)": { 22 | "commandName": "Docker", 23 | "launchBrowser": true, 24 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", 25 | "environmentVariables": { 26 | "ASPNETCORE_HTTP_PORTS": "8080" 27 | }, 28 | "publishAllPorts": true 29 | } 30 | }, 31 | "$schema": "http://json.schemastore.org/launchsettings.json", 32 | "iisSettings": { 33 | "windowsAuthentication": false, 34 | "anonymousAuthentication": true, 35 | "iisExpress": { 36 | "applicationUrl": "http://localhost:26242", 37 | "sslPort": 0 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Services/Externals/EmailService.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.ApplicationServices; 2 | using Microsoft.Extensions.Options; 3 | using System.Net; 4 | using System.Net.Mail; 5 | 6 | namespace Blogger.Infrastructure.Services.Externals; 7 | 8 | public class EmailService(IOptions options) : IEmailService 9 | { 10 | private readonly EmailSettings _emailSettings = options.Value; 11 | 12 | public async Task SendAsync(string email, string subject, string content, CancellationToken cancellationToken) 13 | { 14 | using var smtpClient = new SmtpClient(_emailSettings.SmtpHost, _emailSettings.SmtpPort) 15 | { 16 | EnableSsl = true, 17 | Credentials = new NetworkCredential(_emailSettings.UserName, _emailSettings.Password) 18 | }; 19 | 20 | var mailMessage = new MailMessage 21 | { 22 | SubjectEncoding = System.Text.Encoding.UTF8, 23 | BodyEncoding = System.Text.Encoding.UTF8, 24 | IsBodyHtml = true, 25 | From = new(_emailSettings.From), 26 | Subject = subject, 27 | Body = content 28 | }; 29 | 30 | mailMessage.To.Add(email); 31 | 32 | await smtpClient.SendMailAsync(mailMessage, cancellationToken); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Blogger.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/Author.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Domain.ArticleAggregate; 2 | public class Author : ValueObject 3 | { 4 | public string FullName { get; init; } 5 | 6 | public string Avatar { get; init; } 7 | 8 | public string JobTitle { get; init; } 9 | 10 | private Author(string fullName, string avatar, string jobTitle) 11 | { 12 | FullName = fullName; 13 | Avatar = avatar; 14 | JobTitle = jobTitle; 15 | } 16 | 17 | public override IEnumerable GetEqualityComponents() 18 | { 19 | yield return FullName; 20 | yield return Avatar; 21 | yield return JobTitle; 22 | } 23 | 24 | public static Author CreateDefaultAuthor() => 25 | Create("Nabi Karampour", 26 | "https://avatars.githubusercontent.com/u/3371886?s=400&u=cb8ebf9fc27e463b5d7002aaeeef881eb950b71f&v=4", 27 | "Senior Software Engineer"); 28 | 29 | public static Author Create(string fullName, string avatar, string jobTitle) 30 | { 31 | ArgumentException.ThrowIfNullOrWhiteSpace(fullName); 32 | ArgumentException.ThrowIfNullOrWhiteSpace(avatar); 33 | ArgumentException.ThrowIfNullOrWhiteSpace(jobTitle); 34 | 35 | return new Author(fullName, avatar, jobTitle); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Blogger.APIs/Filters/EndpointValidatorFilter.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.Filters; 2 | 3 | public class EndpointValidatorFilter : IEndpointFilter 4 | { 5 | private readonly IValidator _validator; 6 | public EndpointValidatorFilter(IValidator validator) 7 | { 8 | _validator = validator; 9 | } 10 | 11 | public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) 12 | { 13 | T? inputData = context.GetArgument(0); 14 | 15 | if (inputData is not null) 16 | { 17 | var validationResult = await _validator.ValidateAsync(inputData); 18 | if (!validationResult.IsValid) 19 | { 20 | return Results.ValidationProblem(validationResult.ToDictionary(), 21 | statusCode: (int)HttpStatusCode.UnprocessableEntity); 22 | } 23 | } 24 | 25 | return await next.Invoke(context); 26 | } 27 | } 28 | 29 | public static class ValidatorExtensions 30 | { 31 | public static RouteHandlerBuilder Validator(this RouteHandlerBuilder handlerBuilder) 32 | where T : class 33 | { 34 | handlerBuilder.AddEndpointFilter>(); 35 | return handlerBuilder; 36 | } 37 | } -------------------------------------------------------------------------------- /src/Blogger.Domain/ArticleAggregate/IArticleRepository.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.ArticleAggregate.Models; 2 | 3 | namespace Blogger.Domain.ArticleAggregate; 4 | 5 | public interface IArticleRepository 6 | { 7 | Task HasIdAsync(ArticleId articleId, CancellationToken cancellationToken); 8 | 9 | void Add(Article article); 10 | 11 | Task> GetArchivesAsync(CancellationToken cancellationToken); 12 | 13 | Task GetArticleByIdAsync(ArticleId articleId, CancellationToken cancellationToken); 14 | 15 | Task> GetLatestArticlesAsync(int pageNumber, int pageSize,string title, CancellationToken cancellationToken); 16 | 17 | Task> GetPopularArticlesAsync(int size, CancellationToken cancellationToken); 18 | 19 | Task> GetPopularTagsAsync(int size,CancellationToken cancellationToken); 20 | 21 | Task> GetLatestArticlesAsync(Tag tag, CancellationToken cancellationToken); 22 | 23 | Task> GetTagsAsync(CancellationToken cancellationToken); 24 | 25 | Task SaveChangesAsync(CancellationToken cancellationToken); 26 | void Delete(Article draft); 27 | Task GetDraftByIdAsync(ArticleId draftId, CancellationToken cancellationToken); 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Persistence/Configurations/SubscriberConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.SubscriberAggregate; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace Blogger.Infrastructure.Persistence.Configurations; 6 | internal class SubscriberConfiguration : IEntityTypeConfiguration 7 | { 8 | public void Configure(EntityTypeBuilder builder) 9 | { 10 | builder.ToTable(BloggerDbContextSchema.SubscriberDbSchema.TableName); 11 | 12 | builder.HasKey(x => x.Id); 13 | 14 | builder.Property(x => x.Id) 15 | .ValueGeneratedNever() 16 | .HasConversion( 17 | id => id.Email.Address, 18 | value => SubscriberId.Create(value)); 19 | 20 | builder.Property(x => x.JoinedOnUtc) 21 | .IsRequired(); 22 | 23 | builder.OwnsMany(x => x.ArticleIds, sb => 24 | { 25 | sb.ToTable(BloggerDbContextSchema.SubscriberDbSchema.ArticleIdTableName); 26 | 27 | sb.Property(x => x.Slug) 28 | .HasColumnName(BloggerDbContextSchema.ArticleDbSchema.ForeignKey); 29 | 30 | }).UsePropertyAccessMode(PropertyAccessMode.Field); 31 | 32 | builder.Navigation(x => x.ArticleIds) 33 | .Metadata.SetField(BloggerDbContextSchema.SubscriberDbSchema.ArticleIdBackendField); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Blogger.UnitTests/Blogger.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | true 6 | 7 | 8 | 9 | 10 | all 11 | runtime; build; native; contentfiles; analyzers; buildtransitive 12 | 13 | 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/ReplyToComment/ReplyToCommentCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.ApplicationServices; 2 | using Blogger.Domain.CommentAggregate; 3 | 4 | namespace Blogger.Application.Comments.ReplyToComment; 5 | 6 | public class ReplyToCommentCommandHandler( 7 | ICommentRepository commentRepository, 8 | IEmailService emailService, 9 | ILinkGenerator linkGenerator) : IRequestHandler 10 | { 11 | public async Task Handle(ReplyToCommentCommand request, CancellationToken cancellationToken) 12 | { 13 | var comment = await commentRepository.GetCommentByIdAsync(request.CommentId, cancellationToken); 14 | if (comment is null) throw new NotFoundCommentException(); 15 | 16 | var link = linkGenerator.Generate(); 17 | var approveLink = ApproveLink.Create(link, DateTime.UtcNow.AddHours(ApplicationSettings.ApproveLink.ExpirationOnHours)); 18 | 19 | var Reply = comment.ReplyComment(request.Client, request.Content, approveLink); 20 | await commentRepository.SaveChangesAsync(cancellationToken); 21 | 22 | var content = EmailTemplates.GetConfirmEngagementEmail(request.Client.FullName, approveLink.ToString()); 23 | await emailService.SendAsync(request.Client.Email, 24 | ApplicationSettings.ApproveLink.ConfirmEmailSubject, 25 | content, 26 | cancellationToken); 27 | 28 | return new ReplyToCommentCommandResponse(Reply.Id); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Blogger.FunctionalTests/Blogger.FunctionalTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | true 6 | 7 | 8 | 9 | 10 | all 11 | runtime; build; native; contentfiles; analyzers; buildtransitive 12 | 13 | 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Blogger.Application/Articles/UpdateDraft/UpdateDraftCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.ArticleAggregate; 2 | 3 | namespace Blogger.Application.Articles.UpdateDraft; 4 | 5 | public class UpdateDraftCommandHandler(IArticleRepository articleRepository) : IRequestHandler 6 | { 7 | public async Task Handle(UpdateDraftCommand request, CancellationToken cancellationToken) 8 | { 9 | var draft = await articleRepository.GetDraftByIdAsync(request.DraftId, cancellationToken); 10 | if (draft is null) 11 | { 12 | throw new DraftNotFoundException(); 13 | } 14 | 15 | var newDraftId = ArticleId.CreateUniqueId(request.Title); 16 | if (!draft.Id.Equals(newDraftId) && 17 | await articleRepository.HasIdAsync(newDraftId, cancellationToken)) 18 | { 19 | throw new DraftTitleDuplicatedException(newDraftId.ToString()); 20 | } 21 | 22 | if (draft.Id.Equals(newDraftId)) 23 | { 24 | draft.UpdateDraft(request.Title, request.Summary, request.Body); 25 | draft.UpdateTags(request.Tags); 26 | } 27 | else 28 | { 29 | articleRepository.Delete(draft); 30 | 31 | var newDraft = Article.CreateDraft(request.Title, request.Body, request.Summary); 32 | if (request.Tags.Any()) 33 | { 34 | newDraft.AddTags(request.Tags); 35 | } 36 | 37 | articleRepository.Add(newDraft); 38 | } 39 | 40 | await articleRepository.SaveChangesAsync(cancellationToken); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Blogger.APIs/ErrorHandling/GlobalExceptionHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs.ErrorHandling; 2 | 3 | public sealed class GlobalExceptionHandler : IExceptionHandler 4 | { 5 | private readonly ILogger _logger; 6 | 7 | public GlobalExceptionHandler(ILogger logger) 8 | { 9 | _logger = logger; 10 | } 11 | 12 | public async ValueTask TryHandleAsync( 13 | HttpContext httpContext, 14 | Exception exception, 15 | CancellationToken cancellationToken) 16 | { 17 | _logger.LogError( 18 | exception, "Exception occurred: {Message}", exception.Message); 19 | 20 | ProblemDetails problemDetails = CreateProblemDetailFromException(exception); 21 | 22 | httpContext.Response.StatusCode = problemDetails.Status!.Value; 23 | 24 | await httpContext.Response 25 | .WriteAsJsonAsync(problemDetails, cancellationToken); 26 | 27 | return true; 28 | } 29 | 30 | private static ProblemDetails CreateProblemDetailFromException(Exception exception) 31 | { 32 | return exception is DomainException 33 | ? new ProblemDetails 34 | { 35 | Status = StatusCodes.Status400BadRequest, 36 | Title = "Bad Request", 37 | Detail = exception.Message 38 | } 39 | : new ProblemDetails 40 | { 41 | Status = StatusCodes.Status500InternalServerError, 42 | Title = "Server error", 43 | Detail = "Server error" 44 | }; 45 | } 46 | } -------------------------------------------------------------------------------- /tests/Blogger.UnitTests/Domain/ArticleAggregateTests/TagTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.ArticleAggregate; 2 | 3 | namespace Blogger.UnitTests.Domain.ArticleAggregateTests; 4 | 5 | public class TagTests 6 | { 7 | [Fact] 8 | public void Create_ShouldReturnTagWithKebabCaseValue() 9 | { 10 | // Arrange 11 | var tagValue = "Test Value"; 12 | 13 | // Act 14 | var tag = Tag.Create(tagValue); 15 | 16 | // Assert 17 | tag.Value.Should().Be("test-value"); 18 | } 19 | 20 | [Fact] 21 | public void ToString_ShouldReturnValue() 22 | { 23 | // Arrange 24 | var tagValue = "Test Value"; 25 | var tag = Tag.Create(tagValue); 26 | 27 | // Act 28 | var result = tag.ToString(); 29 | 30 | // Assert 31 | result.Should().Be("test-value"); 32 | } 33 | 34 | [Fact] 35 | public void TagsWithSameValue_ShouldBeEqual() 36 | { 37 | // Arrange 38 | var tagValue1 = "Test Value"; 39 | var tagValue2 = "Test Value"; 40 | 41 | // Act 42 | var tag1 = Tag.Create(tagValue1); 43 | var tag2 = Tag.Create(tagValue2); 44 | 45 | // Assert 46 | tag1.Should().Be(tag2); 47 | } 48 | 49 | [Fact] 50 | public void TagsWithDifferentValues_ShouldNotBeEqual() 51 | { 52 | // Arrange 53 | var tagValue1 = "Test Value 1"; 54 | var tagValue2 = "Test Value 2"; 55 | 56 | // Act 57 | var tag1 = Tag.Create(tagValue1); 58 | var tag2 = Tag.Create(tagValue2); 59 | 60 | // Assert 61 | tag1.Should().NotBe(tag2); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASP.NET Core Blog Engine 2 | This document serves as a guide for building a blog application using Clean Architecture and Domain-Driven Design (DDD) software design approach In ASP.NET Core 8. 3 | 4 | > [!TIP] 5 | > Here you can see UI for this api project, Blogger.UI 6 | 7 | ## Give a Star! ⭐ 8 | If you find this `Blogger` valuable and believe in the importance of CQRS, Clean Architecture, Domain-Driven Design, consider showing your support by giving this repository a star! 9 | 10 | ## Getting Started 11 | 12 | This repository provides various resources to get you started with building your blog application: 13 | 14 | > Coming soon 15 | ### Index 16 | - [ ] Problem domain: 17 | - [ ] Ubiquitous Language 18 | - [ ] Domain objects 19 | - [x] Data Model 20 | - [ ] Aggregates 21 | - [ ] Repository 22 | - [ ] Factory 23 | - [ ] Strongly-typed IDs 24 | - [ ] Domain Events 25 | - [ ] Clean Architecture 26 | - [ ] Setting up our domain objects 27 | - [ ] Setting up usescases 28 | - [ ] Persist data by using EF Core 29 | - [ ] Expose our features 30 | 31 | ### Tests 32 | 33 | - [x] Unit Tests (44 Passed) 34 | - [x] Integration Tests (38 Coming) 35 | - [ ] Functional Tests (18 Coming) 36 | 37 | #### Data Model 38 | ![image](https://github.com/thisisnabi/Blogger/assets/3371886/58468347-ea03-412f-b493-91572cda02ee) 39 | 40 | 41 | 42 | ## Contributing 43 | 44 | We welcome contributions to this project! Feel free to open pull requests with improvements, bug fixes, or additional features. 45 | 46 | ## License 47 | 48 | This project is licensed under the MIT License: [MIT License](https://opensource.org/licenses/MIT). 49 | 50 | ## Stay Connected 51 | Feel free to raise any questions or suggestions through GitHub issues. 52 | -------------------------------------------------------------------------------- /src/Blogger.APIs/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | // built-in 2 | global using System.Net; 3 | global using Microsoft.AspNetCore.Mvc; 4 | global using System.Reflection; 5 | global using System.Collections.Immutable; 6 | 7 | // third-party 8 | global using Mapster; 9 | global using MapsterMapper; 10 | global using MediatR; 11 | global using FluentValidation; 12 | 13 | // solution 14 | global using Blogger.APIs; 15 | global using Blogger.Application; 16 | global using Blogger.APIs.Filters; 17 | global using Blogger.Infrastructure; 18 | global using Blogger.APIs.Abstractions; 19 | global using Blogger.APIs.ErrorHandling; 20 | global using Blogger.Application.Articles.CreateArticle; 21 | global using Blogger.Application.Articles.GetArchive; 22 | global using Blogger.Application.Articles.GetArticle; 23 | global using Blogger.Application.Articles.GetArticles; 24 | global using Blogger.Application.Articles.GetPopularArticles; 25 | global using Blogger.Application.Articles.GetPopularTags; 26 | global using Blogger.Application.Articles.GetTaggedArticles; 27 | global using Blogger.Application.Articles.GetTags; 28 | global using Blogger.Application.Articles.MakeDraft; 29 | global using Blogger.Application.Articles.PublishDraft; 30 | global using Blogger.Application.Articles.UpdateDraft; 31 | global using Blogger.Application.Comments.ApproveComment; 32 | global using Blogger.Application.Comments.ApproveReply; 33 | global using Blogger.Application.Comments.GetComments; 34 | global using Blogger.Application.Comments.GetReplies; 35 | global using Blogger.Application.Comments.MakeComment; 36 | global using Blogger.Application.Comments.ReplyToComment; 37 | global using Blogger.Application.Subscribers.Subscribe; 38 | global using Blogger.BuildingBlocks.Domain; 39 | global using Blogger.Domain.ArticleAggregate; 40 | global using Blogger.Domain.CommentAggregate; 41 | 42 | global using Microsoft.AspNetCore.Diagnostics; 43 | global using Microsoft.Extensions.DependencyInjection.Extensions; 44 | 45 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Persistence/BloggerDbContext.Schema.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Infrastructure.Persistence; 2 | public static class BloggerDbContextSchema 3 | { 4 | public const string DefaultSchema = "blog"; 5 | public const string DefaultConnectionStringName = "SvcDbContext"; 6 | 7 | public static class SubscriberDbSchema 8 | { 9 | public const string TableName = "Subscribers"; 10 | public const string ArticleIdTableName = "SubscriberArticleIds"; 11 | public const string ArticleIdBackendField = "_articleIds"; 12 | } 13 | 14 | public static class ArticleDbSchema 15 | { 16 | public const string TableName = "Articles"; 17 | public const string ForeignKey = "ArticleId"; 18 | public const string CommentIdTableName = "ArticleCommentIds"; 19 | public const string TagTableName = "Tags"; 20 | public const string LikeTableName = "Likes"; 21 | public const string CommentIdBackendField = "_commentIds"; 22 | public const string TagIdBackendField = "_tags"; 23 | public const string LikeIdBackendField = "_likes"; 24 | public const string AuthorAvatar = "Author_Avatar"; 25 | public const string AuthorJobTitle = "Author_JobTitle"; 26 | public const string AuthorFullName = "Author_FullName"; 27 | } 28 | 29 | 30 | public static class CommentDbSchema 31 | { 32 | public const string TableName = "Comments"; 33 | public const string ForeignKey = "CommentId"; 34 | public const string RepliesTableName = "Replies"; 35 | public const string RepliesBackendField = "_replies"; 36 | public const string ClientFullName = "Client_FullName"; 37 | public const string ClientEmail = "Client_Email"; 38 | public const string ApproveLinkApproveId = "ApproveLink_ApproveId"; 39 | public const string ApproveLinkExpirationOnUtc = "ApproveLink_ApproveExpirationOnUtc"; 40 | public const string ArticleId = "ArticleId"; 41 | } 42 | } -------------------------------------------------------------------------------- /src/Blogger.Application/Comments/MakeComment/MakeCommentCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.ApplicationServices; 2 | using Blogger.Application.Articles; 3 | using Blogger.Domain.CommentAggregate; 4 | 5 | namespace Blogger.Application.Comments.MakeComment; 6 | 7 | public class MakeCommentCommandHandler( 8 | ICommentRepository commentRepository, 9 | IArticleService articleService, 10 | IEmailService emailService, 11 | ILinkGenerator linkGenerator) : IRequestHandler 12 | { 13 | private readonly ICommentRepository _commentRepository = commentRepository; 14 | private readonly IArticleService _articleService = articleService; 15 | private readonly ILinkGenerator _linkGenerator = linkGenerator; 16 | 17 | public async Task Handle(MakeCommentCommand request, CancellationToken cancellationToken) 18 | { 19 | if (!await _articleService.IsArticleIdValidAsync(request.ArticleId, cancellationToken)) 20 | { 21 | throw new NotFoundArticleException(); 22 | } 23 | 24 | var link = _linkGenerator.Generate(); 25 | var approveLink = ApproveLink.Create(link, DateTime.UtcNow.AddHours(ApplicationSettings.ApproveLink.ExpirationOnHours)); 26 | 27 | var comment = Comment.Create(request.ArticleId, request.Client, request.Content, approveLink); 28 | comment.RaiseMakeCommentEvent(); 29 | 30 | await _commentRepository.CreateAsync(comment, cancellationToken); 31 | await _commentRepository.SaveChangesAsync(cancellationToken); 32 | 33 | 34 | 35 | 36 | var content = EmailTemplates.GetConfirmEngagementEmail( request.Client.FullName, approveLink.ToString()); 37 | await emailService.SendAsync(request.Client.Email, 38 | ApplicationSettings.ApproveLink.ConfirmEmailSubject, 39 | content, 40 | cancellationToken); 41 | 42 | return new MakeCommentCommandResponse(comment.Id); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Blogger.UnitTests/BuildingBlocks/EntityTests.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.UnitTests.BuildingBlocks; 2 | 3 | public class EntityTests 4 | { 5 | [Fact] 6 | public void entities_of_different_type_should_not_be_equal() 7 | { 8 | // Arrange 9 | var id = Guid.NewGuid(); 10 | var entityA = new ConcreteEntity(id); 11 | var entityB = new OtherConcreteEntity(id); 12 | 13 | // Act & Assert 14 | (entityA == entityB).Should().BeFalse(); 15 | (entityA != entityB).Should().BeTrue(); 16 | 17 | entityB.Equals(entityA).Should().BeFalse(); 18 | entityA.Equals(entityB).Should().BeFalse(); 19 | 20 | (entityA.GetHashCode() == entityB.GetHashCode()).Should().BeFalse(); 21 | } 22 | 23 | [Fact] 24 | public void entities_of_same_type_should_be_equal_when_ids_match() 25 | { 26 | // Arrange 27 | var id = Guid.NewGuid(); 28 | var entityA = new ConcreteEntity(id); 29 | var entityB = new ConcreteEntity(id); 30 | 31 | // Act & Assert 32 | (entityA == entityB).Should().BeTrue(); 33 | (entityA != entityB).Should().BeFalse(); 34 | 35 | entityA.Equals(entityB).Should().BeTrue(); 36 | entityB.Equals(entityA).Should().BeTrue(); 37 | 38 | (entityA.GetHashCode() == entityB.GetHashCode()).Should().BeTrue(); 39 | } 40 | 41 | [Fact] 42 | public void entities_of_same_type_should_not_be_equal_when_ids_different() 43 | { 44 | // Arrange 45 | var entityA = new ConcreteEntity(Guid.NewGuid()); 46 | var entityB = new ConcreteEntity(Guid.NewGuid()); 47 | 48 | // Act & Assert 49 | (entityA == entityB).Should().BeFalse(); 50 | (entityA != entityB).Should().BeTrue(); 51 | 52 | entityA.Equals(entityB).Should().BeFalse(); 53 | entityB.Equals(entityA).Should().BeFalse(); 54 | 55 | (entityA.GetHashCode() == entityB.GetHashCode()).Should().BeFalse(); 56 | } 57 | 58 | private class ConcreteEntity(Guid id) : Entity(id) { } 59 | private class OtherConcreteEntity(Guid id) : Entity(id) { } 60 | } -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Persistence/Repositories/CommentRepository.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.Infrastructure.Persistence.Repositories; 2 | 3 | public class CommentRepository(BloggerDbContext bloggerDbContext) : ICommentRepository 4 | { 5 | public Task GetCommentByApproveLinkAsync(string link, CancellationToken cancellationToken) 6 | { 7 | return bloggerDbContext.Comments.FirstOrDefaultAsync(x => x.ApproveLink.ApproveId == link, cancellationToken); 8 | } 9 | 10 | public async Task> GetApprovedArticleCommentsAsync(ArticleId articleId, CancellationToken cancellationToken) 11 | { 12 | var que = await bloggerDbContext.Comments.Where(x => x.ArticleId.Slug == articleId.Slug) 13 | .Where(c => c.IsApproved) 14 | .ToListAsync(cancellationToken); 15 | 16 | return que.ToImmutableList(); 17 | } 18 | 19 | public async Task> GetApprovedRepliesAsync(CommentId commentId, CancellationToken cancellationToken) 20 | { 21 | var replies = await bloggerDbContext.Comments 22 | .AsNoTracking() 23 | .Where(x => x.IsApproved && x.Id == commentId) 24 | .SelectMany(x => x.Replies.Where(x => x.IsApproved)) 25 | .ToListAsync(cancellationToken); 26 | return [.. replies]; 27 | } 28 | 29 | 30 | public async Task CreateAsync(Comment comment, CancellationToken cancellationToken) 31 | { 32 | await bloggerDbContext.Comments.AddAsync(comment, cancellationToken); 33 | } 34 | 35 | public Task GetCommentByIdAsync(CommentId commentId, CancellationToken cancellationToken) 36 | { 37 | return bloggerDbContext.Comments.FirstOrDefaultAsync(x => x.Id == commentId, cancellationToken); 38 | } 39 | 40 | public async Task SaveChangesAsync(CancellationToken cancellationToken) 41 | { 42 | await bloggerDbContext.SaveChangesAsync(cancellationToken); 43 | } 44 | 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/Blogger.Infrastructure/Services/LinkGenerator.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.ApplicationServices; 2 | 3 | namespace Blogger.Infrastructure.Services; 4 | public class LinkGenerator : ILinkGenerator 5 | { 6 | 7 | private const string Characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 8 | private static readonly object LockObject = new object(); 9 | private static long _lastTimestamp = -1L; 10 | private static long _sequence = 0L; 11 | private const long MaxSequence = 4095; // 12-bit sequence 12 | 13 | // implementing snowflakes algorithm to generate short and unique link 14 | public string Generate() 15 | { 16 | lock (LockObject) 17 | { 18 | long timestamp = GetTimestamp(); 19 | 20 | if (timestamp == _lastTimestamp) 21 | { 22 | _sequence = (_sequence + 1) & MaxSequence; 23 | if (_sequence == 0) 24 | { 25 | // Wait for the next millisecond if the sequence is full 26 | timestamp = WaitForNextMillis(_lastTimestamp); 27 | } 28 | } 29 | else 30 | { 31 | _sequence = 0; 32 | } 33 | 34 | _lastTimestamp = timestamp; 35 | 36 | string uniqueId = Base62Encode(timestamp) + Base62Encode(_sequence); 37 | return uniqueId; 38 | } 39 | } 40 | 41 | private static long GetTimestamp() 42 | { 43 | return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); 44 | } 45 | 46 | private static long WaitForNextMillis(long lastTimestamp) 47 | { 48 | long timestamp; 49 | do 50 | { 51 | timestamp = GetTimestamp(); 52 | } while (timestamp <= lastTimestamp); 53 | return timestamp; 54 | } 55 | 56 | private static string Base62Encode(long value) 57 | { 58 | var sb = new StringBuilder(); 59 | int baseSize = Characters.Length; 60 | do 61 | { 62 | sb.Insert(0, Characters[(int)(value % baseSize)]); 63 | value /= baseSize; 64 | } while (value > 0); 65 | return sb.ToString(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Blogger.Domain/CommentAggregate/Comment.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Domain.ArticleAggregate; 2 | 3 | namespace Blogger.Domain.CommentAggregate; 4 | 5 | public class Comment : AggregateRoot 6 | { 7 | private Comment(CommentId id) : base(id) 8 | { 9 | _replies = []; 10 | } 11 | 12 | private Comment() : this(null!) { } 13 | 14 | private readonly IList _replies; 15 | public IReadOnlyCollection Replies => [.. _replies]; 16 | 17 | public Client Client { get; init; } = null!; 18 | 19 | public ApproveLink ApproveLink { get; init; } = null!; 20 | 21 | public ArticleId ArticleId { get; init; } = null!; 22 | 23 | public DateTime CreatedOnUtc { get; init; } 24 | 25 | public string Content { get; init; } = null!; 26 | 27 | public bool IsApproved { get; private set; } 28 | 29 | public static Comment Create(ArticleId articleId, Client client, string content, ApproveLink approveLink) 30 | { 31 | return new Comment(CommentId.CreateUniqueId()) 32 | { 33 | ArticleId = articleId, 34 | Content = content, 35 | CreatedOnUtc = DateTime.UtcNow, 36 | Client = client, 37 | IsApproved = false, 38 | ApproveLink = approveLink 39 | }; 40 | } 41 | 42 | public void Approve() => IsApproved = true; 43 | 44 | public Reply ReplyComment(Client client, string content, ApproveLink approveLink) 45 | { 46 | if (!IsApproved) 47 | { 48 | throw new UnapprovedCommentException(); 49 | } 50 | 51 | var reply = Reply.Create(client, content, approveLink); 52 | _replies.Add(reply); 53 | 54 | return reply; 55 | } 56 | 57 | public ReplyId ApproveReply(string link) 58 | { 59 | var reply = _replies.FirstOrDefault(x => x.ApproveLink.ApproveId == link); 60 | if (reply is null) 61 | { 62 | throw new InvalidReplyApprovalLinkException(); 63 | } 64 | 65 | reply.Approve(); 66 | 67 | return reply.Id; 68 | } 69 | 70 | public void RaiseMakeCommentEvent() 71 | { 72 | var makeCommnetEvent = new MakeCommentEvent { CommentId = Id }; 73 | AddEvent(makeCommnetEvent); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Articles/MakeDraftCommandHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles.MakeDraft; 2 | using Blogger.Domain.ArticleAggregate; 3 | using Blogger.Infrastructure.Persistence.Repositories; 4 | using Blogger.IntegrationTests.Fixtures; 5 | 6 | using FluentAssertions; 7 | 8 | namespace Blogger.IntegrationTests.Articles; 9 | public class MakeDraftCommandHandlerTests : IClassFixture 10 | { 11 | private readonly BloggerDbContextFixture _fixture; 12 | 13 | public MakeDraftCommandHandlerTests(BloggerDbContextFixture fixture) 14 | { 15 | _fixture = fixture; 16 | } 17 | 18 | [Fact] 19 | public async Task Handle_ShouldCreateDraft_WhenDraftDoesNotExist() 20 | { 21 | // Arrange 22 | var request = new MakeDraftCommand("Existing Draft", "Draft body", "Draft summary", []); 23 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 24 | var sut = new MakeDraftCommandHandler(articleRepository); 25 | var articleId = ArticleId.CreateUniqueId(request.Title); 26 | 27 | // Act 28 | var response = await sut.Handle(request, CancellationToken.None); 29 | 30 | // Assert 31 | response.Should().NotBeNull(); 32 | response.DraftId.Should().Be(articleId); 33 | 34 | var draft = articleRepository.GetDraftByIdAsync(articleId, CancellationToken.None); 35 | draft.Should().NotBeNull(); 36 | } 37 | 38 | [Fact] 39 | public async Task Handle_ShouldThrowDraftAlreadyExistsException_WhenDraftAlreadyExists() 40 | { 41 | // Arrange 42 | var request = new MakeDraftCommand("Existing Draft", "Draft body", "Draft summary", []); 43 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 44 | var sut = new MakeDraftCommandHandler(articleRepository); 45 | 46 | var oldDraft = Article.CreateDraft("Existing Draft", "Draft body", "Draft summary"); 47 | 48 | articleRepository.Add(oldDraft); 49 | await articleRepository.SaveChangesAsync(CancellationToken.None); 50 | 51 | // Act 52 | var draft = async () => await sut.Handle(request, CancellationToken.None); 53 | 54 | // Assert 55 | await draft.Should().ThrowAsync(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Articles/CreateArticleCommandHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles.CreateArticle; 2 | using Blogger.Domain.ArticleAggregate; 3 | using Blogger.Infrastructure.Persistence.Repositories; 4 | using Blogger.IntegrationTests.Fixtures; 5 | using FluentAssertions; 6 | 7 | namespace Blogger.IntegrationTests.Articles; 8 | 9 | public class CreateArticleCommandHandlerTests : IClassFixture 10 | { 11 | private readonly BloggerDbContextFixture _fixture; 12 | 13 | public CreateArticleCommandHandlerTests(BloggerDbContextFixture fixture) 14 | { 15 | _fixture = fixture; 16 | } 17 | 18 | [Fact] 19 | public async Task Handle_ShouldCreateArticle_WhenArticleDoesNotExist() 20 | { 21 | // Arrange 22 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 23 | var _sut = new CreateArticleCommandHandler(articleRepository); 24 | var command = new CreateArticleCommand("Test Title", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag2")]); 25 | var articleId = ArticleId.CreateUniqueId("Test Title"); 26 | 27 | // Act 28 | var response = await _sut.Handle(command, CancellationToken.None); 29 | 30 | // Assert 31 | response.Should().NotBeNull(); 32 | response.ArticleId.Should().NotBeNull(); 33 | response.ArticleId.Should().Be(articleId); 34 | 35 | var article = articleRepository.GetDraftByIdAsync(articleId, CancellationToken.None); 36 | article.Should().NotBeNull(); 37 | } 38 | 39 | [Fact] 40 | public async Task Handle_ShouldThrowException_WhenArticleAlreadyExists() 41 | { 42 | // Arrange 43 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 44 | var _sut = new CreateArticleCommandHandler(articleRepository); 45 | var command = new CreateArticleCommand("Test Title", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag2")]); 46 | var response = await _sut.Handle(command, CancellationToken.None); 47 | 48 | // Act 49 | Func act = async () => await _sut.Handle(command, CancellationToken.None); 50 | 51 | // Assert 52 | await act.Should().ThrowAsync(); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/Blogger.APIs/DependencyInjection.cs: -------------------------------------------------------------------------------- 1 | namespace Blogger.APIs; 2 | 3 | public static class DependencyInjection 4 | { 5 | private readonly static Assembly[] Assemblies = AppDomain.CurrentDomain.GetAssemblies(); 6 | 7 | public static IServiceCollection ConfigureMapster(this IServiceCollection services) 8 | { 9 | var typeAdapterConfig = TypeAdapterConfig.GlobalSettings; 10 | typeAdapterConfig.Scan(Assemblies); 11 | 12 | services.AddSingleton(typeAdapterConfig); 13 | services.AddScoped(); 14 | 15 | return services; 16 | } 17 | 18 | public static IServiceCollection ConfigureValidator(this IServiceCollection services) 19 | { 20 | services.AddValidatorsFromAssemblies(Assemblies); 21 | 22 | return services; 23 | } 24 | 25 | public static IServiceCollection ConfigureCors(this IServiceCollection services) 26 | { 27 | services.AddCors(options => 28 | { 29 | options.AddPolicy(name: "AllowOrigin", 30 | builder => 31 | { 32 | builder.AllowAnyOrigin() 33 | .AllowAnyHeader() 34 | .AllowAnyMethod(); 35 | }); 36 | }); 37 | 38 | return services; 39 | } 40 | 41 | public static IServiceCollection AddEndpoints(this IServiceCollection services) 42 | { 43 | var assembly = typeof(IAssemblyMarker).Assembly; 44 | 45 | ServiceDescriptor[] serviceDescriptors = assembly 46 | .DefinedTypes 47 | .Where(type => type is { IsAbstract: false, IsInterface: false } && 48 | type.IsAssignableTo(typeof(IEndpoint))) 49 | .Select(type => ServiceDescriptor.Transient(typeof(IEndpoint), type)) 50 | .ToArray(); 51 | 52 | services.TryAddEnumerable(serviceDescriptors); 53 | 54 | return services; 55 | } 56 | 57 | 58 | public static IApplicationBuilder MapEndpoints(this WebApplication app) 59 | { 60 | IEnumerable endpoints = app.Services 61 | .GetRequiredService>(); 62 | 63 | foreach (IEndpoint endpoint in endpoints) 64 | { 65 | endpoint.MapEndpoint(app); 66 | } 67 | 68 | return app; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Articles/GetArchiveQueryHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles.GetArchive; 2 | using Blogger.Domain.ArticleAggregate; 3 | using Blogger.Infrastructure.Persistence.Repositories; 4 | using Blogger.IntegrationTests.Fixtures; 5 | 6 | using FluentAssertions; 7 | 8 | namespace Blogger.IntegrationTests.Articles; 9 | public class GetArchiveQueryHandlerTests : IClassFixture 10 | { 11 | private readonly BloggerDbContextFixture _fixture; 12 | 13 | public GetArchiveQueryHandlerTests(BloggerDbContextFixture fixture) 14 | { 15 | _fixture = fixture; 16 | } 17 | 18 | 19 | [Fact] 20 | public async Task Handle_ShouldReturnArchive_WhenArticlesExist() 21 | { 22 | // Arrange 23 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 24 | var sut = new GetArchiveQueryHandler(articleRepository); 25 | 26 | var article_1 = Article.CreateArticle("Title 1", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag2")]); 27 | var article_2 = Article.CreateArticle("Title 2", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag2")]); 28 | 29 | articleRepository.Add(article_1); 30 | articleRepository.Add(article_2); 31 | await articleRepository.SaveChangesAsync(CancellationToken.None); 32 | 33 | // Act 34 | var response = await sut.Handle(new GetArchiveQuery(), CancellationToken.None); 35 | 36 | // Assert 37 | response.Should().NotBeNull(); 38 | response.Should().HaveCount(1); 39 | 40 | var archiveResponse = response.First(); 41 | archiveResponse.Articles.Should().HaveCount(2); 42 | archiveResponse.Year.Should().Be(DateTime.UtcNow.Year); 43 | archiveResponse.Month.Should().Be(DateTime.UtcNow.Month); 44 | } 45 | 46 | [Fact] 47 | public async Task Handle_ShouldReturnEmpty_WhenNoArticlesExist() 48 | { 49 | // Arrange 50 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 51 | var sut = new GetArchiveQueryHandler(articleRepository); 52 | 53 | // Act 54 | var response = await sut.Handle(new GetArchiveQuery(), CancellationToken.None); 55 | 56 | // Assert 57 | response.Should().NotBeNull(); 58 | response.Should().BeEmpty(); 59 | } 60 | 61 | 62 | 63 | } 64 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Articles/GetArticleQueryHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles.GetArticle; 2 | using Blogger.Application.Comments.MakeComment; 3 | using Blogger.Domain.ArticleAggregate; 4 | using Blogger.Infrastructure.Persistence.Repositories; 5 | using Blogger.IntegrationTests.Fixtures; 6 | 7 | using FluentAssertions; 8 | 9 | namespace Blogger.IntegrationTests.Articles; 10 | public class GetArticleQueryHandlerTests : IClassFixture 11 | { 12 | private readonly BloggerDbContextFixture _fixture; 13 | 14 | public GetArticleQueryHandlerTests(BloggerDbContextFixture fixture) 15 | { 16 | _fixture = fixture; 17 | } 18 | 19 | [Fact] 20 | public async Task Handle_ShouldReturnArticle_WhenArticleExists() 21 | { 22 | // Arrange 23 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 24 | var _sut = new GetArticleQueryHandler(articleRepository); 25 | 26 | var article = Article.CreateArticle("Title 1", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag2")]); 27 | articleRepository.Add(article); 28 | await articleRepository.SaveChangesAsync(CancellationToken.None); 29 | var request = new GetArticleQuery(article.Id); 30 | 31 | // Act 32 | var response = await _sut.Handle(request, CancellationToken.None); 33 | 34 | // Assert 35 | response.Should().NotBeNull(); 36 | response.ArticleId.Should().Be(article.Id); 37 | response.Title.Should().Be(article.Title); 38 | response.Body.Should().Be(article.Body); 39 | response.Summary.Should().Be(article.Summary); 40 | response.Tags.Should().BeEquivalentTo(article.Tags); 41 | } 42 | 43 | [Fact] 44 | public async Task Handle_ShouldThrowNotFoundArticleException_WhenArticleDoesNotExist() 45 | { 46 | // Arrange 47 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 48 | var _sut = new GetArticleQueryHandler(articleRepository); 49 | 50 | var articleId = ArticleId.Create("thisisnabi"); 51 | 52 | var request = new GetArticleQuery(articleId); 53 | 54 | // Act 55 | Func act = async () => await _sut.Handle(request, CancellationToken.None); 56 | 57 | // Assert 58 | await act.Should().ThrowAsync(); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Articles/GetTaggedArticlesQueryHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles.GetTaggedArticles; 2 | using Blogger.Domain.ArticleAggregate; 3 | using Blogger.Infrastructure.Persistence.Repositories; 4 | using Blogger.IntegrationTests.Fixtures; 5 | using FluentAssertions; 6 | 7 | namespace Blogger.IntegrationTests.Articles; 8 | public class GetTaggedArticlesQueryHandlerTests : IClassFixture 9 | { 10 | private readonly BloggerDbContextFixture _fixture; 11 | 12 | public GetTaggedArticlesQueryHandlerTests(BloggerDbContextFixture fixture) 13 | { 14 | _fixture = fixture; 15 | } 16 | 17 | 18 | [Fact] 19 | public async Task Handle_ShouldReturnTaggedArticles_WhenArticlesExist() 20 | { 21 | // Arrange 22 | var tag = Tag.Create("testTag"); 23 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 24 | var sut = new GetTaggedArticlesQueryHandler(articleRepository); 25 | 26 | var article_1 = Article.CreateArticle("Title 1", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("testTag")]); 27 | var article_2 = Article.CreateArticle("Title 2", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag4")]); 28 | 29 | articleRepository.Add(article_1); 30 | articleRepository.Add(article_2); 31 | 32 | await articleRepository.SaveChangesAsync(CancellationToken.None); 33 | 34 | var request = new GetTaggedArticlesQuery(tag); 35 | 36 | // Act 37 | var response = await sut.Handle(request, CancellationToken.None); 38 | 39 | // Assert 40 | response.Should().NotBeNull(); 41 | response.Should().HaveCount(1); 42 | 43 | response.First().ArticleId.Should().Be(article_1.Id); 44 | response.First().Tags.Should().Contain(tag); 45 | } 46 | 47 | [Fact] 48 | public async Task Handle_ShouldReturnEmpty_WhenNoArticlesExist() 49 | { 50 | // Arrange 51 | var tag = Tag.Create("testTag"); 52 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 53 | var sut = new GetTaggedArticlesQueryHandler(articleRepository); 54 | 55 | 56 | var request = new GetTaggedArticlesQuery(tag); 57 | 58 | // Act 59 | var response = await sut.Handle(request, CancellationToken.None); 60 | 61 | // Assert 62 | response.Should().NotBeNull(); 63 | response.Should().BeEmpty(); 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Articles/GetTagsQueryHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using Blogger.Application.Articles.GetTags; 3 | using Blogger.Domain.ArticleAggregate; 4 | using Blogger.Infrastructure.Persistence.Repositories; 5 | using Blogger.IntegrationTests.Fixtures; 6 | 7 | using FluentAssertions; 8 | 9 | namespace Blogger.IntegrationTests.Articles; 10 | public class GetTagsQueryHandlerTests : IClassFixture 11 | { 12 | private readonly BloggerDbContextFixture _fixture; 13 | 14 | public GetTagsQueryHandlerTests(BloggerDbContextFixture fixture) 15 | { 16 | _fixture = fixture; 17 | } 18 | 19 | [Fact] 20 | public async Task Handle_ShouldReturnTags_WhenTagsExist() 21 | { 22 | // Arrange 23 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 24 | var sut = new GetTagsQueryHandler(articleRepository); 25 | 26 | var article_1 = Article.CreateArticle("Title 1", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag3")]); 27 | var article_2 = Article.CreateArticle("Title 2", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag4")]); 28 | 29 | articleRepository.Add(article_1); 30 | articleRepository.Add(article_2); 31 | 32 | await articleRepository.SaveChangesAsync(CancellationToken.None); 33 | 34 | var request = new GetTagsQuery(); 35 | 36 | // Act 37 | var response = (await sut.Handle(request, CancellationToken.None)).ToImmutableList(); 38 | 39 | // Assert 40 | response.Should().NotBeNull(); 41 | response.Should().HaveCount(3); 42 | 43 | response[0].Tag.Value.Should().Be("tag1"); 44 | response[0].Count.Should().Be(2); 45 | 46 | response[1].Tag.Value.Should().Be("tag3"); 47 | response[1].Count.Should().Be(1); 48 | 49 | response[2].Tag.Value.Should().Be("tag4"); 50 | response[2].Count.Should().Be(1); 51 | } 52 | 53 | [Fact] 54 | public async Task Handle_ShouldReturnEmpty_WhenNoTagsExist() 55 | { 56 | // Arrange 57 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 58 | var sut = new GetTagsQueryHandler(articleRepository); 59 | 60 | var request = new GetTagsQuery(); 61 | 62 | // Act 63 | var response = await sut.Handle(request, CancellationToken.None); 64 | 65 | // Assert 66 | response.Should().NotBeNull(); 67 | response.Should().BeEmpty(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Subscribers/SubscribeCommandHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Subscribers; 2 | using Blogger.Application.Subscribers.Subscribe; 3 | using Blogger.Domain.SubscriberAggregate; 4 | using Blogger.Infrastructure.Persistence.Repositories; 5 | using Blogger.IntegrationTests.Fixtures; 6 | 7 | using FluentAssertions; 8 | 9 | namespace Blogger.IntegrationTests.Subscribers; 10 | public class SubscribeCommandHandlerTests : IClassFixture 11 | { 12 | private readonly BloggerDbContextFixture _fixture; 13 | 14 | public SubscribeCommandHandlerTests(BloggerDbContextFixture fixture) 15 | { 16 | _fixture = fixture; 17 | } 18 | 19 | [Fact] 20 | public async Task Handle_DuplicateSubscriber_ShouldThrowDuplicateSubscriptionException() 21 | { 22 | // Arrange 23 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 24 | var subscribeRepository = new SubscriberRepository(dbContext); 25 | 26 | var subscriberId = SubscriberId.CreateUniqueId("thisisnabi@dev.com"); 27 | var subscriber = Subscriber.Create(subscriberId); 28 | await subscribeRepository.CreateAsync(subscriber, CancellationToken.None); 29 | await subscribeRepository.SavaChangesAsync(CancellationToken.None); 30 | 31 | var subscriberService = new SubscriberService(subscribeRepository); 32 | 33 | var sut = new SubscribeCommandHandler(subscribeRepository, subscriberService); 34 | 35 | var command = new SubscribeCommand(subscriberId); 36 | 37 | // Act 38 | Func act = async () => await sut.Handle(command, CancellationToken.None); 39 | 40 | // Assert 41 | await act.Should().ThrowAsync(); 42 | } 43 | 44 | [Fact] 45 | public async Task Handle_NewSubscriber_ShouldCreateSubscriberAndSaveChanges() 46 | { 47 | // Arrange 48 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 49 | var subscribeRepository = new SubscriberRepository(dbContext); 50 | 51 | var subscriberId = SubscriberId.CreateUniqueId("thisisnabi@dev.com"); 52 | 53 | var subscriberService = new SubscriberService(subscribeRepository); 54 | 55 | var sut = new SubscribeCommandHandler(subscribeRepository, subscriberService); 56 | 57 | var command = new SubscribeCommand(subscriberId); 58 | 59 | // Act 60 | await sut.Handle(command, CancellationToken.None); 61 | 62 | // Assert 63 | var subscriber = await subscribeRepository.FindByIdAsync(subscriberId); 64 | subscriber.Should().NotBeNull(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/Blogger.UnitTests/Domain/ArticleAggregateTests/AuthorTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.BuildingBlocks.Domain; 2 | using Blogger.Domain.ArticleAggregate; 3 | 4 | using FluentAssertions; 5 | 6 | namespace Blogger.UnitTests.Domain.ArticleAggregateTests; 7 | 8 | public class AuthorTests 9 | { 10 | [Fact] 11 | public void Create_GivenSomeValidParameters_AuthorObjectCreatedSuccessfully() 12 | { 13 | // Arrange 14 | var fullName = "Nabi Karampour"; 15 | var avatar = "../../images/thisisnabi.png"; 16 | var jobTitle = "Software Developer"; 17 | 18 | // Act 19 | var actual = Author.Create(fullName, avatar, jobTitle); 20 | 21 | // Assert 22 | actual.Should().BeAssignableTo>(); 23 | actual.Avatar.Should().Be(avatar); 24 | actual.FullName.Should().Be(fullName); 25 | actual.JobTitle.Should().Be(jobTitle); 26 | } 27 | 28 | [Theory] 29 | [InlineData("", "avatar", "jobTitle")] 30 | [InlineData("fullName", "", "jobTitle")] 31 | [InlineData("fullName", "avatars", "")] 32 | [InlineData(" ", "avatar", "jobTitle")] 33 | [InlineData("fullName", " ", "jobTitle")] 34 | [InlineData("fullName", "avatars", " ")] 35 | public void Create_GivenSomeInCorrectParameters_ThrowArgumentException(string fullName, string avatar, 36 | string jobTitle) 37 | { 38 | // Arrange 39 | // Act 40 | Action actual = () => Author.Create(fullName, avatar, jobTitle); 41 | 42 | // Assert 43 | actual.Should().Throw("parameters are incorrect but did not throw Exception"); 44 | } 45 | 46 | [Fact] 47 | public void CreateDefaultAuthor_withoutAnyParameters_AuthorObjectCreatedSuccessfullyWithDefaultValues() 48 | { 49 | // Arrange 50 | const string expectedFullName = "Nabi Karampour"; 51 | const string expectedJobTitle = "Senior Software Engineer"; 52 | const string expectedAvatar = "https://avatars.githubusercontent.com/u/3371886?s=400&u=cb8ebf9fc27e463b5d7002aaeeef881eb950b71f&v=4"; 53 | 54 | // Act 55 | var actual = Author.CreateDefaultAuthor(); 56 | 57 | // Assert 58 | actual.Avatar.Should().Be(expectedAvatar); 59 | actual.FullName.Should().Be(expectedFullName); 60 | actual.JobTitle.Should().Be(expectedJobTitle); 61 | } 62 | 63 | 64 | private class AuthorTestDto 65 | { 66 | internal AuthorTestDto(string fullName, string avatar, string jobTitle) 67 | { 68 | FullName = fullName; 69 | Avatar = avatar; 70 | JobTitle = jobTitle; 71 | } 72 | 73 | internal string FullName { get; } 74 | 75 | internal string Avatar { get; } 76 | 77 | internal string JobTitle { get; } 78 | } 79 | } -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Articles/GetPopularTagsQueryHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles.GetPopularTags; 2 | using Blogger.Domain.ArticleAggregate; 3 | using Blogger.Infrastructure.Persistence.Repositories; 4 | using Blogger.IntegrationTests.Fixtures; 5 | 6 | using FluentAssertions; 7 | 8 | namespace Blogger.IntegrationTests.Articles; 9 | public class GetPopularTagsQueryHandlerTests : IClassFixture 10 | { 11 | private readonly BloggerDbContextFixture _fixture; 12 | 13 | public GetPopularTagsQueryHandlerTests(BloggerDbContextFixture fixture) 14 | { 15 | _fixture = fixture; 16 | } 17 | 18 | [Fact] 19 | public async Task Handle_ShouldReturnPopularTags_WhenTagsExist() 20 | { 21 | // Arrange 22 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 23 | var sut = new GetPopularTagsQueryHandler(articleRepository); 24 | 25 | var article_1 = Article.CreateArticle("Title 1", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag2")]); 26 | var article_2 = Article.CreateArticle("Title 2", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag4")]); 27 | var article_3 = Article.CreateArticle("Title 3", "Test Body", "Test Summary", [Tag.Create("tag4"), Tag.Create("tag2")]); 28 | var article_4 = Article.CreateArticle("Title 4", "Test Body", "Test Summary", [Tag.Create("tag4"), Tag.Create("tag2")]); 29 | var article_5 = Article.CreateArticle("Title 5", "Test Body", "Test Summary", [Tag.Create("tag2"), Tag.Create("tag3")]); 30 | 31 | articleRepository.Add(article_1); 32 | articleRepository.Add(article_2); 33 | articleRepository.Add(article_3); 34 | articleRepository.Add(article_4); 35 | articleRepository.Add(article_5); 36 | 37 | await articleRepository.SaveChangesAsync(CancellationToken.None); 38 | 39 | 40 | var request = new GetPopularTagsQuery(2); 41 | 42 | // Act 43 | var response = await sut.Handle(request, CancellationToken.None); 44 | 45 | // Assert 46 | response.Should().NotBeNull(); 47 | response.Should().HaveCount(2); 48 | 49 | response[0].Tag.Value.Should().Be("tag2"); 50 | response[1].Tag.Value.Should().Be("tag4"); 51 | } 52 | 53 | [Fact] 54 | public async Task Handle_ShouldReturnEmpty_WhenNoTagsExist() 55 | { 56 | // Arrange 57 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 58 | var sut = new GetPopularTagsQueryHandler(articleRepository); 59 | 60 | 61 | var request = new GetPopularTagsQuery(2); 62 | 63 | // Act 64 | var response = await sut.Handle(request, CancellationToken.None); 65 | 66 | // Assert 67 | response.Should().NotBeNull(); 68 | response.Should().BeEmpty(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Comments/ApproveCommentCommandHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection.Metadata; 2 | 3 | using Blogger.Application; 4 | using Blogger.Application.ApplicationServices; 5 | using Blogger.Application.Articles; 6 | using Blogger.Application.Comments.ApproveComment; 7 | using Blogger.Application.Comments.MakeComment; 8 | using Blogger.Domain.ArticleAggregate; 9 | using Blogger.Domain.CommentAggregate; 10 | using Blogger.Infrastructure.Persistence.Repositories; 11 | using Blogger.Infrastructure.Services; 12 | using Blogger.Infrastructure.Services.Externals; 13 | using Blogger.IntegrationTests.Fixtures; 14 | 15 | using FluentAssertions; 16 | 17 | namespace Blogger.IntegrationTests.Comments; 18 | public class ApproveCommentCommandHandlerTests : IClassFixture 19 | { 20 | private readonly BloggerDbContextFixture _fixture; 21 | 22 | public ApproveCommentCommandHandlerTests(BloggerDbContextFixture fixture) 23 | { 24 | _fixture = fixture; 25 | } 26 | 27 | [Fact] 28 | public async Task Handle_CommentNotFound_ShouldThrowInvalidCommentApprovalLinkException() 29 | { 30 | // Arrange 31 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 32 | var commentRepository = new CommentRepository(dbContext); 33 | var command = new ApproveCommentCommand("invalid-link"); 34 | 35 | var sut = new ApproveCommentCommandHandler(commentRepository); 36 | 37 | // Act 38 | Func act = async () => await sut.Handle(command, CancellationToken.None); 39 | 40 | // Assert 41 | await act.Should().ThrowAsync(); 42 | } 43 | 44 | 45 | [Fact] 46 | public async Task Handle_CommentFound_ShouldApproveCommentAndSaveChanges() 47 | { 48 | // Arrange 49 | var link = new LinkGenerator().Generate(); 50 | var approveLink = ApproveLink.Create(link, DateTime.UtcNow.AddHours(ApplicationSettings.ApproveLink.ExpirationOnHours)); 51 | 52 | var command = new ApproveCommentCommand(approveLink.ApproveId); 53 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 54 | var commentRepository = new CommentRepository(dbContext); 55 | 56 | var articleId = ArticleId.Create("this-is-nabi"); 57 | var comment = Comment.Create(articleId, Client.Create("Nabi Karampour", "thisisnabi@outlook.com"),"Hi Bye", approveLink); 58 | await commentRepository.CreateAsync(comment, CancellationToken.None); 59 | await commentRepository.SaveChangesAsync(CancellationToken.None); 60 | 61 | var sut = new ApproveCommentCommandHandler(commentRepository); 62 | 63 | // Act 64 | var response = await sut.Handle(command, CancellationToken.None); 65 | 66 | // Assert 67 | response.ArticleId.Should().Be(articleId); 68 | response.CommentId.Should().Be(comment.Id); 69 | comment.IsApproved.Should().BeTrue(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Comments/ApproveReplyCommandHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application; 2 | using Blogger.Application.Comments.ApproveComment; 3 | using Blogger.Application.Comments.ApproveReply; 4 | using Blogger.Domain.ArticleAggregate; 5 | using Blogger.Domain.CommentAggregate; 6 | using Blogger.Infrastructure.Persistence.Repositories; 7 | using Blogger.Infrastructure.Services; 8 | using Blogger.IntegrationTests.Fixtures; 9 | 10 | using FluentAssertions; 11 | 12 | namespace Blogger.IntegrationTests.Comments; 13 | public class ApproveReplyCommandHandlerTests : IClassFixture 14 | { 15 | private readonly BloggerDbContextFixture _fixture; 16 | 17 | public ApproveReplyCommandHandlerTests(BloggerDbContextFixture fixture) 18 | { 19 | _fixture = fixture; 20 | } 21 | 22 | [Fact] 23 | public async Task Handle_CommentNotFound_ShouldThrowCommentNotFoundException() 24 | { 25 | // Arrange 26 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 27 | 28 | var command = new ApproveReplyCommand(CommentId.CreateUniqueId(), "invalid-link"); 29 | var commentRepository = new CommentRepository(dbContext); 30 | var sut = new ApproveReplyCommandHandler(commentRepository); 31 | 32 | // Act 33 | Func act = async () => await sut.Handle(command, CancellationToken.None); 34 | 35 | // Assert 36 | await act.Should().ThrowAsync(); 37 | } 38 | 39 | [Fact] 40 | public async Task Handle_CommentFound_ShouldApproveReplyAndSaveChanges() 41 | { 42 | // Arrange 43 | var link = new LinkGenerator().Generate(); 44 | var approveLinkComment = ApproveLink.Create(link, DateTime.UtcNow.AddHours(ApplicationSettings.ApproveLink.ExpirationOnHours)); 45 | var approveLink = ApproveLink.Create(link, DateTime.UtcNow.AddHours(ApplicationSettings.ApproveLink.ExpirationOnHours)); 46 | 47 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 48 | var commentRepository = new CommentRepository(dbContext); 49 | 50 | var articleId = ArticleId.Create("this-is-nabi"); 51 | var client = Client.Create("Nabi Karampour", "thisisnabi@outlook.com"); 52 | var comment = Comment.Create(articleId, client, "Hi Bye", approveLinkComment); 53 | comment.Approve(); 54 | var replay =comment.ReplyComment(client, "Hi Bye", approveLink); 55 | 56 | await commentRepository.CreateAsync(comment, CancellationToken.None); 57 | await commentRepository.SaveChangesAsync(CancellationToken.None); 58 | 59 | var command = new ApproveReplyCommand(comment.Id, approveLink.ApproveId); 60 | var sut = new ApproveReplyCommandHandler(commentRepository); 61 | 62 | // Act 63 | var response = await sut.Handle(command, CancellationToken.None); 64 | 65 | // Assert 66 | response.ArticleId.Should().Be(articleId); 67 | response.CommentId.Should().Be(comment.Id); 68 | response.ReplyId.Should().Be(replay.Id); 69 | replay.IsApproved.Should().BeTrue(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Comments/GetCommentsHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection.Metadata; 2 | 3 | using Blogger.Application; 4 | using Blogger.Application.Comments.GetComments; 5 | using Blogger.Domain.ArticleAggregate; 6 | using Blogger.Domain.CommentAggregate; 7 | using Blogger.Infrastructure.Persistence.Repositories; 8 | using Blogger.Infrastructure.Services; 9 | using Blogger.IntegrationTests.Fixtures; 10 | 11 | using FluentAssertions; 12 | 13 | using NSubstitute; 14 | 15 | namespace Blogger.IntegrationTests.Comments; 16 | public class GetCommentsHandlerTests : IClassFixture 17 | { 18 | private readonly BloggerDbContextFixture _fixture; 19 | 20 | public GetCommentsHandlerTests(BloggerDbContextFixture fixture) 21 | { 22 | _fixture = fixture; 23 | } 24 | 25 | [Fact] 26 | public async Task Handle_NoCommentsFound_ShouldReturnEmptyList() 27 | { 28 | // Arrange 29 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 30 | var commentRepository = new CommentRepository(dbContext); 31 | 32 | var articleId = ArticleId.Create("this-is-nabi"); 33 | var query = new GetCommentsQuery(articleId); 34 | 35 | var sut = new GetCommentsHandler(commentRepository); 36 | 37 | // Act 38 | var result = await sut.Handle(query, CancellationToken.None); 39 | 40 | // Assert 41 | result.Should().BeEmpty(); 42 | } 43 | 44 | [Fact] 45 | public async Task Handle_CommentsFound_ShouldReturnComments() 46 | { 47 | // Arrange 48 | var articleId_1 = ArticleId.Create("this-is-nabi"); 49 | var articleId_2 = ArticleId.Create("this-is-nabi_2"); 50 | 51 | var link = new LinkGenerator().Generate(); 52 | var approveLinkComment = ApproveLink.Create(link, DateTime.UtcNow.AddHours(ApplicationSettings.ApproveLink.ExpirationOnHours)); 53 | 54 | var client1 = Client.Create("Nabi Karampour 1", "thisisnabi@outlook.com"); 55 | var client2 = Client.Create("Nabi Karampour 2", "thisisnabi@outlook.com"); 56 | var comment1 = Comment.Create(articleId_1, client1, "Hi Bye 1", approveLinkComment); 57 | comment1.Approve(); 58 | 59 | var comment2 = Comment.Create(articleId_2, client2, "Hi Bye 2", approveLinkComment); 60 | comment2.Approve(); 61 | 62 | var query = new GetCommentsQuery(articleId_1); 63 | 64 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 65 | var commentRepository = new CommentRepository(dbContext); 66 | 67 | await commentRepository.CreateAsync(comment1, CancellationToken.None); 68 | await commentRepository.CreateAsync(comment2, CancellationToken.None); 69 | 70 | await commentRepository.SaveChangesAsync(CancellationToken.None); 71 | 72 | var sut = new GetCommentsHandler(commentRepository); 73 | 74 | // Act 75 | var result = await sut.Handle(query, CancellationToken.None); 76 | 77 | // Assert 78 | result.Should().HaveCount(1); 79 | result.First().FullName.Should().Be(client1.FullName); 80 | result.First().Content.Should().Be(comment1.Content); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Comments/GetRepliesHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application; 2 | using Blogger.Application.Comments.GetReplies; 3 | using Blogger.Domain.ArticleAggregate; 4 | using Blogger.Domain.CommentAggregate; 5 | using Blogger.Infrastructure.Persistence.Repositories; 6 | using Blogger.Infrastructure.Services; 7 | using Blogger.IntegrationTests.Fixtures; 8 | 9 | using FluentAssertions; 10 | 11 | namespace Blogger.IntegrationTests.Comments; 12 | 13 | public class GetRepliesHandlerTests : IClassFixture 14 | { 15 | private readonly BloggerDbContextFixture _fixture; 16 | 17 | public GetRepliesHandlerTests(BloggerDbContextFixture fixture) 18 | { 19 | _fixture = fixture; 20 | } 21 | 22 | [Fact] 23 | public async Task Handle_NoRepliesFound_ShouldReturnEmptyList() 24 | { 25 | // Arrange 26 | var query = new GetRepliesQuery(CommentId.CreateUniqueId()); 27 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 28 | var commentRepository = new CommentRepository(dbContext); 29 | var sut = new GetRepliesHandler(commentRepository); 30 | 31 | // Act 32 | var result = await sut.Handle(query, CancellationToken.None); 33 | 34 | // Assert 35 | result.Should().BeEmpty(); 36 | } 37 | 38 | 39 | [Fact] 40 | public async Task Handle_RepliesFound_ShouldReturnReplies() 41 | { 42 | // Arrange 43 | var articleId_1 = ArticleId.Create("this-is-nabi"); 44 | 45 | var link = new LinkGenerator().Generate(); 46 | var approveLinkComment = Domain.CommentAggregate.ApproveLink.Create(link, DateTime.UtcNow.AddHours(ApplicationSettings.ApproveLink.ExpirationOnHours)); 47 | 48 | var client1 = Client.Create("Nabi Karampour 1", "thisisnabi@outlook.com"); 49 | var client2 = Client.Create("Nabi Karampour 2", "thisisnabi@outlook.com"); 50 | 51 | var comment1 = Comment.Create(articleId_1, client1, "Hi Bye 1", approveLinkComment); 52 | comment1.Approve(); 53 | var replayContent = "Hi Bye -- Replay 1"; 54 | var replay1 = comment1.ReplyComment(client1, replayContent, approveLinkComment); 55 | 56 | replay1.Approve(); 57 | 58 | var comment2 = Comment.Create(articleId_1, client1, "Hi Bye 1", approveLinkComment); 59 | comment2.Approve(); 60 | comment2.ReplyComment(client2, "Hi Bye -- Replay 2", approveLinkComment); 61 | 62 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 63 | var commentRepository = new CommentRepository(dbContext); 64 | 65 | await commentRepository.CreateAsync(comment1, CancellationToken.None); 66 | await commentRepository.CreateAsync(comment2, CancellationToken.None); 67 | 68 | await commentRepository.SaveChangesAsync(CancellationToken.None); 69 | 70 | var query = new GetRepliesQuery(comment1.Id); 71 | 72 | var sut = new GetRepliesHandler(commentRepository); 73 | 74 | // act 75 | var result = await sut.Handle(query, CancellationToken.None); 76 | 77 | // assert 78 | result.Should().HaveCount(1); 79 | result.First().FullName.Should().Be(client1.FullName); 80 | result.First().Content.Should().Be(replayContent); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Comments/MakeCommentCommandHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.ApplicationServices; 2 | using Blogger.Application.Articles; 3 | using Blogger.Application.Comments.MakeComment; 4 | using Blogger.Domain.ArticleAggregate; 5 | using Blogger.Domain.CommentAggregate; 6 | using Blogger.Infrastructure.Persistence.Repositories; 7 | using Blogger.Infrastructure.Services; 8 | using Blogger.IntegrationTests.Fixtures; 9 | using FluentAssertions; 10 | using NSubstitute; 11 | 12 | namespace Blogger.IntegrationTests.Comments; 13 | public class MakeCommentCommandHandlerTests : IClassFixture 14 | { 15 | private readonly BloggerDbContextFixture _fixture; 16 | 17 | public MakeCommentCommandHandlerTests(BloggerDbContextFixture fixture) 18 | { 19 | _fixture = fixture; 20 | } 21 | 22 | [Fact] 23 | public async Task Handle_ShouldThrowNotFoundArticleException_ForInvalidArticleId() 24 | { 25 | // Arrange 26 | var command = new MakeCommentCommand(ArticleId.Create("this-is-nabi"), Client.Create("", "thisisnabi@gmail.com"), "Test Content"); 27 | 28 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 29 | var commentRepository = new CommentRepository(dbContext); 30 | var articleRepository = new ArticleRepository(dbContext); 31 | var articleService = new ArticleService(articleRepository); 32 | var linkGenerator = new LinkGenerator(); 33 | 34 | var emailService = Substitute.For(); 35 | var sut = new MakeCommentCommandHandler(commentRepository, articleService, emailService, linkGenerator); 36 | 37 | // Act 38 | Func act = async () => await sut.Handle(command, CancellationToken.None); 39 | 40 | // Assert 41 | await act.Should().ThrowAsync(); 42 | } 43 | 44 | 45 | [Fact] 46 | public async Task Handle_ShouldCreateComment_ForValidArticleId() 47 | { 48 | // Arrange 49 | var articleId = ArticleId.Create("this-is-nabi"); 50 | var command = new MakeCommentCommand(articleId, Client.Create("Nabi Karampour", "thisisnabi@gmail.com"), "Test Content"); 51 | 52 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 53 | var articleRepository = new ArticleRepository(dbContext); 54 | articleRepository.Add(Article.CreateArticle("This Is Nabi", "Nabi", "Nabi", [Tag.Create("test")])); 55 | await articleRepository.SaveChangesAsync(CancellationToken.None); 56 | 57 | var commentRepository = new CommentRepository(dbContext); 58 | var articleService = new ArticleService(articleRepository); 59 | var linkGenerator = new LinkGenerator(); 60 | 61 | var emailService = Substitute.For(); 62 | var sut = new MakeCommentCommandHandler(commentRepository, articleService, emailService, linkGenerator); 63 | 64 | // Act 65 | var result = await sut.Handle(command, CancellationToken.None); 66 | 67 | // Assert 68 | result.Should().NotBeNull(); 69 | result.CommentId.Should().NotBe(Guid.Empty); 70 | await emailService.Received(1).SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); 71 | 72 | var comment = await commentRepository.GetCommentByIdAsync(result.CommentId, CancellationToken.None); 73 | comment.Should().NotBeNull(); 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Articles/PublishDraftCommandHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles.MakeDraft; 2 | using Blogger.Application.Articles.PublishDraft; 3 | using Blogger.Application.Articles.UpdateDraft; 4 | using Blogger.Domain.ArticleAggregate; 5 | using Blogger.Infrastructure.Persistence.Repositories; 6 | using Blogger.IntegrationTests.Fixtures; 7 | 8 | using FluentAssertions; 9 | 10 | namespace Blogger.IntegrationTests.Articles; 11 | public class PublishDraftCommandHandlerTests : IClassFixture 12 | { 13 | private readonly BloggerDbContextFixture _fixture; 14 | 15 | public PublishDraftCommandHandlerTests(BloggerDbContextFixture fixture) 16 | { 17 | _fixture = fixture; 18 | } 19 | 20 | [Fact] 21 | public async Task Handle_ShouldPublishDraft_WhenDraftExists() 22 | { 23 | // Arrange 24 | var request = new MakeDraftCommand("Existing Draft", "Draft body", "Draft summary", []); 25 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 26 | var sut = new PublishDraftCommandHandler(articleRepository); 27 | 28 | var draftId = ArticleId.CreateUniqueId("Existing Draft"); 29 | var draft = Article.CreateDraft("Existing Draft", "Draft body", "Draft summary"); 30 | draft.AddTags([Tag.Create("tag1")]); 31 | 32 | articleRepository.Add(draft); 33 | await articleRepository.SaveChangesAsync(CancellationToken.None); 34 | 35 | // Act 36 | await sut.Handle(new PublishDraftCommand(draftId), CancellationToken.None); 37 | 38 | // Assert 39 | var article = await articleRepository.GetArticleByIdAsync(draftId, CancellationToken.None); 40 | article.Should().NotBeNull(); 41 | article!.Id.Should().Be(draftId); 42 | } 43 | 44 | [Fact] 45 | public async Task Handle_ShouldThrowDraftNotFoundException_WhenDraftDoesNotExist() 46 | { 47 | // Arrange 48 | var request = new MakeDraftCommand("Not Existing Draft", "Draft body", "Draft summary", []); 49 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 50 | var sut = new PublishDraftCommandHandler(articleRepository); 51 | var draftId = ArticleId.CreateUniqueId("Nothing"); 52 | 53 | // Act 54 | Func act = async () => await sut.Handle(new PublishDraftCommand(draftId), CancellationToken.None); 55 | 56 | // Assert 57 | await act.Should().ThrowAsync(); 58 | } 59 | 60 | [Fact] 61 | public async Task Handle_ShouldThrowDraftTagsMissingException_WhenDraftDoesNotHaveTags() 62 | { 63 | // Arrange 64 | var request = new MakeDraftCommand("Not Existing Draft", "Draft body", "Draft summary", []); 65 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 66 | var sut = new PublishDraftCommandHandler(articleRepository); 67 | var draftId = ArticleId.CreateUniqueId("Not Existing Draft"); 68 | var draft = Article.CreateDraft("Not Existing Draft", "Draft body", "Draft summary"); 69 | 70 | articleRepository.Add(draft); 71 | await articleRepository.SaveChangesAsync(CancellationToken.None); 72 | 73 | // Act 74 | Func act = async () => await sut.Handle(new PublishDraftCommand(draftId), CancellationToken.None); 75 | 76 | // Assert 77 | await act.Should().ThrowAsync(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Comments/ReplyToCommentCommandHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Comments.ReplyToComment; 2 | using Blogger.Domain.CommentAggregate; 3 | using System.Reflection.Metadata; 4 | 5 | using Blogger.IntegrationTests.Fixtures; 6 | 7 | using FluentAssertions; 8 | 9 | using NSubstitute; 10 | using Blogger.Infrastructure.Persistence.Repositories; 11 | using Blogger.Application.Articles; 12 | using Blogger.Application.Comments.MakeComment; 13 | using Blogger.Infrastructure.Services.Externals; 14 | using Blogger.Infrastructure.Services; 15 | using Blogger.Application.ApplicationServices; 16 | using Blogger.Application; 17 | using Blogger.Domain.ArticleAggregate; 18 | using Microsoft.EntityFrameworkCore; 19 | 20 | namespace Blogger.IntegrationTests.Comments; 21 | 22 | public class ReplyToCommentCommandHandlerTests : IClassFixture 23 | { 24 | private readonly BloggerDbContextFixture _fixture; 25 | 26 | public ReplyToCommentCommandHandlerTests(BloggerDbContextFixture fixture) 27 | { 28 | _fixture = fixture; 29 | } 30 | 31 | [Fact] 32 | public async Task Handle_CommentNotFound_ShouldThrowNotFoundCommentException() 33 | { 34 | // Arrange 35 | var commentId = CommentId.CreateUniqueId(); 36 | var command = new ReplyToCommentCommand (commentId, Client.Create("Nabi Karampour","thisisnabi@outlook.com"), "Test content"); 37 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 38 | var commentRepository = new CommentRepository(dbContext); 39 | 40 | var linkGenerator = new LinkGenerator(); 41 | var emailService = Substitute.For(); 42 | var sut = new ReplyToCommentCommandHandler(commentRepository, emailService, linkGenerator); 43 | 44 | // Act 45 | Func act = async () => await sut.Handle(command, CancellationToken.None); 46 | 47 | // Assert 48 | await act.Should().ThrowAsync(); 49 | } 50 | 51 | [Fact] 52 | public async Task Handle_CommentFound_ShouldReplyToCommentAndSendEmail() 53 | { 54 | // Arrange 55 | var link = new LinkGenerator().Generate(); 56 | var approvelink = ApproveLink.Create(link, DateTime.UtcNow.AddHours(ApplicationSettings.ApproveLink.ExpirationOnHours)); 57 | 58 | var articleId = ArticleId.Create("this-is-nabi"); 59 | var client = Client.Create("Nabi Karampour", "thisisnabi@outlook.com"); 60 | var comment = Comment.Create(articleId, client, "Hi Bye", approvelink); 61 | 62 | comment.Approve(); 63 | 64 | var dbContext = _fixture.BuildDbContext(Guid.NewGuid().ToString()); 65 | var commentRepository = new CommentRepository(dbContext); 66 | await commentRepository.CreateAsync(comment, CancellationToken.None); 67 | await commentRepository.SaveChangesAsync(CancellationToken.None); 68 | 69 | var linkGenerator = new LinkGenerator(); 70 | var emailService = Substitute.For(); 71 | 72 | var sut = new ReplyToCommentCommandHandler(commentRepository, emailService, linkGenerator); 73 | var replayCommand = new ReplyToCommentCommand(comment.Id, client, "Hi Bye"); 74 | 75 | // Act 76 | var response = await sut.Handle(replayCommand, CancellationToken.None); 77 | 78 | // Assert 79 | response.ReplyId.Should().NotBeNull(); 80 | await emailService.Received(1).SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Blogger.IntegrationTests/Articles/GetPopularArticlesHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Blogger.Application.Articles.GetPopularArticles; 2 | using Blogger.Domain.ArticleAggregate; 3 | using Blogger.Infrastructure.Persistence.Repositories; 4 | using Blogger.IntegrationTests.Fixtures; 5 | 6 | using FluentAssertions; 7 | 8 | namespace Blogger.IntegrationTests.Articles; 9 | public class GetPopularArticlesHandlerTests : IClassFixture 10 | { 11 | private readonly BloggerDbContextFixture _fixture; 12 | 13 | public GetPopularArticlesHandlerTests(BloggerDbContextFixture fixture) 14 | { 15 | _fixture = fixture; 16 | } 17 | 18 | [Fact] 19 | public async Task Handle_ShouldReturnPopularArticles_WhenArticlesExist() 20 | { 21 | // Arrange 22 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 23 | var _sut = new GetPopularArticlesHandler(articleRepository); 24 | 25 | var article_1 = Article.CreateArticle("Title 1", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag2")]); 26 | var article_2 = Article.CreateArticle("Title 2", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag2")]); 27 | var article_3 = Article.CreateArticle("Title 3", "Test Body", "Test Summary", [Tag.Create("tag1"), Tag.Create("tag2")]); 28 | 29 | var like_1 = Like.Create("127.0.0.1", DateTime.UtcNow); 30 | var like_2 = Like.Create("127.0.0.2", DateTime.UtcNow); 31 | var like_3 = Like.Create("127.0.0.3", DateTime.UtcNow); 32 | var like_4 = Like.Create("127.0.0.4", DateTime.UtcNow); 33 | var like_5 = Like.Create("127.0.0.1", DateTime.UtcNow); 34 | var like_6 = Like.Create("127.0.0.2", DateTime.UtcNow); 35 | var like_7 = Like.Create("127.0.0.3", DateTime.UtcNow); 36 | var like_8 = Like.Create("127.0.0.4", DateTime.UtcNow); 37 | var like_9 = Like.Create("127.0.0.1", DateTime.UtcNow); 38 | 39 | article_1.Like(like_1); 40 | article_1.Like(like_2); 41 | articleRepository.Add(article_1); 42 | 43 | article_2.Like(like_3); 44 | article_2.Like(like_4); 45 | article_2.Like(like_5); 46 | articleRepository.Add(article_2); 47 | 48 | article_3.Like(like_6); 49 | article_3.Like(like_7); 50 | article_3.Like(like_8); 51 | article_3.Like(like_9); 52 | articleRepository.Add(article_3); 53 | await articleRepository.SaveChangesAsync(CancellationToken.None); 54 | 55 | var request = new GetPopularArticlesQuery(2); 56 | 57 | // Act 58 | var response = await _sut.Handle(request, CancellationToken.None); 59 | 60 | // Assert 61 | response.Should().NotBeNull(); 62 | response.Should().HaveCount(2); 63 | 64 | var firstArticleResponse = response.First(); 65 | firstArticleResponse.ArticleId.Should().Be(article_3.Id); 66 | 67 | var secondArticleResponse = response.Last(); 68 | secondArticleResponse.ArticleId.Should().Be(article_2.Id); 69 | } 70 | 71 | 72 | [Fact] 73 | public async Task Handle_ShouldReturnEmpty_WhenNoArticlesExist() 74 | { 75 | // Arrange 76 | var articleRepository = new ArticleRepository(_fixture.BuildDbContext(Guid.NewGuid().ToString())); 77 | var _sut = new GetPopularArticlesHandler(articleRepository); 78 | 79 | var request = new GetPopularArticlesQuery(2); 80 | 81 | // Act 82 | var response = await _sut.Handle(request, CancellationToken.None); 83 | 84 | // Assert 85 | response.Should().NotBeNull(); 86 | response.Should().BeEmpty(); 87 | } 88 | 89 | 90 | } 91 | --------------------------------------------------------------------------------