diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 0fc8aa3995..8c20805c1f 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -45,6 +45,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextExample", "sr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextTests", "test\MultiDbContextTests\MultiDbContextTests.csproj", "{EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestBuildingBlocks", "test\TestBuildingBlocks\TestBuildingBlocks.csproj", "{210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -199,6 +201,18 @@ Global {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x64.Build.0 = Release|Any CPU {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x86.ActiveCfg = Release|Any CPU {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x86.Build.0 = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|x64.ActiveCfg = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|x64.Build.0 = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|x86.ActiveCfg = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|x86.Build.0 = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|Any CPU.Build.0 = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x64.ActiveCfg = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x64.Build.0 = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.ActiveCfg = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -216,6 +230,7 @@ Global {21D27239-138D-4604-8E49-DCBE41BCE4C8} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {6CAFDDBE-00AB-4784-801B-AB419C3C3A26} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index 125c6a23a1..7ff26077e5 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -74,7 +74,7 @@ The next option is to use the ActionFilter attributes that ship with the library - `HttpReadOnly`: all of the above Not only does this reduce boilerplate, but it also provides a more meaningful HTTP response code. -An attempt to use one blacklisted methods will result in a HTTP 405 Method Not Allowed response. +An attempt to use one of the blacklisted methods will result in a HTTP 405 Method Not Allowed response. ```c# [HttpReadOnly] diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs index b58223da50..95781423b6 100644 --- a/src/Examples/GettingStarted/Data/SampleDbContext.cs +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -12,9 +12,9 @@ public SampleDbContext(DbContextOptions options) { } - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder builder) { - modelBuilder.Entity(); + builder.Entity(); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs new file mode 100644 index 0000000000..90a864f07c --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs @@ -0,0 +1,51 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace JsonApiDotNetCoreExample.Controllers +{ + [Route("[controller]")] + public sealed class NonJsonApiController : ControllerBase + { + [HttpGet] + public IActionResult Get() + { + var result = new[] {"Welcome!"}; + return Ok(result); + } + + [HttpPost] + public async Task PostAsync() + { + string name = await new StreamReader(Request.Body).ReadToEndAsync(); + + if (string.IsNullOrEmpty(name)) + { + return BadRequest("Please send your name."); + } + + var result = "Hello, " + name; + return Ok(result); + } + + [HttpPut] + public IActionResult Put([FromBody] string name) + { + var result = "Hi, " + name; + return Ok(result); + } + + [HttpPatch] + public IActionResult Patch(string name) + { + var result = "Good day, " + name; + return Ok(result); + } + + [HttpDelete] + public IActionResult Delete() + { + return Ok("Bye."); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs index 62fa1e96c3..0a737184b7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCoreExample.Controllers { public sealed class PassportsController : JsonApiController { - public PassportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + public PassportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PersonRolesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PersonRolesController.cs deleted file mode 100644 index 75c930126f..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PersonRolesController.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - public sealed class PersonRolesController : JsonApiController - { - public PersonRolesController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs deleted file mode 100644 index cbdbdbc2a8..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs +++ /dev/null @@ -1,106 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers.Restricted -{ - [DisableRoutingConvention, Route("[controller]")] - [HttpReadOnly] - public class ReadOnlyController : BaseJsonApiController
- { - public ReadOnlyController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public IActionResult Get() => Ok(); - - [HttpPost] - public IActionResult Post() => Ok(); - - [HttpPatch] - public IActionResult Patch() => Ok(); - - [HttpDelete] - public IActionResult Delete() => Ok(); - } - - [DisableRoutingConvention, Route("[controller]")] - [NoHttpPost] - public class NoHttpPostController : BaseJsonApiController
- { - public NoHttpPostController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public IActionResult Get() => Ok(); - - [HttpPost] - public IActionResult Post() => Ok(); - - [HttpPatch] - public IActionResult Patch() => Ok(); - - [HttpDelete] - public IActionResult Delete() => Ok(); - } - - [DisableRoutingConvention, Route("[controller]")] - [NoHttpPatch] - public class NoHttpPatchController : BaseJsonApiController
- { - public NoHttpPatchController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public IActionResult Get() => Ok(); - - [HttpPost] - public IActionResult Post() => Ok(); - - [HttpPatch] - public IActionResult Patch() => Ok(); - - [HttpDelete] - public IActionResult Delete() => Ok(); - } - - [DisableRoutingConvention, Route("[controller]")] - [NoHttpDelete] - public class NoHttpDeleteController : BaseJsonApiController
- { - public NoHttpDeleteController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public IActionResult Get() => Ok(); - - [HttpPost] - public IActionResult Post() => Ok(); - - [HttpPatch] - public IActionResult Patch() => Ok(); - - [HttpDelete] - public IActionResult Delete() => Ok(); - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs deleted file mode 100644 index bccf2192d6..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs +++ /dev/null @@ -1,20 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - [DisableQueryString("skipCache")] - public sealed class TagsController : JsonApiController - { - public TagsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs deleted file mode 100644 index 9487df1c3b..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace JsonApiDotNetCoreExample.Controllers -{ - [Route("[controller]")] - public class TestValuesController : ControllerBase - { - [HttpGet] - public IActionResult Get() - { - var result = new[] { "value" }; - return Ok(result); - } - - [HttpPost] - public IActionResult Post(string name) - { - var result = "Hello, " + name; - return Ok(result); - } - - [HttpPatch] - public IActionResult Patch(string name) - { - var result = "Hello, " + name; - return Ok(result); - } - - [HttpDelete] - public IActionResult Delete() - { - return Ok("Deleted"); - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs deleted file mode 100644 index 8b662cde09..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - public sealed class ThrowingResourcesController : JsonApiController - { - public ThrowingResourcesController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs deleted file mode 100644 index 1745269b38..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - public sealed class TodoCollectionsController : JsonApiController - { - private readonly IDbContextResolver _dbResolver; - - public TodoCollectionsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IDbContextResolver contextResolver, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - _dbResolver = contextResolver; - } - - [HttpPatch("{id}")] - public override async Task PatchAsync(Guid id, [FromBody] TodoItemCollection resource, CancellationToken cancellationToken) - { - if (resource.Name == "PRE-ATTACH-TEST") - { - var targetTodoId = resource.TodoItems.First().Id; - var todoItemContext = _dbResolver.GetContext().Set(); - await todoItemContext.Where(ti => ti.Id == targetTodoId).FirstOrDefaultAsync(cancellationToken); - } - - return await base.PatchAsync(id, resource, cancellationToken); - } - - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs deleted file mode 100644 index 721f126648..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; - -namespace JsonApiDotNetCoreExample.Controllers -{ - [ApiController] - [DisableRoutingConvention, Route("custom/route/todoItems")] - public class TodoItemsCustomController : CustomJsonApiController - { - public TodoItemsCustomController( - IJsonApiOptions options, - IResourceService resourceService) - : base(options, resourceService) - { } - } - - public class CustomJsonApiController - : CustomJsonApiController where T : class, IIdentifiable - { - public CustomJsonApiController( - IJsonApiOptions options, - IResourceService resourceService) - : base(options, resourceService) - { - } - } - - public class CustomJsonApiController - : ControllerBase where T : class, IIdentifiable - { - private readonly IJsonApiOptions _options; - private readonly IResourceService _resourceService; - - private IActionResult Forbidden() - { - return new StatusCodeResult((int)HttpStatusCode.Forbidden); - } - - public CustomJsonApiController( - IJsonApiOptions options, - IResourceService resourceService) - { - _options = options; - _resourceService = resourceService; - } - - public CustomJsonApiController( - IResourceService resourceService) - { - _resourceService = resourceService; - } - - [HttpGet] - public async Task GetAsync(CancellationToken cancellationToken) - { - var resources = await _resourceService.GetAsync(cancellationToken); - return Ok(resources); - } - - [HttpGet("{id}")] - public async Task GetAsync(TId id, CancellationToken cancellationToken) - { - try - { - var resource = await _resourceService.GetAsync(id, cancellationToken); - return Ok(resource); - } - catch (ResourceNotFoundException) - { - return NotFound(); - } - } - - [HttpGet("{id}/relationships/{relationshipName}")] - public async Task GetRelationshipsAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - try - { - var relationship = await _resourceService.GetRelationshipAsync(id, relationshipName, cancellationToken); - return Ok(relationship); - } - catch (ResourceNotFoundException) - { - return NotFound(); - } - } - - [HttpGet("{id}/{relationshipName}")] - public async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - var relationship = await _resourceService.GetSecondaryAsync(id, relationshipName, cancellationToken); - return Ok(relationship); - } - - [HttpPost] - public async Task PostAsync([FromBody] T resource, CancellationToken cancellationToken) - { - if (resource == null) - return UnprocessableEntity(); - - if (_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) - return Forbidden(); - - resource = await _resourceService.CreateAsync(resource, cancellationToken); - - return Created($"{HttpContext.Request.Path}/{resource.Id}", resource); - } - - [HttpPatch("{id}")] - public async Task PatchAsync(TId id, [FromBody] T resource, CancellationToken cancellationToken) - { - if (resource == null) - return UnprocessableEntity(); - - try - { - var updated = await _resourceService.UpdateAsync(id, resource, cancellationToken); - return Ok(updated); - } - catch (ResourceNotFoundException) - { - return NotFound(); - } - } - - [HttpPatch("{id}/relationships/{relationshipName}")] - public async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds, CancellationToken cancellationToken) - { - await _resourceService.SetRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - - return Ok(); - } - - [HttpDelete("{id}")] - public async Task DeleteAsync(TId id, CancellationToken cancellationToken) - { - await _resourceService.DeleteAsync(id, cancellationToken); - return NoContent(); - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs deleted file mode 100644 index b4d0f9fe2c..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - public abstract class AbstractTodoItemsController - : BaseJsonApiController where T : class, IIdentifiable - { - protected AbstractTodoItemsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService service) - : base(options, loggerFactory, service) - { } - } - - [DisableRoutingConvention] - [Route("/abstract")] - public class TodoItemsTestController : AbstractTodoItemsController - { - public TodoItemsTestController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService service) - : base(options, loggerFactory, service) - { } - - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } - - [HttpGet("{id}")] - public override async Task GetAsync(int id, CancellationToken cancellationToken) - { - return await base.GetAsync(id, cancellationToken); - } - - [HttpGet("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); - } - - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); - } - - [HttpPost] - public override async Task PostAsync([FromBody] TodoItem resource, CancellationToken cancellationToken) - { - await Task.Yield(); - - return NotFound(new Error(HttpStatusCode.NotFound) - { - Title = "NotFound ActionResult with explicit error object." - }); - } - - [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync( - int id, string relationshipName, [FromBody] ISet secondaryResourceIds, CancellationToken cancellationToken) - { - return await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } - - [HttpPatch("{id}")] - public override async Task PatchAsync(int id, [FromBody] TodoItem resource, CancellationToken cancellationToken) - { - await Task.Yield(); - - return Conflict("Something went wrong"); - } - - [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync( - int id, string relationshipName, [FromBody] object secondaryResourceIds, CancellationToken cancellationToken) - { - return await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } - - [HttpDelete("{id}")] - public override async Task DeleteAsync(int id, CancellationToken cancellationToken) - { - await Task.Yield(); - - return NotFound(); - } - - [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(int id, string relationshipName, [FromBody] ISet secondaryResourceIds, CancellationToken cancellationToken) - { - return await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/UsersController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/UsersController.cs index 2411879bb7..312c0f50bc 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/UsersController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/UsersController.cs @@ -15,14 +15,4 @@ public UsersController( : base(options, loggerFactory, resourceService) { } } - - public sealed class SuperUsersController : JsonApiController - { - public SuperUsersController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { } - } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 7221b18492..8c063c42d7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,87 +1,55 @@ -using System; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCoreExample.Data { public sealed class AppDbContext : DbContext { - public ISystemClock SystemClock { get; } - public DbSet TodoItems { get; set; } - public DbSet Passports { get; set; } public DbSet People { get; set; } - public DbSet TodoItemCollections { get; set; } - public DbSet KebabCasedModels { get; set; } public DbSet
Articles { get; set; } public DbSet AuthorDifferentDbContextName { get; set; } public DbSet Users { get; set; } - public DbSet PersonRoles { get; set; } - public DbSet ArticleTags { get; set; } - public DbSet Tags { get; set; } - public DbSet Blogs { get; set; } - public AppDbContext(DbContextOptions options, ISystemClock systemClock) : base(options) + public AppDbContext(DbContextOptions options) : base(options) { - SystemClock = systemClock ?? throw new ArgumentNullException(nameof(systemClock)); } - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder builder) { - modelBuilder.Entity(); - - modelBuilder.Entity().HasBaseType(); - - modelBuilder.Entity() - .Property(t => t.CreatedDate).HasDefaultValueSql("CURRENT_TIMESTAMP").IsRequired(); - - modelBuilder.Entity() + builder.Entity() .HasOne(t => t.Assignee) .WithMany(p => p.AssignedTodoItems); - modelBuilder.Entity() + builder.Entity() .HasOne(t => t.Owner) .WithMany(p => p.TodoItems); - modelBuilder.Entity() + builder.Entity() .HasKey(bc => new {bc.ArticleId, bc.TagId}); - modelBuilder.Entity() + builder.Entity() .HasKey(bc => new {bc.ArticleId, bc.TagId}); - modelBuilder.Entity() + builder.Entity() .HasOne(t => t.StakeHolderTodoItem) .WithMany(t => t.StakeHolders) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasOne(t => t.DependentOnTodo); - - modelBuilder.Entity() + builder.Entity() .HasMany(t => t.ChildrenTodos) .WithOne(t => t.ParentTodo); - modelBuilder.Entity() + builder.Entity() .HasOne(p => p.Person) .WithOne(p => p.Passport) .HasForeignKey("PassportKey") .OnDelete(DeleteBehavior.SetNull); - modelBuilder.Entity() + builder.Entity() .HasOne(p => p.OneToOnePerson) .WithOne(p => p.OneToOneTodoItem) .HasForeignKey("OneToOnePersonKey"); - - modelBuilder.Entity() - .HasOne(p => p.Owner) - .WithMany(p => p.TodoCollections) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(p => p.Role) - .WithOne(p => p.Person) - .HasForeignKey("PersonRoleKey"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableHooksDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableHooksDefinition.cs index bd533d715c..19c0708ba2 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableHooksDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableHooksDefinition.cs @@ -11,17 +11,21 @@ namespace JsonApiDotNetCoreExample.Definitions { public abstract class LockableHooksDefinition : ResourceHooksDefinition where T : class, IIsLockable, IIdentifiable { - protected LockableHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + private readonly IResourceGraph _resourceGraph; + protected LockableHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + { + _resourceGraph = resourceGraph; + } protected void DisallowLocked(IEnumerable resources) { - foreach (var e in resources ?? Enumerable.Empty()) + foreach (var resource in resources ?? Enumerable.Empty()) { - if (e.IsLocked) + if (resource.IsLocked) { throw new JsonApiException(new Error(HttpStatusCode.Forbidden) { - Title = "You are not allowed to update fields or relationships of locked todo items." + Title = $"You are not allowed to update fields or relationships of locked resource of type '{_resourceGraph.GetResourceContext().PublicName}'." }); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportHooksDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportHooksDefinition.cs index 4ecc08dec3..ec5cd3ed8f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportHooksDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportHooksDefinition.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreExample.Definitions { - public class PassportHooksDefinition : ResourceHooksDefinition + public class PassportHooksDefinition : LockableHooksDefinition { public PassportHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { @@ -29,26 +29,12 @@ public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = fal public override void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) { - resourcesByRelationship.GetByRelationship().ToList().ForEach(kvp => DoesNotTouchLockedPassports(kvp.Value)); + resourcesByRelationship.GetByRelationship().ToList().ForEach(kvp => DisallowLocked(kvp.Value)); } public override IEnumerable OnReturn(HashSet resources, ResourcePipeline pipeline) { return resources.Where(p => !p.IsLocked); } - - private void DoesNotTouchLockedPassports(IEnumerable resources) - { - foreach (var passport in resources ?? Enumerable.Empty()) - { - if (passport.IsLocked) - { - throw new JsonApiException(new Error(HttpStatusCode.Forbidden) - { - Title = "You are not allowed to update fields or relationships of locked persons." - }); - } - } - } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoHooksDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemHooksDefinition.cs similarity index 86% rename from src/Examples/JsonApiDotNetCoreExample/Definitions/TodoHooksDefinition.cs rename to src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemHooksDefinition.cs index c3fc7af2e5..33fa998337 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoHooksDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemHooksDefinition.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreExample.Definitions { - public class TodoHooksDefinition : LockableHooksDefinition + public class TodoItemHooksDefinition : LockableHooksDefinition { - public TodoHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + public TodoItemHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Dockerfile b/src/Examples/JsonApiDotNetCoreExample/Dockerfile deleted file mode 100644 index c5a5d90ff4..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM microsoft/dotnet:latest - -COPY . /app - -WORKDIR /app - -RUN ["dotnet", "restore"] - -RUN ["dotnet", "build"] - -EXPOSE 14140/tcp - -CMD ["dotnet", "run", "--server.urls", "http://*:14140"] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs deleted file mode 100644 index a84436df31..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - public sealed class Address : Identifiable - { - [Attr] - public string Street { get; set; } - - [Attr] - public string ZipCode { get; set; } - - [HasOne] - public Country Country { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs index 455ed51039..65addba5a3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs @@ -13,9 +13,6 @@ public sealed class Article : Identifiable [Attr] public string Url { get; set; } - [HasOne] - public Author Author { get; set; } - [NotMapped] [HasManyThrough(nameof(ArticleTags))] public ISet Tags { get; set; } @@ -25,11 +22,5 @@ public sealed class Article : Identifiable [HasManyThrough(nameof(IdentifiableArticleTags))] public ICollection IdentifiableTags { get; set; } public ICollection IdentifiableArticleTags { get; set; } - - [HasMany] - public ICollection Revisions { get; set; } - - [HasOne] - public Blog Blog { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs index 7280263468..cd023ba729 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -13,15 +12,6 @@ public sealed class Author : Identifiable [Attr] public string LastName { get; set; } - [Attr] - public DateTime? DateOfBirth { get; set; } - - [Attr] - public string BusinessEmail { get; set; } - - [HasOne] - public Address LivingAddress { get; set; } - [HasMany] public IList
Articles { get; set; } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs deleted file mode 100644 index 0330150ca3..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - public sealed class Blog : Identifiable - { - [Attr] - public string Title { get; set; } - - [Attr] - public string CompanyName { get; set; } - - [HasMany] - public IList
Articles { get; set; } - - [HasOne] - public Author Owner { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Gender.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Gender.cs deleted file mode 100644 index 4990932a0a..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Gender.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace JsonApiDotNetCoreExample.Models -{ - public enum Gender - { - Unknown, - Male, - Female - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs index 3183540aba..1d64298d7d 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/IdentifiableArticleTag.cs @@ -12,7 +12,5 @@ public class IdentifiableArticleTag : Identifiable public int TagId { get; set; } [HasOne] public Tag Tag { get; set; } - - public string SomeMetaData { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs b/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs deleted file mode 100644 index d9e4c4bbf9..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - public class KebabCasedModel : Identifiable - { - [Attr] - public string CompoundAttr { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index 16daf865f5..0945d47938 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -1,58 +1,14 @@ -using System; -using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Authentication; namespace JsonApiDotNetCoreExample.Models { - public class Passport : Identifiable + public class Passport : Identifiable, IIsLockable { - private readonly ISystemClock _systemClock; - private int? _socialSecurityNumber; - - [Attr] - public int? SocialSecurityNumber - { - get => _socialSecurityNumber; - set - { - if (value != _socialSecurityNumber) - { - LastSocialSecurityNumberChange = _systemClock.UtcNow.LocalDateTime; - _socialSecurityNumber = value; - } - } - } - - [Attr] - public DateTime LastSocialSecurityNumberChange { get; set; } - [Attr] public bool IsLocked { get; set; } [HasOne] public Person Person { get; set; } - - [Attr] - [NotMapped] - public string BirthCountryName - { - get => BirthCountry?.Name; - set - { - BirthCountry ??= new Country(); - BirthCountry.Name = value; - } - } - - [EagerLoad] - public Country BirthCountry { get; set; } - - public Passport(AppDbContext appDbContext) - { - _systemClock = appDbContext.SystemClock; - } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index b5d67fb5a0..17f6a26473 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,72 +1,31 @@ using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExample.Models { - public sealed class PersonRole : Identifiable - { - [HasOne] - public Person Person { get; set; } - } - public sealed class Person : Identifiable, IIsLockable { - private string _firstName; - public bool IsLocked { get; set; } [Attr] - public string FirstName - { - get => _firstName; - set - { - if (value != _firstName) - { - _firstName = value; - Initials = string.Concat(value.Split(' ').Select(x => char.ToUpperInvariant(x[0]))); - } - } - } - - [Attr] - public string Initials { get; set; } + public string FirstName { get; set; } [Attr] public string LastName { get; set; } - [Attr(PublicName = "the-Age")] - public int Age { get; set; } - - [Attr] - public Gender Gender { get; set; } - - [Attr] - public string Category { get; set; } - [HasMany] public ISet TodoItems { get; set; } [HasMany] public ISet AssignedTodoItems { get; set; } - [HasMany] - public HashSet TodoCollections { get; set; } - - [HasOne] - public PersonRole Role { get; set; } - [HasOne] public TodoItem OneToOneTodoItem { get; set; } [HasOne] public TodoItem StakeHolderTodoItem { get; set; } - [HasOne(Links = LinkTypes.All, CanInclude = false)] - public TodoItem UnIncludeableItem { get; set; } - [HasOne] public Passport Passport { get; set; } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Revision.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Revision.cs deleted file mode 100644 index 7b9beb3f7c..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Revision.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - public sealed class Revision : Identifiable - { - [Attr] - public DateTime PublishTime { get; set; } - - [HasOne] - public Author Author { get; set; } - - [HasOne] - public Article Article { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index ace6f23711..995adea17e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -9,13 +7,5 @@ public class Tag : Identifiable { [Attr] public string Name { get; set; } - - [Attr] - public TagColor Color { get; set; } - - [NotMapped] - [HasManyThrough(nameof(ArticleTags))] - public ISet
Articles { get; set; } - public ISet ArticleTags { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs deleted file mode 100644 index 8ae4552afe..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TagColor.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace JsonApiDotNetCoreExample.Models -{ - public enum TagColor - { - Red, - Green, - Blue - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs deleted file mode 100644 index cf2f963e2a..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization; - -namespace JsonApiDotNetCoreExample.Models -{ - public sealed class ThrowingResource : Identifiable - { - [Attr] - public string FailsOnSerialize - { - get - { - var isSerializingResponse = new StackTrace().GetFrames() - .Any(frame => frame.GetMethod().DeclaringType == typeof(JsonApiWriter)); - - if (isSerializingResponse) - { - throw new InvalidOperationException($"The value for the '{nameof(FailsOnSerialize)}' property is currently unavailable."); - } - - return string.Empty; - } - set { } - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 64afada036..bc06420ad7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -12,28 +11,6 @@ public class TodoItem : Identifiable, IIsLockable [Attr] public string Description { get; set; } - [Attr] - public long Ordinal { get; set; } - - [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowCreate)] - public string AlwaysChangingValue - { - get => Guid.NewGuid().ToString(); - set { } - } - - [Attr] - public DateTime CreatedDate { get; set; } - - [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] - public DateTime? AchievedDate { get; set; } - - [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] - public string CalculatedValue => "calculated"; - - [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] - public DateTimeOffset? OffsetDate { get; set; } - [HasOne] public Person Owner { get; set; } @@ -46,13 +23,6 @@ public string AlwaysChangingValue [HasMany] public ISet StakeHolders { get; set; } - [HasOne] - public TodoItemCollection Collection { get; set; } - - // cyclical to-one structure - [HasOne] - public TodoItem DependentOnTodo { get; set; } - // cyclical to-many structure [HasOne] public TodoItem ParentTodo { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs deleted file mode 100644 index 4f3c88e152..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - [Resource("todoCollections")] - public sealed class TodoItemCollection : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasMany] - public ISet TodoItems { get; set; } - - [HasOne] - public Person Owner { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs index fde27f2922..f61cf6e7ef 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs @@ -1,46 +1,14 @@ -using System; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Authentication; namespace JsonApiDotNetCoreExample.Models { public class User : Identifiable { - private readonly ISystemClock _systemClock; - private string _password; - - [Attr] public string UserName { get; set; } + [Attr] + public string UserName { get; set; } [Attr(Capabilities = AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] - public string Password - { - get => _password; - set - { - if (value != _password) - { - _password = value; - LastPasswordChange = _systemClock.UtcNow.LocalDateTime; - } - } - } - - [Attr] public DateTime LastPasswordChange { get; set; } - - public User(AppDbContext appDbContext) - { - _systemClock = appDbContext.SystemClock; - } - } - - public sealed class SuperUser : User - { - [Attr] public int SecurityLevel { get; set; } - - public SuperUser(AppDbContext appDbContext) : base(appDbContext) - { - } + public string Password { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs deleted file mode 100644 index b71a1e9e57..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Services -{ - public class CustomArticleService : JsonApiResourceService
- { - public CustomArticleService( - IResourceRepositoryAccessor repositoryAccessor, - IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IJsonApiRequest request, - IResourceChangeTracker
resourceChangeTracker, - IResourceHookExecutorFacade hookExecutor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, hookExecutor) - { } - - public override async Task
GetAsync(int id, CancellationToken cancellationToken) - { - var resource = await base.GetAsync(id, cancellationToken); - resource.Caption = "None for you Glen Coco"; - return resource; - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 628e566a58..553ad27999 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -1,8 +1,6 @@ using System; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -16,9 +14,11 @@ namespace JsonApiDotNetCoreExample { public class Startup : EmptyStartup { + private static readonly Version _postgresCiBuildVersion = new Version(9, 6); private readonly string _connectionString; - public Startup(IConfiguration configuration) : base(configuration) + public Startup(IConfiguration configuration) + : base(configuration) { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); @@ -26,31 +26,18 @@ public Startup(IConfiguration configuration) : base(configuration) public override void ConfigureServices(IServiceCollection services) { - ConfigureClock(services); - - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddDbContext(options => { options.EnableSensitiveDataLogging(); - options.UseNpgsql(_connectionString, innerOptions => innerOptions.SetPostgresVersion(new Version(9, 6))); - }, - // TODO: Remove ServiceLifetime.Transient, after all integration tests have been converted to use IntegrationTestContext. - ServiceLifetime.Transient); + options.UseNpgsql(_connectionString, postgresOptions => postgresOptions.SetPostgresVersion(_postgresCiBuildVersion)); + }); services.AddJsonApi(ConfigureJsonApiOptions, discovery => discovery.AddCurrentAssembly()); - - // once all tests have been moved to WebApplicationFactory format we can get rid of this line below - services.AddClientSerialization(); - } - - protected virtual void ConfigureClock(IServiceCollection services) - { - services.AddSingleton(); } - protected virtual void ConfigureJsonApiOptions(JsonApiOptions options) + protected void ConfigureJsonApiOptions(JsonApiOptions options) { options.IncludeExceptionStackTraceInErrors = true; options.Namespace = "api/v1"; @@ -68,7 +55,7 @@ public override void Configure(IApplicationBuilder app, IWebHostEnvironment envi var appDbContext = scope.ServiceProvider.GetRequiredService(); appDbContext.Database.EnsureCreated(); } - + app.UseRouting(); app.UseJsonApi(); app.UseEndpoints(endpoints => endpoints.MapControllers()); diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs deleted file mode 100644 index e1af39084c..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCoreExample -{ - public class TestStartup : Startup - { - public TestStartup(IConfiguration configuration) : base(configuration) - { - } - - protected override void ConfigureClock(IServiceCollection services) - { - services.AddSingleton(); - } - - /// - /// Advances the clock one second each time the current time is requested. - /// - private class TickingSystemClock : ISystemClock - { - private DateTimeOffset _utcNow; - - public DateTimeOffset UtcNow - { - get - { - var utcNow = _utcNow; - _utcNow = _utcNow.AddSeconds(1); - return utcNow; - } - } - - public TickingSystemClock() - : this(new DateTimeOffset(new DateTime(2000, 1, 1))) - { - } - - public TickingSystemClock(DateTimeOffset utcNow) - { - _utcNow = utcNow; - } - } - } -} diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index c787096182..363db95d4b 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -52,12 +52,12 @@ public async Task CreateAsync(WorkItem resource, CancellationToken can return (await QueryAsync(async connection => { var query = - @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + - @"(@description, @isLocked, @ordinal, @uniqueId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; + @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + + @"(@title, @isBlocked, @durationInHours, @projectId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; return await connection.QueryAsync(new CommandDefinition(query, new { - description = resource.Title, ordinal = resource.DurationInHours, uniqueId = resource.ProjectId, isLocked = resource.IsBlocked + title = resource.Title, isBlocked = resource.IsBlocked, durationInHours = resource.DurationInHours, projectId = resource.ProjectId }, cancellationToken: cancellationToken)); })).SingleOrDefault(); } diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index 63df921f96..28808bb6ba 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -146,7 +146,7 @@ private void ProcessAttributes(IIdentifiable resource, IEnumerable + - \ No newline at end of file diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 4c8141f974..6845430880 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCore.Middleware; @@ -47,10 +48,10 @@ public ServiceDiscoveryFacadeTests() } [Fact] - public void DiscoverResources_Adds_Resources_From_Added_Assembly_To_Graph() + public void Can_add_resources_from_assembly_to_graph() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddAssembly(typeof(Person).Assembly); // Act @@ -58,17 +59,19 @@ public void DiscoverResources_Adds_Resources_From_Added_Assembly_To_Graph() // Assert var resourceGraph = _resourceGraphBuilder.Build(); + var personResource = resourceGraph.GetResourceContext(typeof(Person)); + personResource.Should().NotBeNull(); + var articleResource = resourceGraph.GetResourceContext(typeof(Article)); - Assert.NotNull(personResource); - Assert.NotNull(articleResource); + articleResource.Should().NotBeNull(); } [Fact] - public void DiscoverResources_Adds_Resources_From_Current_Assembly_To_Graph() + public void Can_add_resource_from_current_assembly_to_graph() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); // Act @@ -76,15 +79,16 @@ public void DiscoverResources_Adds_Resources_From_Current_Assembly_To_Graph() // Assert var resourceGraph = _resourceGraphBuilder.Build(); - var testModelResource = resourceGraph.GetResourceContext(typeof(TestModel)); - Assert.NotNull(testModelResource); + + var resource = resourceGraph.GetResourceContext(typeof(TestResource)); + resource.Should().NotBeNull(); } [Fact] - public void DiscoverInjectables_Adds_Resource_Services_From_Current_Assembly_To_Container() + public void Can_add_resource_service_from_current_assembly_to_container() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); // Act @@ -92,15 +96,16 @@ public void DiscoverInjectables_Adds_Resource_Services_From_Current_Assembly_To_ // Assert var services = _services.BuildServiceProvider(); - var service = services.GetRequiredService>(); - Assert.IsType(service); + + var resourceService = services.GetRequiredService>(); + resourceService.Should().BeOfType(); } [Fact] - public void DiscoverInjectables_Adds_Resource_Repositories_From_Current_Assembly_To_Container() + public void Can_add_resource_repository_from_current_assembly_to_container() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); // Act @@ -108,14 +113,16 @@ public void DiscoverInjectables_Adds_Resource_Repositories_From_Current_Assembly // Assert var services = _services.BuildServiceProvider(); - Assert.IsType(services.GetRequiredService>()); + + var resourceRepository = services.GetRequiredService>(); + resourceRepository.Should().BeOfType(); } [Fact] - public void AddCurrentAssembly_Adds_Resource_Definitions_From_Current_Assembly_To_Container() + public void Can_add_resource_definition_from_current_assembly_to_container() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); // Act @@ -123,14 +130,16 @@ public void AddCurrentAssembly_Adds_Resource_Definitions_From_Current_Assembly_T // Assert var services = _services.BuildServiceProvider(); - Assert.IsType(services.GetRequiredService>()); + + var resourceDefinition = services.GetRequiredService>(); + resourceDefinition.Should().BeOfType(); } [Fact] - public void AddCurrentAssembly_Adds_Resource_Hooks_Definitions_From_Current_Assembly_To_Container() + public void Can_add_resource_hooks_definition_from_current_assembly_to_container() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); _options.EnableResourceHooks = true; @@ -140,21 +149,23 @@ public void AddCurrentAssembly_Adds_Resource_Hooks_Definitions_From_Current_Asse // Assert var services = _services.BuildServiceProvider(); - Assert.IsType(services.GetRequiredService>()); + + var resourceHooksDefinition = services.GetRequiredService>(); + resourceHooksDefinition.Should().BeOfType(); } - public sealed class TestModel : Identifiable { } + public sealed class TestResource : Identifiable { } - public class TestModelService : JsonApiResourceService + public class TestResourceService : JsonApiResourceService { - public TestModelService( + public TestResourceService( IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, + IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, hookExecutor) @@ -162,9 +173,9 @@ public TestModelService( } } - public class TestModelRepository : EntityFrameworkCoreRepository + public class TestResourceRepository : EntityFrameworkCoreRepository { - public TestModelRepository( + public TestResourceRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, @@ -175,14 +186,14 @@ public TestModelRepository( { } } - public class TestModelResourceHooksDefinition : ResourceHooksDefinition + public class TestResourceHooksDefinition : ResourceHooksDefinition { - public TestModelResourceHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + public TestResourceHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } } - public class TestModelResourceDefinition : JsonApiResourceDefinition + public class TestResourceDefinition : JsonApiResourceDefinition { - public TestModelResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + public TestResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs deleted file mode 100644 index 5fd33a87f5..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class ActionResultTests - { - private readonly TestFixture _fixture; - - public ActionResultTests(TestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task ActionResult_With_Error_Object_Is_Converted_To_Error_Collection() - { - // Arrange - var route = "/abstract"; - var request = new HttpRequestMessage(HttpMethod.Post, route); - var content = new - { - data = new - { - type = "todoItems", - id = 1, - attributes = new Dictionary - { - {"ordinal", 1} - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("NotFound ActionResult with explicit error object.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Empty_ActionResult_Is_Converted_To_Error_Collection() - { - // Arrange - var route = "/abstract/123"; - var request = new HttpRequestMessage(HttpMethod.Delete, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("NotFound", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task ActionResult_With_String_Object_Is_Converted_To_Error_Collection() - { - // Arrange - var route = "/abstract/123"; - var request = new HttpRequestMessage(HttpMethod.Patch, route); - var content = new - { - data = new - { - type = "todoItems", - id = 123, - attributes = new Dictionary - { - {"ordinal", 1} - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.InternalServerError, errorDocument.Errors[0].StatusCode); - Assert.Equal("An unhandled error occurred while processing this request.", errorDocument.Errors[0].Title); - Assert.Equal("Data being returned must be errors or resources.", errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs deleted file mode 100644 index 86c021e86d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility -{ - [Collection("WebHostCollection")] - public sealed class CustomControllerTests - { - private readonly TestFixture _fixture; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public CustomControllerTests(TestFixture fixture) - { - _fixture = fixture; - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()); - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - } - - [Fact] - public async Task NonJsonApiControllers_DoNotUse_Dasherized_Routes() - { - // Arrange - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "testValues"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task CustomRouteControllers_Uses_Dasherized_Collection_Route() - { - // Arrange - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/custom/route/todoItems"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task CustomRouteControllers_Uses_Dasherized_Item_Route() - { - // Arrange - var context = _fixture.GetRequiredService(); - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - todoItem.Owner = person; - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = $"/custom/route/todoItems/{todoItem.Id}"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task CustomRouteControllers_Creates_Proper_Relationship_Links() - { - // Arrange - var context = _fixture.GetRequiredService(); - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - todoItem.Owner = person; - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = $"/custom/route/todoItems/{todoItem.Id}"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonConvert.DeserializeObject(body); - - var result = deserializedBody["data"]["relationships"]["owner"]["links"]["related"].ToString(); - Assert.EndsWith($"{route}/owner", result); - } - - [Fact] - public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() - { - // Arrange - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var route = "/custom/route/todoItems/99999999"; - - var requestBody = new - { - data = new - { - type = "todoItems", - id = "99999999", - attributes = new Dictionary - { - ["ordinal"] = 1 - } - } - }; - - var content = JsonConvert.SerializeObject(requestBody); - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Patch, route) {Content = new StringContent(content)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var responseBody = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(responseBody); - - Assert.Single(errorDocument.Errors); - Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.4", errorDocument.Errors[0].Links.About); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs deleted file mode 100644 index ff49489904..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility -{ - public sealed class CustomErrorHandlingTests - { - [Fact] - public void When_using_custom_exception_handler_it_must_create_error_document_and_log() - { - // Arrange - var loggerFactory = new FakeLoggerFactory(); - var options = new JsonApiOptions {IncludeExceptionStackTraceInErrors = true}; - var handler = new CustomExceptionHandler(loggerFactory, options); - - // Act - var errorDocument = handler.HandleException(new NoPermissionException("YouTube")); - - // Assert - Assert.Single(errorDocument.Errors); - Assert.Equal("For support, email to: support@company.com?subject=YouTube", - errorDocument.Errors[0].Meta.Data["support"]); - Assert.NotEmpty((string[]) errorDocument.Errors[0].Meta.Data["StackTrace"]); - - Assert.Single(loggerFactory.Logger.Messages); - Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages.Single().LogLevel); - Assert.Contains("Access is denied.", loggerFactory.Logger.Messages.Single().Text); - } - - public class CustomExceptionHandler : ExceptionHandler - { - public CustomExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) - : base(loggerFactory, options) - { - } - - protected override LogLevel GetLogLevel(Exception exception) - { - if (exception is NoPermissionException) - { - return LogLevel.Warning; - } - - return base.GetLogLevel(exception); - } - - protected override ErrorDocument CreateErrorDocument(Exception exception) - { - if (exception is NoPermissionException noPermissionException) - { - noPermissionException.Errors[0].Meta.Data.Add("support", - "For support, email to: support@company.com?subject=" + noPermissionException.CustomerCode); - } - - return base.CreateErrorDocument(exception); - } - } - - public class NoPermissionException : JsonApiException - { - public string CustomerCode { get; } - - public NoPermissionException(string customerCode) : base(new Error(HttpStatusCode.Forbidden) - { - Title = "Access is denied.", - Detail = $"Customer '{customerCode}' does not have permission to access this location." - }) - { - CustomerCode = customerCode; - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs deleted file mode 100644 index 144bc9218c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility -{ - [Collection("WebHostCollection")] - public sealed class IgnoreDefaultValuesTests : IAsyncLifetime - { - private readonly AppDbContext _dbContext; - private readonly TodoItem _todoItem; - - public IgnoreDefaultValuesTests(TestFixture fixture) - { - _dbContext = fixture.GetRequiredService(); - _todoItem = new TodoItem - { - CreatedDate = default, - Owner = new Person { Age = default } - }; - _dbContext.TodoItems.Add(_todoItem); - } - - public async Task InitializeAsync() - { - await _dbContext.SaveChangesAsync(); - } - - public Task DisposeAsync() - { - return Task.CompletedTask; - } - - [Theory] - [InlineData(null, null, null, DefaultValueHandling.Include)] - [InlineData(null, null, "false", DefaultValueHandling.Include)] - [InlineData(null, null, "true", DefaultValueHandling.Include)] - [InlineData(null, null, "unknown", null)] - [InlineData(null, null, "", null)] - [InlineData(null, false, null, DefaultValueHandling.Include)] - [InlineData(null, false, "false", DefaultValueHandling.Include)] - [InlineData(null, false, "true", DefaultValueHandling.Include)] - [InlineData(null, false, "unknown", null)] - [InlineData(null, false, "", null)] - [InlineData(null, true, null, DefaultValueHandling.Include)] - [InlineData(null, true, "false", DefaultValueHandling.Ignore)] - [InlineData(null, true, "true", DefaultValueHandling.Include)] - [InlineData(null, true, "unknown", null)] - [InlineData(null, true, "", null)] - [InlineData(DefaultValueHandling.Ignore, null, null, DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, null, "false", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, null, "true", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, null, "unknown", null)] - [InlineData(DefaultValueHandling.Ignore, null, "", null)] - [InlineData(DefaultValueHandling.Ignore, false, null, DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, false, "false", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, false, "true", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, false, "unknown", null)] - [InlineData(DefaultValueHandling.Ignore, false, "", null)] - [InlineData(DefaultValueHandling.Ignore, true, null, DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, true, "false", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, true, "true", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Ignore, true, "unknown", null)] - [InlineData(DefaultValueHandling.Ignore, true, "", null)] - [InlineData(DefaultValueHandling.Include, null, null, DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, null, "false", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, null, "true", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, null, "unknown", null)] - [InlineData(DefaultValueHandling.Include, null, "", null)] - [InlineData(DefaultValueHandling.Include, false, null, DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, false, "false", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, false, "true", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, false, "unknown", null)] - [InlineData(DefaultValueHandling.Include, false, "", null)] - [InlineData(DefaultValueHandling.Include, true, null, DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, true, "false", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Include, true, "true", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, true, "unknown", null)] - [InlineData(DefaultValueHandling.Include, true, "", null)] - public async Task CheckBehaviorCombination(DefaultValueHandling? defaultValue, bool? allowQueryStringOverride, string queryStringValue, DefaultValueHandling? expected) - { - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var services = server.Host.Services; - var client = server.CreateClient(); - - var options = (JsonApiOptions)services.GetRequiredService(typeof(IJsonApiOptions)); - - if (defaultValue != null) - { - options.SerializerSettings.DefaultValueHandling = defaultValue.Value; - } - if (allowQueryStringOverride != null) - { - options.AllowQueryStringOverrideForSerializerDefaultValueHandling = allowQueryStringOverride.Value; - } - - var queryString = queryStringValue != null - ? $"&defaults={queryStringValue}" - : ""; - var route = $"/api/v1/todoItems/{_todoItem.Id}?include=owner{queryString}"; - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - var isQueryStringValueEmpty = queryStringValue == string.Empty; - var isDisallowedOverride = !options.AllowQueryStringOverrideForSerializerDefaultValueHandling && queryStringValue != null; - var isQueryStringInvalid = queryStringValue != null && !bool.TryParse(queryStringValue, out _); - - if (isQueryStringValueEmpty) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Missing query string parameter value.", errorDocument.Errors[0].Title); - Assert.Equal("Missing value for 'defaults' query string parameter.", errorDocument.Errors[0].Detail); - Assert.Equal("defaults", errorDocument.Errors[0].Source.Parameter); - } - else if (isDisallowedOverride) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); - Assert.Equal("The parameter 'defaults' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); - Assert.Equal("defaults", errorDocument.Errors[0].Source.Parameter); - } - else if (isQueryStringInvalid) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified defaults is invalid.", errorDocument.Errors[0].Title); - Assert.Equal("The value 'unknown' must be 'true' or 'false'.", errorDocument.Errors[0].Detail); - Assert.Equal("defaults", errorDocument.Errors[0].Source.Parameter); - } - else - { - if (expected == null) - { - throw new Exception("Invalid test combination. Should never get here."); - } - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var deserializeBody = JsonConvert.DeserializeObject(body); - Assert.Equal(expected == DefaultValueHandling.Include, deserializeBody.SingleData.Attributes.ContainsKey("createdDate")); - Assert.Equal(expected == DefaultValueHandling.Include, deserializeBody.Included[0].Attributes.ContainsKey("the-Age")); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs deleted file mode 100644 index e4ac1e61cc..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility -{ - [Collection("WebHostCollection")] - public sealed class IgnoreNullValuesTests : IAsyncLifetime - { - private readonly AppDbContext _dbContext; - private readonly TodoItem _todoItem; - - public IgnoreNullValuesTests(TestFixture fixture) - { - _dbContext = fixture.GetRequiredService(); - _todoItem = new TodoItem - { - Description = null, - Ordinal = 1, - CreatedDate = new DateTime(2002, 2,2), - AchievedDate = new DateTime(2002, 2,4), - Owner = new Person { FirstName = "Bob", LastName = null } - }; - _dbContext.TodoItems.Add(_todoItem); - } - - public async Task InitializeAsync() - { - await _dbContext.SaveChangesAsync(); - } - - public Task DisposeAsync() - { - return Task.CompletedTask; - } - - [Theory] - [InlineData(null, null, null, NullValueHandling.Include)] - [InlineData(null, null, "false", NullValueHandling.Include)] - [InlineData(null, null, "true", NullValueHandling.Include)] - [InlineData(null, null, "unknown", null)] - [InlineData(null, null, "", null)] - [InlineData(null, false, null, NullValueHandling.Include)] - [InlineData(null, false, "false", NullValueHandling.Include)] - [InlineData(null, false, "true", NullValueHandling.Include)] - [InlineData(null, false, "unknown", null)] - [InlineData(null, false, "", null)] - [InlineData(null, true, null, NullValueHandling.Include)] - [InlineData(null, true, "false", NullValueHandling.Ignore)] - [InlineData(null, true, "true", NullValueHandling.Include)] - [InlineData(null, true, "unknown", null)] - [InlineData(null, true, "", null)] - [InlineData(NullValueHandling.Ignore, null, null, NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, null, "false", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, null, "true", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, null, "unknown", null)] - [InlineData(NullValueHandling.Ignore, null, "", null)] - [InlineData(NullValueHandling.Ignore, false, null, NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, false, "false", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, false, "true", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, false, "unknown", null)] - [InlineData(NullValueHandling.Ignore, false, "", null)] - [InlineData(NullValueHandling.Ignore, true, null, NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, true, "false", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, true, "true", NullValueHandling.Include)] - [InlineData(NullValueHandling.Ignore, true, "unknown", null)] - [InlineData(NullValueHandling.Ignore, true, "", null)] - [InlineData(NullValueHandling.Include, null, null, NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, null, "false", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, null, "true", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, null, "unknown", null)] - [InlineData(NullValueHandling.Include, null, "", null)] - [InlineData(NullValueHandling.Include, false, null, NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, false, "false", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, false, "true", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, false, "unknown", null)] - [InlineData(NullValueHandling.Include, false, "", null)] - [InlineData(NullValueHandling.Include, true, null, NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, true, "false", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Include, true, "true", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, true, "unknown", null)] - [InlineData(NullValueHandling.Include, true, "", null)] - public async Task CheckBehaviorCombination(NullValueHandling? defaultValue, bool? allowQueryStringOverride, string queryStringValue, NullValueHandling? expected) - { - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var services = server.Host.Services; - var client = server.CreateClient(); - - var options = (JsonApiOptions)services.GetRequiredService(typeof(IJsonApiOptions)); - - if (defaultValue != null) - { - options.SerializerSettings.NullValueHandling = defaultValue.Value; - } - if (allowQueryStringOverride != null) - { - options.AllowQueryStringOverrideForSerializerNullValueHandling = allowQueryStringOverride.Value; - } - - var queryString = queryStringValue != null - ? $"&nulls={queryStringValue}" - : ""; - var route = $"/api/v1/todoItems/{_todoItem.Id}?include=owner{queryString}"; - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - var isQueryStringValueEmpty = queryStringValue == string.Empty; - var isDisallowedOverride = !options.AllowQueryStringOverrideForSerializerNullValueHandling && queryStringValue != null; - var isQueryStringInvalid = queryStringValue != null && !bool.TryParse(queryStringValue, out _); - - if (isQueryStringValueEmpty) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Missing query string parameter value.", errorDocument.Errors[0].Title); - Assert.Equal("Missing value for 'nulls' query string parameter.", errorDocument.Errors[0].Detail); - Assert.Equal("nulls", errorDocument.Errors[0].Source.Parameter); - } - else if (isDisallowedOverride) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); - Assert.Equal("The parameter 'nulls' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); - Assert.Equal("nulls", errorDocument.Errors[0].Source.Parameter); - } - else if (isQueryStringInvalid) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified nulls is invalid.", errorDocument.Errors[0].Title); - Assert.Equal("The value 'unknown' must be 'true' or 'false'.", errorDocument.Errors[0].Detail); - Assert.Equal("nulls", errorDocument.Errors[0].Source.Parameter); - } - else - { - if (expected == null) - { - throw new Exception("Invalid test combination. Should never get here."); - } - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var deserializeBody = JsonConvert.DeserializeObject(body); - Assert.Equal(expected == NullValueHandling.Include, deserializeBody.SingleData.Attributes.ContainsKey("description")); - Assert.Equal(expected == NullValueHandling.Include, deserializeBody.Included[0].Attributes.ContainsKey("lastName")); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs deleted file mode 100644 index 16f660e3f6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class HttpReadOnlyTests - { - [Fact] - public async Task Allows_GET_Requests() - { - // Arrange - const string route = "readonly"; - const string method = "GET"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Rejects_POST_Requests() - { - // Arrange - const string route = "readonly"; - const string method = "POST"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support POST requests.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Rejects_PATCH_Requests() - { - // Arrange - const string route = "readonly"; - const string method = "PATCH"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support PATCH requests.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Rejects_DELETE_Requests() - { - // Arrange - const string route = "readonly"; - const string method = "DELETE"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support DELETE requests.", errorDocument.Errors[0].Detail); - } - - private async Task MakeRequestAsync(string route, string method) - { - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod(method); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var response = await client.SendAsync(request); - return response; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs deleted file mode 100644 index 7103dc3a2c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class NoHttpDeleteTests - { - [Fact] - public async Task Allows_GET_Requests() - { - // Arrange - const string route = "nohttpdelete"; - const string method = "GET"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Allows_POST_Requests() - { - // Arrange - const string route = "nohttpdelete"; - const string method = "POST"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Allows_PATCH_Requests() - { - // Arrange - const string route = "nohttpdelete"; - const string method = "PATCH"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Rejects_DELETE_Requests() - { - // Arrange - const string route = "nohttpdelete"; - const string method = "DELETE"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support DELETE requests.", errorDocument.Errors[0].Detail); - } - - private async Task MakeRequestAsync(string route, string method) - { - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod(method); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var response = await client.SendAsync(request); - return response; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs deleted file mode 100644 index 7d26157c46..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class NoHttpPatchTests - { - [Fact] - public async Task Allows_GET_Requests() - { - // Arrange - const string route = "nohttppatch"; - const string method = "GET"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Allows_POST_Requests() - { - // Arrange - const string route = "nohttppatch"; - const string method = "POST"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Rejects_PATCH_Requests() - { - // Arrange - const string route = "nohttppatch"; - const string method = "PATCH"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support PATCH requests.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Allows_DELETE_Requests() - { - // Arrange - const string route = "nohttppatch"; - const string method = "DELETE"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - private async Task MakeRequestAsync(string route, string method) - { - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod(method); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var response = await client.SendAsync(request); - return response; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs deleted file mode 100644 index 23d63eca51..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class NoHttpPostTests - { - [Fact] - public async Task Allows_GET_Requests() - { - // Arrange - const string route = "nohttppost"; - const string method = "GET"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Rejects_POST_Requests() - { - // Arrange - const string route = "nohttppost"; - const string method = "POST"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support POST requests.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Allows_PATCH_Requests() - { - // Arrange - const string route = "nohttppost"; - const string method = "PATCH"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Allows_DELETE_Requests() - { - // Arrange - const string route = "nohttppost"; - const string method = "DELETE"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - private async Task MakeRequestAsync(string route, string method) - { - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod(method); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var response = await client.SendAsync(request); - return response; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs deleted file mode 100644 index 6388be509f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public class InjectableResourceTests - { - private readonly TestFixture _fixture; - private readonly AppDbContext _context; - private readonly Faker _personFaker; - private readonly Faker _passportFaker; - private readonly Faker _countryFaker; - - public InjectableResourceTests(TestFixture fixture) - { - _fixture = fixture; - _context = fixture.GetRequiredService(); - - _personFaker = new Faker() - .RuleFor(t => t.FirstName, f => f.Name.FirstName()) - .RuleFor(t => t.LastName, f => f.Name.LastName()); - _passportFaker = new Faker() - .CustomInstantiator(f => new Passport(_context)) - .RuleFor(t => t.SocialSecurityNumber, f => f.Random.Number(100, 10_000)); - _countryFaker = new Faker() - .RuleFor(c => c.Name, f => f.Address.Country()); - } - - [Fact] - public async Task Can_Get_Single_Passport() - { - // Arrange - var passport = _passportFaker.Generate(); - passport.BirthCountry = _countryFaker.Generate(); - - _context.Passports.Add(passport); - await _context.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports/" + passport.StringId); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - - Assert.NotNull(document.SingleData); - Assert.Equal(passport.IsLocked, document.SingleData.Attributes["isLocked"]); - } - - [Fact] - public async Task Can_Get_Passports() - { - // Arrange - await _context.ClearTableAsync(); - - var passports = _passportFaker.Generate(3); - foreach (var passport in passports) - { - passport.BirthCountry = _countryFaker.Generate(); - } - - _context.Passports.AddRange(passports); - await _context.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports"); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - - Assert.Equal(3, document.ManyData.Count); - foreach (var passport in passports) - { - Assert.Contains(document.ManyData, - resource => (long)resource.Attributes["socialSecurityNumber"] == passport.SocialSecurityNumber); - Assert.Contains(document.ManyData, - resource => (string)resource.Attributes["birthCountryName"] == passport.BirthCountryName); - } - } - - [Fact] - public async Task Can_Get_Passports_With_Filter() - { - // Arrange - await _context.ClearTableAsync(); - - var passports = _passportFaker.Generate(3); - foreach (var passport in passports) - { - passport.SocialSecurityNumber = 11111; - passport.BirthCountry = _countryFaker.Generate(); - passport.Person = _personFaker.Generate(); - passport.Person.FirstName = "Jack"; - } - - passports[2].SocialSecurityNumber = 12345; - passports[2].Person.FirstName= "Joe"; - - _context.Passports.AddRange(passports); - await _context.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&filter=and(equals(socialSecurityNumber,'12345'),equals(person.firstName,'Joe'))"); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - - Assert.Single(document.ManyData); - Assert.Equal(12345L, document.ManyData[0].Attributes["socialSecurityNumber"]); - - Assert.Single(document.Included); - Assert.Equal("Joe", document.Included[0].Attributes["firstName"]); - } - - [Fact] - public async Task Can_Get_Passports_With_Sparse_Fieldset() - { - // Arrange - await _context.ClearTableAsync(); - - var passports = _passportFaker.Generate(2); - foreach (var passport in passports) - { - passport.BirthCountry = _countryFaker.Generate(); - passport.Person = _personFaker.Generate(); - } - - _context.Passports.AddRange(passports); - await _context.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&fields[passports]=socialSecurityNumber&fields[people]=firstName"); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - - Assert.Equal(2, document.ManyData.Count); - foreach (var passport in passports) - { - Assert.Contains(document.ManyData, - resource => (long)resource.Attributes["socialSecurityNumber"] == passport.SocialSecurityNumber); - } - - Assert.DoesNotContain(document.ManyData, - resource => resource.Attributes.ContainsKey("isLocked")); - - Assert.Equal(2, document.Included.Count); - foreach (var person in passports.Select(p => p.Person)) - { - Assert.Contains(document.Included, - resource => (string) resource.Attributes["firstName"] == person.FirstName); - } - - Assert.DoesNotContain(document.Included, - resource => resource.Attributes.ContainsKey("lastName")); - } - - [Fact] - public async Task Fail_When_Deleting_Missing_Passport() - { - // Arrange - - var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/passports/1234567890"); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.NotFound, response); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'passports' with ID '1234567890' does not exist.", errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs deleted file mode 100644 index 1d995bb721..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs +++ /dev/null @@ -1,201 +0,0 @@ -using System; -using System.Linq.Expressions; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Client.Internal; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public sealed class KebabCaseFormatterTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - private readonly Faker _faker; - - public KebabCaseFormatterTests(IntegrationTestContext testContext) - { - _testContext = testContext; - - _faker = new Faker() - .RuleFor(m => m.CompoundAttr, f => f.Lorem.Sentence()); - } - - [Fact] - public async Task KebabCaseFormatter_GetAll_IsReturned() - { - // Arrange - var model = _faker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.KebabCasedModels.Add(model); - - await dbContext.SaveChangesAsync(); - }); - - var route = "api/v1/kebab-cased-models"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(model.StringId); - responseDocument.ManyData[0].Attributes["compound-attr"].Should().Be(model.CompoundAttr); - } - - [Fact] - public async Task KebabCaseFormatter_GetSingle_IsReturned() - { - // Arrange - var model = _faker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.KebabCasedModels.Add(model); - - await dbContext.SaveChangesAsync(); - }); - - var route = "api/v1/kebab-cased-models/" + model.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(model.StringId); - responseDocument.SingleData.Attributes["compound-attr"].Should().Be(model.CompoundAttr); - } - - [Fact] - public async Task KebabCaseFormatter_Create_IsCreated() - { - // Arrange - var model = _faker.Generate(); - var serializer = GetSerializer(kcm => new { kcm.CompoundAttr }); - - var route = "api/v1/kebab-cased-models"; - - var requestBody = serializer.Serialize(model); - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["compound-attr"].Should().Be(model.CompoundAttr); - } - - [Fact] - public async Task KebabCaseFormatter_Update_IsUpdated() - { - // Arrange - var model = _faker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.KebabCasedModels.Add(model); - - await dbContext.SaveChangesAsync(); - }); - - model.CompoundAttr = _faker.Generate().CompoundAttr; - var serializer = GetSerializer(kcm => new { kcm.CompoundAttr }); - - var route = "api/v1/kebab-cased-models/" + model.StringId; - - var requestBody = serializer.Serialize(model); - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var stored = await dbContext.KebabCasedModels.SingleAsync(x => x.Id == model.Id); - Assert.Equal(model.CompoundAttr, stored.CompoundAttr); - }); - } - - [Fact] - public async Task KebabCaseFormatter_ErrorWithStackTrace_CasingConventionIsApplied() - { - // Arrange - var route = "api/v1/kebab-cased-models/1"; - - const string requestBody = "{ \"data\": {"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - var meta = responseDocument["errors"][0]["meta"]; - Assert.NotNull(meta["stack-trace"]); - } - - private IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - using var scope = _testContext.Factory.Services.CreateScope(); - var serializer = scope.ServiceProvider.GetRequiredService(); - var graph = scope.ServiceProvider.GetRequiredService(); - - serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; - serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; - - return serializer; - } - } - - public sealed class KebabCaseStartup : TestStartup - { - public KebabCaseStartup(IConfiguration configuration) : base(configuration) - { - } - - protected override void ConfigureJsonApiOptions(JsonApiOptions options) - { - base.ConfigureJsonApiOptions(options); - - ((DefaultContractResolver)options.SerializerSettings.ContractResolver).NamingStrategy = new KebabCaseNamingStrategy(); - } - } - - public sealed class KebabCasedModelsController : JsonApiController - { - public KebabCasedModelsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs deleted file mode 100644 index 397914ba7b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public sealed class NonJsonApiControllerTests - { - [Fact] - public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Get() - { - // Arrange - const string route = "testValues"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("[\"value\"]", body); - } - - [Fact] - public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Post() - { - // Arrange - const string route = "testValues?name=Jack"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Post, route) {Content = new StringContent("XXX")}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("Hello, Jack", body); - } - - [Fact] - public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Patch() - { - // Arrange - const string route = "testValues?name=Jack"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Patch, route) {Content = new StringContent("XXX")}; - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("Hello, Jack", body); - } - - [Fact] - public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Delete() - { - // Arrange - const string route = "testValues"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Delete, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("Deleted", body); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs deleted file mode 100644 index fa7b5a36f1..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ /dev/null @@ -1,634 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Acceptance.Spec; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public sealed class ResourceDefinitionTests : FunctionalTestCollection - { - private readonly Faker _userFaker; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - private readonly Faker
_articleFaker; - private readonly Faker _authorFaker; - private readonly Faker _tagFaker; - - public ResourceDefinitionTests(ResourceHooksApplicationFactory factory) : base(factory) - { - _authorFaker = new Faker() - .RuleFor(a => a.LastName, f => f.Random.Words(2)); - - _articleFaker = new Faker
() - .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) - .RuleFor(a => a.Author, f => _authorFaker.Generate()); - - _userFaker = new Faker() - .CustomInstantiator(f => new User(_dbContext)) - .RuleFor(u => u.UserName, f => f.Internet.UserName()) - .RuleFor(u => u.Password, f => f.Internet.Password()); - - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - - _tagFaker = new Faker() - .CustomInstantiator(f => new Tag()) - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); - - var options = (JsonApiOptions) _factory.Services.GetRequiredService(); - options.DisableTopPagination = false; - options.DisableChildrenPagination = false; - } - - [Fact] - public async Task Can_Create_User_With_Password() - { - // Arrange - var user = _userFaker.Generate(); - - var serializer = GetSerializer(p => new { p.Password, p.UserName }); - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/users"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(serializer.Serialize(user)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - // response assertions - var body = await response.Content.ReadAsStringAsync(); - var returnedUser = _deserializer.DeserializeSingle(body).Data; - var document = JsonConvert.DeserializeObject(body); - Assert.False(document.SingleData.Attributes.ContainsKey("password")); - Assert.Equal(user.UserName, document.SingleData.Attributes["userName"]); - - // db assertions - var dbUser = await _dbContext.Users.FindAsync(returnedUser.Id); - Assert.Equal(user.UserName, dbUser.UserName); - Assert.Equal(user.Password, dbUser.Password); - } - - [Fact] - public async Task Can_Update_User_Password() - { - // Arrange - var user = _userFaker.Generate(); - _dbContext.Users.Add(user); - await _dbContext.SaveChangesAsync(); - - user.Password = _userFaker.Generate().Password; - var serializer = GetSerializer(p => new { p.Password }); - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/users/{user.Id}"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(serializer.Serialize(user)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - // response assertions - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - Assert.False(document.SingleData.Attributes.ContainsKey("password")); - Assert.Equal(user.UserName, document.SingleData.Attributes["userName"]); - - // db assertions - var dbUser = _dbContext.Users.AsNoTracking().Single(u => u.Id == user.Id); - Assert.Equal(user.Password, dbUser.Password); - } - - [Fact] - public async Task Unauthorized_TodoItem() - { - // Arrange - var route = "/api/v1/todoItems/1337"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update the author of todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Unauthorized_Passport() - { - // Arrange - var route = "/api/v1/people/1?include=passport"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to include passports on individual persons.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Unauthorized_Article() - { - // Arrange - var article = _articleFaker.Generate(); - article.Caption = "Classified"; - _dbContext.Articles.Add(article); - await _dbContext.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to see this article.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Article_Is_Hidden() - { - // Arrange - var articles = _articleFaker.Generate(3); - string toBeExcluded = "This should not be included"; - articles[0].Caption = toBeExcluded; - - _dbContext.Articles.AddRange(articles); - await _dbContext.SaveChangesAsync(); - - var route = "/api/v1/articles"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); - Assert.DoesNotContain(toBeExcluded, body); - } - - [Fact] - public async Task Article_Through_Secondary_Endpoint_Is_Hidden() - { - // Arrange - var articles = _articleFaker.Generate(3); - string toBeExcluded = "This should not be included"; - articles[0].Caption = toBeExcluded; - var author = _authorFaker.Generate(); - author.Articles = articles; - - _dbContext.AuthorDifferentDbContextName.Add(author); - await _dbContext.SaveChangesAsync(); - - var route = $"/api/v1/authors/{author.Id}/articles"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); - Assert.DoesNotContain(toBeExcluded, body); - } - - [Fact] - public async Task Passport_Through_Secondary_Endpoint_Is_Hidden() - { - // Arrange - var person = _personFaker.Generate(); - person.Passport = new Passport(_dbContext) {IsLocked = true}; - - _dbContext.People.Add(person); - await _dbContext.SaveChangesAsync(); - - var route = $"/api/v1/people/{person.Id}/passport"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); - var document = JsonConvert.DeserializeObject(body); - Assert.Null(document.Data); - - } - - [Fact] - public async Task Tag_Is_Hidden() - { - // Arrange - var article = _articleFaker.Generate(); - var tags = _tagFaker.Generate(2); - string toBeExcluded = "This should not be included"; - tags[0].Name = toBeExcluded; - - var articleTags = new[] - { - new ArticleTag - { - Article = article, - Tag = tags[0] - }, - new ArticleTag - { - Article = article, - Tag = tags[1] - } - }; - _dbContext.ArticleTags.AddRange(articleTags); - await _dbContext.SaveChangesAsync(); - - // Workaround for https://github.com/dotnet/efcore/issues/21026 - var options = (JsonApiOptions) _factory.Services.GetRequiredService(); - options.DisableTopPagination = false; - options.DisableChildrenPagination = true; - - var route = "/api/v1/articles?include=tags"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); - Assert.DoesNotContain(toBeExcluded, body); - } - ///// - ///// In the Cascade Permission Error tests, we ensure that all the relevant - ///// resources are provided in the hook definitions. In this case, - ///// re-relating the meta object to a different article would require - ///// also a check for the lockedTodo, because we're implicitly updating - ///// its foreign key. - ///// - [Fact] - public async Task Cascade_Permission_Error_Create_ToOne_Relationship() - { - // Arrange - var lockedPerson = _personFaker.Generate(); - lockedPerson.IsLocked = true; - var passport = new Passport(_dbContext); - lockedPerson.Passport = passport; - _dbContext.People.AddRange(lockedPerson); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "people", - relationships = new Dictionary - { - { "passport", new - { - data = new { type = "passports", id = lockedPerson.Passport.StringId } - } - } - } - } - }; - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/people"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() - { - // Arrange - var person = _personFaker.Generate(); - var passport = new Passport(_dbContext) { IsLocked = true }; - person.Passport = passport; - _dbContext.People.AddRange(person); - var newPassport = new Passport(_dbContext); - _dbContext.Passports.Add(newPassport); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "people", - id = person.Id, - relationships = new Dictionary - { - { "passport", new - { - data = new { type = "passports", id = newPassport.StringId } - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked persons.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion() - { - // Arrange - var person = _personFaker.Generate(); - var passport = new Passport(_dbContext) { IsLocked = true }; - person.Passport = passport; - _dbContext.People.AddRange(person); - var newPassport = new Passport(_dbContext); - _dbContext.Passports.Add(newPassport); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "people", - id = person.Id, - relationships = new Dictionary - { - { "passport", new - { - data = (object)null - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked persons.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() - { - // Arrange - var lockedPerson = _personFaker.Generate(); - lockedPerson.IsLocked = true; - var passport = new Passport(_dbContext); - lockedPerson.Passport = passport; - _dbContext.People.AddRange(lockedPerson); - await _dbContext.SaveChangesAsync(); - - var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/passports/{lockedPerson.Passport.StringId}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Create_ToMany_Relationship() - { - // Arrange - var persons = _personFaker.Generate(2); - var lockedTodo = _todoItemFaker.Generate(); - lockedTodo.IsLocked = true; - lockedTodo.StakeHolders = persons.ToHashSet(); - _dbContext.TodoItems.Add(lockedTodo); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "todoItems", - relationships = new Dictionary - { - { "stakeHolders", new - { - data = new[] - { - new { type = "people", id = persons[0].StringId }, - new { type = "people", id = persons[1].StringId } - } - - } - } - } - } - }; - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/todoItems"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() - { - // Arrange - var persons = _personFaker.Generate(2); - var lockedTodo = _todoItemFaker.Generate(); - lockedTodo.IsLocked = true; - lockedTodo.StakeHolders = persons.ToHashSet(); - _dbContext.TodoItems.Add(lockedTodo); - var unlockedTodo = _todoItemFaker.Generate(); - _dbContext.TodoItems.Add(unlockedTodo); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "todoItems", - id = unlockedTodo.Id, - relationships = new Dictionary - { - { "stakeHolders", new - { - data = new[] - { - new { type = "people", id = persons[0].StringId }, - new { type = "people", id = persons[1].StringId } - } - - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{unlockedTodo.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Delete_ToMany_Relationship() - { - // Arrange - var persons = _personFaker.Generate(2); - var lockedTodo = _todoItemFaker.Generate(); - lockedTodo.IsLocked = true; - lockedTodo.StakeHolders = persons.ToHashSet(); - _dbContext.TodoItems.Add(lockedTodo); - await _dbContext.SaveChangesAsync(); - - var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/people/{persons[0].Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs deleted file mode 100644 index 214f8adbda..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Acceptance.Spec; -using JsonApiDotNetCoreExampleTests.Helpers.Extensions; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public sealed class SerializationTests : FunctionalTestCollection - { - public SerializationTests(StandardApplicationFactory factory) - : base(factory) - { - } - - [Fact] - public async Task When_getting_person_it_must_match_JSON_text() - { - // Arrange - var person = new Person - { - Id = 123, - FirstName = "John", - LastName = "Doe", - Age = 57, - Gender = Gender.Male, - Category = "Family" - }; - - await _dbContext.ClearTableAsync(); - _dbContext.People.Add(person); - await _dbContext.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/people/" + person.Id); - - // Act - var response = await _factory.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var bodyText = await response.Content.ReadAsStringAsync(); - var json = JsonConvert.DeserializeObject(bodyText).ToString(); - - var expected = @"{ - ""links"": { - ""self"": ""http://localhost/api/v1/people/123"" - }, - ""data"": { - ""type"": ""people"", - ""id"": ""123"", - ""attributes"": { - ""firstName"": ""John"", - ""initials"": ""J"", - ""lastName"": ""Doe"", - ""the-Age"": 57, - ""gender"": ""Male"", - ""category"": ""Family"" - }, - ""relationships"": { - ""todoItems"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/todoItems"", - ""related"": ""http://localhost/api/v1/people/123/todoItems"" - } - }, - ""assignedTodoItems"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/assignedTodoItems"", - ""related"": ""http://localhost/api/v1/people/123/assignedTodoItems"" - } - }, - ""todoCollections"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/todoCollections"", - ""related"": ""http://localhost/api/v1/people/123/todoCollections"" - } - }, - ""role"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/role"", - ""related"": ""http://localhost/api/v1/people/123/role"" - } - }, - ""oneToOneTodoItem"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/oneToOneTodoItem"", - ""related"": ""http://localhost/api/v1/people/123/oneToOneTodoItem"" - } - }, - ""stakeHolderTodoItem"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/stakeHolderTodoItem"", - ""related"": ""http://localhost/api/v1/people/123/stakeHolderTodoItem"" - } - }, - ""unIncludeableItem"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/unIncludeableItem"", - ""related"": ""http://localhost/api/v1/people/123/unIncludeableItem"" - } - }, - ""passport"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/passport"", - ""related"": ""http://localhost/api/v1/people/123/passport"" - } - } - }, - ""links"": { - ""self"": ""http://localhost/api/v1/people/123"" - } - } -}"; - Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs deleted file mode 100644 index 42492f3abd..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class DisableQueryAttributeTests - { - private readonly TestFixture _fixture; - - public DisableQueryAttributeTests(TestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task Cannot_Sort_If_Query_String_Parameter_Is_Blocked_By_Controller() - { - // Arrange - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/countries?sort=name"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); - Assert.Equal("The parameter 'sort' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); - Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Cannot_Use_Custom_Query_String_Parameter_If_Blocked_By_Controller() - { - // Arrange - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/tags?skipCache=true"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); - Assert.Equal("The parameter 'skipCache' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); - Assert.Equal("skipCache", errorDocument.Errors[0].Source.Parameter); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs deleted file mode 100644 index 6b51a357fd..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests -{ - public sealed class LinksWithNamespaceTests : FunctionalTestCollection - { - public LinksWithNamespaceTests(StandardApplicationFactory factory) : base(factory) - { - } - - [Fact] - public async Task GET_RelativeLinks_False_With_Namespace_Returns_AbsoluteLinks() - { - // Arrange - var person = new Person(); - - _dbContext.People.Add(person); - await _dbContext.SaveChangesAsync(); - - var route = "/api/v1/people/" + person.StringId; - var request = new HttpRequestMessage(HttpMethod.Get, route); - - var options = (JsonApiOptions) _factory.GetRequiredService(); - options.UseRelativeLinks = false; - - // Act - var response = await _factory.Client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseString); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("http://localhost/api/v1/people/" + person.StringId, document.Links.Self); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs deleted file mode 100644 index 9670d5003a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Net; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests -{ - public sealed class LinksWithoutNamespaceTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - - public LinksWithoutNamespaceTests(IntegrationTestContext testContext) - { - _testContext = testContext; - - testContext.ConfigureServicesAfterStartup(services => - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); - } - - [Fact] - public async Task GET_RelativeLinks_True_Without_Namespace_Returns_RelativeLinks() - { - // Arrange - var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); - options.UseRelativeLinks = true; - - var person = new Person(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(person); - - await dbContext.SaveChangesAsync(); - }); - - var route = "/people/" + person.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be("/people/" + person.StringId); - } - - [Fact] - public async Task GET_RelativeLinks_False_Without_Namespace_Returns_AbsoluteLinks() - { - // Arrange - var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); - options.UseRelativeLinks = false; - - var person = new Person(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(person); - - await dbContext.SaveChangesAsync(); - }); - - var route = "/people/" + person.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be("http://localhost/people/" + person.StringId); - } - } - - public sealed class NoNamespaceStartup : TestStartup - { - public NoNamespaceStartup(IConfiguration configuration) : base(configuration) - { - } - - protected override void ConfigureJsonApiOptions(JsonApiOptions options) - { - base.ConfigureJsonApiOptions(options); - - options.Namespace = null; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs deleted file mode 100644 index dacac855a8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests -{ - [Collection("WebHostCollection")] - public sealed class Relationships - { - private readonly AppDbContext _context; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public Relationships(TestFixture fixture) - { - _context = fixture.GetRequiredService(); - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - } - - [Fact] - public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() - { - // Arrange - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); - - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var data = JsonConvert.DeserializeObject(responseString).ManyData[0]; - var expectedOwnerSelfLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/owner"; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["owner"].Links.Self); - Assert.Equal(expectedOwnerRelatedLink, data.Relationships["owner"].Links.Related); - } - - [Fact] - public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var data = JsonConvert.DeserializeObject(responseString).SingleData; - var expectedOwnerSelfLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/owner"; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["owner"].Links.Self); - Assert.Equal(expectedOwnerRelatedLink, data.Relationships["owner"].Links.Related); - } - - [Fact] - public async Task Correct_RelationshipObjects_For_OneToMany_Relationships() - { - // Arrange - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); - - var person = _personFaker.Generate(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/people"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var data = JsonConvert.DeserializeObject(responseString).ManyData[0]; - var expectedOwnerSelfLink = $"http://localhost/api/v1/people/{person.Id}/relationships/todoItems"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/people/{person.Id}/todoItems"; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["todoItems"].Links.Self); - Assert.Equal(expectedOwnerRelatedLink, data.Relationships["todoItems"].Links.Related); - } - - [Fact] - public async Task Correct_RelationshipObjects_For_OneToMany_Relationships_ById() - { - // Arrange - var person = _personFaker.Generate(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/people/{person.Id}"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var data = JsonConvert.DeserializeObject(responseString).SingleData; - var expectedOwnerSelfLink = $"http://localhost/api/v1/people/{person.Id}/relationships/todoItems"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/people/{person.Id}/todoItems"; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["todoItems"].Links.Self); - Assert.Equal(expectedOwnerRelatedLink, data.Relationships["todoItems"].Links.Related); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs deleted file mode 100644 index 1030ff426c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Linq.Expressions; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Client.Internal; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public class FunctionalTestCollection : IClassFixture where TFactory : class, IApplicationFactory - { - public static MediaTypeHeaderValue JsonApiContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - protected readonly TFactory _factory; - protected readonly HttpClient _client; - protected readonly AppDbContext _dbContext; - protected IResponseDeserializer _deserializer; - - public FunctionalTestCollection(TFactory factory) - { - _factory = factory; - _client = _factory.CreateClient(); - _dbContext = _factory.GetRequiredService(); - _deserializer = GetDeserializer(); - ClearDbContext(); - } - - protected Task<(string, HttpResponseMessage)> Get(string route) - { - return SendRequest("GET", route); - } - - protected Task<(string, HttpResponseMessage)> Post(string route, string content) - { - return SendRequest("POST", route, content); - } - - protected Task<(string, HttpResponseMessage)> Patch(string route, string content) - { - return SendRequest("PATCH", route, content); - } - - protected Task<(string, HttpResponseMessage)> Delete(string route) - { - return SendRequest("DELETE", route); - } - - protected IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - var serializer = GetService(); - var graph = GetService(); - serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; - serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; - return serializer; - } - - protected IResponseDeserializer GetDeserializer() - { - var options = GetService(); - var formatter = new ResourceNameFormatter(options); - var resourcesContexts = GetService().GetResourceContexts(); - var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); - foreach (var rc in resourcesContexts) - { - if (rc.ResourceType == typeof(TodoItem) || rc.ResourceType == typeof(TodoItemCollection)) - { - continue; - } - builder.Add(rc.ResourceType, rc.IdentityType, rc.PublicName); - } - builder.Add(formatter.FormatResourceName(typeof(TodoItem))); - builder.Add(formatter.FormatResourceName(typeof(TodoItemCollection))); - return new ResponseDeserializer(builder.Build(), new ResourceFactory(_factory.ServiceProvider)); - } - - protected AppDbContext GetDbContext() => GetService(); - - protected T GetService() => _factory.GetRequiredService(); - - protected void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) - { - var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); - } - - protected void ClearDbContext() - { - _dbContext.ClearTable(); - _dbContext.ClearTable(); - _dbContext.ClearTable(); - _dbContext.ClearTable(); - _dbContext.SaveChanges(); - } - - private async Task<(string, HttpResponseMessage)> SendRequest(string method, string route, string content = null) - { - var request = new HttpRequestMessage(new HttpMethod(method), route); - if (content != null) - { - request.Content = new StringContent(content); - request.Content.Headers.ContentType = JsonApiContentType; - } - var response = await _client.SendAsync(request); - var body = await response.Content?.ReadAsStringAsync(); - return (body, response); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs deleted file mode 100644 index d597adc62c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public class ThrowingResourceTests : FunctionalTestCollection - { - public ThrowingResourceTests(StandardApplicationFactory factory) : base(factory) - { - } - - [Fact] - public async Task GetThrowingResource_Fails() - { - // Arrange - var throwingResource = new ThrowingResource(); - _dbContext.Add(throwingResource); - await _dbContext.SaveChangesAsync(); - - // Act - var (body, response) = await Get($"/api/v1/throwingResources/{throwingResource.Id}"); - - // Assert - AssertEqualStatusCode(HttpStatusCode.InternalServerError, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.InternalServerError, errorDocument.Errors[0].StatusCode); - Assert.Equal("An unhandled error occurred while processing this request.", errorDocument.Errors[0].Title); - Assert.Equal("Exception has been thrown by the target of an invocation.", errorDocument.Errors[0].Detail); - - var stackTraceLines = - ((JArray) errorDocument.Errors[0].Meta.Data["stackTrace"]).Select(token => token.Value()); - - Assert.Contains(stackTraceLines, line => line.Contains( - "System.InvalidOperationException: The value for the 'FailsOnSerialize' property is currently unavailable.")); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs deleted file mode 100644 index 8054663ef3..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Linq.Expressions; -using System.Net; -using System.Net.Http; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Client.Internal; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public class TestFixture : IDisposable where TStartup : class - { - private readonly TestServer _server; - public readonly IServiceProvider ServiceProvider; - public TestFixture() - { - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - _server = new TestServer(builder); - ServiceProvider = _server.Host.Services; - - Client = _server.CreateClient(); - Context = GetRequiredService().GetContext() as AppDbContext; - } - - public HttpClient Client { get; set; } - public AppDbContext Context { get; private set; } - - public static IRequestSerializer GetSerializer(IServiceProvider serviceProvider, Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - var serializer = (IRequestSerializer)serviceProvider.GetRequiredService(typeof(IRequestSerializer)); - var graph = (IResourceGraph)serviceProvider.GetRequiredService(typeof(IResourceGraph)); - serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; - serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; - return serializer; - } - - public IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - var serializer = GetRequiredService(); - var graph = GetRequiredService(); - serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; - serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; - return serializer; - } - - public IResponseDeserializer GetDeserializer() - { - var options = GetRequiredService(); - - var resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) - .Add() - .Add
() - .Add() - .Add() - .Add() - .Add() - .Add() - .Add() - .Add() - .Add("todoItems") - .Add().Build(); - return new ResponseDeserializer(resourceGraph, new ResourceFactory(ServiceProvider)); - } - - public T GetRequiredService() => (T)ServiceProvider.GetRequiredService(typeof(T)); - - public void ReloadDbContext() - { - ISystemClock systemClock = ServiceProvider.GetRequiredService(); - DbContextOptions options = GetRequiredService>(); - - Context = new AppDbContext(options, systemClock); - } - - public void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) - { - var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); - } - - private bool disposedValue; - - private void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - Client.Dispose(); - _server.Dispose(); - } - - disposedValue = true; - } - } - - public void Dispose() - { - Dispose(true); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs new file mode 100644 index 0000000000..1b9718ca7e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs @@ -0,0 +1,52 @@ +using System; +using Bogus; +using JsonApiDotNetCoreExample.Models; +using TestBuildingBlocks; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests +{ + internal sealed class ExampleFakers : FakerContainer + { + private readonly Lazy> _lazyAuthorFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(author => author.FirstName, f => f.Person.FirstName) + .RuleFor(author => author.LastName, f => f.Person.LastName)); + + private readonly Lazy> _lazyArticleFaker = new Lazy>(() => + new Faker
() + .UseSeed(GetFakerSeed()) + .RuleFor(article => article.Caption, f => f.Lorem.Word()) + .RuleFor(article => article.Url, f => f.Internet.Url())); + + private readonly Lazy> _lazyUserFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(user => user.UserName, f => f.Person.UserName) + .RuleFor(user => user.Password, f => f.Internet.Password())); + + private readonly Lazy> _lazyTodoItemFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(todoItem => todoItem.Description, f => f.Random.Words())); + + private readonly Lazy> _lazyPersonFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(person => person.FirstName, f => f.Person.FirstName) + .RuleFor(person => person.LastName, f => f.Person.LastName)); + + private readonly Lazy> _lazyTagFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(tag => tag.Name, f => f.Lorem.Word())); + + public Faker Author => _lazyAuthorFaker.Value; + public Faker
Article => _lazyArticleFaker.Value; + public Faker User => _lazyUserFaker.Value; + public Faker TodoItem => _lazyTodoItemFaker.Value; + public Faker Person => _lazyPersonFaker.Value; + public Faker Tag => _lazyTagFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs new file mode 100644 index 0000000000..249b61cad4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCoreExample; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests +{ + /// + /// A test context for tests that reference the JsonApiDotNetCoreExample project. + /// + /// The server Startup class, which can be defined in the test project. + /// The EF Core database context, which can be defined in the test project. + public class ExampleIntegrationTestContext : BaseIntegrationTestContext + where TStartup : class + where TDbContext : DbContext + { + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs b/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs deleted file mode 100644 index 04e237acb8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Net.Http; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCoreExampleTests -{ - public class CustomApplicationFactoryBase : WebApplicationFactory, IApplicationFactory - { - public readonly HttpClient Client; - private readonly IServiceScope _scope; - - public IServiceProvider ServiceProvider => _scope.ServiceProvider; - - public CustomApplicationFactoryBase() - { - Client = CreateClient(); - _scope = Services.CreateScope(); - } - - public T GetRequiredService() => (T)_scope.ServiceProvider.GetRequiredService(typeof(T)); - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseStartup(); - } - } - - public interface IApplicationFactory - { - IServiceProvider ServiceProvider { get; } - - T GetRequiredService(); - HttpClient CreateClient(); - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs deleted file mode 100644 index 72f879054b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs +++ /dev/null @@ -1,33 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; - -namespace JsonApiDotNetCoreExampleTests -{ - public class ResourceHooksApplicationFactory : CustomApplicationFactoryBase - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - base.ConfigureWebHost(builder); - - builder.ConfigureServices(services => - { - services.AddClientSerialization(); - }); - - builder.ConfigureTestServices(services => - { - services.AddJsonApi(options => - { - options.Namespace = "api/v1"; - options.DefaultPageSize = new PageSize(5); - options.IncludeTotalResourceCount = true; - options.EnableResourceHooks = true; - options.LoadDatabaseValues = true; - options.IncludeExceptionStackTraceInErrors = true; - }, - discovery => discovery.AddAssembly(typeof(JsonApiDotNetCoreExample.Program).Assembly)); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs deleted file mode 100644 index 8c45e2e5e7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; - -namespace JsonApiDotNetCoreExampleTests -{ - public class StandardApplicationFactory : CustomApplicationFactoryBase - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - base.ConfigureWebHost(builder); - - builder.ConfigureTestServices(services => - { - services.AddClientSerialization(); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs deleted file mode 100644 index 8cccc0c8e2..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace JsonApiDotNetCoreExampleTests.Helpers.Extensions -{ - public static class StringExtensions - { - public static string NormalizeLineEndings(this string text) - { - return text.Replace("\r\n", "\n").Replace("\r", "\n"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs deleted file mode 100644 index 3cbe45501c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExample.Models; - -namespace JsonApiDotNetCoreExampleTests.Helpers.Models -{ - /// - /// this "client" version of the is required because the - /// base property that is overridden here does not have a setter. For a model - /// defined on a JSON:API client, it would not make sense to have an exposed attribute - /// without a setter. - /// - public class TodoItemClient : TodoItem - { - [Attr] - public new string CalculatedValue { get; set; } - } - - [Resource("todoCollections")] - public sealed class TodoItemCollectionClient : Identifiable - { - [Attr] - public string Name { get; set; } - public int OwnerId { get; set; } - - [HasMany] - public ISet TodoItems { get; set; } - - [HasOne] - public Person Owner { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs index 1a9017472e..0f64043bd8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -13,17 +13,17 @@ public CompositeDbContext(DbContextOptions options) { } - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder builder) { - modelBuilder.Entity() + builder.Entity() .HasKey(car => new {car.RegionId, car.LicensePlate}); - modelBuilder.Entity() + builder.Entity() .HasOne(engine => engine.Car) .WithOne(car => car.Engine) .HasForeignKey(); - modelBuilder.Entity() + builder.Entity() .HasMany(dealership => dealership.Inventory) .WithOne(car => car.Dealership); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index b55f9ab1b0..ad647062c3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -5,18 +5,20 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { public sealed class CompositeKeyTests - : IClassFixture, CompositeDbContext>> + : IClassFixture, CompositeDbContext>> { - private readonly IntegrationTestContext, CompositeDbContext> _testContext; + private readonly ExampleIntegrationTestContext, CompositeDbContext> _testContext; - public CompositeKeyTests(IntegrationTestContext, CompositeDbContext> testContext) + public CompositeKeyTests(ExampleIntegrationTestContext, CompositeDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 3a1019c249..4eafc2e40a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -4,16 +4,18 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation { public sealed class AcceptHeaderTests - : IClassFixture, PolicyDbContext>> + : IClassFixture, PolicyDbContext>> { - private readonly IntegrationTestContext, PolicyDbContext> _testContext; + private readonly ExampleIntegrationTestContext, PolicyDbContext> _testContext; - public AcceptHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) + public AcceptHeaderTests(ExampleIntegrationTestContext, PolicyDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 2cce83a8fe..790370f12e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -3,16 +3,18 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation { public sealed class ContentTypeHeaderTests - : IClassFixture, PolicyDbContext>> + : IClassFixture, PolicyDbContext>> { - private readonly IntegrationTestContext, PolicyDbContext> _testContext; + private readonly ExampleIntegrationTestContext, PolicyDbContext> _testContext; - public ContentTypeHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) + public ContentTypeHeaderTests(ExampleIntegrationTestContext, PolicyDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs new file mode 100644 index 0000000000..4db7d81157 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public sealed class ActionResultDbContext : DbContext + { + public DbSet Toothbrushes { get; set; } + + public ActionResultDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs new file mode 100644 index 0000000000..87c091a597 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs @@ -0,0 +1,144 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public sealed class ActionResultTests + : IClassFixture, ActionResultDbContext>> + { + private readonly ExampleIntegrationTestContext, ActionResultDbContext> _testContext; + + public ActionResultTests(ExampleIntegrationTestContext, ActionResultDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resource_by_ID() + { + // Arrange + var toothbrush = new Toothbrush(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Toothbrushes.Add(toothbrush); + await dbContext.SaveChangesAsync(); + }); + + var route = "/toothbrushes/" + toothbrush.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(toothbrush.StringId); + } + + [Fact] + public async Task Converts_empty_ActionResult_to_error_collection() + { + // Arrange + var route = "/toothbrushes/" + BaseToothbrushesController._emptyActionResultId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("NotFound"); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Converts_ActionResult_with_error_object_to_error_collection() + { + // Arrange + var route = "/toothbrushes/" + BaseToothbrushesController._actionResultWithErrorObjectId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("No toothbrush with that ID exists."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_convert_ActionResult_with_string_parameter_to_error_collection() + { + // Arrange + var route = "/toothbrushes/" + BaseToothbrushesController._actionResultWithStringParameter; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.InternalServerError); + responseDocument.Errors[0].Title.Should().Be("An unhandled error occurred while processing this request."); + responseDocument.Errors[0].Detail.Should().Be("Data being returned must be errors or resources."); + } + + [Fact] + public async Task Converts_ObjectResult_with_error_object_to_error_collection() + { + // Arrange + var route = "/toothbrushes/" + BaseToothbrushesController._objectResultWithErrorObjectId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadGateway); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadGateway); + responseDocument.Errors[0].Title.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Converts_ObjectResult_with_error_objects_to_error_collection() + { + // Arrange + var route = "/toothbrushes/" + BaseToothbrushesController._objectResultWithErrorCollectionId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(3); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + responseDocument.Errors[0].Title.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().BeNull(); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.Unauthorized); + responseDocument.Errors[1].Title.Should().BeNull(); + responseDocument.Errors[1].Detail.Should().BeNull(); + + responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.ExpectationFailed); + responseDocument.Errors[2].Title.Should().Be("This is not a very great request."); + responseDocument.Errors[2].Detail.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs new file mode 100644 index 0000000000..0b03712d62 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public abstract class BaseToothbrushesController : BaseJsonApiController + { + public const int _emptyActionResultId = 11111111; + public const int _actionResultWithErrorObjectId = 22222222; + public const int _actionResultWithStringParameter = 33333333; + public const int _objectResultWithErrorObjectId = 44444444; + public const int _objectResultWithErrorCollectionId = 55555555; + + protected BaseToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + public override async Task GetAsync(int id, CancellationToken cancellationToken) + { + if (id == _emptyActionResultId) + { + return NotFound(); + } + + if (id == _actionResultWithErrorObjectId) + { + return NotFound(new Error(HttpStatusCode.NotFound) + { + Title = "No toothbrush with that ID exists." + }); + } + + if (id == _actionResultWithStringParameter) + { + return Conflict("Something went wrong."); + } + + if (id == _objectResultWithErrorObjectId) + { + return Error(new Error(HttpStatusCode.BadGateway)); + } + + if (id == _objectResultWithErrorCollectionId) + { + var errors = new[] + { + new Error(HttpStatusCode.PreconditionFailed), + new Error(HttpStatusCode.Unauthorized), + new Error(HttpStatusCode.ExpectationFailed) + { + Title = "This is not a very great request." + } + }; + return Error(errors); + } + + return await base.GetAsync(id, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs new file mode 100644 index 0000000000..be88ad4f4e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public sealed class Toothbrush : Identifiable + { + [Attr] + public bool IsElectric { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs new file mode 100644 index 0000000000..8cd4e1d646 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public sealed class ToothbrushesController : BaseToothbrushesController + { + public ToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + [HttpGet("{id}")] + public override Task GetAsync(int id, CancellationToken cancellationToken) + { + return base.GetAsync(id, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs new file mode 100644 index 0000000000..f124fe6ae6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs @@ -0,0 +1,37 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + public sealed class ApiControllerAttributeTests + : IClassFixture, CustomRouteDbContext>> + { + private readonly ExampleIntegrationTestContext, CustomRouteDbContext> _testContext; + + public ApiControllerAttributeTests(ExampleIntegrationTestContext, CustomRouteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() + { + // Arrange + var route = "/world-civilians/missing"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Country.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs similarity index 56% rename from src/Examples/JsonApiDotNetCoreExample/Models/Country.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs index 0a30443aed..c5f75a2c38 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Country.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs @@ -1,9 +1,9 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes { - public class Country : Identifiable + public sealed class Civilian : Identifiable { [Attr] public string Name { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs new file mode 100644 index 0000000000..dd2df9b6d6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + [ApiController] + [DisableRoutingConvention, Route("world-civilians")] + public sealed class CiviliansController : JsonApiController + { + public CiviliansController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + [HttpGet("missing")] + public async Task GetMissingAsync() + { + await Task.Yield(); + return NotFound(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs new file mode 100644 index 0000000000..4b76077d27 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + public sealed class CustomRouteDbContext : DbContext + { + public DbSet Towns { get; set; } + public DbSet Civilians { get; set; } + + public CustomRouteDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs new file mode 100644 index 0000000000..0fa5c5d008 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs @@ -0,0 +1,24 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + internal sealed class CustomRouteFakers : FakerContainer + { + private readonly Lazy> _lazyTownFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(town => town.Name, f => f.Address.City()) + .RuleFor(town => town.Latitude, f => f.Address.Latitude()) + .RuleFor(town => town.Longitude, f => f.Address.Longitude())); + + private readonly Lazy> _lazyCivilianFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(civilian => civilian.Name, f => f.Person.FullName)); + + public Faker Town => _lazyTownFaker.Value; + public Faker Civilian => _lazyCivilianFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs new file mode 100644 index 0000000000..5978ae3b4c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs @@ -0,0 +1,82 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + public sealed class CustomRouteTests + : IClassFixture, CustomRouteDbContext>> + { + private readonly ExampleIntegrationTestContext, CustomRouteDbContext> _testContext; + private readonly CustomRouteFakers _fakers = new CustomRouteFakers(); + + public CustomRouteTests(ExampleIntegrationTestContext, CustomRouteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resource_at_custom_route() + { + // Arrange + var town = _fakers.Town.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Towns.Add(town); + await dbContext.SaveChangesAsync(); + }); + + var route = "/world-api/civilization/popular/towns/" + town.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("towns"); + responseDocument.SingleData.Id.Should().Be(town.StringId); + responseDocument.SingleData.Attributes["name"].Should().Be(town.Name); + responseDocument.SingleData.Attributes["latitude"].Should().Be(town.Latitude); + responseDocument.SingleData.Attributes["longitude"].Should().Be(town.Longitude); + responseDocument.SingleData.Relationships["civilians"].Links.Self.Should().Be($"http://localhost/world-api/civilization/popular/towns/{town.Id}/relationships/civilians"); + responseDocument.SingleData.Relationships["civilians"].Links.Related.Should().Be($"http://localhost/world-api/civilization/popular/towns/{town.Id}/civilians"); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/world-api/civilization/popular/towns/{town.Id}"); + responseDocument.Links.Self.Should().Be($"http://localhost/world-api/civilization/popular/towns/{town.Id}"); + } + + [Fact] + public async Task Can_get_resources_at_custom_action_method() + { + // Arrange + var town = _fakers.Town.Generate(7); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Towns.AddRange(town); + await dbContext.SaveChangesAsync(); + }); + + var route = "/world-api/civilization/popular/towns/largest-5"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(5); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Type == "towns"); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Attributes.Any()); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Relationships.Any()); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs new file mode 100644 index 0000000000..0f2dc93926 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + public sealed class Town : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public double Latitude { get; set; } + + [Attr] + public double Longitude { get; set; } + + [HasMany] + public ISet Civilians { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs new file mode 100644 index 0000000000..af390bd683 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs @@ -0,0 +1,37 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + [DisableRoutingConvention, Route("world-api/civilization/popular/towns")] + public sealed class TownsController : JsonApiController + { + private readonly CustomRouteDbContext _dbContext; + + public TownsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService, CustomRouteDbContext dbContext) + : base(options, loggerFactory, resourceService) + { + _dbContext = dbContext; + } + + [HttpGet("largest-{count}")] + public async Task GetLargestTownsAsync(int count, CancellationToken cancellationToken) + { + var query = _dbContext.Towns + .OrderByDescending(town => town.Civilians.Count) + .Take(count); + + var results = await query.ToListAsync(cancellationToken); + return Ok(results); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs index 6ca3157f55..80ae5c9d14 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index c86e8f5bd8..76d7d9a972 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -3,18 +3,20 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading { public sealed class EagerLoadingTests - : IClassFixture, EagerLoadingDbContext>> + : IClassFixture, EagerLoadingDbContext>> { - private readonly IntegrationTestContext, EagerLoadingDbContext> _testContext; + private readonly ExampleIntegrationTestContext, EagerLoadingDbContext> _testContext; private readonly EagerLoadingFakers _fakers = new EagerLoadingFakers(); - public EagerLoadingTests(IntegrationTestContext, EagerLoadingDbContext> testContext) + public EagerLoadingTests(ExampleIntegrationTestContext, EagerLoadingDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs new file mode 100644 index 0000000000..385d2d6493 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs @@ -0,0 +1,37 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class AlternateExceptionHandler : ExceptionHandler + { + public AlternateExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) + : base(loggerFactory, options) + { + } + + protected override LogLevel GetLogLevel(Exception exception) + { + if (exception is ConsumerArticleIsNoLongerAvailableException) + { + return LogLevel.Warning; + } + + return base.GetLogLevel(exception); + } + + protected override ErrorDocument CreateErrorDocument(Exception exception) + { + if (exception is ConsumerArticleIsNoLongerAvailableException articleException) + { + articleException.Errors[0].Meta.Data.Add("support", + $"Please contact us for info about similar articles at {articleException.SupportEmailAddress}."); + } + + return base.CreateErrorDocument(exception); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs new file mode 100644 index 0000000000..320355edfa --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ConsumerArticle : Identifiable + { + [Attr] + public string Code { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs new file mode 100644 index 0000000000..f4420dea90 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs @@ -0,0 +1,23 @@ +using System.Net; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ConsumerArticleIsNoLongerAvailableException : JsonApiException + { + public string ArticleCode { get; } + public string SupportEmailAddress { get; } + + public ConsumerArticleIsNoLongerAvailableException(string articleCode, string supportEmailAddress) + : base(new Error(HttpStatusCode.Gone) + { + Title = "The requested article is no longer available.", + Detail = $"Article with code '{articleCode}' is no longer available." + }) + { + ArticleCode = articleCode; + SupportEmailAddress = supportEmailAddress; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs new file mode 100644 index 0000000000..2d867ea0f7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs @@ -0,0 +1,41 @@ +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ConsumerArticleService : JsonApiResourceService + { + public const string UnavailableArticlePrefix = "X"; + + private const string _supportEmailAddress = "company@email.com"; + + public ConsumerArticleService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, + IResourceHookExecutorFacade hookExecutor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, hookExecutor) + { + } + + public override async Task GetAsync(int id, CancellationToken cancellationToken) + { + var consumerArticle = await base.GetAsync(id, cancellationToken); + + if (consumerArticle.Code.StartsWith(UnavailableArticlePrefix)) + { + throw new ConsumerArticleIsNoLongerAvailableException(consumerArticle.Code, _supportEmailAddress); + } + + return consumerArticle; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs new file mode 100644 index 0000000000..dcc0ad7e8e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ConsumerArticlesController : JsonApiController + { + public ConsumerArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs new file mode 100644 index 0000000000..9e4d0feab8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ErrorDbContext : DbContext + { + public DbSet ConsumerArticles { get; set; } + public DbSet ThrowingArticles { get; set; } + + public ErrorDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs new file mode 100644 index 0000000000..6b1dd941ed --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -0,0 +1,127 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ExceptionHandlerTests + : IClassFixture, ErrorDbContext>> + { + private readonly ExampleIntegrationTestContext, ErrorDbContext> _testContext; + + public ExceptionHandlerTests(ExampleIntegrationTestContext, ErrorDbContext> testContext) + { + _testContext = testContext; + + FakeLoggerFactory loggerFactory = null; + + testContext.ConfigureLogging(options => + { + loggerFactory = new FakeLoggerFactory(); + + options.ClearProviders(); + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Warning); + }); + + testContext.ConfigureServicesBeforeStartup(services => + { + if (loggerFactory != null) + { + services.AddSingleton(_ => loggerFactory); + } + }); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceService(); + services.AddScoped(); + }); + } + + [Fact] + public async Task Logs_and_produces_error_response_for_custom_exception() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var consumerArticle = new ConsumerArticle + { + Code = ConsumerArticleService.UnavailableArticlePrefix + "123" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ConsumerArticles.Add(consumerArticle); + await dbContext.SaveChangesAsync(); + }); + + var route = "/consumerArticles/" + consumerArticle.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Gone); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Gone); + responseDocument.Errors[0].Title.Should().Be("The requested article is no longer available."); + responseDocument.Errors[0].Detail.Should().Be("Article with code 'X123' is no longer available."); + responseDocument.Errors[0].Meta.Data["support"].Should().Be("Please contact us for info about similar articles at company@email.com."); + + loggerFactory.Logger.Messages.Should().HaveCount(1); + loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); + loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available."); + } + + [Fact] + public async Task Logs_and_produces_error_response_on_serialization_failure() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var throwingArticle = new ThrowingArticle(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ThrowingArticles.Add(throwingArticle); + await dbContext.SaveChangesAsync(); + }); + + var route = "/throwingArticles/" + throwingArticle.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.InternalServerError); + responseDocument.Errors[0].Title.Should().Be("An unhandled error occurred while processing this request."); + responseDocument.Errors[0].Detail.Should().Be("Exception has been thrown by the target of an invocation."); + + var stackTraceLines = + ((JArray) responseDocument.Errors[0].Meta.Data["stackTrace"]).Select(token => token.Value()); + + stackTraceLines.Should().ContainMatch("* System.InvalidOperationException: Article status could not be determined.*"); + + loggerFactory.Logger.Messages.Should().HaveCount(1); + loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); + loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs new file mode 100644 index 0000000000..af4f7890ac --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs @@ -0,0 +1,14 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ThrowingArticle : Identifiable + { + [Attr] + [NotMapped] + public string Status => throw new InvalidOperationException("Article status could not be determined."); + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs new file mode 100644 index 0000000000..6616498f85 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ThrowingArticlesController : JsonApiController + { + public ThrowingArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index e6c9226cc4..9b5bde276a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -2,18 +2,20 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation { public sealed class IdObfuscationTests - : IClassFixture, ObfuscationDbContext>> + : IClassFixture, ObfuscationDbContext>> { - private readonly IntegrationTestContext, ObfuscationDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ObfuscationDbContext> _testContext; private readonly ObfuscationFakers _fakers = new ObfuscationFakers(); - public IdObfuscationTests(IntegrationTestContext, ObfuscationDbContext> testContext) + public IdObfuscationTests(ExampleIntegrationTestContext, ObfuscationDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs index 4d31f063da..06fd5ac9ab 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs deleted file mode 100644 index 00a281ff6f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ /dev/null @@ -1,885 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Extensions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Includes -{ - public sealed class IncludeTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - - public IncludeTests(IntegrationTestContext testContext) - { - _testContext = testContext; - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped, JsonApiResourceService
>(); - }); - - var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); - options.MaximumIncludeDepth = null; - } - - [Fact] - public async Task Can_include_in_primary_resources() - { - // Arrange - var article = new Article - { - Caption = "One", - Author = new Author - { - LastName = "Smith" - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync
(); - dbContext.Articles.Add(article); - - await dbContext.SaveChangesAsync(); - }); - - var route = "/api/v1/articles?include=author"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(article.StringId); - responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("authors"); - responseDocument.Included[0].Id.Should().Be(article.Author.StringId); - responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); - } - - [Fact] - public async Task Can_include_in_primary_resource_by_ID() - { - // Arrange - var article = new Article - { - Caption = "One", - Author = new Author - { - LastName = "Smith" - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Articles.Add(article); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/articles/{article.StringId}?include=author"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(article.StringId); - responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("authors"); - responseDocument.Included[0].Id.Should().Be(article.Author.StringId); - responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); - } - - [Fact] - public async Task Can_include_in_secondary_resource() - { - // Arrange - var blog = new Blog - { - Owner = new Author - { - LastName = "Smith", - Articles = new List
- { - new Article - { - Caption = "One" - } - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Blogs.Add(blog); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(blog.Owner.StringId); - responseDocument.SingleData.Attributes["lastName"].Should().Be(blog.Owner.LastName); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("articles"); - responseDocument.Included[0].Id.Should().Be(blog.Owner.Articles[0].StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); - } - - [Fact] - public async Task Can_include_in_secondary_resources() - { - // Arrange - var blog = new Blog - { - Articles = new List
- { - new Article - { - Caption = "One", - Author = new Author - { - LastName = "Smith" - } - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Blogs.Add(blog); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/blogs/{blog.StringId}/articles?include=author"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(blog.Articles[0].StringId); - responseDocument.ManyData[0].Attributes["caption"].Should().Be(blog.Articles[0].Caption); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("authors"); - responseDocument.Included[0].Id.Should().Be(blog.Articles[0].Author.StringId); - responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Articles[0].Author.LastName); - } - - [Fact] - public async Task Can_include_HasOne_relationships() - { - // Arrange - var todoItem = new TodoItem - { - Description = "Work", - Owner = new Person - { - FirstName = "Joel" - }, - Assignee = new Person - { - FirstName = "James" - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/todoItems/{todoItem.StringId}?include=owner,assignee"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(todoItem.StringId); - responseDocument.SingleData.Attributes["description"].Should().Be(todoItem.Description); - - responseDocument.Included.Should().HaveCount(2); - - responseDocument.Included[0].Type.Should().Be("people"); - responseDocument.Included[0].Id.Should().Be(todoItem.Owner.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(todoItem.Owner.FirstName); - - responseDocument.Included[1].Type.Should().Be("people"); - responseDocument.Included[1].Id.Should().Be(todoItem.Assignee.StringId); - responseDocument.Included[1].Attributes["firstName"].Should().Be(todoItem.Assignee.FirstName); - } - - [Fact] - public async Task Can_include_HasMany_relationship() - { - // Arrange - var article = new Article - { - Caption = "One", - Revisions = new List - { - new Revision - { - PublishTime = 24.July(2019) - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Articles.Add(article); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/articles/{article.StringId}?include=revisions"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(article.StringId); - responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("revisions"); - responseDocument.Included[0].Id.Should().Be(article.Revisions.Single().StringId); - responseDocument.Included[0].Attributes["publishTime"].Should().Be(article.Revisions.Single().PublishTime); - } - - [Fact] - public async Task Can_include_HasManyThrough_relationship() - { - // Arrange - var article = new Article - { - Caption = "One", - ArticleTags = new HashSet - { - new ArticleTag - { - Tag = new Tag - { - Name = "Hot" - } - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Articles.Add(article); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/articles/{article.StringId}?include=tags"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(article.StringId); - responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("tags"); - responseDocument.Included[0].Id.Should().Be(article.ArticleTags.Single().Tag.StringId); - responseDocument.Included[0].Attributes["name"].Should().Be(article.ArticleTags.Single().Tag.Name); - } - - [Fact] - public async Task Can_include_HasManyThrough_relationship_in_secondary_resource() - { - // Arrange - var article = new Article - { - Caption = "One", - ArticleTags = new HashSet - { - new ArticleTag - { - Tag = new Tag - { - Name = "Hot" - } - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Articles.Add(article); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/articles/{article.StringId}/tags?include=articles"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Type.Should().Be("tags"); - responseDocument.ManyData[0].Id.Should().Be(article.ArticleTags.ElementAt(0).Tag.StringId); - responseDocument.ManyData[0].Attributes["name"].Should().Be(article.ArticleTags.Single().Tag.Name); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("articles"); - responseDocument.Included[0].Id.Should().Be(article.StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(article.Caption); - } - - [Fact] - public async Task Can_include_chain_of_HasOne_relationships() - { - // Arrange - var article = new Article - { - Caption = "One", - Author = new Author - { - LastName = "Smith", - LivingAddress = new Address - { - Street = "Main Road", - Country = new Country - { - Name = "United States of America" - } - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Articles.Add(article); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/articles/{article.StringId}?include=author.livingAddress.country"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(article.StringId); - responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); - - responseDocument.Included.Should().HaveCount(3); - - responseDocument.Included[0].Type.Should().Be("authors"); - responseDocument.Included[0].Id.Should().Be(article.Author.StringId); - responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); - - responseDocument.Included[1].Type.Should().Be("addresses"); - responseDocument.Included[1].Id.Should().Be(article.Author.LivingAddress.StringId); - responseDocument.Included[1].Attributes["street"].Should().Be(article.Author.LivingAddress.Street); - - responseDocument.Included[2].Type.Should().Be("countries"); - responseDocument.Included[2].Id.Should().Be(article.Author.LivingAddress.Country.StringId); - responseDocument.Included[2].Attributes["name"].Should().Be(article.Author.LivingAddress.Country.Name); - } - - [Fact] - public async Task Can_include_chain_of_HasMany_relationships() - { - // Arrange - var blog = new Blog - { - Title = "Some", - Articles = new List
- { - new Article - { - Caption = "One", - Revisions = new List - { - new Revision - { - PublishTime = 24.July(2019) - } - } - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Blogs.Add(blog); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/blogs/{blog.StringId}?include=articles.revisions"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(blog.StringId); - responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); - - responseDocument.Included.Should().HaveCount(2); - - responseDocument.Included[0].Type.Should().Be("articles"); - responseDocument.Included[0].Id.Should().Be(blog.Articles[0].StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Articles[0].Caption); - - responseDocument.Included[1].Type.Should().Be("revisions"); - responseDocument.Included[1].Id.Should().Be(blog.Articles[0].Revisions.Single().StringId); - responseDocument.Included[1].Attributes["publishTime"].Should().Be(blog.Articles[0].Revisions.Single().PublishTime); - } - - [Fact] - public async Task Can_include_chain_of_recursive_relationships() - { - // Arrange - var todoItem = new TodoItem - { - Description = "Root", - Collection = new TodoItemCollection - { - Name = "Primary", - Owner = new Person - { - FirstName = "Jack" - }, - TodoItems = new HashSet - { - new TodoItem - { - Description = "This is nested.", - Owner = new Person - { - FirstName = "Jill" - } - }, - new TodoItem - { - Description = "This is nested too." - } - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/v1/todoItems/{todoItem.StringId}?include=collection.todoItems.owner"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(todoItem.StringId); - responseDocument.SingleData.Attributes["description"].Should().Be(todoItem.Description); - - responseDocument.Included.Should().HaveCount(5); - - responseDocument.Included[0].Type.Should().Be("todoCollections"); - responseDocument.Included[0].Id.Should().Be(todoItem.Collection.StringId); - responseDocument.Included[0].Attributes["name"].Should().Be(todoItem.Collection.Name); - - responseDocument.Included[1].Type.Should().Be("todoItems"); - responseDocument.Included[1].Id.Should().Be(todoItem.StringId); - responseDocument.Included[1].Attributes["description"].Should().Be(todoItem.Description); - - responseDocument.Included[2].Type.Should().Be("todoItems"); - responseDocument.Included[2].Id.Should().Be(todoItem.Collection.TodoItems.First().StringId); - responseDocument.Included[2].Attributes["description"].Should().Be(todoItem.Collection.TodoItems.First().Description); - - responseDocument.Included[3].Type.Should().Be("people"); - responseDocument.Included[3].Id.Should().Be(todoItem.Collection.TodoItems.First().Owner.StringId); - responseDocument.Included[3].Attributes["firstName"].Should().Be(todoItem.Collection.TodoItems.First().Owner.FirstName); - - responseDocument.Included[4].Type.Should().Be("todoItems"); - responseDocument.Included[4].Id.Should().Be(todoItem.Collection.TodoItems.Skip(1).First().StringId); - responseDocument.Included[4].Attributes["description"].Should().Be(todoItem.Collection.TodoItems.Skip(1).First().Description); - } - - [Fact] - public async Task Can_include_chain_of_relationships_with_multiple_paths() - { - // Arrange - var todoItem = new TodoItem - { - Description = "Root", - Collection = new TodoItemCollection - { - Name = "Primary", - Owner = new Person - { - FirstName = "Jack", - Role = new PersonRole() - }, - TodoItems = new HashSet - { - new TodoItem - { - Description = "This is nested.", - Owner = new Person - { - FirstName = "Jill" - } - }, - new TodoItem - { - Description = "This is nested too." - } - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/v1/todoItems/{todoItem.StringId}?include=collection.owner.role,collection.todoItems.owner"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(todoItem.StringId); - responseDocument.SingleData.Attributes["description"].Should().Be(todoItem.Description); - - responseDocument.Included.Should().HaveCount(7); - - responseDocument.Included[0].Type.Should().Be("todoCollections"); - responseDocument.Included[0].Id.Should().Be(todoItem.Collection.StringId); - responseDocument.Included[0].Attributes["name"].Should().Be(todoItem.Collection.Name); - - responseDocument.Included[1].Type.Should().Be("people"); - responseDocument.Included[1].Id.Should().Be(todoItem.Collection.Owner.StringId); - responseDocument.Included[1].Attributes["firstName"].Should().Be(todoItem.Collection.Owner.FirstName); - - responseDocument.Included[2].Type.Should().Be("personRoles"); - responseDocument.Included[2].Id.Should().Be(todoItem.Collection.Owner.Role.StringId); - - responseDocument.Included[3].Type.Should().Be("todoItems"); - responseDocument.Included[3].Id.Should().Be(todoItem.StringId); - responseDocument.Included[3].Attributes["description"].Should().Be(todoItem.Description); - - responseDocument.Included[4].Type.Should().Be("todoItems"); - responseDocument.Included[4].Id.Should().Be(todoItem.Collection.TodoItems.First().StringId); - responseDocument.Included[4].Attributes["description"].Should().Be(todoItem.Collection.TodoItems.First().Description); - - responseDocument.Included[5].Type.Should().Be("people"); - responseDocument.Included[5].Id.Should().Be(todoItem.Collection.TodoItems.First().Owner.StringId); - responseDocument.Included[5].Attributes["firstName"].Should().Be(todoItem.Collection.TodoItems.First().Owner.FirstName); - - responseDocument.Included[6].Type.Should().Be("todoItems"); - responseDocument.Included[6].Id.Should().Be(todoItem.Collection.TodoItems.Skip(1).First().StringId); - responseDocument.Included[6].Attributes["description"].Should().Be(todoItem.Collection.TodoItems.Skip(1).First().Description); - } - - [Fact] - public async Task Prevents_duplicate_includes_over_single_resource() - { - // Arrange - var person = new Person - { - FirstName = "Janice" - }; - - var todoItem = new TodoItem - { - Description = "Root", - Owner = person, - Assignee = person - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TodoItems.Add(todoItem); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/todoItems/{todoItem.StringId}?include=owner&include=assignee"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(todoItem.StringId); - responseDocument.SingleData.Attributes["description"].Should().Be(todoItem.Description); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("people"); - responseDocument.Included[0].Id.Should().Be(person.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(person.FirstName); - } - - [Fact] - public async Task Prevents_duplicate_includes_over_multiple_resources() - { - // Arrange - var person = new Person - { - FirstName = "Janice" - }; - - var todoItems = new List - { - new TodoItem - { - Description = "First", - Owner = person - }, - new TodoItem - { - Description = "Second", - Owner = person - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.TodoItems.AddRange(todoItems); - - await dbContext.SaveChangesAsync(); - }); - - var route = "/api/v1/todoItems?include=owner"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(2); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("people"); - responseDocument.Included[0].Id.Should().Be(person.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(person.FirstName); - } - - [Fact] - public async Task Cannot_include_unknown_relationship() - { - // Arrange - var route = "/api/v1/people?include=doesNotExist"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - responseDocument.Errors[0].Title.Should().Be("The specified include is invalid."); - responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource 'people'."); - responseDocument.Errors[0].Source.Parameter.Should().Be("include"); - } - - [Fact] - public async Task Cannot_include_unknown_nested_relationship() - { - // Arrange - var route = "/api/v1/people?include=todoItems.doesNotExist"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - responseDocument.Errors[0].Title.Should().Be("The specified include is invalid."); - responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' in 'todoItems.doesNotExist' does not exist on resource 'todoItems'."); - responseDocument.Errors[0].Source.Parameter.Should().Be("include"); - } - - [Fact] - public async Task Cannot_include_relationship_with_blocked_capability() - { - // Arrange - var route = "/api/v1/people?include=unIncludeableItem"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - responseDocument.Errors[0].Title.Should().Be("Including the requested relationship is not allowed."); - responseDocument.Errors[0].Detail.Should().Be("Including the relationship 'unIncludeableItem' on 'people' is not allowed."); - responseDocument.Errors[0].Source.Parameter.Should().Be("include"); - } - - [Fact] - public async Task Ignores_null_parent_in_nested_include() - { - // Arrange - var todoItems = new List - { - new TodoItem - { - Description = "Owned", - Owner = new Person - { - FirstName = "Julian" - } - }, - new TodoItem - { - Description = "Unowned" - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.TodoItems.AddRange(todoItems); - - await dbContext.SaveChangesAsync(); - }); - - var route = "/api/v1/todoItems?include=owner.role"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(2); - - var resourcesWithOwner = responseDocument.ManyData.Where(resource => resource.Relationships.First(pair => pair.Key == "owner").Value.SingleData != null).ToArray(); - resourcesWithOwner.Should().HaveCount(1); - resourcesWithOwner[0].Attributes["description"].Should().Be(todoItems[0].Description); - - var resourcesWithoutOwner = responseDocument.ManyData.Where(resource => resource.Relationships.First(pair => pair.Key == "owner").Value.SingleData == null).ToArray(); - resourcesWithoutOwner.Should().HaveCount(1); - resourcesWithoutOwner[0].Attributes["description"].Should().Be(todoItems[1].Description); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("people"); - responseDocument.Included[0].Id.Should().Be(todoItems[0].Owner.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(todoItems[0].Owner.FirstName); - } - - [Fact] - public async Task Can_include_at_configured_maximum_inclusion_depth() - { - // Arrange - var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); - options.MaximumIncludeDepth = 1; - - var blog = new Blog(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Blogs.Add(blog); - - await dbContext.SaveChangesAsync(); - }); - - var route = $"/api/v1/blogs/{blog.StringId}/articles?include=author,revisions"; - - // Act - var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Cannot_exceed_configured_maximum_inclusion_depth() - { - // Arrange - var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); - options.MaximumIncludeDepth = 1; - - var route = "/api/v1/blogs/123/owner?include=articles.revisions"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - responseDocument.Errors[0].Title.Should().Be("The specified include is invalid."); - responseDocument.Errors[0].Detail.Should().Be("Including 'articles.revisions' exceeds the maximum inclusion depth of 1."); - responseDocument.Errors[0].Source.Parameter.Should().Be("include"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs new file mode 100644 index 0000000000..a5fa5e1f26 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -0,0 +1,362 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class AbsoluteLinksWithNamespaceTests + : IClassFixture, LinksDbContext>> + { + private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new LinksFakers(); + + public AbsoluteLinksWithNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Get_primary_resource_by_ID_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/photoAlbums/" + album.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("http://localhost/api/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be("http://localhost/api/photoAlbums?include=photos"); + responseDocument.Links.Last.Should().Be("http://localhost/api/photoAlbums?include=photos"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}"); + responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_secondary_resource_returns_absolute_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photos/{photo.StringId}/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{photo.Album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{photo.Album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{photo.Album.StringId}/photos"); + } + + [Fact] + public async Task Get_secondary_resources_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photoAlbums/{album.StringId}/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_HasOne_relationship_returns_absolute_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photos/{photo.StringId}/relationships/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photos/{photo.StringId}/relationships/album"); + responseDocument.Links.Related.Should().Be($"http://localhost/api/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Get_HasMany_relationship_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Related.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + } + + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photoAlbums", + relationships = new + { + photos = new + { + data = new[] + { + new + { + type = "photos", + id = existingPhoto.StringId + } + } + } + } + } + }; + + var route = "/api/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Links.Self.Should().Be("http://localhost/api/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + var newAlbumId = responseDocument.SingleData.Id; + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{newAlbumId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{newAlbumId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{newAlbumId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}/album"); + } + + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + var existingAlbum = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photos", + id = existingPhoto.StringId, + relationships = new + { + album = new + { + data = new + { + type = "photoAlbums", + id = existingAlbum.StringId + } + } + } + } + }; + + var route = $"/api/photos/{existingPhoto.StringId}?include=album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}?include=album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}"); + responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}/album"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{existingAlbum.StringId}"); + responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{existingAlbum.StringId}/relationships/photos"); + responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{existingAlbum.StringId}/photos"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs new file mode 100644 index 0000000000..7de9adbaee --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -0,0 +1,362 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class AbsoluteLinksWithoutNamespaceTests + : IClassFixture, LinksDbContext>> + { + private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new LinksFakers(); + + public AbsoluteLinksWithoutNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Get_primary_resource_by_ID_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/photoAlbums/" + album.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("http://localhost/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be("http://localhost/photoAlbums?include=photos"); + responseDocument.Links.Last.Should().Be("http://localhost/photoAlbums?include=photos"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}"); + responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_secondary_resource_returns_absolute_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photos/{photo.StringId}/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/photoAlbums/{photo.Album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{photo.Album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{photo.Album.StringId}/photos"); + } + + [Fact] + public async Task Get_secondary_resources_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photoAlbums/{album.StringId}/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_HasOne_relationship_returns_absolute_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photos/{photo.StringId}/relationships/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photos/{photo.StringId}/relationships/album"); + responseDocument.Links.Related.Should().Be($"http://localhost/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Get_HasMany_relationship_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photoAlbums/{album.StringId}/relationships/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Related.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"http://localhost/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + } + + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photoAlbums", + relationships = new + { + photos = new + { + data = new[] + { + new + { + type = "photos", + id = existingPhoto.StringId + } + } + } + } + } + }; + + var route = "/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Links.Self.Should().Be("http://localhost/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + var newAlbumId = responseDocument.SingleData.Id; + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/photoAlbums/{newAlbumId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{newAlbumId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{newAlbumId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/photos/{existingPhoto.StringId}/album"); + } + + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + var existingAlbum = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photos", + id = existingPhoto.StringId, + relationships = new + { + album = new + { + data = new + { + type = "photoAlbums", + id = existingAlbum.StringId + } + } + } + } + }; + + var route = $"/photos/{existingPhoto.StringId}?include=album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}?include=album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}"); + responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be($"http://localhost/photos/{existingPhoto.StringId}/album"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/photoAlbums/{existingAlbum.StringId}"); + responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{existingAlbum.StringId}/relationships/photos"); + responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{existingAlbum.StringId}/photos"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs new file mode 100644 index 0000000000..bb34911642 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class LinksDbContext : DbContext + { + public DbSet PhotoAlbums { get; set; } + public DbSet Photos { get; set; } + + public LinksDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs new file mode 100644 index 0000000000..6e751dd00b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs @@ -0,0 +1,22 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + internal sealed class LinksFakers : FakerContainer + { + private readonly Lazy> _lazyPhotoAlbumFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photoAlbum => photoAlbum.Name, f => f.Lorem.Sentence())); + + private readonly Lazy> _lazyPhotoFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photo => photo.Url, f => f.Image.PlaceImgUrl())); + + public Faker PhotoAlbum => _lazyPhotoAlbumFaker.Value; + public Faker Photo => _lazyPhotoFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs new file mode 100644 index 0000000000..8af6915e1d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs @@ -0,0 +1,15 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class Photo : Identifiable + { + [Attr] + public string Url { get; set; } + + [HasOne] + public PhotoAlbum Album { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs new file mode 100644 index 0000000000..d5a6b448e3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class PhotoAlbum : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public ISet Photos { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs new file mode 100644 index 0000000000..8d13f66b99 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class PhotoAlbumsController : JsonApiController + { + public PhotoAlbumsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs new file mode 100644 index 0000000000..e0dcb9a316 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class PhotosController : JsonApiController + { + public PhotosController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index 7d6b023bef..e06f78f6a7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -1,47 +1,49 @@ -using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links { public sealed class RelativeLinksWithNamespaceTests - : IClassFixture> + : IClassFixture, LinksDbContext>> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new LinksFakers(); - public RelativeLinksWithNamespaceTests(IntegrationTestContext testContext) + public RelativeLinksWithNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) { _testContext = testContext; + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); - options.Namespace = "api/v1"; - options.UseRelativeLinks = true; - options.DefaultPageSize = new PageSize(10); options.IncludeTotalResourceCount = true; } [Fact] - public async Task Get_primary_resource_by_ID_returns_links() + public async Task Get_primary_resource_by_ID_returns_relative_links() { // Arrange - var person = new Person(); + var album = _fakers.PhotoAlbum.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.People.Add(person); + dbContext.PhotoAlbums.Add(album); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/people/" + person.StringId; + var route = "/api/photoAlbums/" + album.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -49,7 +51,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + responseDocument.Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); @@ -57,32 +59,99 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Next.Should().BeNull(); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + responseDocument.SingleData.Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.SingleData.Relationships["todoItems"].Links.Self.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); - responseDocument.SingleData.Relationships["todoItems"].Links.Related.Should().Be($"/api/v1/people/{person.StringId}/todoItems"); + responseDocument.Links.Self.Should().Be("/api/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be("/api/photoAlbums?include=photos"); + responseDocument.Links.Last.Should().Be("/api/photoAlbums?include=photos"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}"); + responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}/album"); } [Fact] - public async Task Get_primary_resources_with_include_returns_links() + public async Task Get_secondary_resource_returns_relative_links() { // Arrange - var person = new Person + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - TodoItems = new HashSet - { - new TodoItem() - } - }; + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photos/{photo.StringId}/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/api/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/api/photoAlbums/{photo.Album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{photo.Album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{photo.Album.StringId}/photos"); + } + + [Fact] + public async Task Get_secondary_resources_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.People.Add(person); + dbContext.PhotoAlbums.Add(album); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/people?include=todoItems"; + var route = $"/api/photoAlbums/{album.StringId}/photos"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -90,56 +159,204 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be("/api/v1/people?include=todoItems"); - responseDocument.Links.Last.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.First.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + responseDocument.ManyData[0].Links.Self.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}/album"); + } - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be($"/api/v1/todoItems/{person.TodoItems.ElementAt(0).StringId}"); + [Fact] + public async Task Get_HasOne_relationship_returns_relative_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photos/{photo.StringId}/relationships/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/api/photos/{photo.StringId}/relationships/album"); + responseDocument.Links.Related.Should().Be($"/api/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Get_HasMany_relationship_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Related.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.ManyData[0].Relationships.Should().BeNull(); } [Fact] - public async Task Get_HasMany_relationship_returns_links() + public async Task Create_resource_with_side_effects_and_include_returns_relative_links() { // Arrange - var person = new Person + var existingPhoto = _fakers.Photo.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - TodoItems = new HashSet + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new { - new TodoItem() + type = "photoAlbums", + relationships = new + { + photos = new + { + data = new[] + { + new + { + type = "photos", + id = existingPhoto.StringId + } + } + } + } } }; + var route = "/api/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Links.Self.Should().Be("/api/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + var newAlbumId = responseDocument.SingleData.Id; + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/api/photoAlbums/{newAlbumId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{newAlbumId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{newAlbumId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"/api/photos/{existingPhoto.StringId}/album"); + } + + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + var existingAlbum = _fakers.PhotoAlbum.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.People.Add(person); + dbContext.AddRange(existingPhoto, existingAlbum); await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + var requestBody = new + { + data = new + { + type = "photos", + id = existingPhoto.StringId, + relationships = new + { + album = new + { + data = new + { + type = "photoAlbums", + id = existingAlbum.StringId + } + } + } + } + }; + + var route = $"/api/photos/{existingPhoto.StringId}?include=album"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); - responseDocument.Links.Related.Should().Be($"/api/v1/people/{person.StringId}/todoItems"); - responseDocument.Links.First.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); + responseDocument.Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}?include=album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}"); + responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be($"/api/photos/{existingPhoto.StringId}/album"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/api/photoAlbums/{existingAlbum.StringId}"); + responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{existingAlbum.StringId}/relationships/photos"); + responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{existingAlbum.StringId}/photos"); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs new file mode 100644 index 0000000000..dfbffa6123 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -0,0 +1,362 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class RelativeLinksWithoutNamespaceTests + : IClassFixture, LinksDbContext>> + { + private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new LinksFakers(); + + public RelativeLinksWithoutNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Get_primary_resource_by_ID_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/photoAlbums/" + album.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photoAlbums/{album.StringId}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/photoAlbums/{album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{album.StringId}/photos"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be("/photoAlbums?include=photos"); + responseDocument.Links.Last.Should().Be("/photoAlbums?include=photos"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"/photoAlbums/{album.StringId}"); + responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{album.StringId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_secondary_resource_returns_relative_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photos/{photo.StringId}/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/photoAlbums/{photo.Album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{photo.Album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{photo.Album.StringId}/photos"); + } + + [Fact] + public async Task Get_secondary_resources_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photoAlbums/{album.StringId}/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be($"/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_HasOne_relationship_returns_relative_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photos/{photo.StringId}/relationships/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photos/{photo.StringId}/relationships/album"); + responseDocument.Links.Related.Should().Be($"/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Get_HasMany_relationship_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photoAlbums/{album.StringId}/relationships/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Related.Should().Be($"/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + } + + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photoAlbums", + relationships = new + { + photos = new + { + data = new[] + { + new + { + type = "photos", + id = existingPhoto.StringId + } + } + } + } + } + }; + + var route = "/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Links.Self.Should().Be("/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + var newAlbumId = responseDocument.SingleData.Id; + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/photoAlbums/{newAlbumId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{newAlbumId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{newAlbumId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/photos/{existingPhoto.StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"/photos/{existingPhoto.StringId}/album"); + } + + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + var existingAlbum = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photos", + id = existingPhoto.StringId, + relationships = new + { + album = new + { + data = new + { + type = "photoAlbums", + id = existingAlbum.StringId + } + } + } + } + }; + + var route = $"/photos/{existingPhoto.StringId}?include=album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photos/{existingPhoto.StringId}?include=album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/photos/{existingPhoto.StringId}"); + responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be($"/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be($"/photos/{existingPhoto.StringId}/album"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/photoAlbums/{existingAlbum.StringId}"); + responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{existingAlbum.StringId}/relationships/photos"); + responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{existingAlbum.StringId}/photos"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs new file mode 100644 index 0000000000..f640452877 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + public sealed class AuditDbContext : DbContext + { + public DbSet AuditEntries { get; set; } + + public AuditDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs new file mode 100644 index 0000000000..5a1e4d74e7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + public sealed class AuditEntriesController : JsonApiController + { + public AuditEntriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs new file mode 100644 index 0000000000..e9fcfcfe5e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs @@ -0,0 +1,15 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + public sealed class AuditEntry : Identifiable + { + [Attr] + public string UserName { get; set; } + + [Attr] + public DateTimeOffset CreatedAt { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs new file mode 100644 index 0000000000..214fcd9bda --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs @@ -0,0 +1,17 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + internal sealed class AuditFakers : FakerContainer + { + private readonly Lazy> _lazyAuditEntryFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(auditEntry => auditEntry.UserName, f => f.Internet.UserName()) + .RuleFor(auditEntry => auditEntry.CreatedAt, f => f.Date.PastOffset())); + + public Faker AuditEntry => _lazyAuditEntryFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs index 61c99f6bb7..754aedc690 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs @@ -1,21 +1,21 @@ using System.Net; using System.Threading.Tasks; using FluentAssertions; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging { - public sealed class LoggingTests : IClassFixture> + public sealed class LoggingTests + : IClassFixture, AuditDbContext>> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext, AuditDbContext> _testContext; + private readonly AuditFakers _fakers = new AuditFakers(); - public LoggingTests(IntegrationTestContext testContext) + public LoggingTests(ExampleIntegrationTestContext, AuditDbContext> testContext) { _testContext = testContext; @@ -28,8 +28,7 @@ public LoggingTests(IntegrationTestContext testContext) options.ClearProviders(); options.AddProvider(loggerFactory); options.SetMinimumLevel(LogLevel.Trace); - options.AddFilter((category, level) => level == LogLevel.Trace && - (category == typeof(JsonApiReader).FullName || category == typeof(JsonApiWriter).FullName)); + options.AddFilter((category, level) => true); }); testContext.ConfigureServicesBeforeStartup(services => @@ -42,7 +41,66 @@ public LoggingTests(IntegrationTestContext testContext) } [Fact] - public async Task Logs_request_body_on_error() + public async Task Logs_request_body_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var newEntry = _fakers.AuditEntry.Generate(); + + var requestBody = new + { + data = new + { + type = "auditEntries", + attributes = new + { + userName = newEntry.UserName, + createdAt = newEntry.CreatedAt + } + } + }; + + // Arrange + var route = "/auditEntries"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + loggerFactory.Logger.Messages.Should().NotBeEmpty(); + + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && + message.Text.StartsWith("Received request at 'http://localhost/auditEntries' with body: <<")); + } + + [Fact] + public async Task Logs_response_body_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + // Arrange + var route = "/auditEntries"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + loggerFactory.Logger.Messages.Should().NotBeEmpty(); + + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && + message.Text.StartsWith("Sending 200 response for request at 'http://localhost/auditEntries' with body: <<")); + } + + [Fact] + public async Task Logs_invalid_request_body_error_at_Information_level() { // Arrange var loggerFactory = _testContext.Factory.Services.GetRequiredService(); @@ -51,19 +109,18 @@ public async Task Logs_request_body_on_error() // Arrange var requestBody = "{ \"data\" {"; - var route = "/api/v1/todoItems"; + var route = "/auditEntries"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + loggerFactory.Logger.Messages.Should().NotBeEmpty(); - loggerFactory.Logger.Messages.Should().HaveCount(2); - loggerFactory.Logger.Messages.Should().Contain(message => message.Text.StartsWith("Received request at ") && message.Text.Contains("with body:")); - loggerFactory.Logger.Messages.Should().Contain(message => message.Text.StartsWith("Sending 422 response for request at ") && message.Text.Contains("Failed to deserialize request body.")); + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && + message.Text.Contains("Failed to deserialize request body.")); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs new file mode 100644 index 0000000000..94b827c2f7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class ProductFamiliesController : JsonApiController + { + public ProductFamiliesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs new file mode 100644 index 0000000000..75a8e34441 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class ProductFamily : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public IList Tickets { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index ab33245072..6a943c0615 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -1,44 +1,47 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class ResourceMetaTests : IClassFixture> + public sealed class ResourceMetaTests + : IClassFixture, SupportDbContext>> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext, SupportDbContext> _testContext; + private readonly SupportFakers _fakers = new SupportFakers(); - public ResourceMetaTests(IntegrationTestContext testContext) + public ResourceMetaTests(ExampleIntegrationTestContext, SupportDbContext> testContext) { _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped, SupportTicketDefinition>(); + }); } [Fact] - public async Task ResourceDefinition_That_Implements_GetMeta_Contains_Resource_Meta() + public async Task Returns_resource_meta_from_ResourceDefinition() { // Arrange - var todoItems = new[] - { - new TodoItem {Id = 1, Description = "Important: Pay the bills"}, - new TodoItem {Id = 2, Description = "Plan my birthday party"}, - new TodoItem {Id = 3, Description = "Important: Call mom"} - }; + var tickets = _fakers.SupportTicket.Generate(3); + tickets[0].Description = "Critical: " + tickets[0].Description; + tickets[2].Description = "Critical: " + tickets[2].Description; await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.TodoItems.AddRange(todoItems); - + await dbContext.ClearTableAsync(); + dbContext.SupportTickets.AddRange(tickets); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/todoItems"; + var route = "/supportTickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -53,25 +56,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task ResourceDefinition_That_Implements_GetMeta_Contains_Include_Meta() + public async Task Returns_resource_meta_from_ResourceDefinition_in_included_resources() { // Arrange - var person = new Person - { - TodoItems = new HashSet - { - new TodoItem {Id = 1, Description = "Important: Pay the bills"} - } - }; + var family = _fakers.ProductFamily.Generate(); + family.Tickets = _fakers.SupportTicket.Generate(1); + family.Tickets[0].Description = "Critical: " + family.Tickets[0].Description; await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.People.Add(person); + await dbContext.ClearTableAsync(); + dbContext.ProductFamilies.Add(family); await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/people/{person.StringId}?include=todoItems"; + var route = $"/productFamilies/{family.StringId}?include=tickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs index 353ae641de..71a85c5122 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -1,30 +1,27 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Extensions; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json.Linq; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class ResponseMetaTests : IClassFixture> + public sealed class ResponseMetaTests + : IClassFixture, SupportDbContext>> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext, SupportDbContext> _testContext; - public ResponseMetaTests(IntegrationTestContext testContext) + public ResponseMetaTests(ExampleIntegrationTestContext, SupportDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - services.AddSingleton(); + services.AddSingleton(); }); var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); @@ -32,20 +29,23 @@ public ResponseMetaTests(IntegrationTestContext testConte } [Fact] - public async Task Registered_IResponseMeta_Adds_TopLevel_Meta() + public async Task Returns_top_level_meta() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); - var route = "/api/v1/people"; + var route = "/supportTickets"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - var expected = @"{ + responseDocument.Should().BeJson(@"{ ""meta"": { ""license"": ""MIT"", ""projectUrl"": ""https://github.com/json-api-dotnet/JsonApiDotNetCore/"", @@ -57,32 +57,11 @@ public async Task Registered_IResponseMeta_Adds_TopLevel_Meta() ] }, ""links"": { - ""self"": ""http://localhost/api/v1/people"", - ""first"": ""http://localhost/api/v1/people"" + ""self"": ""http://localhost/supportTickets"", + ""first"": ""http://localhost/supportTickets"" }, ""data"": [] -}"; - - responseDocument.ToString().NormalizeLineEndings().Should().Be(expected.NormalizeLineEndings()); - } - } - - public sealed class TestResponseMeta : IResponseMeta - { - public IReadOnlyDictionary GetMeta() - { - return new Dictionary - { - ["license"] = "MIT", - ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", - ["versions"] = new[] - { - "v4.0.0", - "v3.1.0", - "v2.5.2", - "v1.3.1" - } - }; +}"); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs new file mode 100644 index 0000000000..6d8850c63a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class SupportDbContext : DbContext + { + public DbSet ProductFamilies { get; set; } + public DbSet SupportTickets { get; set; } + + public SupportDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs new file mode 100644 index 0000000000..9fd4704cdd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs @@ -0,0 +1,22 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + internal sealed class SupportFakers : FakerContainer + { + private readonly Lazy> _lazyProductFamilyFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(productFamily => productFamily.Name, f => f.Commerce.ProductName())); + + private readonly Lazy> _lazySupportTicketFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(supportTicket => supportTicket.Description, f => f.Lorem.Paragraph())); + + public Faker ProductFamily => _lazyProductFamilyFaker.Value; + public Faker SupportTicket => _lazySupportTicketFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportResponseMeta.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportResponseMeta.cs new file mode 100644 index 0000000000..1b62252195 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportResponseMeta.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Serialization; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class SupportResponseMeta : IResponseMeta + { + public IReadOnlyDictionary GetMeta() + { + return new Dictionary + { + ["license"] = "MIT", + ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", + ["versions"] = new[] + { + "v4.0.0", + "v3.1.0", + "v2.5.2", + "v1.3.1" + } + }; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs new file mode 100644 index 0000000000..b006ba51e2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class SupportTicket : Identifiable + { + [Attr] + public string Description { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs similarity index 55% rename from src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs index 5d0dc01cf5..3b7b945dae 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs @@ -1,19 +1,18 @@ using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCoreExample.Models; -namespace JsonApiDotNetCoreExample.Definitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class TodoItemDefinition : JsonApiResourceDefinition + public sealed class SupportTicketDefinition : JsonApiResourceDefinition { - public TodoItemDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + public SupportTicketDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IDictionary GetMeta(TodoItem resource) + public override IDictionary GetMeta(SupportTicket resource) { - if (resource.Description != null && resource.Description.StartsWith("Important:")) + if (resource.Description != null && resource.Description.StartsWith("Critical:")) { return new Dictionary { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs new file mode 100644 index 0000000000..ad155dc489 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class SupportTicketsController : JsonApiController + { + public SupportTicketsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs index df6affafb5..efbab3be27 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -2,42 +2,48 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class TopLevelCountTests : IClassFixture> + public sealed class TopLevelCountTests + : IClassFixture, SupportDbContext>> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext, SupportDbContext> _testContext; + private readonly SupportFakers _fakers = new SupportFakers(); - public TopLevelCountTests(IntegrationTestContext testContext) + public TopLevelCountTests(ExampleIntegrationTestContext, SupportDbContext> testContext) { _testContext = testContext; + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; } [Fact] - public async Task Total_Resource_Count_Included_For_Collection() + public async Task Renders_resource_count_for_collection() { // Arrange - var todoItem = new TodoItem(); + var ticket = _fakers.SupportTicket.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.TodoItems.Add(todoItem); - + await dbContext.ClearTableAsync(); + dbContext.SupportTickets.Add(ticket); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/todoItems"; + var route = "/supportTickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -50,12 +56,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Total_Resource_Count_Included_For_Empty_Collection() + public async Task Renders_resource_count_for_empty_collection() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); - var route = "/api/v1/todoItems"; + var route = "/supportTickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -68,22 +77,24 @@ public async Task Total_Resource_Count_Included_For_Empty_Collection() } [Fact] - public async Task Total_Resource_Count_Excluded_From_POST_Response() + public async Task Hides_resource_count_in_create_resource_response() { // Arrange + var newDescription = _fakers.SupportTicket.Generate().Description; + var requestBody = new { data = new { - type = "todoItems", + type = "supportTickets", attributes = new { - description = "Something" + description = newDescription } } }; - var route = "/api/v1/todoItems"; + var route = "/supportTickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -95,14 +106,16 @@ public async Task Total_Resource_Count_Excluded_From_POST_Response() } [Fact] - public async Task Total_Resource_Count_Excluded_From_PATCH_Response() + public async Task Hides_resource_count_in_update_resource_response() { // Arrange - var todoItem = new TodoItem(); + var existingTicket = _fakers.SupportTicket.Generate(); + + var newDescription = _fakers.SupportTicket.Generate().Description; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.TodoItems.Add(todoItem); + dbContext.SupportTickets.Add(existingTicket); await dbContext.SaveChangesAsync(); }); @@ -110,16 +123,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "todoItems", - id = todoItem.StringId, + type = "supportTickets", + id = existingTicket.StringId, attributes = new { - description = "Something else" + description = newDescription } } }; - var route = "/api/v1/todoItems/" + todoItem.StringId; + var route = "/supportTickets/" + existingTicket.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 326e348ea9..50a489f9d9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -3,21 +3,23 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation { - public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> + public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ModelStateDbContext> _testContext; - public ModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + public ModelStateValidationTests(ExampleIntegrationTestContext, ModelStateDbContext> testContext) { _testContext = testContext; } [Fact] - public async Task When_posting_resource_with_omitted_required_attribute_value_it_must_fail() + public async Task Cannot_create_resource_with_omitted_required_attribute() { // Arrange var requestBody = new @@ -48,7 +50,7 @@ public async Task When_posting_resource_with_omitted_required_attribute_value_it } [Fact] - public async Task When_posting_resource_with_null_for_required_attribute_value_it_must_fail() + public async Task Cannot_create_resource_with_null_for_required_attribute_value() { // Arrange var requestBody = new @@ -80,7 +82,7 @@ public async Task When_posting_resource_with_null_for_required_attribute_value_i } [Fact] - public async Task When_posting_resource_with_invalid_attribute_value_it_must_fail() + public async Task Cannot_create_resource_with_invalid_attribute_value() { // Arrange var requestBody = new @@ -112,7 +114,7 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_fai } [Fact] - public async Task When_posting_resource_with_valid_attribute_value_it_must_succeed() + public async Task Can_create_resource_with_valid_attribute_value() { // Arrange var requestBody = new @@ -142,7 +144,7 @@ public async Task When_posting_resource_with_valid_attribute_value_it_must_succe } [Fact] - public async Task When_posting_resource_with_multiple_violations_it_must_fail() + public async Task Cannot_create_resource_with_multiple_violations() { // Arrange var requestBody = new @@ -184,7 +186,7 @@ public async Task When_posting_resource_with_multiple_violations_it_must_fail() } [Fact] - public async Task When_posting_resource_with_annotated_relationships_it_must_succeed() + public async Task Can_create_resource_with_annotated_relationships() { // Arrange var parentDirectory = new SystemDirectory @@ -273,7 +275,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_posting_annotated_to_many_relationship_it_must_succeed() + public async Task Can_add_to_annotated_ToMany_relationship() { // Arrange var directory = new SystemDirectory @@ -318,7 +320,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_omitted_required_attribute_value_it_must_succeed() + public async Task Can_update_resource_with_omitted_required_attribute_value() { // Arrange var directory = new SystemDirectory @@ -358,7 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_null_for_required_attribute_value_it_must_fail() + public async Task Cannot_update_resource_with_null_for_required_attribute_value() { // Arrange var directory = new SystemDirectory @@ -402,7 +404,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_invalid_attribute_value_it_must_fail() + public async Task Cannot_update_resource_with_invalid_attribute_value() { // Arrange var directory = new SystemDirectory @@ -446,7 +448,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_invalid_ID_it_must_fail() + public async Task Cannot_update_resource_with_invalid_ID() { // Arrange var directory = new SystemDirectory @@ -510,7 +512,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_valid_attribute_value_it_must_succeed() + public async Task Can_update_resource_with_valid_attribute_value() { // Arrange var directory = new SystemDirectory @@ -550,7 +552,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_annotated_relationships_it_must_succeed() + public async Task Can_update_resource_with_annotated_relationships() { // Arrange var directory = new SystemDirectory @@ -662,7 +664,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_multiple_self_references_it_must_succeed() + public async Task Can_update_resource_with_multiple_self_references() { // Arrange var directory = new SystemDirectory @@ -721,7 +723,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_collection_of_self_references_it_must_succeed() + public async Task Can_update_resource_with_collection_of_self_references() { // Arrange var directory = new SystemDirectory @@ -775,7 +777,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_annotated_ToOne_relationship_it_must_succeed() + public async Task Can_replace_annotated_ToOne_relationship() { // Arrange var directory = new SystemDirectory @@ -822,7 +824,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_annotated_ToMany_relationship_it_must_succeed() + public async Task Can_replace_annotated_ToMany_relationship() { // Arrange var directory = new SystemDirectory @@ -879,7 +881,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_deleting_annotated_to_many_relationship_it_must_succeed() + public async Task Can_remove_from_annotated_ToMany_relationship() { // Arrange var directory = new SystemDirectory diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index 33619cb477..f24da7a5d1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -2,21 +2,23 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation { - public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> + public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ModelStateDbContext> _testContext; - public NoModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + public NoModelStateValidationTests(ExampleIntegrationTestContext, ModelStateDbContext> testContext) { _testContext = testContext; } [Fact] - public async Task When_posting_resource_with_invalid_attribute_value_it_must_succeed() + public async Task Can_create_resource_with_invalid_attribute_value() { // Arrange var requestBody = new @@ -45,7 +47,7 @@ public async Task When_posting_resource_with_invalid_attribute_value_it_must_suc } [Fact] - public async Task When_patching_resource_with_invalid_attribute_value_it_must_succeed() + public async Task Can_update_resource_with_invalid_attribute_value() { // Arrange var directory = new SystemDirectory diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs new file mode 100644 index 0000000000..c46c4f410f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class DivingBoard : Identifiable + { + [Attr] + [Required] + [Range(1, 20)] + public decimal HeightInMeters { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs new file mode 100644 index 0000000000..826249bb22 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class DivingBoardsController : JsonApiController + { + public DivingBoardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs new file mode 100644 index 0000000000..d871dac25e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs @@ -0,0 +1,30 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json.Serialization; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class KebabCasingConventionStartup : TestableStartup + where TDbContext : DbContext + { + public KebabCasingConventionStartup(IConfiguration configuration) : base(configuration) + { + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.IncludeExceptionStackTraceInErrors = true; + options.Namespace = "public-api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.ValidateModelState = true; + + var resolver = (DefaultContractResolver) options.SerializerSettings.ContractResolver; + resolver!.NamingStrategy = new KebabCaseNamingStrategy(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs new file mode 100644 index 0000000000..08d4d28f3f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class KebabCasingTests + : IClassFixture, SwimmingDbContext>> + { + private readonly ExampleIntegrationTestContext, SwimmingDbContext> _testContext; + private readonly SwimmingFakers _fakers = new SwimmingFakers(); + + public KebabCasingTests(ExampleIntegrationTestContext, SwimmingDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resources_with_include() + { + // Arrange + var pools = _fakers.SwimmingPool.Generate(2); + pools[1].DivingBoards = _fakers.DivingBoard.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.SwimmingPools.AddRange(pools); + await dbContext.SaveChangesAsync(); + }); + + var route = "/public-api/swimming-pools?include=diving-boards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Type == "swimming-pools"); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Attributes.ContainsKey("is-indoor")); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("water-slides")); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("diving-boards")); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("diving-boards"); + responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); + responseDocument.Included[0].Attributes["height-in-meters"].As().Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters, 0.00000000001M); + responseDocument.Included[0].Relationships.Should().BeNull(); + responseDocument.Included[0].Links.Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); + + responseDocument.Meta["total-resources"].Should().Be(2); + } + + [Fact] + public async Task Can_filter_secondary_resources_with_sparse_fieldset() + { + // Arrange + var pool = _fakers.SwimmingPool.Generate(); + pool.WaterSlides = _fakers.WaterSlide.Generate(2); + pool.WaterSlides[0].LengthInMeters = 1; + pool.WaterSlides[1].LengthInMeters = 5; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.SwimmingPools.Add(pool); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/public-api/swimming-pools/{pool.StringId}/water-slides?filter=greaterThan(length-in-meters,'1')&fields[water-slides]=length-in-meters"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Type.Should().Be("water-slides"); + responseDocument.ManyData[0].Id.Should().Be(pool.WaterSlides[1].StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var newPool = _fakers.SwimmingPool.Generate(); + + var requestBody = new + { + data = new + { + type = "swimming-pools", + attributes = new Dictionary + { + ["is-indoor"] = newPool.IsIndoor + } + } + }; + + var route = "/public-api/swimming-pools"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("swimming-pools"); + responseDocument.SingleData.Attributes["is-indoor"].Should().Be(newPool.IsIndoor); + + var newPoolId = int.Parse(responseDocument.SingleData.Id); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships["water-slides"].Links.Self.Should().Be($"/public-api/swimming-pools/{newPoolId}/relationships/water-slides"); + responseDocument.SingleData.Relationships["water-slides"].Links.Related.Should().Be($"/public-api/swimming-pools/{newPoolId}/water-slides"); + responseDocument.SingleData.Relationships["diving-boards"].Links.Self.Should().Be($"/public-api/swimming-pools/{newPoolId}/relationships/diving-boards"); + responseDocument.SingleData.Relationships["diving-boards"].Links.Related.Should().Be($"/public-api/swimming-pools/{newPoolId}/diving-boards"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var poolInDatabase = await dbContext.SwimmingPools + .FirstAsync(pool => pool.Id == newPoolId); + + poolInDatabase.IsIndoor.Should().Be(newPool.IsIndoor); + }); + } + + [Fact] + public async Task Applies_casing_convention_on_error_stack_trace() + { + // Arrange + var requestBody = "{ \"data\": {"; + + var route = "/public-api/swimming-pools"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Failed to deserialize request body."); + responseDocument.Errors[0].Meta.Data.Should().ContainKey("stack-trace"); + } + + [Fact] + public async Task Applies_casing_convention_on_source_pointer_from_ModelState() + { + // Arrange + var existingBoard = _fakers.DivingBoard.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DivingBoards.Add(existingBoard); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "diving-boards", + id = existingBoard.StringId, + attributes = new Dictionary + { + ["height-in-meters"] = -1 + } + } + }; + + var route = "/public-api/diving-boards/" + existingBoard.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors[0].Title.Should().Be("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/height-in-meters"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs new file mode 100644 index 0000000000..9d8de4aa26 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class SwimmingDbContext : DbContext + { + public DbSet SwimmingPools { get; set; } + public DbSet WaterSlides { get; set; } + public DbSet DivingBoards { get; set; } + + public SwimmingDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs new file mode 100644 index 0000000000..8636078884 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs @@ -0,0 +1,28 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + internal sealed class SwimmingFakers : FakerContainer + { + private readonly Lazy> _lazySwimmingPoolFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(swimmingPool => swimmingPool.IsIndoor, f => f.Random.Bool())); + + private readonly Lazy> _lazyWaterSlideFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(waterSlide => waterSlide.LengthInMeters, f => f.Random.Decimal(3, 100))); + + private readonly Lazy> _lazyDivingBoardFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(divingBoard => divingBoard.HeightInMeters, f => f.Random.Decimal(1, 15))); + + public Faker SwimmingPool => _lazySwimmingPoolFaker.Value; + public Faker WaterSlide => _lazyWaterSlideFaker.Value; + public Faker DivingBoard => _lazyDivingBoardFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs new file mode 100644 index 0000000000..6c1274816e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class SwimmingPool : Identifiable + { + [Attr] + public bool IsIndoor { get; set; } + + [HasMany] + public IList WaterSlides { get; set; } + + [HasMany] + public IList DivingBoards { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs new file mode 100644 index 0000000000..6af99a7ea4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class SwimmingPoolsController : JsonApiController + { + public SwimmingPoolsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs new file mode 100644 index 0000000000..b7dea1d5ba --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class WaterSlide : Identifiable + { + [Attr] + public decimal LengthInMeters { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs new file mode 100644 index 0000000000..768757e43f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs @@ -0,0 +1,154 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.Testing; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NonJsonApiControllers +{ + public sealed class NonJsonApiControllerTests : IClassFixture> + { + private readonly WebApplicationFactory _factory; + + public NonJsonApiControllerTests(WebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Get_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/NonJsonApi"); + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("[\"Welcome!\"]"); + } + + [Fact] + public async Task Post_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi") + { + Content = new StringContent("Jack") + { + Headers = + { + ContentType = new MediaTypeHeaderValue("text/plain") + } + } + }; + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Hello, Jack"); + } + + [Fact] + public async Task Post_skips_error_handler() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi"); + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Please send your name."); + } + + [Fact] + public async Task Put_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Put, "/NonJsonApi") + { + Content = new StringContent("\"Jane\"") + { + Headers = + { + ContentType = new MediaTypeHeaderValue("application/json") + } + } + }; + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Hi, Jane"); + } + + [Fact] + public async Task Patch_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Patch, "/NonJsonApi?name=Janice"); + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Good day, Janice"); + } + + [Fact] + public async Task Delete_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Delete, "/NonJsonApi"); + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Bye."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs deleted file mode 100644 index c4ab87ea6d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using FluentAssertions; -using FluentAssertions.Primitives; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests -{ - public static class ObjectAssertionsExtensions - { - /// - /// Used to assert on a nullable column, whose value is returned as in JSON:API response body. - /// - public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expected, string because = "", - params object[] becauseArgs) - { - if (expected == null) - { - source.Subject.Should().BeNull(because, becauseArgs); - } - else - { - // We lose a little bit of precision (milliseconds) on roundtrip through PostgreSQL database. - - var value = new DateTimeOffset((DateTime) source.Subject); - value.Should().BeCloseTo(expected.Value, because: because, becauseArgs: becauseArgs); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs new file mode 100644 index 0000000000..ecc7308906 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class AccountPreferences : Identifiable + { + [Attr] + public bool UseDarkTheme { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs new file mode 100644 index 0000000000..62838a6df8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs @@ -0,0 +1,18 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class Appointment : Identifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public DateTimeOffset StartTime { get; set; } + + [Attr] + public DateTimeOffset EndTime { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs new file mode 100644 index 0000000000..cc1e1765e9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class Blog : Identifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public string PlatformName { get; set; } + + [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] + public bool ShowAdvertisements => PlatformName.EndsWith("(using free account)"); + + [HasMany] + public IList Posts { get; set; } + + [HasOne] + public WebAccount Owner { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs new file mode 100644 index 0000000000..10205816ab --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class BlogPost : Identifiable + { + [Attr] + public string Caption { get; set; } + + [Attr] + public string Url { get; set; } + + [HasOne] + public WebAccount Author { get; set; } + + [HasOne] + public WebAccount Reviewer { get; set; } + + [NotMapped] + [HasManyThrough(nameof(BlogPostLabels))] + public ISet