diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d7616e7 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +RMQ_USER= +RMQ_PW= +POSTGRES_DB= +POSTGRES_USER= +POSTGRES_PW= +GH_APP_NAME= +GH_CLIENT_ID= +GH_CLIENT_SECRET= \ No newline at end of file diff --git a/Application/Paging/PagedStoriesDto.cs b/Application/Paging/PagedStoriesDto.cs new file mode 100644 index 0000000..6a11cef --- /dev/null +++ b/Application/Paging/PagedStoriesDto.cs @@ -0,0 +1,9 @@ +using Application.Stories; + +namespace Application.Paging; + +public record PagedStoriesDto +{ + public List Stories { get; init; } = new (); + public int TotalPagesCount { get; init; } +} \ No newline at end of file diff --git a/Application/Paging/PagingExtension.cs b/Application/Paging/PagingExtension.cs new file mode 100644 index 0000000..d8978d8 --- /dev/null +++ b/Application/Paging/PagingExtension.cs @@ -0,0 +1,20 @@ +using Application.Stories; + +namespace Application.Paging; + +public static class PagingExtension +{ + public static PagedStoriesDto Paginate(this IEnumerable stories, int skip, int take) + { + if (take <= 0) throw new ArgumentOutOfRangeException(); + + var pagedStories = stories.Skip(skip).Take(take).ToList(); + var totalPagesCount = (int)Math.Ceiling((double)stories.Count() / take); + + return new () + { + Stories = pagedStories, + TotalPagesCount = totalPagesCount, + }; + } +} \ No newline at end of file diff --git a/Application/Stories/IStoriesRepository.cs b/Application/Stories/IStoriesRepository.cs index c69c878..38d2f4e 100644 --- a/Application/Stories/IStoriesRepository.cs +++ b/Application/Stories/IStoriesRepository.cs @@ -8,7 +8,7 @@ public interface IStoriesRepository Task GetByIdAsync(int id); Task> GetByAuthorAsync(string author); Task> GetAllAsync(); - List GetAll(IEnumerable sortingParameters, string? search, int skip, int take); + List GetAll(IEnumerable sortingParameters, string? search); Task AddAsync(Story story); Task UpdateAsync(int id, Story updatedStory); Task DeleteAsync(int id); diff --git a/Application/Stories/IStoriesService.cs b/Application/Stories/IStoriesService.cs index c8e8b19..d29d5ce 100644 --- a/Application/Stories/IStoriesService.cs +++ b/Application/Stories/IStoriesService.cs @@ -1,8 +1,10 @@ +using Application.Paging; + namespace Application.Stories; public interface IStoriesService { Task AddAsync(StoryDto storyDto); Task> GetAllAsync(); - List GetStories(string? orderBy, string? search, int pageNumber, int pageSize); + PagedStoriesDto GetStories(string? orderBy, string? search, int pageNumber, int pageSize); } \ No newline at end of file diff --git a/Application/Stories/StoriesService.cs b/Application/Stories/StoriesService.cs index aa1be67..90b9f31 100644 --- a/Application/Stories/StoriesService.cs +++ b/Application/Stories/StoriesService.cs @@ -1,3 +1,4 @@ +using Application.Paging; using Application.Sort; using AutoMapper; using Domain.Entities; @@ -35,17 +36,19 @@ public async Task> GetAllAsync() return _mapper.Map>(stories); } - public List GetStories(string? orderBy, string? search, int pageNumber, int pageSize) + public PagedStoriesDto GetStories(string? orderBy, string? search, int pageNumber, int pageSize) { var parsedSortingParameters = _sortingParameteresParser.Parse(orderBy); var skip = (pageNumber - 1) * pageSize; var take = pageSize; - var sortedStories = _storiesRepository.GetAll(parsedSortingParameters, search, skip, take); + var sortedStories = _storiesRepository.GetAll(parsedSortingParameters, search); var dtos = _mapper.Map>(sortedStories); var rankedStories = _rankingService.Rank(dtos); - return rankedStories; + var pagedStories = rankedStories.Paginate(skip, take); + + return pagedStories; } -} +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..92c84d5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,14 @@ +This is a respectful environment for everyone before everything else! + +I'd like to ensure that everyone can participate in this project in a friendly and constructive manner. + +So, the **standard guidelines** are: + +- Be respectful +- Be collaborative + +The things I consider **inappropriate**: + +- Personal attacks, insults, or derogatory comments. +- Posting or sharing explicit content. +- Sharing personal information without consent. diff --git a/HackerNewsCommentsFeed/Configuration/CustomExceptionHandler.cs b/HackerNewsCommentsFeed/Configuration/CustomExceptionHandler.cs index 1964d7e..22a5a50 100644 --- a/HackerNewsCommentsFeed/Configuration/CustomExceptionHandler.cs +++ b/HackerNewsCommentsFeed/Configuration/CustomExceptionHandler.cs @@ -36,6 +36,7 @@ private static Task HandleExceptionAsync(HttpContext httpContext, Exception exce { NotFoundException => (int) HttpStatusCode.NotFound, AlreadyExistsException => (int) HttpStatusCode.BadRequest, + ArgumentException => (int) HttpStatusCode.BadRequest, _ => (int) HttpStatusCode.InternalServerError }; diff --git a/HackerNewsCommentsFeed/Configuration/Registrations.cs b/HackerNewsCommentsFeed/Configuration/Registrations.cs index f23efc0..1345523 100644 --- a/HackerNewsCommentsFeed/Configuration/Registrations.cs +++ b/HackerNewsCommentsFeed/Configuration/Registrations.cs @@ -42,7 +42,11 @@ private static IServiceCollection AddGithubAuth(this IServiceCollection services options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = "Github"; }) - .AddCookie() + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => { + options.Cookie.SameSite = SameSiteMode.None; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + }) .AddOAuth("Github", config => { config.ClientId = clientId ?? string.Empty; diff --git a/Infrastructure/Db/Repositories/StoriesRepository.cs b/Infrastructure/Db/Repositories/StoriesRepository.cs index 65bced1..87723a4 100644 --- a/Infrastructure/Db/Repositories/StoriesRepository.cs +++ b/Infrastructure/Db/Repositories/StoriesRepository.cs @@ -61,7 +61,7 @@ public async Task> GetAllAsync() return stories; } - public List GetAll(IEnumerable sortingParameters, string? search, int skip, int take) + public List GetAll(IEnumerable sortingParameters, string? search) { var included = _dbContext.Stories .AsNoTracking() @@ -73,9 +73,6 @@ public List GetAll(IEnumerable sortingParameters, stri var sortedStories = _sorter .Sort(filteredStories, sortingParameters) - .OrderByDescending(s => s.Score) - .Skip(skip) - .Take(take) .ToList(); return sortedStories; diff --git a/README.md b/README.md index fdc9c37..f19a76f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,29 @@ [![.NET](https://github.com/lvchkn/Hackernews-Feed/actions/workflows/build-and-test.yml/badge.svg?branch=main)](https://github.com/lvchkn/Hackernews-Feed/actions/workflows/build-and-test.yml) # Hackernews-Feed + +This API acts as a kind of proxy backend for my custom [HN client](https://github.com/lvchkn/hn-client) and provides RESTful endpoints that allow for sorting, filtering and paging stories from Hackernews. + +This is only partially implemented and still is under development, but the idea is that users may authenticate to the custom client page via Github, specify their topics of interest, and get a personal stories feed based on their preferences. + +## How to run locally + +1. Create a `.env` file in the project's root directory and fill it in according to the `.env.example` file. +1. Start the compose stack + + ```bash + docker compose -f docker-compose.local up + ``` + +1. Run the following command from the root's directory: + + ```bash + dotnet run --project HackerNewsCommentsFeed/HackerNewsCommentsFeed.csproj + ``` + +1. Navigate to `http://localhost/index.html` or `https://localhost:7245` to see the Swagger OpenAPI definition. + +## How to run the tests + +Just run the `dotnet test` command in the project's root directory. +Note that the entries in the `.env` file related to the authentication must be prepopulated before running the tests to avoid errors during the test server's startup. diff --git a/Tests/Integration/DataSeeder.cs b/Tests/Integration/DataSeeder.cs index 3a6ae08..64050b5 100644 --- a/Tests/Integration/DataSeeder.cs +++ b/Tests/Integration/DataSeeder.cs @@ -5,11 +5,12 @@ namespace Tests.Integration; public static class DataSeeder { - public static void SeedStories(this AppDbContext dbContext) + public static int SeedStories(this AppDbContext dbContext) { dbContext.Stories.RemoveRange(dbContext.Stories); - - dbContext.Stories?.AddRange( + + var stories = new [] + { new Story() { By = "User1", @@ -44,10 +45,14 @@ public static void SeedStories(this AppDbContext dbContext) Title = "B Story", Score = 250, Time = 9, - } - ); + }, + }; + + dbContext.Stories?.AddRange(stories); dbContext.SaveChanges(); + + return stories.Length; } public static void SeedUsers(this AppDbContext dbContext) diff --git a/Tests/Integration/StoriesControllerTests.cs b/Tests/Integration/StoriesControllerTests.cs index 062f071..1f06842 100644 --- a/Tests/Integration/StoriesControllerTests.cs +++ b/Tests/Integration/StoriesControllerTests.cs @@ -1,5 +1,7 @@ +using System.Net; using System.Net.Http.Headers; using System.Text.Json; +using Application.Paging; using Application.Stories; using Application.Tags; using Domain.Entities; @@ -28,7 +30,7 @@ public StoriesControllerTests(CustomWebApplicationFactory webAppFactory } [Fact] - public async Task All_stories_are_returned_when_no_query_provided() + public async Task Up_to_first_10_stories_are_returned_when_no_query_provided() { // Arrange var client = _webAppFactory.CreateClient(); @@ -36,16 +38,20 @@ public async Task All_stories_are_returned_when_no_query_provided() using var scope = _webAppFactory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext?.SeedStories(); + var storiesCount = dbContext?.SeedStories(); + + var expectedStories = dbContext?.Stories + .Select(s => MapToStoryDto(s)) + .ToList(); // Act var response = await client.GetAsync("/api/stories"); var responseJson = await response.Content.ReadAsStringAsync(); - var returnedStories = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); + var pagedData = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); // Assert - returnedStories.Should().NotBeNull(); - returnedStories?.Length.Should().Be(5); + pagedData?.Stories.Should().BeEquivalentTo(expectedStories, options => options.Excluding(s => s.Rank)); + pagedData?.Stories.Count.Should().Be(storiesCount); } [Fact] @@ -57,9 +63,9 @@ public async Task Stories_are_sorted_by_title_in_desc_order() using var scope = _webAppFactory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext?.SeedStories(); + var storiesCount = dbContext?.SeedStories(); - var expectedResults = dbContext?.Stories + var expectedStories = dbContext?.Stories .OrderByDescending(s => s.Title) .Select(s => MapToStoryDto(s)) .ToList(); @@ -67,10 +73,11 @@ public async Task Stories_are_sorted_by_title_in_desc_order() // Act var response = await client.GetAsync("/api/stories?orderBy=title,asc"); var responseJson = await response.Content.ReadAsStringAsync(); - var returnedStories = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); + var pagedData = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); // Assert - returnedStories.Should().BeEquivalentTo(expectedResults, options => options.Excluding(s => s.Rank)); + pagedData?.Stories.Should().BeEquivalentTo(expectedStories, options => options.Excluding(s => s.Rank)); + pagedData?.Stories.Count.Should().Be(storiesCount); } [Theory] @@ -85,15 +92,21 @@ public async Task Stories_are_filtered_by_title_with_fuzzy_search(string search) using var scope = _webAppFactory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext?.SeedStories(); + var storiesCount = dbContext?.SeedStories(); + + var expectedStories = dbContext?.Stories + .Where(s => EF.Functions.ILike(s.Title, $"%{search}%")) + .Select(s => MapToStoryDto(s)) + .ToList(); // Act var response = await client.GetAsync($"/api/stories?search={search}"); var responseJson = await response.Content.ReadAsStringAsync(); - var returnedStories = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); + var pagedData = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); // Assert - returnedStories?.Length.Should().Be(5); + pagedData?.Stories.Should().BeEquivalentTo(expectedStories, options => options.Excluding(s => s.Rank)); + pagedData?.Stories.Count.Should().Be(expectedStories?.Count); } [Theory] @@ -108,10 +121,10 @@ public async Task Stories_are_ordered_and_filtered(string search) using var scope = _webAppFactory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext?.SeedStories(); + var storiesCount = dbContext?.SeedStories(); var orderBy = "score asc"; - var expectedResults = dbContext?.Stories + var expectedStories = dbContext?.Stories .Where(s => EF.Functions.ILike(s.Title, $"%{search}%")) .OrderBy(s => s.Score) .Select(s => MapToStoryDto(s)) @@ -120,21 +133,29 @@ public async Task Stories_are_ordered_and_filtered(string search) // Act var response = await client.GetAsync($"/api/stories?orderBy={orderBy}&search={search}"); var responseJson = await response.Content.ReadAsStringAsync(); - var returnedStories = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); + var pagedData = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); // Assert - returnedStories?.Should().BeEquivalentTo(expectedResults, options => options.Excluding(s => s.Rank)); + pagedData?.Stories.Should().BeEquivalentTo(expectedStories, options => options.Excluding(s => s.Rank)); + pagedData?.Stories.Count.Should().Be(expectedStories?.Count); } [Theory] - [InlineData(2, 2, 2)] - [InlineData(2, 1, 1)] - [InlineData(4, 1, 1)] - [InlineData(5, 0, 0)] - [InlineData(5, 1, 1)] - [InlineData(1, 5, 5)] - [InlineData(3, 2, 1)] - public async Task Pagination_Works(int pageNumber, int pageSize, int result) + [InlineData(2, 2, 2, 3)] + [InlineData(2, 1, 1, 5)] + [InlineData(4, 2, 0, 3)] + [InlineData(5, 1, 1, 5)] + [InlineData(1, 5, 5, 1)] + [InlineData(3, 2, 1, 3)] + [InlineData(2, 3, 2, 2)] + [InlineData(1, 3, 3, 2)] + [InlineData(0, 2, 2, 3)] + [InlineData(-1, 2, 2, 3)] + [InlineData(20, 30, 0, 1)] + [InlineData(20, 3, 0, 2)] + [InlineData(-20, 3, 3, 2)] + [InlineData(-20, 30, 5, 1)] + public async Task Pagination_Works(int pageNumber, int pageSize, int returnedStoriesCount, int totalPagesCount) { // Arrange var client = _webAppFactory.CreateClient(); @@ -142,14 +163,38 @@ public async Task Pagination_Works(int pageNumber, int pageSize, int result) using var scope = _webAppFactory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext?.SeedStories(); + var storiesCount = dbContext?.SeedStories(); // Act var response = await client.GetAsync($"/api/stories?pageNumber={pageNumber}&pageSize={pageSize}"); var responseJson = await response.Content.ReadAsStringAsync(); - var returnedStories = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); + var pagedData = JsonSerializer.Deserialize(responseJson, _jsonSerializerOptions); + + // Assert + pagedData?.Stories.Count.Should().Be(returnedStoriesCount); + pagedData?.TotalPagesCount.Should().Be(totalPagesCount); + } + + [Theory] + [InlineData(2, 0)] + [InlineData(0, 0)] + [InlineData(2, -1)] + [InlineData(-1, -1)] + public async Task Pagination_Edge_Cases(int pageNumber, int pageSize) + { + // Arrange + var client = _webAppFactory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test"); + + using var scope = _webAppFactory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var storiesCount = dbContext?.SeedStories(); + + // Act + var response = await client.GetAsync($"/api/stories?pageNumber={pageNumber}&pageSize={pageSize}"); + // Assert - returnedStories?.Length.Should().Be(result); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } private static StoryDto MapToStoryDto(Story story)