Skip to content

Commit c09a0e1

Browse files
feat: complete Phase 3 with circular reference tests and documentation (#75)
- Add circular reference handling tests for InclusionMapper - Add filtered includes + pagination integration tests - Update upgrade-guide.md with v1.5.0 test coverage details - Add contributing.md with test patterns and development workflow - Mark Phase 3 complete in roadmap
1 parent f490847 commit c09a0e1

4 files changed

Lines changed: 565 additions & 3 deletions

File tree

JsonApiToolkit.Tests/Integration/JsonApiQueryAsyncTests.cs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,127 @@ public async Task GetArticles_PageSizeLargerThanDataset_ReturnsAllAsync()
855855

856856
#endregion
857857

858+
#region Filtered Includes with Pagination Tests
859+
860+
[Fact]
861+
public async Task GetArticles_FilteredIncludesWithPagination_ReturnsCorrectDataAsync()
862+
{
863+
// Filter on included resource + pagination
864+
var response = await _client.GetAsync(
865+
"/api/articles?filter[author.name]=Alice&include=author&page[number]=1&page[size]=2&sort=id"
866+
);
867+
868+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
869+
870+
var content = await response.Content.ReadAsStringAsync();
871+
var document = JsonSerializer.Deserialize<JsonApiCollectionDocument<ResourceObject>>(
872+
content,
873+
_jsonOptions
874+
);
875+
876+
Assert.NotNull(document?.Data);
877+
// Alice has 2 articles (ids 1 and 2), page size 2 should return both
878+
Assert.Equal(2, document.Data.Count());
879+
880+
// Verify pagination metadata reflects filtered count
881+
Assert.NotNull(document.Meta);
882+
Assert.Equal(2, GetPaginationValue<int>(document.Meta, "totalResources"));
883+
Assert.Equal(1, GetPaginationValue<int>(document.Meta, "totalPages"));
884+
885+
// Verify included author is Alice
886+
Assert.NotNull(document.Included);
887+
Assert.Single(document.Included);
888+
Assert.Equal("Alice", document.Included.First().Attributes?["name"]?.ToString());
889+
}
890+
891+
[Fact]
892+
public async Task GetArticles_FilteredIncludesWithPaginationSecondPage_ReturnsCorrectDataAsync()
893+
{
894+
// Filter to published articles, include author, get second page
895+
var response = await _client.GetAsync(
896+
"/api/articles?filter[isPublished]=true&include=author&page[number]=2&page[size]=2&sort=id"
897+
);
898+
899+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
900+
901+
var content = await response.Content.ReadAsStringAsync();
902+
var document = JsonSerializer.Deserialize<JsonApiCollectionDocument<ResourceObject>>(
903+
content,
904+
_jsonOptions
905+
);
906+
907+
Assert.NotNull(document?.Data);
908+
// 4 published articles, page 2 with size 2 should return 2 articles
909+
Assert.Equal(2, document.Data.Count());
910+
911+
// Verify pagination
912+
Assert.NotNull(document.Meta);
913+
Assert.Equal(4, GetPaginationValue<int>(document.Meta, "totalResources"));
914+
Assert.Equal(2, GetPaginationValue<int>(document.Meta, "totalPages"));
915+
Assert.Equal(2, GetPaginationValue<int>(document.Meta, "currentPage"));
916+
917+
// Verify includes are present
918+
Assert.NotNull(document.Included);
919+
Assert.NotEmpty(document.Included);
920+
}
921+
922+
[Fact]
923+
public async Task GetArticles_MultipleIncludesWithFilterAndPagination_ReturnsAllIncludedAsync()
924+
{
925+
// Complex query: filter + multiple includes + pagination
926+
var response = await _client.GetAsync(
927+
"/api/articles?filter[viewCount][ge]=50&include=author,comments&page[number]=1&page[size]=3&sort=-viewCount"
928+
);
929+
930+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
931+
932+
var content = await response.Content.ReadAsStringAsync();
933+
var document = JsonSerializer.Deserialize<JsonApiCollectionDocument<ResourceObject>>(
934+
content,
935+
_jsonOptions
936+
);
937+
938+
Assert.NotNull(document?.Data);
939+
// viewCount >= 50: articles with 50, 75, 100, 200 = 4 articles, page size 3
940+
Assert.Equal(3, document.Data.Count());
941+
942+
// Verify sorted by viewCount descending
943+
var ids = document.Data.Select(r => r.Id).ToList();
944+
Assert.Equal("4", ids[0]); // viewCount: 200
945+
Assert.Equal("1", ids[1]); // viewCount: 100
946+
Assert.Equal("5", ids[2]); // viewCount: 75
947+
948+
// Verify includes contain both authors and comments
949+
Assert.NotNull(document.Included);
950+
Assert.Contains(document.Included, r => r.Type == "queryTestAuthor");
951+
}
952+
953+
[Fact]
954+
public async Task GetArticles_FilterOnIncludedResourceWithEmptyResult_ReturnsEmptyAsync()
955+
{
956+
// Filter on included resource that matches nothing
957+
var response = await _client.GetAsync(
958+
"/api/articles?filter[author.name]=NonexistentAuthor&include=author&page[number]=1&page[size]=10"
959+
);
960+
961+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
962+
963+
var content = await response.Content.ReadAsStringAsync();
964+
var document = JsonSerializer.Deserialize<JsonApiCollectionDocument<ResourceObject>>(
965+
content,
966+
_jsonOptions
967+
);
968+
969+
Assert.NotNull(document?.Data);
970+
Assert.Empty(document.Data);
971+
972+
// Pagination should reflect zero results
973+
Assert.NotNull(document.Meta);
974+
Assert.Equal(0, GetPaginationValue<int>(document.Meta, "totalResources"));
975+
}
976+
977+
#endregion
978+
858979
public void Dispose()
859980
{
860981
_client?.Dispose();

JsonApiToolkit.Tests/Mapping/InclusionMapperTests.cs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,4 +604,134 @@ public void AddIncludedResources_ComplexScenario_HandlesCorrectly()
604604
}
605605

606606
#endregion
607+
608+
#region Circular Reference Handling
609+
610+
[Fact]
611+
public void AddIncludedResources_CircularReference_DoesNotCauseInfiniteLoop()
612+
{
613+
// Create circular reference: author -> books -> author
614+
var author = new Author { Id = 1, Name = "John" };
615+
var book = new Book
616+
{
617+
Id = 100,
618+
Title = "Test Book",
619+
Author = author,
620+
};
621+
author.Books.Add(book); // Circular: author.Books[0].Author == author
622+
623+
var included = new List<ResourceObject>();
624+
var includePaths = new List<string> { "books", "books.author" };
625+
626+
// Should not throw or infinite loop
627+
InclusionMapper.AddIncludedResources(author, includePaths, included);
628+
629+
// Should have book and author (author deduplicated - not added again via books.author)
630+
Assert.Equal(2, included.Count);
631+
Assert.Contains(included, r => r.Type == "book" && r.Id == "100");
632+
Assert.Contains(included, r => r.Type == "author" && r.Id == "1");
633+
}
634+
635+
[Fact]
636+
public void AddIncludedResources_DeepCircularReference_HandlesCorrectly()
637+
{
638+
// Create deeper circular: author -> books -> chapters, books -> author
639+
var author = new Author { Id = 1, Name = "John" };
640+
var chapter = new Chapter { Id = 10, Title = "Chapter 1" };
641+
var book = new Book
642+
{
643+
Id = 100,
644+
Title = "Test Book",
645+
Author = author,
646+
Chapters = new List<Chapter> { chapter },
647+
};
648+
author.Books.Add(book);
649+
650+
var included = new List<ResourceObject>();
651+
var includePaths = new List<string> { "books", "books.author", "books.chapters" };
652+
653+
InclusionMapper.AddIncludedResources(author, includePaths, included);
654+
655+
// Should have: book, author (via books.author - but deduplicated), chapter
656+
Assert.Equal(3, included.Count);
657+
Assert.Single(included, r => r.Type == "book");
658+
Assert.Single(included, r => r.Type == "author");
659+
Assert.Single(included, r => r.Type == "chapter");
660+
}
661+
662+
[Fact]
663+
public void AddIncludedResources_MutualCircularReference_HandlesCorrectly()
664+
{
665+
// Two books referencing the same author, author referencing both books
666+
var author = new Author { Id = 1, Name = "John" };
667+
var book1 = new Book
668+
{
669+
Id = 100,
670+
Title = "Book 1",
671+
Author = author,
672+
};
673+
var book2 = new Book
674+
{
675+
Id = 200,
676+
Title = "Book 2",
677+
Author = author,
678+
};
679+
author.Books.Add(book1);
680+
author.Books.Add(book2);
681+
682+
var included = new List<ResourceObject>();
683+
var includePaths = new List<string> { "author", "author.books" };
684+
685+
InclusionMapper.AddIncludedResources(
686+
new List<Book> { book1, book2 },
687+
includePaths,
688+
included
689+
);
690+
691+
// Author appears once (deduplicated), both books appear
692+
Assert.Equal(3, included.Count);
693+
Assert.Single(included, r => r.Type == "author");
694+
Assert.Equal(2, included.Count(r => r.Type == "book"));
695+
}
696+
697+
[Fact]
698+
public void AddIncludedResources_SelfReferencingEntity_HandlesCorrectly()
699+
{
700+
// Simulate a self-referencing entity (e.g., parent-child)
701+
var parent = new Author { Id = 1, Name = "Parent" };
702+
var child = new Author { Id = 2, Name = "Child" };
703+
704+
// Simulate parent.Books containing "child" as if it were a hierarchical relationship
705+
// Using the existing model, we'll test through the books relationship
706+
var parentBook = new Book
707+
{
708+
Id = 100,
709+
Title = "Parent's Book",
710+
Author = parent,
711+
};
712+
var childBook = new Book
713+
{
714+
Id = 200,
715+
Title = "Child's Book",
716+
Author = child,
717+
};
718+
parent.Books.Add(parentBook);
719+
child.Books.Add(childBook);
720+
721+
var included = new List<ResourceObject>();
722+
var includePaths = new List<string> { "books", "books.author" };
723+
724+
InclusionMapper.AddIncludedResources(
725+
new List<Author> { parent, child },
726+
includePaths,
727+
included
728+
);
729+
730+
// 2 books + 2 authors (each author referenced via their own book.author)
731+
Assert.Equal(4, included.Count);
732+
Assert.Equal(2, included.Count(r => r.Type == "book"));
733+
Assert.Equal(2, included.Count(r => r.Type == "author"));
734+
}
735+
736+
#endregion
607737
}

0 commit comments

Comments
 (0)