├── tests └── TeoTests │ ├── GlobalUsings.cs │ ├── VerifyDemo │ ├── VerifyDemo.SimplyDemo.verified.json │ ├── VerifyDemo.ComplexDemo.verified.json │ └── VerifyDemo.cs │ ├── Core │ ├── Verify │ │ ├── Extensions.cs │ │ ├── Response.cs │ │ ├── Request.cs │ │ └── Actual.cs │ ├── App.cs │ ├── AppFactory.cs │ └── TestBuilder.cs │ ├── Modules │ ├── TodosModule │ │ ├── TodosWithCalendarTestsV1.GetEmptyCalendarTodos.verified.json │ │ ├── Builder │ │ │ ├── TodosTestState.cs │ │ │ ├── Extensions.cs │ │ │ └── TodosTestBuilder.cs │ │ ├── TodosWithCalendarTestsV1.CreateCalendarTodo.verified.json │ │ ├── TodosWithCalendarTestsV1.CreateCalendarTodoWentWrong.verified.json │ │ ├── TodosWithCalendarTestsV2.GetUniqueCalendarTodo.verified.json │ │ ├── TodosHappyPathTests.CreateTodo.verified.json │ │ ├── TodosWithCalendarTestsV2.cs │ │ ├── TodosWithCalendarTestsV1.GetCalendarTodos.verified.json │ │ ├── TodosHappyPathTests.DoneTodo.verified.json │ │ ├── TodosHappyPathTests.ChangeTodoTitle.verified.json │ │ ├── TodosHappyPathTests.ChangeTodoTags.verified.json │ │ ├── TodosValidationTests.cs │ │ ├── TodosValidationTests.ChangeTitleForCompletedTodo.verified.json │ │ ├── TodosValidationTests.CreateTodo.verified.json │ │ ├── TodosHappyPathTests.ChangeTagsAndMarkTodoAsDone.verified.json │ │ ├── TodosValidationTests.UpdateTodo.verified.json │ │ ├── TodosWithCalendarTestsV1.cs │ │ ├── TodosHappyPathTests.cs │ │ └── TodosHappyPathTests.GetTodosByTagsInQueryString.verified.json │ └── StatsModule │ │ ├── Builder │ │ └── StatsTestBuilder.cs │ │ ├── StatsAppTests.cs │ │ └── StatsAppTests.CollectAndGetStatistics.verified.json │ ├── VerifyInitializer.cs │ └── TeoTests.csproj ├── .bashrc ├── src ├── ExtCalendar │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── Demo.http │ ├── ExtCalendar.csproj │ ├── Properties │ │ └── launchSettings.json │ └── Program.cs └── TeoTodoApp │ ├── Modules │ ├── Todos │ │ ├── Core │ │ │ ├── DAL │ │ │ │ ├── Migrations │ │ │ │ │ ├── migrate-command.sh │ │ │ │ │ ├── 20240416000450_Init.cs │ │ │ │ │ ├── TeoAppDbContextModelSnapshot.cs │ │ │ │ │ └── 20240416000450_Init.Designer.cs │ │ │ │ └── TeoAppDbContext.cs │ │ │ ├── Model │ │ │ │ ├── TagCollection.cs │ │ │ │ └── Todo.cs │ │ │ ├── Exceptions │ │ │ │ ├── GetStatsException.cs │ │ │ │ ├── TodoNotFoundException.cs │ │ │ │ ├── TodoAlreadyDoneException.cs │ │ │ │ ├── CreateCalendarEventException.cs │ │ │ │ ├── InvalidTodoAppArgumentException.cs │ │ │ │ ├── StatsClientUriException.cs │ │ │ │ └── CalendarClientUriException.cs │ │ │ └── Services │ │ │ │ ├── StatsClient.cs │ │ │ │ ├── CalendarClient.cs │ │ │ │ └── TodosService.cs │ │ └── Api │ │ │ ├── UpdateTodo.cs │ │ │ ├── CreateTodo.cs │ │ │ ├── GetTodo.cs │ │ │ ├── GetTodos.cs │ │ │ ├── Demo.http │ │ │ └── TodosController.cs │ └── Stats │ │ ├── Api │ │ ├── DeleteStats.cs │ │ ├── AddStats.cs │ │ ├── Demo.http │ │ ├── GetStats.cs │ │ └── StatsController.cs │ │ └── Core │ │ └── StatsService.cs │ ├── appsettings.Development.json │ ├── Infra │ └── ErrorHandling │ │ ├── Exceptions │ │ ├── TeoAppException.cs │ │ ├── DomainException.cs │ │ ├── NotFoundException.cs │ │ ├── AppNotImplementedException.cs │ │ ├── InfraException.cs │ │ └── AppArgumentException.cs │ │ ├── ProblemDetails.cs │ │ ├── ErrorHandlerMiddleware.cs │ │ └── ErrorMapper.cs │ ├── appsettings.json │ ├── NTeoTestBuildeR.csproj │ ├── Properties │ └── launchSettings.json │ └── Program.cs ├── .config └── dotnet-tools.json ├── docker-compose.yaml ├── .dockerignore ├── .gitattributes ├── NTeoTestBuildeR.sln ├── scripts └── cleanup-code │ └── local-dev-cleanupcode.sh ├── .gitignore ├── README.md └── NTeoTestBuildeR.sln.DotSettings /tests/TeoTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /.bashrc: -------------------------------------------------------------------------------- 1 | alias cleanupcode='sh ./scripts/cleanup-code/local-dev-cleanupcode.sh' 2 | alias cc=cleanupcode -------------------------------------------------------------------------------- /src/ExtCalendar/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/DAL/Migrations/migrate-command.sh: -------------------------------------------------------------------------------- 1 | dotnet ef migrations add Init --project ./src/TeoTodoApp/NTeoTestBuildeR.csproj --output-dir ./Modules/Todos/Core/DAL/Migrations -------------------------------------------------------------------------------- /src/TeoTodoApp/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Stats/Api/DeleteStats.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Modules.Stats.Api; 4 | 5 | [PublicAPI] 6 | public sealed record DeleteStats(string Tag); -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Model/TagCollection.cs: -------------------------------------------------------------------------------- 1 | namespace NTeoTestBuildeR.Modules.Todos.Core.Model; 2 | 3 | public sealed class TagCollection 4 | { 5 | public required string[] Tags { get; init; } 6 | } -------------------------------------------------------------------------------- /src/ExtCalendar/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "jetbrains.resharper.globaltools": { 6 | "version": "2023.3.4", 7 | "commands": [ 8 | "jb" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Infra/ErrorHandling/Exceptions/TeoAppException.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 4 | 5 | [PublicAPI] 6 | public abstract class TeoAppException(string message) 7 | : Exception(message); -------------------------------------------------------------------------------- /src/TeoTodoApp/Infra/ErrorHandling/Exceptions/DomainException.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 4 | 5 | [PublicAPI] 6 | public abstract class DomainException(string message) 7 | : TeoAppException(message); -------------------------------------------------------------------------------- /src/TeoTodoApp/Infra/ErrorHandling/Exceptions/NotFoundException.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 4 | 5 | [PublicAPI] 6 | public abstract class NotFoundException(string message) 7 | : TeoAppException(message); -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Exceptions/GetStatsException.cs: -------------------------------------------------------------------------------- 1 | using NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 4 | 5 | public sealed class GetStatsException(string message) 6 | : InfraException(message); -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Exceptions/TodoNotFoundException.cs: -------------------------------------------------------------------------------- 1 | using NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 4 | 5 | public sealed class TodoNotFoundException(string message) 6 | : NotFoundException(message); -------------------------------------------------------------------------------- /src/TeoTodoApp/Infra/ErrorHandling/Exceptions/AppNotImplementedException.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 4 | 5 | [PublicAPI] 6 | public sealed class AppNotImplementedException(string message) 7 | : TeoAppException(message); -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Stats/Api/AddStats.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Modules.Stats.Api; 4 | 5 | [PublicAPI] 6 | public sealed record AddStats(AddStats.Request Dto) 7 | { 8 | [PublicAPI] 9 | public sealed record Request(string Tag); 10 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Exceptions/TodoAlreadyDoneException.cs: -------------------------------------------------------------------------------- 1 | using NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 4 | 5 | public sealed class TodoAlreadyDoneException(string message) 6 | : DomainException(message); -------------------------------------------------------------------------------- /tests/TeoTests/VerifyDemo/VerifyDemo.SimplyDemo.verified.json: -------------------------------------------------------------------------------- 1 | { 2 | "Id": "Guid_1", 3 | "Name": "Book", 4 | "Amount": 64, 5 | "Attribute": { 6 | "Title": "Surreal Numbers", 7 | "Author": "Donald Ervin Knuth", 8 | "CreatedAt": "DateTime_1" 9 | }, 10 | "EBook": true 11 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Exceptions/CreateCalendarEventException.cs: -------------------------------------------------------------------------------- 1 | using NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 4 | 5 | public sealed class CreateCalendarEventException(string message) 6 | : InfraException(message); -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Exceptions/InvalidTodoAppArgumentException.cs: -------------------------------------------------------------------------------- 1 | using NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 4 | 5 | public sealed class InvalidTodoAppArgumentException(string message) 6 | : AppArgumentException(message); -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Exceptions/StatsClientUriException.cs: -------------------------------------------------------------------------------- 1 | using NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 4 | 5 | public sealed class StatsClientUriException() 6 | : InfraException("Stats client base address is not configured"); -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Api/UpdateTodo.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Api; 4 | 5 | [PublicAPI] 6 | public record UpdateTodo(Guid Id, UpdateTodo.Request Dto) 7 | { 8 | [PublicAPI] 9 | public record Request(string Title, string[] Tags, bool Done); 10 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Exceptions/CalendarClientUriException.cs: -------------------------------------------------------------------------------- 1 | using NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 4 | 5 | public sealed class CalendarClientUriException() 6 | : InfraException("Calendar client base address is not configured"); -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Model/Todo.cs: -------------------------------------------------------------------------------- 1 | namespace NTeoTestBuildeR.Modules.Todos.Core.Model; 2 | 3 | public sealed class Todo 4 | { 5 | public Guid Id { get; init; } 6 | public required string Title { get; set; } 7 | public required TagCollection Tags { get; set; } 8 | public bool Done { get; set; } 9 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Infra/ErrorHandling/ProblemDetails.cs: -------------------------------------------------------------------------------- 1 | namespace NTeoTestBuildeR.Infra.ErrorHandling; 2 | 3 | public record ProblemDetails( 4 | string Type, 5 | int Status, 6 | string Title, 7 | string Detail, 8 | string Instance, 9 | IDictionary Errors, 10 | IDictionary Extensions); -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Api/CreateTodo.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Api; 4 | 5 | [PublicAPI] 6 | public record CreateTodo(CreateTodo.Request Dto) 7 | { 8 | [PublicAPI] 9 | public record Request(string Title, string[] Tags); 10 | 11 | [PublicAPI] 12 | public record Response(Guid Id); 13 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Api/GetTodo.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Api; 4 | 5 | [PublicAPI] 6 | public record GetTodo(GetTodo.Request Dto) 7 | { 8 | [PublicAPI] 9 | public record Request(Guid Id); 10 | 11 | [PublicAPI] 12 | public record Response(string Title, string[] Tags, bool Done); 13 | } -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | shm_size: '4gb' 7 | container_name: n-teo-app-r.postgres 8 | restart: unless-stopped 9 | environment: 10 | - POSTGRES_HOST_AUTH_METHOD=trust 11 | ports: 12 | - 5432:5432 13 | volumes: 14 | - postgres:/var/lib/postgresql/data 15 | 16 | volumes: 17 | postgres: 18 | driver: local -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Stats/Api/Demo.http: -------------------------------------------------------------------------------- 1 | @api = http://localhost:5091 2 | 3 | ### Increase tag to stats 4 | POST {{api}}/stats 5 | Content-Type: application/json 6 | 7 | { 8 | "tag": "phisics" 9 | } 10 | 11 | ### Get stats by tags 12 | GET {{api}}/stats?tags=astronomy&tags=phisics 13 | Content-Type: application/json 14 | 15 | 16 | ### Decrease tag in stats 17 | DELETE {{api}}/stats/phisics 18 | Content-Type: application/json -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Stats/Api/GetStats.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Modules.Stats.Api; 4 | 5 | [PublicAPI] 6 | public sealed record GetStats(GetStats.Query Dto) 7 | { 8 | [PublicAPI] 9 | public sealed record Query(string[] Tags); 10 | 11 | [PublicAPI] 12 | public sealed record Response(Item[] Stats); 13 | 14 | [PublicAPI] 15 | public sealed record Item(string Tag, int Count); 16 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Infra/ErrorHandling/Exceptions/InfraException.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 4 | 5 | [PublicAPI] 6 | public abstract class InfraException(string message) 7 | : TeoAppException(message) 8 | { 9 | public Dictionary Context { get; } = new(); 10 | 11 | public void WithContext(string code, string[] values) => 12 | Context.Add(code, values); 13 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Api/GetTodos.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Modules.Todos.Api; 4 | 5 | [PublicAPI] 6 | public record GetTodos 7 | { 8 | [PublicAPI] 9 | public record Query(string[] Tags); 10 | 11 | [PublicAPI] 12 | public record Response(Item[] Todos); 13 | 14 | [PublicAPI] 15 | public record Item(Guid Id, string Title, Tag[] Tags, bool Done); 16 | 17 | public record Tag(string Name, int? Count); 18 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "ConnectionStrings": { 10 | "TeoTodoApp": "Host=localhost;Database=teo-app;Username=postgres;Password=" 11 | }, 12 | "ExtCalendar": { 13 | "BaseAddress": "http://localhost:5135" 14 | }, 15 | "StatsModule": { 16 | "BaseAddress": "http://localhost:5091" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/TeoTests/Core/Verify/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Headers; 2 | using JetBrains.Annotations; 3 | 4 | namespace TeoTests.Core.Verify; 5 | 6 | public static class Extensions 7 | { 8 | [PublicAPI] 9 | public static IReadOnlyDictionary> Map( 10 | this HttpHeaders requestHeaders) => 11 | requestHeaders.ToDictionary( 12 | keySelector: header => header.Key, 13 | elementSelector: header => header.Value); 14 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosWithCalendarTestsV1.GetEmptyCalendarTodos.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Retrieve items from the calendar", 4 | "Request": { 5 | "Method": { 6 | "Method": "GET" 7 | }, 8 | "Path": "http://localhost/todos?tags=calendar-event", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | } 12 | }, 13 | "Response": { 14 | "StatusCode": "OK", 15 | "Payload": {} 16 | } 17 | } 18 | ] -------------------------------------------------------------------------------- /tests/TeoTests/VerifyDemo/VerifyDemo.ComplexDemo.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Id": "Guid_1", 4 | "ExternalId": "Guid_2", 5 | "Name": "Knuth", 6 | "Amount": 1, 7 | "CreatedAt": "DateTime_1", 8 | "Timestamp": "DateTime_2", 9 | "IsDeleted": false 10 | }, 11 | { 12 | "Id": "Guid_3", 13 | "ExternalId": "Guid_2", 14 | "Name": "Conway", 15 | "Amount": 2, 16 | "CreatedAt": "DateTime_1", 17 | "Timestamp": "DateTime_3", 18 | "IsDeleted": false 19 | } 20 | ] -------------------------------------------------------------------------------- /src/TeoTodoApp/Infra/ErrorHandling/Exceptions/AppArgumentException.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 4 | 5 | [PublicAPI] 6 | public class AppArgumentException(string message) 7 | : TeoAppException(message) 8 | { 9 | public bool HasErrors => Errors.Count > 0; 10 | public Dictionary Errors { get; } = new(); 11 | 12 | public void WithError(string code, string[] values) => 13 | Errors.Add(code, values); 14 | } -------------------------------------------------------------------------------- /tests/TeoTests/Core/Verify/Response.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using JetBrains.Annotations; 3 | 4 | namespace TeoTests.Core.Verify; 5 | 6 | [UsedImplicitly] 7 | public sealed record Response( 8 | HttpStatusCode StatusCode, 9 | IReadOnlyDictionary> Headers, 10 | object? Payload = null) 11 | { 12 | public static Response Create(HttpResponseMessage httpResponse, object? result = null) => 13 | new(httpResponse.StatusCode, 14 | Headers: httpResponse.Headers.Map(), 15 | result); 16 | } -------------------------------------------------------------------------------- /tests/TeoTests/VerifyInitializer.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | using JetBrains.Annotations; 3 | 4 | namespace TeoTests; 5 | 6 | [UsedImplicitly] 7 | public class VerifyInitializer 8 | { 9 | [ModuleInitializer] 10 | public static void Initialize() 11 | { 12 | VerifierSettings.InitializePlugins(); 13 | VerifierSettings.UseStrictJson(); 14 | VerifierSettings.ScrubInlineGuids(); 15 | VerifierSettings.ScrubMembers("traceId"); 16 | VerifierSettings.ScrubMembers("traceparent"); 17 | } 18 | } -------------------------------------------------------------------------------- /src/ExtCalendar/Demo.http: -------------------------------------------------------------------------------- 1 | @api = http://localhost:5135 2 | 3 | ### Create a new event 4 | POST {{api}}/calendar/events 5 | Content-Type: application/json 6 | 7 | { 8 | "name": "150 KGD .NET Meetup", 9 | "type": "KGD .NET", 10 | "when": "2024-05-20T18:00:00.0Z" 11 | } 12 | 13 | ### Id 14 | @id = 963f47cb-4ef5-4ab4-bb41-083e94f6a914 15 | 16 | ### Get event by id 17 | GET {{api}}/calendar/events/{{id}} 18 | Content-Type: application/json 19 | 20 | 21 | ### Get event by id 22 | GET {{api}}/calendar/events?type=KGD .NET 23 | Content-Type: application/json -------------------------------------------------------------------------------- /tests/TeoTests/Core/Verify/Request.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace TeoTests.Core.Verify; 4 | 5 | [UsedImplicitly] 6 | public sealed record Request( 7 | HttpMethod Method, 8 | string Path, 9 | IReadOnlyDictionary> Headers, 10 | object? Payload = null) 11 | { 12 | public static Request Create(HttpRequestMessage httpRequest, object? requestPayload = null) => 13 | new(httpRequest.Method, 14 | httpRequest.RequestUri!.AbsoluteUri, 15 | Headers: httpRequest.Headers.Map(), 16 | requestPayload); 17 | } -------------------------------------------------------------------------------- /src/ExtCalendar/ExtCalendar.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/TeoTests/Core/Verify/Actual.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | 3 | namespace TeoTests.Core.Verify; 4 | 5 | [UsedImplicitly] 6 | public sealed record Actual( 7 | string Description, 8 | Request Request, 9 | Response Response) 10 | { 11 | public static Task Create(string description, 12 | HttpRequestMessage httpRequest, HttpResponseMessage httpResponse, 13 | object? requestPayload, object? responsePayload) => 14 | Task.FromResult(new Actual(description, 15 | Request: Request.Create(httpRequest, requestPayload), 16 | Response: Response.Create(httpResponse, responsePayload))); 17 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/Builder/TodosTestState.cs: -------------------------------------------------------------------------------- 1 | namespace TeoTests.Modules.TodosModule.Builder; 2 | 3 | internal sealed class TodosTestState 4 | { 5 | private Dictionary Todos { get; } = new(); 6 | 7 | public void Upsert(string id, string title, string[] tags, bool done) => 8 | Todos[id] = new(id, title, tags, done); 9 | 10 | public Todo SelectByTitle(string withTitle) => 11 | Todos.Single(todo => 12 | todo.Value.Title != null && todo.Value.Title.Contains(withTitle)).Value; 13 | 14 | internal sealed record Todo(string? Id, string? Title = null, string[]? Tags = null, bool? Done = null); 15 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.cs text diff=csharp 4 | *.cs text eol=lf 5 | *.sh text eol=lf 6 | *.env text eol=lf 7 | *.sql text eol=lf 8 | **/Dockerfile text eol=lf 9 | **/Cert/* -text 10 | *.yml text eol=lf 11 | 12 | *.jpg binary 13 | *.png binary 14 | *.gif binary 15 | 16 | *.doc diff=astextplain 17 | *.DOC diff=astextplain 18 | *.docx diff=astextplain 19 | *.DOCX diff=astextplain 20 | *.dot diff=astextplain 21 | *.DOT diff=astextplain 22 | *.pdf diff=astextplain 23 | *.PDF diff=astextplain 24 | *.rtf diff=astextplain 25 | *.RTF diff=astextplain 26 | 27 | *.verified.txt text eol=lf 28 | *.verified.xml text eol=lf 29 | *.verified.json text eol=lf -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Api/Demo.http: -------------------------------------------------------------------------------- 1 | @api = http://localhost:5091 2 | 3 | ### Create a new todo 4 | POST {{api}}/Todos 5 | Content-Type: application/json 6 | 7 | { 8 | "title": "Define theory of everything", 9 | "tags": [ 10 | "astronomy", "physics", "theoretical" 11 | ] 12 | } 13 | 14 | ### Id 15 | @id = 9961c732-3a57-4cc4-85f4-8b3d397360ca 16 | 17 | ### Get todo by id 18 | GET {{api}}/Todos/{{id}} 19 | Content-Type: application/json 20 | 21 | ### Update todo 22 | PUT {{api}}/Todos/{{id}} 23 | Content-Type: application/json 24 | 25 | { 26 | "title": "Flight to Alpha Centauri", 27 | "tags": [ 28 | "astronomy", "practical" 29 | ], 30 | "done": true 31 | } 32 | 33 | ### Filter todos by tags 34 | GET {{api}}/Todos?Tags=astronomy&Tags=practical 35 | Content-Type: application/json -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosWithCalendarTestsV1.CreateCalendarTodo.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Create a to-do item in the calendar", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Daily stand up", 14 | "Tags": [ 15 | "calendar-event", 16 | "DateTimeOffset_1" 17 | ] 18 | } 19 | }, 20 | "Response": { 21 | "StatusCode": "Created", 22 | "Headers": { 23 | "Location": [ 24 | "/Todos/Guid_1" 25 | ] 26 | }, 27 | "Payload": { 28 | "Id": "Guid_1" 29 | } 30 | } 31 | } 32 | ] -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/DAL/TeoAppDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using NTeoTestBuildeR.Modules.Todos.Core.Model; 3 | 4 | namespace NTeoTestBuildeR.Modules.Todos.Core.DAL; 5 | 6 | public sealed class TeoAppDbContext(DbContextOptions options) : DbContext(options) 7 | { 8 | public required DbSet Todos { get; init; } 9 | 10 | protected override void OnModelCreating(ModelBuilder modelBuilder) 11 | { 12 | base.OnModelCreating(modelBuilder); 13 | modelBuilder.HasDefaultSchema("todos"); 14 | 15 | modelBuilder 16 | .Entity() 17 | .HasKey(todo => todo.Id); 18 | 19 | modelBuilder.Entity() 20 | .OwnsOne(navigationExpression: todo => todo.Tags, 21 | buildAction: ownedNavigationBuilder => ownedNavigationBuilder.ToJson()); 22 | } 23 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Stats/Core/StatsService.cs: -------------------------------------------------------------------------------- 1 | using NTeoTestBuildeR.Modules.Stats.Api; 2 | 3 | namespace NTeoTestBuildeR.Modules.Stats.Core; 4 | 5 | public static class StatsService 6 | { 7 | private readonly static Dictionary Stats = new(); 8 | 9 | public static void AddStats(AddStats cmd) => 10 | Stats[cmd.Dto.Tag] = Stats.TryGetValue(cmd.Dto.Tag, value: out var value) 11 | ? value + 1 12 | : 1; 13 | 14 | public static GetStats.Response GetStats(GetStats query) => 15 | new(Stats 16 | .Where(stat => query.Dto.Tags.Contains(stat.Key)) 17 | .Select(stat => new GetStats.Item(stat.Key, stat.Value)) 18 | .ToArray()); 19 | 20 | public static void DeleteStats(DeleteStats cmd) 21 | { 22 | if (Stats.TryGetValue(cmd.Tag, value: out var value)) 23 | if (value > 0) 24 | Stats[cmd.Tag] = value - 1; 25 | } 26 | } -------------------------------------------------------------------------------- /tests/TeoTests/Core/App.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using WireMock.Server; 3 | 4 | namespace TeoTests.Core; 5 | 6 | public sealed class App 7 | { 8 | private readonly static Lazy LazyInstance = new(() => 9 | { 10 | var appFactory = new AppFactory(() => HttpClient); 11 | var appBuilder = appFactory 12 | .WithWebHostBuilder(builder => builder 13 | .UseContentRoot(Directory.GetCurrentDirectory())); 14 | 15 | var httpClient = appBuilder.CreateClient(); 16 | return new(httpClient, appFactory); 17 | }); 18 | 19 | private volatile AppFactory _appFactory; 20 | private volatile HttpClient _httpClient; 21 | public static HttpClient HttpClient => LazyInstance.Value._httpClient; 22 | public static WireMockServer Wiremock => LazyInstance.Value._appFactory.Wiremock; 23 | 24 | private App(HttpClient httpClient, AppFactory appFactory) 25 | { 26 | _httpClient = httpClient; 27 | _appFactory = appFactory; 28 | } 29 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosWithCalendarTestsV1.CreateCalendarTodoWentWrong.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Create a to-do item in the calendar that went wrong due to empty id", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Conf-call with some company pets", 14 | "Tags": [ 15 | "calendar-event", 16 | "DateTimeOffset_1" 17 | ] 18 | } 19 | }, 20 | "Response": { 21 | "StatusCode": "InternalServerError", 22 | "Payload": { 23 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/create-calendar-event.md", 24 | "status": 500, 25 | "title": "Infrastructure error", 26 | "detail": "Failed to create calendar event", 27 | "instance": "", 28 | "extensions": { 29 | "traceId": "{Scrubbed}" 30 | } 31 | } 32 | } 33 | } 34 | ] -------------------------------------------------------------------------------- /src/TeoTodoApp/NTeoTestBuildeR.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | all 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Stats/Api/StatsController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using NTeoTestBuildeR.Modules.Stats.Core; 3 | 4 | namespace NTeoTestBuildeR.Modules.Stats.Api; 5 | 6 | [ApiController] 7 | [Consumes("application/json")] 8 | [Route(STATS)] 9 | public sealed class StatsController : ControllerBase 10 | { 11 | private const string STATS = "Stats"; 12 | 13 | [HttpPost] 14 | [ProducesResponseType(StatusCodes.Status204NoContent)] 15 | public IActionResult Post([FromBody] AddStats.Request body) 16 | { 17 | StatsService.AddStats(new(body)); 18 | return NoContent(); 19 | } 20 | 21 | [HttpGet] 22 | [ProducesResponseType(type: typeof(GetStats.Query), StatusCodes.Status200OK)] 23 | public IActionResult Get([FromQuery] GetStats.Query query) => 24 | Ok(StatsService.GetStats(new(query))); 25 | 26 | [HttpDelete("{tag}")] 27 | [ProducesResponseType(StatusCodes.Status204NoContent)] 28 | public IActionResult Get([FromRoute] string tag) 29 | { 30 | StatsService.DeleteStats(new(tag)); 31 | return NoContent(); 32 | } 33 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosWithCalendarTestsV2.GetUniqueCalendarTodo.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Retrieve a single to-do item from the calendar", 4 | "Request": { 5 | "Method": { 6 | "Method": "GET" 7 | }, 8 | "Path": "http://localhost/todos/Guid_1", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | } 12 | }, 13 | "Response": { 14 | "StatusCode": "OK", 15 | "Payload": { 16 | "title": "Sort out life", 17 | "tags": [ 18 | "calendar-event", 19 | "someday" 20 | ], 21 | "done": false 22 | } 23 | } 24 | }, 25 | { 26 | "Description": "Second attempt to retrieve the same to-do item", 27 | "Request": { 28 | "Method": { 29 | "Method": "GET" 30 | }, 31 | "Path": "http://localhost/todos/Guid_1", 32 | "Headers": { 33 | "traceparent": "{Scrubbed}" 34 | } 35 | }, 36 | "Response": { 37 | "StatusCode": "OK", 38 | "Payload": { 39 | "title": "Sort out life", 40 | "tags": [ 41 | "calendar-event", 42 | "someday" 43 | ], 44 | "done": false 45 | } 46 | } 47 | } 48 | ] -------------------------------------------------------------------------------- /src/ExtCalendar/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:29934", 8 | "sslPort": 44391 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5135", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7249;http://localhost:5135", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "launchUrl": "swagger", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/TeoTodoApp/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:4873", 8 | "sslPort": 44344 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5091", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7041;http://localhost:5091", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "launchUrl": "swagger", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/TeoTodoApp/Infra/ErrorHandling/ErrorHandlerMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace NTeoTestBuildeR.Infra.ErrorHandling; 4 | 5 | internal sealed class ErrorHandlerMiddleware : IMiddleware 6 | { 7 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) 8 | { 9 | try 10 | { 11 | await next(context); 12 | } 13 | catch (Exception ex) 14 | { 15 | await HandleErrorAsync(context, ex); 16 | } 17 | } 18 | 19 | private async static Task HandleErrorAsync(HttpContext context, Exception ex) 20 | { 21 | SetProblemDetailsContentType(context); 22 | var response = ErrorMapper.Map(ex); 23 | response.Extensions["traceId"] = Activity.Current?.Id!; 24 | context.Response.StatusCode = response.Status; 25 | await context.Response.WriteAsJsonAsync(response); 26 | } 27 | 28 | private static void SetProblemDetailsContentType(HttpContext context) => 29 | context.Response.OnStarting(callback: state => 30 | { 31 | var httpContext = (HttpContext) state; 32 | httpContext.Response.Headers.ContentType = "application/problem+json; charset=utf-8"; 33 | return Task.CompletedTask; 34 | }, context); 35 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosHappyPathTests.CreateTodo.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Create a to-do item with success", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Prove Riemann's hypothesis Guid_1", 14 | "Tags": [ 15 | "match" 16 | ] 17 | } 18 | }, 19 | "Response": { 20 | "StatusCode": "Created", 21 | "Headers": { 22 | "Location": [ 23 | "/Todos/Guid_2" 24 | ] 25 | }, 26 | "Payload": { 27 | "Id": "Guid_2" 28 | } 29 | } 30 | }, 31 | { 32 | "Description": "Get already created to-do item", 33 | "Request": { 34 | "Method": { 35 | "Method": "GET" 36 | }, 37 | "Path": "http://localhost/Todos/Guid_2", 38 | "Headers": { 39 | "traceparent": "{Scrubbed}" 40 | } 41 | }, 42 | "Response": { 43 | "StatusCode": "OK", 44 | "Payload": { 45 | "Title": "Prove Riemann's hypothesis Guid_1", 46 | "Tags": [ 47 | "match" 48 | ], 49 | "Done": false 50 | } 51 | } 52 | } 53 | ] -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/DAL/Migrations/20240416000450_Init.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace NTeoTestBuildeR.Modules.Todos.Core.DAL.Migrations 7 | { 8 | /// 9 | public partial class Init : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.EnsureSchema( 15 | name: "todos"); 16 | 17 | migrationBuilder.CreateTable( 18 | name: "Todos", 19 | schema: "todos", 20 | columns: table => new 21 | { 22 | Id = table.Column(type: "uuid", nullable: false), 23 | Title = table.Column(type: "text", nullable: false), 24 | Done = table.Column(type: "boolean", nullable: false), 25 | Tags = table.Column(type: "jsonb", nullable: false) 26 | }, 27 | constraints: table => 28 | { 29 | table.PrimaryKey("PK_Todos", x => x.Id); 30 | }); 31 | } 32 | 33 | /// 34 | protected override void Down(MigrationBuilder migrationBuilder) 35 | { 36 | migrationBuilder.DropTable( 37 | name: "Todos", 38 | schema: "todos"); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Services/StatsClient.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | using Microsoft.AspNetCore.Http.Extensions; 3 | using NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 4 | 5 | namespace NTeoTestBuildeR.Modules.Todos.Core.Services; 6 | 7 | public sealed class StatsClient(HttpClient httpClient) 8 | { 9 | public async Task AddStats(AddStatsRequest stats) 10 | { 11 | var request = new AddStatsRequest(stats.Tag); 12 | var response = await httpClient.PostAsJsonAsync(requestUri: "/stats", request); 13 | response.EnsureSuccessStatusCode(); 14 | } 15 | 16 | public async Task GetStats(string[] tags) 17 | { 18 | var query = new QueryBuilder(); 19 | foreach (var tag in tags) 20 | query.Add(key: "tags", tag); 21 | 22 | var httpResponse = await httpClient.GetAsync($"/stats{query.ToQueryString()}"); 23 | var result = await httpResponse.Content.ReadFromJsonAsync(); 24 | return result ?? throw new GetStatsException("Failed to get stats"); 25 | } 26 | 27 | public async Task RemoveStats(string tag) 28 | { 29 | var response = await httpClient.DeleteAsync($"/stats/{tag}"); 30 | response.EnsureSuccessStatusCode(); 31 | } 32 | 33 | [PublicAPI] 34 | public sealed record AddStatsRequest(string Tag); 35 | 36 | [PublicAPI] 37 | public sealed record GetStatsResponse(GetStatsResponse.Item[] Stats) 38 | { 39 | [PublicAPI] 40 | public sealed record Item(string Tag, int Count); 41 | } 42 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosWithCalendarTestsV2.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | using NTeoTestBuildeR.Modules.Todos.Core.Services; 4 | using TeoTests.Modules.TodosModule.Builder; 5 | using WireMock.RequestBuilders; 6 | using WireMock.ResponseBuilders; 7 | 8 | namespace TeoTests.Modules.TodosModule; 9 | 10 | public class TodosWithCalendarTestsV2 11 | { 12 | [Fact] 13 | public async Task GetUniqueCalendarTodo() 14 | { 15 | // arrange 16 | var calendarEventId = Guid.Parse("f74ec649-0212-481c-b058-a4e1651c79fb"); 17 | 18 | // act 19 | var actual = await new TodosTestBuilder() 20 | .WithWiremock(configure: GetCalendarEvent(calendarEventId), expectedCallCount: 1) 21 | .GetTodo(description: "Retrieve a single to-do item from the calendar", calendarEventId) 22 | .GetTodo(description: "Second attempt to retrieve the same to-do item", calendarEventId) 23 | .Build(); 24 | 25 | // assert 26 | await Verify(actual); 27 | } 28 | 29 | private static Action<(IRequestBuilder request, IResponseBuilder response)> GetCalendarEvent( 30 | Guid calendarEventId) => server => 31 | { 32 | server.request 33 | .WithPath($"/calendar/events/{calendarEventId}") 34 | .UsingGet(); 35 | 36 | server.response 37 | .WithBody(JsonSerializer.Serialize(new CalendarClient.EventInstanceResponse( 38 | Name: "Sort out life", Type: "todo-list", When: DateTime.UtcNow.AddYears(10)))) 39 | .WithStatusCode(HttpStatusCode.OK); 40 | }; 41 | } -------------------------------------------------------------------------------- /src/ExtCalendar/Program.cs: -------------------------------------------------------------------------------- 1 | using ExtCalendar; 2 | using JetBrains.Annotations; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | var builder = WebApplication.CreateBuilder(args); 6 | 7 | builder.Services.AddEndpointsApiExplorer(); 8 | builder.Services.AddSwaggerGen(); 9 | 10 | var app = builder.Build(); 11 | 12 | if (app.Environment.IsDevelopment()) 13 | { 14 | app.UseSwagger(); 15 | app.UseSwaggerUI(); 16 | } 17 | 18 | app.UseHttpsRedirection(); 19 | 20 | var calendar = new Dictionary(); 21 | 22 | app.MapPost(pattern: "/calendar/events", handler: ([FromBody] SetupEventRequest @event) => 23 | { 24 | var id = Guid.NewGuid(); 25 | calendar.Add(id, value: new ValueTuple(id, @event.Name, @event.Type, @event.When)); 26 | return Results.Created(uri: $"/calendar/events/{id}", value: new {id, @event.Name, @event.Type, @event.When}); 27 | }).WithOpenApi(); 28 | 29 | app.MapGet(pattern: "/calendar/events/{id:guid}", handler: ([FromRoute] Guid id) => 30 | calendar.TryGetValue(id, value: out var @event) 31 | ? Results.Ok(new {@event.Name, @event.Type, @event.When}) 32 | : Results.NotFound()) 33 | .WithOpenApi(); 34 | 35 | app.MapGet(pattern: "/calendar/events", handler: ([FromQuery] string type) => 36 | calendar.Values 37 | .Where(@event => @event.Type == type) 38 | .Select(@event => new {@event.Id, @event.Name, @event.Type, @event.When})) 39 | .WithOpenApi(); 40 | 41 | app.Run(); 42 | 43 | namespace ExtCalendar 44 | { 45 | [PublicAPI] 46 | internal sealed record SetupEventRequest(string Name, string Type, DateTime When); 47 | } -------------------------------------------------------------------------------- /tests/TeoTests/TeoTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | all 24 | 25 | 26 | runtime; build; native; contentfiles; analyzers; buildtransitive 27 | all 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Services/CalendarClient.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | using NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 3 | 4 | namespace NTeoTestBuildeR.Modules.Todos.Core.Services; 5 | 6 | public sealed class CalendarClient(HttpClient httpClient) 7 | { 8 | public async Task CreateEvent(string name, DateTime when) 9 | { 10 | var request = new EventRequest(name, Type: "todo-list", when); 11 | var httpResponse = await httpClient.PostAsJsonAsync(requestUri: "/calendar/events", request); 12 | var result = await httpResponse.Content.ReadFromJsonAsync(); 13 | return result!.Id == Guid.Empty 14 | ? throw new CreateCalendarEventException("Failed to create calendar event") 15 | : result; 16 | } 17 | 18 | public async Task GetEvents() 19 | { 20 | var httpResponse = await httpClient.GetAsync("/calendar/events?type=todo-list"); 21 | var result = await httpResponse.Content.ReadFromJsonAsync(); 22 | return result ?? []; 23 | } 24 | 25 | public async Task GetEvent(Guid id) 26 | { 27 | var httpResponse = await httpClient.GetAsync($"/calendar/events/{id}"); 28 | if (!httpResponse.IsSuccessStatusCode) 29 | return null; 30 | 31 | var result = await httpResponse.Content.ReadFromJsonAsync(); 32 | return result; 33 | } 34 | 35 | [PublicAPI] 36 | public sealed record EventRequest(string Name, string Type, DateTime When); 37 | 38 | [PublicAPI] 39 | public sealed record EventItemResponse(Guid Id, string Name, string Type, DateTime When); 40 | 41 | [PublicAPI] 42 | public sealed record EventInstanceResponse(string Name, string Type, DateTime When); 43 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/StatsModule/Builder/StatsTestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using Microsoft.AspNetCore.Http.Extensions; 3 | using NTeoTestBuildeR.Modules.Stats.Api; 4 | using TeoTests.Core; 5 | using TeoTests.Modules.TodosModule.Builder; 6 | 7 | namespace TeoTests.Modules.StatsModule.Builder; 8 | 9 | internal sealed class StatsTestBuilder : TestBuilder 10 | { 11 | internal StatsTestBuilder AddStats(string description, string? tag) 12 | { 13 | With(async () => 14 | { 15 | var requestPayload = new AddStats.Request(tag!); 16 | var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri: "/stats"); 17 | httpRequest.Content = JsonContent.Create(requestPayload); 18 | var httpResponse = await SendAsync(httpRequest); 19 | return await httpResponse.Deserialize(description, httpRequest); 20 | }); 21 | return this; 22 | } 23 | 24 | internal StatsTestBuilder GetStats(string description, string[] tags) 25 | { 26 | With(async () => 27 | { 28 | var query = new QueryBuilder(); 29 | foreach (var tag in tags) 30 | query.Add(key: "tags", tag); 31 | 32 | var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri: $"/stats{query.ToQueryString()}"); 33 | var httpResponse = await SendAsync(httpRequest); 34 | return await httpResponse.Deserialize(description, httpRequest); 35 | }); 36 | return this; 37 | } 38 | 39 | internal StatsTestBuilder DeleteStats(string description, string tag) 40 | { 41 | With(async () => 42 | { 43 | var httpRequest = new HttpRequestMessage(HttpMethod.Delete, requestUri: $"/stats/{tag}"); 44 | var httpResponse = await SendAsync(httpRequest); 45 | return await httpResponse.Deserialize(description, httpRequest); 46 | }); 47 | return this; 48 | } 49 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/StatsModule/StatsAppTests.cs: -------------------------------------------------------------------------------- 1 | using TeoTests.Modules.StatsModule.Builder; 2 | 3 | namespace TeoTests.Modules.StatsModule; 4 | 5 | public class StatsAppTests 6 | { 7 | [Fact] 8 | public async Task CollectAndGetStatistics() 9 | { 10 | // act 11 | var actual = await new StatsTestBuilder() 12 | .AddStats(description: "Collect first work tag", tag: "work") 13 | .AddStats(description: "Collect second work tag", tag: "work") 14 | .AddStats(description: "Collect one taxes tag", tag: "taxes") 15 | .AddStats(description: "Collect one holiday tag", tag: "holiday") 16 | .AddStats(description: "Collect first car tag", tag: "car") 17 | .AddStats(description: "Collect second car tag", tag: "car") 18 | .GetStats(description: "Retrieve work stats", tags: ["work"]) 19 | .GetStats(description: "Retrieve taxes stats", tags: ["taxes"]) 20 | .GetStats(description: "Retrieve car stats", tags: ["car"]) 21 | .GetStats(description: "Retrieve holiday stats", tags: ["holiday"]) 22 | .GetStats(description: "Retrieve work and car stats", tags: ["work", "car"]) 23 | .GetStats(description: "Retrieve work and taxes stats", tags: ["work", "taxes"]) 24 | .GetStats(description: "Retrieve work, car, and taxes stats", tags: ["work", "car", "taxes"]) 25 | .GetStats(description: "Retrieve all stats", tags: ["work", "car", "taxes", "holiday"]) 26 | .GetStats(description: "Retrieve all stats", tags: ["work", "car", "taxes", "holiday"]) 27 | .DeleteStats(description: "Remove first work tag", tag: "work") 28 | .GetStats(description: "Retrieve work tag (expected value is one)", tags: ["work"]) 29 | .DeleteStats(description: "Remove second work tag", tag: "work") 30 | .GetStats(description: "Retrieve work tag (expected value is zero)", tags: ["work"]) 31 | .Build(); 32 | 33 | // assert 34 | await Verify(actual); 35 | } 36 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Program.cs: -------------------------------------------------------------------------------- 1 | using JetBrains.Annotations; 2 | using Microsoft.EntityFrameworkCore; 3 | using NTeoTestBuildeR.Infra.ErrorHandling; 4 | using NTeoTestBuildeR.Modules.Todos.Core.DAL; 5 | using NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 6 | using NTeoTestBuildeR.Modules.Todos.Core.Services; 7 | 8 | var builder = WebApplication.CreateBuilder(args); 9 | 10 | builder.Services 11 | .AddScoped() 12 | .AddScoped() 13 | .AddControllers() 14 | .Services.AddSwaggerGen(options => options 15 | .CustomSchemaIds(type => type.FullName? 16 | .Replace(oldValue: "+", string.Empty))) 17 | .AddDbContext((serviceProvider, options) => options.UseNpgsql( 18 | connectionString: serviceProvider.GetRequiredService().GetConnectionString("TeoTodoApp"), 19 | npgsqlOptionsAction: npgsqlOptionsBuilder => npgsqlOptionsBuilder.EnableRetryOnFailure())) 20 | .AddScoped() 21 | .AddHttpClient((serviceProvider, client) => client.BaseAddress = new( 22 | serviceProvider.GetRequiredService() 23 | .GetSection("ExtCalendar:BaseAddress") 24 | .Get() ?? 25 | throw new CalendarClientUriException())) 26 | .Services.AddMemoryCache() 27 | .AddHttpClient((serviceProvider, client) => client.BaseAddress = new( 28 | serviceProvider.GetRequiredService() 29 | .GetSection("StatsModule:BaseAddress") 30 | .Get() ?? 31 | throw new StatsClientUriException())); 32 | 33 | var application = builder.Build(); 34 | application 35 | .UseSwagger() 36 | .UseSwaggerUI() 37 | .UseRouting() 38 | .UseMiddleware(); 39 | 40 | using (var scope = application.Services.CreateScope()) 41 | { 42 | var db = scope.ServiceProvider.GetRequiredService(); 43 | if (db.Database.GetPendingMigrations().Any()) 44 | db.Database.Migrate(); 45 | } 46 | 47 | application.MapControllers(); 48 | application.Run(); 49 | 50 | namespace NTeoTestBuildeR 51 | { 52 | [UsedImplicitly] 53 | public class Program; 54 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosWithCalendarTestsV1.GetCalendarTodos.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Retrieve items from the calendar", 4 | "Request": { 5 | "Method": { 6 | "Method": "GET" 7 | }, 8 | "Path": "http://localhost/todos?tags=calendar-event", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | } 12 | }, 13 | "Response": { 14 | "StatusCode": "OK", 15 | "Payload": { 16 | "Todos": [ 17 | { 18 | "Id": "Guid_1", 19 | "Title": "Daily stand up", 20 | "Tags": [ 21 | { 22 | "Name": "calendar-event" 23 | } 24 | ], 25 | "Done": true 26 | }, 27 | { 28 | "Id": "Guid_2", 29 | "Title": "Finish app", 30 | "Tags": [ 31 | { 32 | "Name": "calendar-event" 33 | }, 34 | { 35 | "Name": "someday" 36 | } 37 | ], 38 | "Done": false 39 | }, 40 | { 41 | "Id": "Guid_3", 42 | "Title": "Lunch", 43 | "Tags": [ 44 | { 45 | "Name": "calendar-event" 46 | }, 47 | { 48 | "Name": "in-an-hour" 49 | } 50 | ], 51 | "Done": false 52 | }, 53 | { 54 | "Id": "Guid_4", 55 | "Title": "Meeting", 56 | "Tags": [ 57 | { 58 | "Name": "calendar-event" 59 | }, 60 | { 61 | "Name": "in-a-day" 62 | } 63 | ], 64 | "Done": false 65 | }, 66 | { 67 | "Id": "Guid_5", 68 | "Title": "Sprint review", 69 | "Tags": [ 70 | { 71 | "Name": "calendar-event" 72 | }, 73 | { 74 | "Name": "in-a-week" 75 | } 76 | ], 77 | "Done": false 78 | } 79 | ] 80 | } 81 | } 82 | } 83 | ] -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Api/TodosController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using NTeoTestBuildeR.Modules.Todos.Core.Services; 3 | using ErrorHandling_ProblemDetails = NTeoTestBuildeR.Infra.ErrorHandling.ProblemDetails; 4 | 5 | namespace NTeoTestBuildeR.Modules.Todos.Api; 6 | 7 | [ApiController] 8 | [Consumes("application/json")] 9 | [Route(TODOS)] 10 | [ProducesResponseType(type: typeof(ErrorHandling_ProblemDetails), StatusCodes.Status500InternalServerError)] 11 | public sealed class TodosController(TodosService service) : ControllerBase 12 | { 13 | private const string TODOS = "Todos"; 14 | 15 | [HttpPost] 16 | [ProducesResponseType(type: typeof(CreateTodo.Response), StatusCodes.Status201Created)] 17 | [ProducesResponseType(type: typeof(ErrorHandling_ProblemDetails), StatusCodes.Status400BadRequest)] 18 | public async Task Post(CreateTodo.Request body) 19 | { 20 | var response = await service.Create(new(body)); 21 | return Created(uri: $"/{TODOS}/{response.Id}", response); 22 | } 23 | 24 | [HttpPut("{id:guid}")] 25 | [ProducesResponseType(StatusCodes.Status204NoContent)] 26 | [ProducesResponseType(type: typeof(ErrorHandling_ProblemDetails), StatusCodes.Status404NotFound)] 27 | [ProducesResponseType(type: typeof(ErrorHandling_ProblemDetails), StatusCodes.Status400BadRequest)] 28 | public async Task Put(Guid id, UpdateTodo.Request body) 29 | { 30 | await service.Update(new(id, body)); 31 | return NoContent(); 32 | } 33 | 34 | [HttpGet("{id:guid}")] 35 | [ProducesResponseType(type: typeof(GetTodo.Response), StatusCodes.Status200OK)] 36 | [ProducesResponseType(type: typeof(ErrorHandling_ProblemDetails), StatusCodes.Status404NotFound)] 37 | [ProducesResponseType(type: typeof(ErrorHandling_ProblemDetails), StatusCodes.Status400BadRequest)] 38 | public async Task Get(Guid id) => 39 | Ok(await service.GetTodo(new(new(id)))); 40 | 41 | [HttpGet] 42 | [ProducesResponseType(type: typeof(GetTodos.Response), StatusCodes.Status200OK)] 43 | public async Task Get([FromQuery] GetTodos.Query query) => 44 | Ok(await service.GetTodos(query)); 45 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosHappyPathTests.DoneTodo.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Set up a to-do", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Land on the moon Guid_1", 14 | "Tags": [ 15 | "astronomy" 16 | ] 17 | } 18 | }, 19 | "Response": { 20 | "StatusCode": "Created", 21 | "Headers": { 22 | "Location": [ 23 | "/Todos/Guid_2" 24 | ] 25 | }, 26 | "Payload": { 27 | "Id": "Guid_2" 28 | } 29 | } 30 | }, 31 | { 32 | "Description": "Retrieve already created to-do item", 33 | "Request": { 34 | "Method": { 35 | "Method": "GET" 36 | }, 37 | "Path": "http://localhost/Todos/Guid_2", 38 | "Headers": { 39 | "traceparent": "{Scrubbed}" 40 | } 41 | }, 42 | "Response": { 43 | "StatusCode": "OK", 44 | "Payload": { 45 | "Title": "Land on the moon Guid_1", 46 | "Tags": [ 47 | "astronomy" 48 | ], 49 | "Done": false 50 | } 51 | } 52 | }, 53 | { 54 | "Description": "Mark the to-do as done", 55 | "Request": { 56 | "Method": { 57 | "Method": "PUT" 58 | }, 59 | "Path": "http://localhost/Todos/Guid_2", 60 | "Headers": { 61 | "traceparent": "{Scrubbed}" 62 | }, 63 | "Payload": { 64 | "Title": "Land on the moon Guid_1", 65 | "Tags": [ 66 | "astronomy" 67 | ], 68 | "Done": true 69 | } 70 | }, 71 | "Response": { 72 | "StatusCode": "NoContent" 73 | } 74 | }, 75 | { 76 | "Description": "Retrieve the to-do that has been done", 77 | "Request": { 78 | "Method": { 79 | "Method": "GET" 80 | }, 81 | "Path": "http://localhost/Todos/Guid_2", 82 | "Headers": { 83 | "traceparent": "{Scrubbed}" 84 | } 85 | }, 86 | "Response": { 87 | "StatusCode": "OK", 88 | "Payload": { 89 | "Title": "Land on the moon Guid_1", 90 | "Tags": [ 91 | "astronomy" 92 | ], 93 | "Done": true 94 | } 95 | } 96 | } 97 | ] -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosHappyPathTests.ChangeTodoTitle.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Set up a to-do", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Land on the Mars Guid_1", 14 | "Tags": [ 15 | "astronomy" 16 | ] 17 | } 18 | }, 19 | "Response": { 20 | "StatusCode": "Created", 21 | "Headers": { 22 | "Location": [ 23 | "/Todos/Guid_2" 24 | ] 25 | }, 26 | "Payload": { 27 | "Id": "Guid_2" 28 | } 29 | } 30 | }, 31 | { 32 | "Description": "Retrieve already created to-do item", 33 | "Request": { 34 | "Method": { 35 | "Method": "GET" 36 | }, 37 | "Path": "http://localhost/Todos/Guid_2", 38 | "Headers": { 39 | "traceparent": "{Scrubbed}" 40 | } 41 | }, 42 | "Response": { 43 | "StatusCode": "OK", 44 | "Payload": { 45 | "Title": "Land on the Mars Guid_1", 46 | "Tags": [ 47 | "astronomy" 48 | ], 49 | "Done": false 50 | } 51 | } 52 | }, 53 | { 54 | "Description": "Change title from 'Land' to 'Terraform'", 55 | "Request": { 56 | "Method": { 57 | "Method": "PUT" 58 | }, 59 | "Path": "http://localhost/Todos/Guid_2", 60 | "Headers": { 61 | "traceparent": "{Scrubbed}" 62 | }, 63 | "Payload": { 64 | "Title": "Terraform Mars Guid_1", 65 | "Tags": [ 66 | "astronomy" 67 | ], 68 | "Done": false 69 | } 70 | }, 71 | "Response": { 72 | "StatusCode": "NoContent" 73 | } 74 | }, 75 | { 76 | "Description": "Retrieve the to-do that has been changed", 77 | "Request": { 78 | "Method": { 79 | "Method": "GET" 80 | }, 81 | "Path": "http://localhost/Todos/Guid_2", 82 | "Headers": { 83 | "traceparent": "{Scrubbed}" 84 | } 85 | }, 86 | "Response": { 87 | "StatusCode": "OK", 88 | "Payload": { 89 | "Title": "Terraform Mars Guid_1", 90 | "Tags": [ 91 | "astronomy" 92 | ], 93 | "Done": false 94 | } 95 | } 96 | } 97 | ] -------------------------------------------------------------------------------- /tests/TeoTests/Core/AppFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.AspNetCore.Mvc.Testing; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using NTeoTestBuildeR; 6 | using NTeoTestBuildeR.Modules.Todos.Core.Services; 7 | using Testcontainers.PostgreSql; 8 | using WireMock.Server; 9 | using WireMock.Settings; 10 | 11 | namespace TeoTests.Core; 12 | 13 | public sealed class AppFactory(Func createHimselfClient) : WebApplicationFactory 14 | { 15 | private readonly PostgreSqlContainer _postgresContainer = BuildPostgres(); 16 | 17 | public WireMockServer Wiremock { get; } = WireMockServer.Start( 18 | new WireMockServerSettings 19 | { 20 | StartAdminInterface = true, 21 | HandleRequestsSynchronously = true 22 | }); 23 | 24 | private static PostgreSqlContainer BuildPostgres() => 25 | new PostgreSqlBuilder() 26 | .WithImage("postgres") 27 | .WithDatabase("teo-app") 28 | .WithAutoRemove(autoRemove: true) 29 | .WithCleanUp(cleanUp: true) 30 | .Build(); 31 | 32 | protected override void ConfigureWebHost(IWebHostBuilder builder) => builder 33 | .ConfigureServices(services => OverrideStatsClient(services, createHimselfClient)) 34 | .ConfigureAppConfiguration(ConfigureTestcontainers) 35 | .ConfigureAppConfiguration(ConfigureWiremock); 36 | 37 | private static void OverrideStatsClient(IServiceCollection services, Func create) => 38 | services.AddTransient(_ => 39 | new(create())); 40 | 41 | private void ConfigureTestcontainers(IConfigurationBuilder configurationBuilder) 42 | { 43 | _postgresContainer.StartAsync().Wait(); 44 | var connectionString = _postgresContainer.GetConnectionString(); 45 | 46 | configurationBuilder.AddInMemoryCollection(new KeyValuePair[] 47 | { 48 | new(key: "ConnectionStrings:TeoTodoApp", connectionString) 49 | }); 50 | } 51 | 52 | private void ConfigureWiremock(IConfigurationBuilder configurationBuilder) 53 | { 54 | configurationBuilder.AddInMemoryCollection(new KeyValuePair[] 55 | { 56 | new(key: "ExtCalendar:BaseAddress", value: Wiremock.Urls[0]) 57 | }); 58 | } 59 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosHappyPathTests.ChangeTodoTags.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Set up a to-do", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Land on the Mars Guid_1", 14 | "Tags": [ 15 | "astronomy" 16 | ] 17 | } 18 | }, 19 | "Response": { 20 | "StatusCode": "Created", 21 | "Headers": { 22 | "Location": [ 23 | "/Todos/Guid_2" 24 | ] 25 | }, 26 | "Payload": { 27 | "Id": "Guid_2" 28 | } 29 | } 30 | }, 31 | { 32 | "Description": "Retrieve already created to-do item", 33 | "Request": { 34 | "Method": { 35 | "Method": "GET" 36 | }, 37 | "Path": "http://localhost/Todos/Guid_2", 38 | "Headers": { 39 | "traceparent": "{Scrubbed}" 40 | } 41 | }, 42 | "Response": { 43 | "StatusCode": "OK", 44 | "Payload": { 45 | "Title": "Land on the Mars Guid_1", 46 | "Tags": [ 47 | "astronomy" 48 | ], 49 | "Done": false 50 | } 51 | } 52 | }, 53 | { 54 | "Description": "Change tags by adding one more tag", 55 | "Request": { 56 | "Method": { 57 | "Method": "PUT" 58 | }, 59 | "Path": "http://localhost/Todos/Guid_2", 60 | "Headers": { 61 | "traceparent": "{Scrubbed}" 62 | }, 63 | "Payload": { 64 | "Title": "Land on the Mars Guid_1", 65 | "Tags": [ 66 | "astronomy", 67 | "practical" 68 | ], 69 | "Done": false 70 | } 71 | }, 72 | "Response": { 73 | "StatusCode": "NoContent" 74 | } 75 | }, 76 | { 77 | "Description": "Retrieve the to-do that has been changed", 78 | "Request": { 79 | "Method": { 80 | "Method": "GET" 81 | }, 82 | "Path": "http://localhost/Todos/Guid_2", 83 | "Headers": { 84 | "traceparent": "{Scrubbed}" 85 | } 86 | }, 87 | "Response": { 88 | "StatusCode": "OK", 89 | "Payload": { 90 | "Title": "Land on the Mars Guid_1", 91 | "Tags": [ 92 | "astronomy", 93 | "practical" 94 | ], 95 | "Done": false 96 | } 97 | } 98 | } 99 | ] -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosValidationTests.cs: -------------------------------------------------------------------------------- 1 | using TeoTests.Modules.TodosModule.Builder; 2 | 3 | namespace TeoTests.Modules.TodosModule; 4 | 5 | public class TodosValidationTests 6 | { 7 | [Fact] 8 | public async Task CreateTodo() 9 | { 10 | // act 11 | var actual = await new TodosTestBuilder() 12 | .CreateTodo(description: "Should not create with whitespace title", title: " ", tags: ["tag"]) 13 | .CreateTodo(description: "Should not create without any tags", title: "Title", tags: []) 14 | .CreateTodo(description: "Should not create without title and tags", title: "", tags: []) 15 | .CreateTodo(description: "Should not create with whitespace tag", title: "Title", tags: ["tag with space"]) 16 | .Build(); 17 | 18 | // assert 19 | await Verify(target: actual); 20 | } 21 | 22 | [Fact] 23 | public async Task UpdateTodo() 24 | { 25 | // arrange 26 | var testCase = "C49EDFB3-1DAE-4969-9FAC-D4B5E08A7B53"; 27 | var validTitle = $"Go to the moon {testCase}"; 28 | 29 | // act 30 | var actual = await new TodosTestBuilder() 31 | .CreateTodo(description: "Create valid to-do for updating test cases", validTitle, tags: ["astronomy"]) 32 | .ChangeTitle(description: "Should not update with empty title", validTitle, newTitle: "") 33 | .ChangeTitle(description: "Should not update with whitespace title", validTitle, newTitle: " ") 34 | .ChangeTags(description: "Should not update with empty tags", validTitle, newTags: []) 35 | .ChangeTags(description: "Should not update with whitespace tag", validTitle, newTags: ["tag with space"]) 36 | .Build(); 37 | 38 | // assert 39 | await Verify(target: actual); 40 | } 41 | 42 | [Fact] 43 | public async Task ChangeTitleForCompletedTodo() 44 | { 45 | // arrange 46 | var testCase = "CCC650C6-105F-4D82-B470-D0AA85B104C2"; 47 | var title = $"Calculate the speed of light {testCase}"; 48 | var newTitle = $"Define theory of everything {testCase}"; 49 | var tag = "physics"; 50 | 51 | // act 52 | var actual = await new TodosTestBuilder() 53 | .CreateTodo(description: "Set up a to-do", title, tags: [tag]) 54 | .GetTodo(description: "Retrieve already created to-do item", title) 55 | .DoneTodo(description: "Mark the to-do as done", title) 56 | .ChangeTitle(description: "Try to change the title", title, newTitle) 57 | .Build(); 58 | 59 | // assert 60 | await Verify(target: actual); 61 | } 62 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/DAL/Migrations/TeoAppDbContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using NTeoTestBuildeR.Modules.Todos.Core.DAL; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | 9 | #nullable disable 10 | 11 | namespace NTeoTestBuildeR.Modules.Todos.Core.DAL.Migrations 12 | { 13 | [DbContext(typeof(TeoAppDbContext))] 14 | partial class TeoAppDbContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasDefaultSchema("todos") 21 | .HasAnnotation("ProductVersion", "8.0.2") 22 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 23 | 24 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 25 | 26 | modelBuilder.Entity("NTeoTestBuildeR.Modules.Todos.Core.Model.Todo", b => 27 | { 28 | b.Property("Id") 29 | .ValueGeneratedOnAdd() 30 | .HasColumnType("uuid"); 31 | 32 | b.Property("Done") 33 | .HasColumnType("boolean"); 34 | 35 | b.Property("Title") 36 | .IsRequired() 37 | .HasColumnType("text"); 38 | 39 | b.HasKey("Id"); 40 | 41 | b.ToTable("Todos", "todos"); 42 | }); 43 | 44 | modelBuilder.Entity("NTeoTestBuildeR.Modules.Todos.Core.Model.Todo", b => 45 | { 46 | b.OwnsOne("NTeoTestBuildeR.Modules.Todos.Core.Model.TagCollection", "Tags", b1 => 47 | { 48 | b1.Property("TodoId") 49 | .HasColumnType("uuid"); 50 | 51 | b1.Property("Tags") 52 | .IsRequired() 53 | .HasColumnType("text[]"); 54 | 55 | b1.HasKey("TodoId"); 56 | 57 | b1.ToTable("Todos", "todos"); 58 | 59 | b1.ToJson("Tags"); 60 | 61 | b1.WithOwner() 62 | .HasForeignKey("TodoId"); 63 | }); 64 | 65 | b.Navigation("Tags") 66 | .IsRequired(); 67 | }); 68 | #pragma warning restore 612, 618 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/DAL/Migrations/20240416000450_Init.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using NTeoTestBuildeR.Modules.Todos.Core.DAL; 8 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 | 10 | #nullable disable 11 | 12 | namespace NTeoTestBuildeR.Modules.Todos.Core.DAL.Migrations 13 | { 14 | [DbContext(typeof(TeoAppDbContext))] 15 | [Migration("20240416000450_Init")] 16 | partial class Init 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasDefaultSchema("todos") 24 | .HasAnnotation("ProductVersion", "8.0.2") 25 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 26 | 27 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 28 | 29 | modelBuilder.Entity("NTeoTestBuildeR.Modules.Todos.Core.Model.Todo", b => 30 | { 31 | b.Property("Id") 32 | .ValueGeneratedOnAdd() 33 | .HasColumnType("uuid"); 34 | 35 | b.Property("Done") 36 | .HasColumnType("boolean"); 37 | 38 | b.Property("Title") 39 | .IsRequired() 40 | .HasColumnType("text"); 41 | 42 | b.HasKey("Id"); 43 | 44 | b.ToTable("Todos", "todos"); 45 | }); 46 | 47 | modelBuilder.Entity("NTeoTestBuildeR.Modules.Todos.Core.Model.Todo", b => 48 | { 49 | b.OwnsOne("NTeoTestBuildeR.Modules.Todos.Core.Model.TagCollection", "Tags", b1 => 50 | { 51 | b1.Property("TodoId") 52 | .HasColumnType("uuid"); 53 | 54 | b1.Property("Tags") 55 | .IsRequired() 56 | .HasColumnType("text[]"); 57 | 58 | b1.HasKey("TodoId"); 59 | 60 | b1.ToTable("Todos", "todos"); 61 | 62 | b1.ToJson("Tags"); 63 | 64 | b1.WithOwner() 65 | .HasForeignKey("TodoId"); 66 | }); 67 | 68 | b.Navigation("Tags") 69 | .IsRequired(); 70 | }); 71 | #pragma warning restore 612, 618 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosValidationTests.ChangeTitleForCompletedTodo.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Set up a to-do", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Calculate the speed of light Guid_1", 14 | "Tags": [ 15 | "physics" 16 | ] 17 | } 18 | }, 19 | "Response": { 20 | "StatusCode": "Created", 21 | "Headers": { 22 | "Location": [ 23 | "/Todos/Guid_2" 24 | ] 25 | }, 26 | "Payload": { 27 | "Id": "Guid_2" 28 | } 29 | } 30 | }, 31 | { 32 | "Description": "Retrieve already created to-do item", 33 | "Request": { 34 | "Method": { 35 | "Method": "GET" 36 | }, 37 | "Path": "http://localhost/Todos/Guid_2", 38 | "Headers": { 39 | "traceparent": "{Scrubbed}" 40 | } 41 | }, 42 | "Response": { 43 | "StatusCode": "OK", 44 | "Payload": { 45 | "Title": "Calculate the speed of light Guid_1", 46 | "Tags": [ 47 | "physics" 48 | ], 49 | "Done": false 50 | } 51 | } 52 | }, 53 | { 54 | "Description": "Mark the to-do as done", 55 | "Request": { 56 | "Method": { 57 | "Method": "PUT" 58 | }, 59 | "Path": "http://localhost/Todos/Guid_2", 60 | "Headers": { 61 | "traceparent": "{Scrubbed}" 62 | }, 63 | "Payload": { 64 | "Title": "Calculate the speed of light Guid_1", 65 | "Tags": [ 66 | "physics" 67 | ], 68 | "Done": true 69 | } 70 | }, 71 | "Response": { 72 | "StatusCode": "NoContent" 73 | } 74 | }, 75 | { 76 | "Description": "Try to change the title", 77 | "Request": { 78 | "Method": { 79 | "Method": "PUT" 80 | }, 81 | "Path": "http://localhost/Todos/Guid_2", 82 | "Headers": { 83 | "traceparent": "{Scrubbed}" 84 | }, 85 | "Payload": { 86 | "Title": "Define theory of everything Guid_1", 87 | "Tags": [ 88 | "physics" 89 | ], 90 | "Done": true 91 | } 92 | }, 93 | "Response": { 94 | "StatusCode": "BadRequest", 95 | "Payload": { 96 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/todo-already-done.md", 97 | "status": 400, 98 | "title": "Invalid operation", 99 | "detail": "Cannot update a todo that is already done", 100 | "instance": "", 101 | "extensions": { 102 | "traceId": "{Scrubbed}" 103 | } 104 | } 105 | } 106 | } 107 | ] -------------------------------------------------------------------------------- /tests/TeoTests/Core/TestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using WireMock.RequestBuilders; 3 | using WireMock.ResponseBuilders; 4 | using Request = WireMock.RequestBuilders.Request; 5 | using Response = WireMock.ResponseBuilders.Response; 6 | 7 | namespace TeoTests.Core; 8 | 9 | public abstract class TestBuilder 10 | where TBuilder : TestBuilder 11 | { 12 | private readonly Activity _activity = new Activity(Guid.NewGuid().ToString()).Start(); 13 | private readonly HttpClient _httpClient = App.HttpClient; 14 | private readonly List>> _steps = []; 15 | private readonly Dictionary _wiremockConfigs = new(); 16 | 17 | protected void With(Func> step) => 18 | _steps.Add(step); 19 | 20 | public async Task> Build() 21 | { 22 | try 23 | { 24 | var result = new List(); 25 | 26 | foreach (var step in _steps) 27 | result.Add(await step()); 28 | 29 | AssertWiremockCallsCount(); 30 | 31 | return result; 32 | } 33 | finally 34 | { 35 | _activity.Dispose(); 36 | } 37 | } 38 | 39 | protected Task SendAsync(HttpRequestMessage request) 40 | { 41 | request.Headers.Add(name: "traceparent", _activity.Id); 42 | return _httpClient.SendAsync(request); 43 | } 44 | 45 | internal TBuilder WithWiremock(Action<(IRequestBuilder request, IResponseBuilder response)> configure, 46 | int expectedCallCount = 1) 47 | { 48 | BuildWiremock(configure, expectedCallCount); 49 | return (TBuilder) this; 50 | } 51 | 52 | private void BuildWiremock(Action<(IRequestBuilder request, IResponseBuilder response)> configure, 53 | int expectedCallCount) 54 | { 55 | var requestBuilder = Request.Create().WithHeader(name: "traceparent", pattern: $"*{_activity.TraceId}*"); 56 | var responseBuilder = Response.Create(); 57 | 58 | configure((requestBuilder, responseBuilder)); 59 | 60 | var id = Guid.NewGuid().ToString(); 61 | App.Wiremock 62 | .Given(requestBuilder) 63 | .WithTitle(id) 64 | .RespondWith(responseBuilder); 65 | 66 | _wiremockConfigs.Add(id, value: (requestBuilder, expectedCallCount)); 67 | } 68 | 69 | private void AssertWiremockCallsCount() 70 | { 71 | foreach (var config in _wiremockConfigs) 72 | { 73 | var logs = App.Wiremock.LogEntries 74 | .Where(log => log.MappingTitle == config.Key) 75 | .ToArray(); 76 | 77 | if (logs.Length == config.Value.ExpectedCallCount == false) 78 | throw new InvalidOperationException( 79 | $"The Wiremock configuration {config.Key} is not used as expected. " + 80 | $"Expected: {config.Value.ExpectedCallCount}, Actual: {logs.Length}. " + 81 | $"Ensure all mocks are used at least once during the test.."); 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/Builder/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Headers; 2 | using System.Net.Http.Json; 3 | using Argon; 4 | using JetBrains.Annotations; 5 | using TeoTests.Core.Verify; 6 | 7 | namespace TeoTests.Modules.TodosModule.Builder; 8 | 9 | internal static class Extensions 10 | { 11 | [PublicAPI] 12 | public async static Task DeserializeWith(this HttpResponseMessage httpResponse, 13 | Func> success, string description, 14 | HttpRequestMessage httpRequest, object? requestPayload = null) => 15 | httpResponse.IsSuccessStatusCode 16 | ? await success(await httpResponse.Deserialize()) 17 | : await Actual.Create(description, httpRequest, httpResponse, requestPayload, 18 | responsePayload: await httpResponse.Deserialize()); 19 | 20 | [PublicAPI] 21 | public async static Task DeserializeWith(this HttpResponseMessage httpResponse, 22 | Action success, string description, HttpRequestMessage httpRequest, object? requestPayload = null) 23 | { 24 | if (httpResponse.IsSuccessStatusCode) 25 | { 26 | var responsePayload = await httpResponse.Deserialize(); 27 | success(responsePayload); 28 | return await Actual.Create(description, httpRequest, httpResponse, requestPayload, responsePayload); 29 | } 30 | 31 | var problemDetails = await httpResponse.Deserialize(); 32 | return await Actual.Create(description, httpRequest, httpResponse, requestPayload, problemDetails); 33 | } 34 | 35 | [PublicAPI] 36 | public async static Task DeserializeWith(this HttpResponseMessage httpResponse, 37 | Action success, string description, HttpRequestMessage httpRequest, object? requestPayload = null) 38 | { 39 | if (httpResponse.IsSuccessStatusCode) 40 | { 41 | success(); 42 | return await Actual.Create(description, httpRequest, httpResponse, requestPayload, responsePayload: null); 43 | } 44 | 45 | var problemDetails = await httpResponse.Deserialize(); 46 | return await Actual.Create(description, httpRequest, httpResponse, requestPayload, problemDetails); 47 | } 48 | 49 | [PublicAPI] 50 | public async static Task Deserialize(this HttpResponseMessage httpResponse, 51 | string description, HttpRequestMessage httpRequest, object? requestPayload = null) 52 | { 53 | var responsePayload = await httpResponse.Deserialize(); 54 | return await Actual.Create(description, httpRequest, httpResponse, requestPayload, responsePayload); 55 | } 56 | 57 | [PublicAPI] 58 | public static IReadOnlyDictionary> Map( 59 | this HttpHeaders requestHeaders) => 60 | requestHeaders.ToDictionary( 61 | keySelector: header => header.Key, 62 | elementSelector: header => header.Value); 63 | 64 | private async static Task Deserialize(this HttpResponseMessage httResponse) => 65 | await httResponse.Content.ReadFromJsonAsync(); 66 | 67 | private async static Task Deserialize(this HttpResponseMessage httpResponse) 68 | { 69 | if ((await httpResponse.Content.ReadAsStreamAsync()).Length is 0) 70 | return null; 71 | 72 | var jsonString = await httpResponse.Content.ReadAsStringAsync(); 73 | return JsonConvert.DeserializeObject(jsonString); 74 | } 75 | } -------------------------------------------------------------------------------- /NTeoTestBuildeR.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E8B0482E-92A8-440A-9BBD-7711D6B9AF99}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NTeoTestBuildeR", "src\TeoTodoApp\NTeoTestBuildeR.csproj", "{7CDC14B4-2002-4D4C-97BB-89A34B7682B6}" 6 | EndProject 7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{8EDEA64C-B440-43E2-9D9D-315B1D6CED21}" 8 | ProjectSection(SolutionItems) = preProject 9 | README.md = README.md 10 | .gitignore = .gitignore 11 | .gitattributes = .gitattributes 12 | .bashrc = .bashrc 13 | docker-compose.yaml = docker-compose.yaml 14 | .dockerignore = .dockerignore 15 | EndProjectSection 16 | EndProject 17 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{B6003DD5-A41F-43CF-BCF8-8D504422F423}" 18 | EndProject 19 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cleanup-code", "cleanup-code", "{02936694-50E2-40F3-BAB9-A2278BFA2162}" 20 | ProjectSection(SolutionItems) = preProject 21 | scripts\cleanup-code\local-dev-cleanupcode.sh = scripts\cleanup-code\local-dev-cleanupcode.sh 22 | EndProjectSection 23 | EndProject 24 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".config", ".config", "{877B6679-2CD9-41C2-9F08-2C5A9CE1CA1B}" 25 | ProjectSection(SolutionItems) = preProject 26 | .config\dotnet-tools.json = .config\dotnet-tools.json 27 | EndProjectSection 28 | EndProject 29 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A95DDA69-605E-4423-943C-C17A0EB20B02}" 30 | EndProject 31 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeoTests", "tests\TeoTests\TeoTests.csproj", "{EB6EE593-0916-41BF-870B-564A17A8C2A1}" 32 | EndProject 33 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExtCalendar", "src\ExtCalendar\ExtCalendar.csproj", "{C07F32B1-D8F2-4267-BED0-69E290A0D987}" 34 | EndProject 35 | Global 36 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 37 | Debug|Any CPU = Debug|Any CPU 38 | Release|Any CPU = Release|Any CPU 39 | EndGlobalSection 40 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 41 | {7CDC14B4-2002-4D4C-97BB-89A34B7682B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {7CDC14B4-2002-4D4C-97BB-89A34B7682B6}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {7CDC14B4-2002-4D4C-97BB-89A34B7682B6}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {7CDC14B4-2002-4D4C-97BB-89A34B7682B6}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {EB6EE593-0916-41BF-870B-564A17A8C2A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 46 | {EB6EE593-0916-41BF-870B-564A17A8C2A1}.Debug|Any CPU.Build.0 = Debug|Any CPU 47 | {EB6EE593-0916-41BF-870B-564A17A8C2A1}.Release|Any CPU.ActiveCfg = Release|Any CPU 48 | {EB6EE593-0916-41BF-870B-564A17A8C2A1}.Release|Any CPU.Build.0 = Release|Any CPU 49 | {C07F32B1-D8F2-4267-BED0-69E290A0D987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 50 | {C07F32B1-D8F2-4267-BED0-69E290A0D987}.Debug|Any CPU.Build.0 = Debug|Any CPU 51 | {C07F32B1-D8F2-4267-BED0-69E290A0D987}.Release|Any CPU.ActiveCfg = Release|Any CPU 52 | {C07F32B1-D8F2-4267-BED0-69E290A0D987}.Release|Any CPU.Build.0 = Release|Any CPU 53 | EndGlobalSection 54 | GlobalSection(NestedProjects) = preSolution 55 | {7CDC14B4-2002-4D4C-97BB-89A34B7682B6} = {E8B0482E-92A8-440A-9BBD-7711D6B9AF99} 56 | {02936694-50E2-40F3-BAB9-A2278BFA2162} = {B6003DD5-A41F-43CF-BCF8-8D504422F423} 57 | {877B6679-2CD9-41C2-9F08-2C5A9CE1CA1B} = {8EDEA64C-B440-43E2-9D9D-315B1D6CED21} 58 | {EB6EE593-0916-41BF-870B-564A17A8C2A1} = {A95DDA69-605E-4423-943C-C17A0EB20B02} 59 | {C07F32B1-D8F2-4267-BED0-69E290A0D987} = {E8B0482E-92A8-440A-9BBD-7711D6B9AF99} 60 | EndGlobalSection 61 | EndGlobal 62 | -------------------------------------------------------------------------------- /src/TeoTodoApp/Infra/ErrorHandling/ErrorMapper.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Humanizer; 3 | using NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 4 | 5 | namespace NTeoTestBuildeR.Infra.ErrorHandling; 6 | 7 | internal static class ErrorMapper 8 | { 9 | public static ProblemDetails Map(Exception exception) => 10 | exception switch 11 | { 12 | TeoAppException ex => MapTeoAppException(ex), 13 | _ => MapUnexpectedException() 14 | }; 15 | 16 | private static ProblemDetails MapTeoAppException(Exception ex) => 17 | ex switch 18 | { 19 | AppArgumentException argumentException => new( 20 | Type: ErrorType(ex), 21 | Title: "Request payload is not valid", 22 | Detail: argumentException.Message, 23 | Instance: string.Empty, 24 | Status: 400, 25 | Errors: argumentException.Errors, 26 | Extensions: new Dictionary()), 27 | NotFoundException notFoundException => new( 28 | Type: ErrorType(ex), 29 | Title: "Resource does not exist", 30 | Detail: notFoundException.Message, 31 | Instance: string.Empty, 32 | Status: 404, 33 | Errors: new Dictionary(), 34 | Extensions: new Dictionary()), 35 | DomainException domainException => new( 36 | Type: ErrorType(ex), 37 | Title: "Invalid operation", 38 | Detail: domainException.Message, 39 | Instance: string.Empty, 40 | Status: 400, 41 | Errors: new Dictionary(), 42 | Extensions: new Dictionary()), 43 | InfraException infraException => new( 44 | Type: ErrorType(ex), 45 | Title: "Infrastructure error", 46 | Detail: infraException.Message, 47 | Instance: string.Empty, 48 | Status: 500, 49 | Errors: new Dictionary(), 50 | Extensions: infraException.Context), 51 | AppNotImplementedException appNotImplementedException => new( 52 | Type: ErrorType(ex), 53 | Title: "Not implemented yet", 54 | Detail: appNotImplementedException.Message, 55 | Instance: string.Empty, 56 | Status: 500, 57 | Errors: new Dictionary(), 58 | Extensions: new Dictionary()), 59 | _ => MapUnexpectedException() 60 | }; 61 | 62 | private static ProblemDetails MapUnexpectedException() => 63 | new( 64 | Type: $"{DocUrl()}/internal-server-error.md", 65 | Title: "Internal server error", 66 | Status: (int) HttpStatusCode.InternalServerError, 67 | Detail: "Unexpected error occurred", 68 | Instance: string.Empty, 69 | Errors: new Dictionary(), 70 | Extensions: new Dictionary() 71 | ); 72 | 73 | private static string DocUrl() 74 | { 75 | const string gitHubUrl = "https://github.com"; 76 | const string repoUrl = $"{gitHubUrl}/ArturWincenciak/teo-test-builder"; 77 | const string docUrl = $"{repoUrl}/doc/problem-details"; 78 | return docUrl; 79 | } 80 | 81 | private static string ErrorCode(Exception ex) => ex 82 | .GetType().Name 83 | .Underscore() 84 | .Replace(oldValue: "_exception", string.Empty, StringComparison.OrdinalIgnoreCase) 85 | .Dasherize(); 86 | 87 | private static string ErrorType(Exception ex) => 88 | $"{DocUrl()}/{ErrorCode(ex)}.md"; 89 | } -------------------------------------------------------------------------------- /tests/TeoTests/VerifyDemo/VerifyDemo.cs: -------------------------------------------------------------------------------- 1 | namespace TeoTests.VerifyDemo; 2 | 3 | public class VerifyDemo 4 | { 5 | [Fact] 6 | public async Task SimplyDemo() 7 | { 8 | // Arrange 9 | var book = ArrangeSimply(); 10 | 11 | // Act 12 | var actual = Act(book); 13 | 14 | // Assert 15 | await Verify(actual); 16 | } 17 | 18 | [Fact] 19 | public async Task ComplexDemo() 20 | { 21 | // Arrange 22 | var items = ArrangeComplex(); 23 | 24 | // Act 25 | var actual = Act(items); 26 | 27 | // Assert 28 | await Verify(actual); 29 | } 30 | 31 | [Fact] 32 | public void DeprecatedApproach() 33 | { 34 | // Arrange 35 | var items = ArrangeComplex(); 36 | 37 | // Act 38 | var actual = Act(items); 39 | 40 | // Assert 41 | var expectedFirstItem = items.First(); 42 | var actualFirstItem = ((List) actual).First(); 43 | Assert.Equal(expectedFirstItem.Id, actualFirstItem.Id); 44 | Assert.Equal(expectedFirstItem.ExternalId, actualFirstItem.ExternalId); 45 | Assert.Equal(expectedFirstItem.Name, actualFirstItem.Name); 46 | Assert.Equal(expectedFirstItem.Amount, actualFirstItem.Amount); 47 | Assert.Equal(expectedFirstItem.CreatedAt, actualFirstItem.CreatedAt); 48 | Assert.Equal(expectedFirstItem.Timestamp, actualFirstItem.Timestamp); 49 | Assert.Equal(expectedFirstItem.IsDeleted, actualFirstItem.IsDeleted); 50 | 51 | var expectedSecondItem = items.Last(); 52 | var actualSecondItem = ((List) actual).Last(); 53 | Assert.Equal(expectedSecondItem.Id, actualSecondItem.Id); 54 | Assert.Equal(expectedSecondItem.ExternalId, actualSecondItem.ExternalId); 55 | Assert.Equal(expectedSecondItem.Name, actualSecondItem.Name); 56 | Assert.Equal(expectedSecondItem.Amount, actualSecondItem.Amount); 57 | Assert.Equal(expectedSecondItem.CreatedAt, actualSecondItem.CreatedAt); 58 | Assert.Equal(expectedSecondItem.Timestamp, actualSecondItem.Timestamp); 59 | Assert.Equal(expectedSecondItem.IsDeleted, actualSecondItem.IsDeleted); 60 | } 61 | 62 | private static object Act(object input) => input; 63 | 64 | private static object ArrangeSimply() => 65 | new 66 | { 67 | Id = Guid.NewGuid(), 68 | Name = "Book", 69 | Amount = 64, 70 | Attribute = new 71 | { 72 | Title = "Surreal Numbers", 73 | Author = "Donald Ervin Knuth", 74 | CreatedAt = DateTime.UtcNow 75 | }, 76 | EBook = true 77 | }; 78 | 79 | private static List ArrangeComplex() 80 | { 81 | var externalId = Guid.NewGuid(); 82 | var createdAt = DateTime.UtcNow; 83 | 84 | var items = new List 85 | { 86 | new( 87 | Id: Guid.NewGuid(), 88 | externalId, 89 | Name: "Knuth", 90 | Amount: 1, 91 | createdAt, 92 | Timestamp: DateTime.UtcNow.AddTicks(128), 93 | IsDeleted: false 94 | ), 95 | new( 96 | Id: Guid.NewGuid(), 97 | externalId, 98 | Name: "Conway", 99 | Amount: 2, 100 | createdAt, 101 | Timestamp: DateTime.UtcNow.AddTicks(256), 102 | IsDeleted: false 103 | ) 104 | }; 105 | 106 | return items; 107 | } 108 | 109 | private record Item( 110 | Guid Id, 111 | Guid ExternalId, 112 | string Name, 113 | int Amount, 114 | DateTime CreatedAt, 115 | DateTime Timestamp, 116 | bool IsDeleted 117 | ); 118 | } -------------------------------------------------------------------------------- /scripts/cleanup-code/local-dev-cleanupcode.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit codes 4 | SUCCESS=0 5 | INVALID_ARGUMENT_ERROR=1 6 | YOU_NEED_NO_CHANGES_BEFORE_RUN_CLEANUP_ERROR=3 7 | 8 | # Default arguments' values 9 | AUTO_COMMIT=yes 10 | 11 | echo "" 12 | echo "--- --- ---" 13 | echo "Alright Cleanup Code Command-Line Tool" 14 | echo "Default settings:" 15 | echo "- auto commit re-formatted code (-a): '$AUTO_COMMIT'" 16 | echo "--- --- ---" 17 | echo "" 18 | 19 | while getopts a: flag 20 | do 21 | case "${flag}" in 22 | a) AUTO_COMMIT=${OPTARG};; 23 | *) echo "" 24 | echo "--- --- ---" 25 | echo "Invalid argument's flag is not handled" 26 | echo "--- --- ---" 27 | echo "" 28 | exit $INVALID_ARGUMENT_ERROR ;; 29 | esac 30 | done 31 | 32 | if [ "$AUTO_COMMIT" != "yes" ] && [ "$AUTO_COMMIT" != "no" ] 33 | then 34 | echo "" 35 | echo "--- --- ---" 36 | echo "INVALID ARGUMENT OF '-a' equals '$AUTO_COMMIT'" 37 | echo "Set 'yes' or 'no' or omit to use default equals 'no'" 38 | echo "--- --- ---" 39 | echo "" 40 | exit $INVALID_ARGUMENT_ERROR 41 | fi 42 | 43 | UNSTAGED_CHANGES=$(git diff --name-only) 44 | if [ -z "$UNSTAGED_CHANGES" ] 45 | then 46 | echo "" 47 | echo "--- --- ---" 48 | echo "Right, there are no unstaged changes" 49 | echo "--- --- ---" 50 | echo "" 51 | else 52 | echo "" 53 | echo "--- --- ---" 54 | echo "There are unstaged changes" 55 | echo "Commit them before run the script" 56 | echo "--- --- ---" 57 | echo "" 58 | 59 | git diff --name-only 60 | exit $YOU_NEED_NO_CHANGES_BEFORE_RUN_CLEANUP_ERROR 61 | fi 62 | 63 | STAGED_UNCOMMITTED=$(git diff --staged --name-only) 64 | if [ -z "$STAGED_UNCOMMITTED" ] 65 | then 66 | echo "" 67 | echo "--- --- ---" 68 | echo "Right, there is no any changes, repo is ready to cleanup" 69 | echo "--- --- ---" 70 | echo "" 71 | else 72 | echo "" 73 | echo "--- --- ---" 74 | echo "There are staged, uncommitted changes" 75 | echo "Commit them before run the script" 76 | echo "--- --- ---" 77 | echo "" 78 | 79 | git diff --staged --name-only 80 | exit $YOU_NEED_NO_CHANGES_BEFORE_RUN_CLEANUP_ERROR 81 | fi 82 | 83 | echo "" 84 | echo "--- --- ---" 85 | echo "Restore dotnet tools (the JetBrains CleanupCode Tool)" 86 | echo "--- --- ---" 87 | echo "" 88 | 89 | dotnet tool restore 90 | dotnet jb cleanupcode --version 91 | 92 | echo "" 93 | echo "--- --- ---" 94 | echo "Let's get started, keep calm and wait, it may take few moments" 95 | echo "--- --- ---" 96 | echo "" 97 | 98 | dotnet jb cleanupcode NTeoTestBuildeR.sln --verbosity=WARN --exclude=**.verified.json 99 | 100 | REFORMATTED_FILES=$(git diff --name-only) 101 | 102 | if [ -z "$REFORMATTED_FILES" ] 103 | then 104 | echo "" 105 | echo "--- --- ---" 106 | echo "No files re-formatted, everything is clean, congratulation!" 107 | echo "--- --- ---" 108 | echo "" 109 | exit $SUCCESS 110 | fi 111 | 112 | if [ "$AUTO_COMMIT" = "no" ] 113 | then 114 | echo "" 115 | echo "--- --- ---" 116 | echo "There is re-formatted code but it will not be auto committed" 117 | echo "--- --- ---" 118 | echo "" 119 | exit $SUCCESS 120 | fi 121 | 122 | echo "" 123 | echo "--- --- ---" 124 | echo "There are re-formatted files to be committed" 125 | echo "--- --- ---" 126 | echo "" 127 | 128 | git diff --name-only 129 | 130 | for FILE in "${REFORMATTED_FILES[@]}" 131 | do 132 | git add ${FILE} 133 | done 134 | 135 | echo "" 136 | echo "--- --- ---" 137 | echo "Staged files to be committed" 138 | echo "--- --- ---" 139 | echo "" 140 | 141 | git diff --staged --name-only 142 | 143 | echo "" 144 | echo "--- --- ---" 145 | echo "Create commit" 146 | echo "--- --- ---" 147 | echo "" 148 | 149 | git commit -m "Cleanup: re-format code by JetBrains CleanupCode Tool" 150 | 151 | echo "" 152 | echo "--- --- ---" 153 | echo "Commit created" 154 | echo "--- --- ---" 155 | echo "" 156 | 157 | git status 158 | 159 | echo "" 160 | echo "--- --- ---" 161 | echo "All re-formatted code has been committed with success" 162 | echo "--- --- ---" 163 | echo "" 164 | exit $SUCCESS -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosValidationTests.CreateTodo.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Should not create with whitespace title", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": " ", 14 | "Tags": [ 15 | "tag" 16 | ] 17 | } 18 | }, 19 | "Response": { 20 | "StatusCode": "BadRequest", 21 | "Payload": { 22 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/invalid-todo-app-argument.md", 23 | "status": 400, 24 | "title": "Request payload is not valid", 25 | "detail": "Invalid argument for creating a new todo", 26 | "instance": "", 27 | "errors": { 28 | "Title": [ 29 | "Title is required, cannot be empty or white spaces" 30 | ] 31 | }, 32 | "extensions": { 33 | "traceId": "{Scrubbed}" 34 | } 35 | } 36 | } 37 | }, 38 | { 39 | "Description": "Should not create without any tags", 40 | "Request": { 41 | "Method": { 42 | "Method": "POST" 43 | }, 44 | "Path": "http://localhost/todos", 45 | "Headers": { 46 | "traceparent": "{Scrubbed}" 47 | }, 48 | "Payload": { 49 | "Title": "Title" 50 | } 51 | }, 52 | "Response": { 53 | "StatusCode": "BadRequest", 54 | "Payload": { 55 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/invalid-todo-app-argument.md", 56 | "status": 400, 57 | "title": "Request payload is not valid", 58 | "detail": "Invalid argument for creating a new todo", 59 | "instance": "", 60 | "errors": { 61 | "Tags": [ 62 | "At least one tag is required" 63 | ] 64 | }, 65 | "extensions": { 66 | "traceId": "{Scrubbed}" 67 | } 68 | } 69 | } 70 | }, 71 | { 72 | "Description": "Should not create without title and tags", 73 | "Request": { 74 | "Method": { 75 | "Method": "POST" 76 | }, 77 | "Path": "http://localhost/todos", 78 | "Headers": { 79 | "traceparent": "{Scrubbed}" 80 | }, 81 | "Payload": { 82 | "Title": "" 83 | } 84 | }, 85 | "Response": { 86 | "StatusCode": "BadRequest", 87 | "Payload": { 88 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/invalid-todo-app-argument.md", 89 | "status": 400, 90 | "title": "Request payload is not valid", 91 | "detail": "Invalid argument for creating a new todo", 92 | "instance": "", 93 | "errors": { 94 | "Title": [ 95 | "Title is required, cannot be empty or white spaces" 96 | ], 97 | "Tags": [ 98 | "At least one tag is required" 99 | ] 100 | }, 101 | "extensions": { 102 | "traceId": "{Scrubbed}" 103 | } 104 | } 105 | } 106 | }, 107 | { 108 | "Description": "Should not create with whitespace tag", 109 | "Request": { 110 | "Method": { 111 | "Method": "POST" 112 | }, 113 | "Path": "http://localhost/todos", 114 | "Headers": { 115 | "traceparent": "{Scrubbed}" 116 | }, 117 | "Payload": { 118 | "Title": "Title", 119 | "Tags": [ 120 | "tag with space" 121 | ] 122 | } 123 | }, 124 | "Response": { 125 | "StatusCode": "BadRequest", 126 | "Payload": { 127 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/invalid-todo-app-argument.md", 128 | "status": 400, 129 | "title": "Request payload is not valid", 130 | "detail": "Invalid argument for creating a new todo", 131 | "instance": "", 132 | "errors": { 133 | "Tags": [ 134 | "Tags cannot be empty or contain spaces" 135 | ] 136 | }, 137 | "extensions": { 138 | "traceId": "{Scrubbed}" 139 | } 140 | } 141 | } 142 | } 143 | ] -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosHappyPathTests.ChangeTagsAndMarkTodoAsDone.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Set up first theoretical to-do", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Define theory of everything Guid_1", 14 | "Tags": [ 15 | "astronomy" 16 | ] 17 | } 18 | }, 19 | "Response": { 20 | "StatusCode": "Created", 21 | "Headers": { 22 | "Location": [ 23 | "/Todos/Guid_2" 24 | ] 25 | }, 26 | "Payload": { 27 | "Id": "Guid_2" 28 | } 29 | } 30 | }, 31 | { 32 | "Description": "Set up second practical to-do", 33 | "Request": { 34 | "Method": { 35 | "Method": "POST" 36 | }, 37 | "Path": "http://localhost/todos", 38 | "Headers": { 39 | "traceparent": "{Scrubbed}" 40 | }, 41 | "Payload": { 42 | "Title": "Flight to Alpha Centauri Guid_1", 43 | "Tags": [ 44 | "astronomy" 45 | ] 46 | } 47 | }, 48 | "Response": { 49 | "StatusCode": "Created", 50 | "Headers": { 51 | "Location": [ 52 | "/Todos/Guid_3" 53 | ] 54 | }, 55 | "Payload": { 56 | "Id": "Guid_3" 57 | } 58 | } 59 | }, 60 | { 61 | "Description": "Change tags of the theory", 62 | "Request": { 63 | "Method": { 64 | "Method": "PUT" 65 | }, 66 | "Path": "http://localhost/Todos/Guid_2", 67 | "Headers": { 68 | "traceparent": "{Scrubbed}" 69 | }, 70 | "Payload": { 71 | "Title": "Define theory of everything Guid_1", 72 | "Tags": [ 73 | "physics", 74 | "theoretical" 75 | ], 76 | "Done": false 77 | } 78 | }, 79 | "Response": { 80 | "StatusCode": "NoContent" 81 | } 82 | }, 83 | { 84 | "Description": "Change tags of the practice", 85 | "Request": { 86 | "Method": { 87 | "Method": "PUT" 88 | }, 89 | "Path": "http://localhost/Todos/Guid_3", 90 | "Headers": { 91 | "traceparent": "{Scrubbed}" 92 | }, 93 | "Payload": { 94 | "Title": "Flight to Alpha Centauri Guid_1", 95 | "Tags": [ 96 | "astronomy", 97 | "practical" 98 | ], 99 | "Done": false 100 | } 101 | }, 102 | "Response": { 103 | "StatusCode": "NoContent" 104 | } 105 | }, 106 | { 107 | "Description": "Mark the theoretical to-do as done", 108 | "Request": { 109 | "Method": { 110 | "Method": "PUT" 111 | }, 112 | "Path": "http://localhost/Todos/Guid_2", 113 | "Headers": { 114 | "traceparent": "{Scrubbed}" 115 | }, 116 | "Payload": { 117 | "Title": "Define theory of everything Guid_1", 118 | "Tags": [ 119 | "physics", 120 | "theoretical" 121 | ], 122 | "Done": true 123 | } 124 | }, 125 | "Response": { 126 | "StatusCode": "NoContent" 127 | } 128 | }, 129 | { 130 | "Description": "Retrieve the theoretical to-do that has been done", 131 | "Request": { 132 | "Method": { 133 | "Method": "GET" 134 | }, 135 | "Path": "http://localhost/Todos/Guid_2", 136 | "Headers": { 137 | "traceparent": "{Scrubbed}" 138 | } 139 | }, 140 | "Response": { 141 | "StatusCode": "OK", 142 | "Payload": { 143 | "Title": "Define theory of everything Guid_1", 144 | "Tags": [ 145 | "physics", 146 | "theoretical" 147 | ], 148 | "Done": true 149 | } 150 | } 151 | }, 152 | { 153 | "Description": "Retrieve the practical that has not been done yet", 154 | "Request": { 155 | "Method": { 156 | "Method": "GET" 157 | }, 158 | "Path": "http://localhost/Todos/Guid_3", 159 | "Headers": { 160 | "traceparent": "{Scrubbed}" 161 | } 162 | }, 163 | "Response": { 164 | "StatusCode": "OK", 165 | "Payload": { 166 | "Title": "Flight to Alpha Centauri Guid_1", 167 | "Tags": [ 168 | "astronomy", 169 | "practical" 170 | ], 171 | "Done": false 172 | } 173 | } 174 | } 175 | ] -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosValidationTests.UpdateTodo.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Create valid to-do for updating test cases", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Go to the moon Guid_1", 14 | "Tags": [ 15 | "astronomy" 16 | ] 17 | } 18 | }, 19 | "Response": { 20 | "StatusCode": "Created", 21 | "Headers": { 22 | "Location": [ 23 | "/Todos/Guid_2" 24 | ] 25 | }, 26 | "Payload": { 27 | "Id": "Guid_2" 28 | } 29 | } 30 | }, 31 | { 32 | "Description": "Should not update with empty title", 33 | "Request": { 34 | "Method": { 35 | "Method": "PUT" 36 | }, 37 | "Path": "http://localhost/Todos/Guid_2", 38 | "Headers": { 39 | "traceparent": "{Scrubbed}" 40 | }, 41 | "Payload": { 42 | "Title": "", 43 | "Tags": [ 44 | "astronomy" 45 | ], 46 | "Done": false 47 | } 48 | }, 49 | "Response": { 50 | "StatusCode": "BadRequest", 51 | "Payload": { 52 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/invalid-todo-app-argument.md", 53 | "status": 400, 54 | "title": "Request payload is not valid", 55 | "detail": "Invalid argument for updating existing todo", 56 | "instance": "", 57 | "errors": { 58 | "Title": [ 59 | "Title is required, cannot be empty or white spaces" 60 | ] 61 | }, 62 | "extensions": { 63 | "traceId": "{Scrubbed}" 64 | } 65 | } 66 | } 67 | }, 68 | { 69 | "Description": "Should not update with whitespace title", 70 | "Request": { 71 | "Method": { 72 | "Method": "PUT" 73 | }, 74 | "Path": "http://localhost/Todos/Guid_2", 75 | "Headers": { 76 | "traceparent": "{Scrubbed}" 77 | }, 78 | "Payload": { 79 | "Title": " ", 80 | "Tags": [ 81 | "astronomy" 82 | ], 83 | "Done": false 84 | } 85 | }, 86 | "Response": { 87 | "StatusCode": "BadRequest", 88 | "Payload": { 89 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/invalid-todo-app-argument.md", 90 | "status": 400, 91 | "title": "Request payload is not valid", 92 | "detail": "Invalid argument for updating existing todo", 93 | "instance": "", 94 | "errors": { 95 | "Title": [ 96 | "Title is required, cannot be empty or white spaces" 97 | ] 98 | }, 99 | "extensions": { 100 | "traceId": "{Scrubbed}" 101 | } 102 | } 103 | } 104 | }, 105 | { 106 | "Description": "Should not update with empty tags", 107 | "Request": { 108 | "Method": { 109 | "Method": "PUT" 110 | }, 111 | "Path": "http://localhost/Todos/Guid_2", 112 | "Headers": { 113 | "traceparent": "{Scrubbed}" 114 | }, 115 | "Payload": { 116 | "Title": "Go to the moon Guid_1", 117 | "Done": false 118 | } 119 | }, 120 | "Response": { 121 | "StatusCode": "BadRequest", 122 | "Payload": { 123 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/invalid-todo-app-argument.md", 124 | "status": 400, 125 | "title": "Request payload is not valid", 126 | "detail": "Invalid argument for updating existing todo", 127 | "instance": "", 128 | "errors": { 129 | "Tags": [ 130 | "At least one tag is required" 131 | ] 132 | }, 133 | "extensions": { 134 | "traceId": "{Scrubbed}" 135 | } 136 | } 137 | } 138 | }, 139 | { 140 | "Description": "Should not update with whitespace tag", 141 | "Request": { 142 | "Method": { 143 | "Method": "PUT" 144 | }, 145 | "Path": "http://localhost/Todos/Guid_2", 146 | "Headers": { 147 | "traceparent": "{Scrubbed}" 148 | }, 149 | "Payload": { 150 | "Title": "Go to the moon Guid_1", 151 | "Tags": [ 152 | "tag with space" 153 | ], 154 | "Done": false 155 | } 156 | }, 157 | "Response": { 158 | "StatusCode": "BadRequest", 159 | "Payload": { 160 | "type": "https://github.com/ArturWincenciak/teo-test-builder/doc/problem-details/invalid-todo-app-argument.md", 161 | "status": 400, 162 | "title": "Request payload is not valid", 163 | "detail": "Invalid argument for updating existing todo", 164 | "instance": "", 165 | "errors": { 166 | "Tags": [ 167 | "Tags cannot be empty or contain spaces" 168 | ] 169 | }, 170 | "extensions": { 171 | "traceId": "{Scrubbed}" 172 | } 173 | } 174 | } 175 | } 176 | ] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Our files 2 | **/*TEMP.yml 3 | **/*temp.yml 4 | 5 | # CodeceptJS 6 | **/Codeceptjs/output 7 | **/Codeceptjs/codeceptjs/tests/execution_* 8 | 9 | ## Ignore Visual Studio temporary files, build results, and 10 | ## files generated by popular Visual Studio add-ons. 11 | 12 | # User-specific files 13 | *.suo 14 | *.user 15 | *.userosscache 16 | *.sln.docstates 17 | *my.app.config 18 | 19 | #RiderC# 20 | .idea/ 21 | *.*.iml 22 | 23 | # User-specific files (MonoDevelop/Xamarin Studio) 24 | *.userprefs 25 | 26 | # User-specific files (Extensions) 27 | *.sln.startup.json 28 | 29 | # Build results 30 | [Dd]ebug/ 31 | [Dd]ebugPublic/ 32 | [Rr]elease/ 33 | [Rr]eleases/ 34 | x64/ 35 | x86/ 36 | build/ 37 | bld/ 38 | [Bb]in/ 39 | [Oo]bj/ 40 | 41 | # Wynik buildu Naszego 42 | /Assemblies/* 43 | /Assemblies/IvrServerModuleLibs/*.dll 44 | /_PiriosBuild/* 45 | 46 | # Visual Studio 2015 cache/options directory 47 | .vs/ 48 | 49 | # Visual Studio Code catch/option dirctory 50 | .vscode/ 51 | 52 | # MSTest test Results 53 | [Tt]est[Rr]esult*/ 54 | [Bb]uild[Ll]og.* 55 | 56 | # NUNIT 57 | *.VisualState.xml 58 | TestResult.xml 59 | 60 | # Build Results of an ATL Project 61 | [Dd]ebugPS/ 62 | [Rr]eleasePS/ 63 | dlldata.c 64 | 65 | # DNX 66 | project.lock.json 67 | artifacts/ 68 | 69 | *_i.c 70 | *_p.c 71 | *_i.h 72 | *.ilk 73 | *.meta 74 | *.obj 75 | *.pch 76 | *.pdb 77 | *.pgc 78 | *.pgd 79 | *.rsp 80 | *.sbr 81 | *.tlb 82 | *.tli 83 | *.tlh 84 | *.tmp 85 | *.tmp_proj 86 | *.log 87 | *.vspscc 88 | *.vssscc 89 | .builds 90 | *.pidb 91 | *.svclog 92 | *.scc 93 | 94 | # Chutzpah Test files 95 | _Chutzpah* 96 | 97 | # Visual C++ cache files 98 | ipch/ 99 | *.aps 100 | *.ncb 101 | *.opensdf 102 | *.opendb 103 | *.vc.db 104 | *.sdf 105 | *.cachefile 106 | 107 | # Visual Studio profiler 108 | *.psess 109 | *.vsp 110 | *.vspx 111 | 112 | # TFS 2012 Local Workspace 113 | $tf/ 114 | 115 | # Guidance Automation Toolkit 116 | *.gpState 117 | 118 | # ReSharper is a .NET coding add-in 119 | _ReSharper*/ 120 | *.[Rr]e[Ss]harper 121 | *.DotSettings.user 122 | 123 | # JustCode is a .NET coding add-in 124 | .JustCode 125 | 126 | # TeamCity is a build add-in 127 | _TeamCity* 128 | 129 | # DotCover is a Code Coverage Tool 130 | *.dotCover 131 | 132 | # NCrunch 133 | _NCrunch_* 134 | .*crunch*.local.xml 135 | 136 | # MightyMoose 137 | *.mm.* 138 | AutoTest.Net/ 139 | 140 | # Web workbench (sass) 141 | .sass-cache/ 142 | 143 | # Installshield output folder 144 | [Ee]xpress/ 145 | 146 | # DocProject is a documentation generator add-in 147 | DocProject/buildhelp/ 148 | DocProject/Help/*.HxT 149 | DocProject/Help/*.HxC 150 | DocProject/Help/*.hhc 151 | DocProject/Help/*.hhk 152 | DocProject/Help/*.hhp 153 | DocProject/Help/Html2 154 | DocProject/Help/html 155 | 156 | # Click-Once directory 157 | publish/ 158 | 159 | # Publish Web Output 160 | *.[Pp]ublish.xml 161 | *.azurePubxml 162 | ## TODO: Comment the next line if you want to checkin your 163 | ## web deploy settings but do note that will include unencrypted 164 | ## passwords 165 | #*.pubxml 166 | 167 | *.publishproj 168 | 169 | # NuGet Packages 170 | *.nupkg 171 | # The packages folder can be ignored because of Package Restore 172 | **/packages/* 173 | # except build/, which is used as an MSBuild target. 174 | !**/packages/build/ 175 | # Uncomment if necessary however generally it will be regenerated when needed 176 | #!**/packages/repositories.config 177 | 178 | # Windows Azure Build Output 179 | csx/ 180 | *.build.csdef 181 | 182 | # Windows Store app package directory 183 | AppPackages/ 184 | 185 | # Visual Studio cache files 186 | # files ending in .cache can be ignored 187 | *.[Cc]ache 188 | # but keep track of directories ending in .cache 189 | !*.[Cc]ache/ 190 | 191 | # Others 192 | ClientBin/ 193 | [Ss]tyle[Cc]op.* 194 | ~$* 195 | *~ 196 | *.dbmdl 197 | *.dbproj.schemaview 198 | *.pfx 199 | !**/Cert/* 200 | *.publishsettings 201 | node_modules/ 202 | orleans.codegen.cs 203 | 204 | # RIA/Silverlight projects 205 | Generated_Code/ 206 | 207 | # Backup & report files from converting an old project file 208 | # to a newer Visual Studio version. Backup files are not needed, 209 | # because we have git ;-) 210 | _UpgradeReport_Files/ 211 | Backup*/ 212 | UpgradeLog*.XML 213 | UpgradeLog*.htm 214 | 215 | # SQL Server files 216 | *.mdf 217 | *.ldf 218 | 219 | # Business Intelligence projects 220 | *.rdl.data 221 | *.bim.layout 222 | *.bim_*.settings 223 | 224 | # Microsoft Fakes 225 | FakesAssemblies/ 226 | 227 | # Node.js Tools for Visual Studio 228 | .ntvs_analysis.dat 229 | 230 | # Visual Studio 6 build log 231 | *.plg 232 | 233 | # Visual Studio 6 workspace options file 234 | *.opt 235 | 236 | # LightSwitch generated files 237 | GeneratedArtifacts/ 238 | _Pvt_Extensions/ 239 | ModelManifest.xml 240 | 241 | *.dll.config 242 | *.40695406 243 | 244 | src/WebClient/ElevenQ.WebClient/ClientApp/components/Spinner/Spinner.css.d.ts 245 | 246 | #SqlServer Migration 247 | *.jfm 248 | *.cs.generated.d.ts 249 | 250 | src/Server/.vscode 251 | src/ElevenQ.Server.code-workspace 252 | 253 | # Mac desktop service store files 254 | .DS_Store 255 | 256 | TODO.md 257 | 258 | # Verify Tests 259 | *.received.* 260 | *.received/ 261 | 262 | # Cache 263 | .mono/* -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosWithCalendarTestsV1.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | using NTeoTestBuildeR.Modules.Todos.Core.Services; 4 | using TeoTests.Modules.TodosModule.Builder; 5 | using WireMock.Matchers; 6 | using WireMock.RequestBuilders; 7 | using WireMock.ResponseBuilders; 8 | using static System.DateTime; 9 | 10 | namespace TeoTests.Modules.TodosModule; 11 | 12 | public class TodosWithCalendarTestsV1 13 | { 14 | [Fact] 15 | public async Task GetCalendarTodos() 16 | { 17 | // act 18 | var actual = await new TodosTestBuilder() 19 | .WithWiremock(configure: GetCalendarEventsReturnsFewItems(), expectedCallCount: 1) 20 | .GetTodos(description: "Retrieve items from the calendar", tags: ["calendar-event"]) 21 | .Build(); 22 | 23 | // assert 24 | await Verify(actual); 25 | } 26 | 27 | [Fact] 28 | public async Task GetEmptyCalendarTodos() 29 | { 30 | // act 31 | var actual = await new TodosTestBuilder() 32 | .WithWiremock(GetCalendarEventsReturnsZeroItems()) 33 | .GetTodos(description: "Retrieve items from the calendar", tags: ["calendar-event"]) 34 | .Build(); 35 | 36 | // assert 37 | await Verify(actual); 38 | } 39 | 40 | [Fact] 41 | public async Task CreateCalendarTodo() 42 | { 43 | // arrange 44 | var eventName = "Daily stand up"; 45 | var calendarType = "todo-list"; 46 | var when = UtcNow.AddHours(24); 47 | 48 | // act 49 | var actual = await new TodosTestBuilder() 50 | .WithWiremock(AddCalendarEventWithCreatedStatus(eventName, calendarType, when)) 51 | .CreateTodo(description: "Create a to-do item in the calendar", 52 | eventName, tags: ["calendar-event", $"{when:O}"]) 53 | .Build(); 54 | 55 | // assert 56 | await Verify(actual); 57 | } 58 | 59 | [Fact] 60 | public async Task CreateCalendarTodoWentWrong() 61 | { 62 | // arrange 63 | var eventName = "Conf-call with some company pets"; 64 | var calendarType = "todo-list"; 65 | var when = UtcNow.AddHours(1); 66 | 67 | // act 68 | var actual = await new TodosTestBuilder() 69 | .WithWiremock(AddCalendarEventReturnsEmptyId(eventName, calendarType, when)) 70 | .CreateTodo(description: "Create a to-do item in the calendar that went wrong due to empty id", 71 | eventName, tags: ["calendar-event", $"{when:O}"]) 72 | .Build(); 73 | 74 | // assert 75 | await Verify(actual); 76 | } 77 | 78 | private static Action<(IRequestBuilder request, IResponseBuilder response)> GetCalendarEventsReturnsFewItems() => 79 | server => 80 | { 81 | server.request 82 | .WithPath("/calendar/events") 83 | .WithParam(key: "type", "todo-list") 84 | .UsingGet(); 85 | 86 | server.response 87 | .WithBody(JsonSerializer.Serialize(new CalendarClient.EventItemResponse[] 88 | { 89 | new(Id: Guid.NewGuid(), Name: "Daily stand up", Type: "todo-list", When: UtcNow.AddHours(-1)), 90 | new(Id: Guid.NewGuid(), Name: "Lunch", Type: "todo-list", When: UtcNow.AddMinutes(30)), 91 | new(Id: Guid.NewGuid(), Name: "Meeting", Type: "todo-list", When: UtcNow.AddHours(4)), 92 | new(Id: Guid.NewGuid(), Name: "Sprint review", Type: "todo-list", When: UtcNow.AddDays(2)), 93 | new(Id: Guid.NewGuid(), Name: "Finish app", Type: "todo-list", When: UtcNow.AddDays(120)) 94 | })) 95 | .WithStatusCode(HttpStatusCode.OK); 96 | }; 97 | 98 | private static Action<(IRequestBuilder request, IResponseBuilder response)> GetCalendarEventsReturnsZeroItems() => 99 | server => 100 | { 101 | server.request 102 | .WithPath("/calendar/events") 103 | .WithParam(key: "type", "todo-list") 104 | .UsingGet(); 105 | 106 | server.response 107 | .WithBody(JsonSerializer.Serialize(Array.Empty())) 108 | .WithStatusCode(HttpStatusCode.OK); 109 | }; 110 | 111 | private static Action<(IRequestBuilder request, IResponseBuilder response)> AddCalendarEventWithCreatedStatus( 112 | string eventName, string calendarType, DateTime when) => 113 | server => 114 | { 115 | server.request 116 | .WithPath("/calendar/events") 117 | .UsingPost() 118 | .WithBody(new ExactMatcher( 119 | ignoreCase: true, JsonSerializer.Serialize( 120 | new CalendarClient.EventRequest(eventName, calendarType, when)))); 121 | 122 | server.response 123 | .WithBody(JsonSerializer.Serialize( 124 | new CalendarClient.EventItemResponse(Id: Guid.NewGuid(), eventName, calendarType, when))) 125 | .WithStatusCode(HttpStatusCode.Created); 126 | }; 127 | 128 | private static Action<(IRequestBuilder request, IResponseBuilder response)> AddCalendarEventReturnsEmptyId( 129 | string eventName, string calendarType, DateTime when) => 130 | server => 131 | { 132 | server.request 133 | .WithPath("/calendar/events") 134 | .UsingPost() 135 | .WithBody(new ExactMatcher( 136 | ignoreCase: true, JsonSerializer.Serialize( 137 | new CalendarClient.EventRequest(eventName, calendarType, when)))); 138 | 139 | server.response 140 | .WithBody(JsonSerializer.Serialize( 141 | new CalendarClient.EventItemResponse(Guid.Empty, eventName, calendarType, when))) 142 | .WithStatusCode(HttpStatusCode.Created); 143 | }; 144 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/Builder/TodosTestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using Microsoft.AspNetCore.Http.Extensions; 3 | using NTeoTestBuildeR.Modules.Todos.Api; 4 | using TeoTests.Core; 5 | using TeoTests.Core.Verify; 6 | 7 | namespace TeoTests.Modules.TodosModule.Builder; 8 | 9 | internal sealed class TodosTestBuilder : TestBuilder 10 | { 11 | private readonly TodosTestState _state = new(); 12 | 13 | internal TodosTestBuilder CreateTodo(string description, string? title, string?[]? tags) 14 | { 15 | With(async () => 16 | { 17 | var requestPayload = new CreateTodo.Request(Title: title!, Tags: tags!); 18 | var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri: "/todos"); 19 | httpRequest.Content = JsonContent.Create(requestPayload); 20 | var httpResponse = await SendAsync(httpRequest); 21 | 22 | return await httpResponse.DeserializeWith(success: resultPayload => 23 | _state.Upsert(id: resultPayload!.Id.ToString(), title: title!, tags: tags!, done: false), 24 | description, httpRequest, requestPayload); 25 | }); 26 | return this; 27 | } 28 | 29 | internal TodosTestBuilder GetTodo(string description, string whichTitle) 30 | { 31 | With(async () => 32 | { 33 | var testCase = _state.SelectByTitle(whichTitle); 34 | var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri: $"/Todos/{testCase.Id}"); 35 | var httpResponse = await SendAsync(httpRequest); 36 | 37 | return await httpResponse.DeserializeWith(success: resultPayload => 38 | _state.Upsert(id: testCase.Id!, resultPayload!.Title, resultPayload.Tags, resultPayload.Done), 39 | description, httpRequest); 40 | }); 41 | return this; 42 | } 43 | 44 | internal TodosTestBuilder GetTodo(string description, Guid? id) 45 | { 46 | With(async () => 47 | { 48 | var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri: $"/todos/{id}"); 49 | var httpResponse = await SendAsync(httpRequest); 50 | return await httpResponse.Deserialize(description, httpRequest); 51 | }); 52 | return this; 53 | } 54 | 55 | internal TodosTestBuilder DoneTodo(string description, string whichTitle) 56 | { 57 | With(async () => 58 | { 59 | var testCase = _state.SelectByTitle(whichTitle); 60 | var httpRequest = new HttpRequestMessage(HttpMethod.Put, requestUri: $"/Todos/{testCase.Id}"); 61 | var requestPayload = new UpdateTodo.Request(Title: testCase.Title!, Tags: testCase.Tags!, Done: true); 62 | httpRequest.Content = JsonContent.Create(requestPayload); 63 | var httpResponse = await SendAsync(httpRequest); 64 | 65 | return await httpResponse.DeserializeWith(success: () => 66 | _state.Upsert(id: testCase.Id!, title: testCase.Title!, tags: testCase.Tags!, done: true), 67 | description, httpRequest, requestPayload); 68 | }); 69 | return this; 70 | } 71 | 72 | internal TodosTestBuilder ChangeTitle(string description, string oldTitle, string? newTitle) 73 | { 74 | With(async () => 75 | { 76 | var testCase = _state.SelectByTitle(oldTitle); 77 | var httpRequest = new HttpRequestMessage(HttpMethod.Put, requestUri: $"/Todos/{testCase.Id}"); 78 | var requestPayload = new UpdateTodo.Request(Title: newTitle!, Tags: testCase.Tags!, testCase.Done!.Value); 79 | httpRequest.Content = JsonContent.Create(requestPayload); 80 | var httpResponse = await SendAsync(httpRequest); 81 | 82 | return await httpResponse.DeserializeWith(success: () => 83 | _state.Upsert(id: testCase.Id!, title: newTitle!, tags: testCase.Tags!, testCase.Done!.Value), 84 | description, httpRequest, requestPayload); 85 | }); 86 | return this; 87 | } 88 | 89 | internal TodosTestBuilder ChangeTags(string description, string whichTitle, string?[]? newTags) 90 | { 91 | With(async () => 92 | { 93 | var testCase = _state.SelectByTitle(whichTitle); 94 | var httpRequest = new HttpRequestMessage(HttpMethod.Put, requestUri: $"/Todos/{testCase.Id}"); 95 | var requestPayload = new UpdateTodo.Request(Title: testCase.Title!, Tags: newTags!, testCase.Done!.Value); 96 | httpRequest.Content = JsonContent.Create(requestPayload); 97 | var httpResponse = await SendAsync(httpRequest); 98 | 99 | return await httpResponse.DeserializeWith(success: () => 100 | _state.Upsert(id: testCase.Id!, title: testCase.Title!, tags: newTags!, testCase.Done!.Value), 101 | description, httpRequest, requestPayload); 102 | }); 103 | return this; 104 | } 105 | 106 | internal TodosTestBuilder GetTodos(string description, string?[]? tags) 107 | { 108 | With(async () => 109 | { 110 | var query = new QueryBuilder {{"tags", tags!}}; 111 | var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri: $"/todos{query.ToQueryString()}"); 112 | var httpResponse = await SendAsync(httpRequest); 113 | 114 | return await httpResponse.DeserializeWith( 115 | success: resultPayload => 116 | { 117 | var sortedItems = resultPayload!.Todos 118 | .OrderBy(todo => todo.Title) 119 | .ToArray(); 120 | 121 | return Actual.Create(description, httpRequest, httpResponse, requestPayload: null, 122 | responsePayload: new GetTodos.Response(sortedItems)); 123 | }, description, httpRequest); 124 | }); 125 | return this; 126 | } 127 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosHappyPathTests.cs: -------------------------------------------------------------------------------- 1 | using TeoTests.Modules.TodosModule.Builder; 2 | 3 | namespace TeoTests.Modules.TodosModule; 4 | 5 | public class TodosHappyPathTests 6 | { 7 | [Fact] 8 | public async Task CreateTodo() 9 | { 10 | // act 11 | var testCase = "1D1E4128-31F3-4108-8CF6-C2E7F2E495BC"; 12 | var title = $"Prove Riemann's hypothesis {testCase}"; 13 | var tag = "match"; 14 | 15 | // act 16 | var actual = await new TodosTestBuilder() 17 | .CreateTodo(description: "Create a to-do item with success", title, tags: [tag]) 18 | .GetTodo(description: "Get already created to-do item", title) 19 | .Build(); 20 | 21 | // assert 22 | await Verify(actual); 23 | } 24 | 25 | [Fact] 26 | public async Task DoneTodo() 27 | { 28 | // assert 29 | var testCase = "1D1E4128-31F3-4108-8CF6-C2E7F2E495BC"; 30 | var title = $"Land on the moon {testCase}"; 31 | var tag = "astronomy"; 32 | 33 | // act 34 | var actual = await new TodosTestBuilder() 35 | .CreateTodo(description: "Set up a to-do", title, tags: [tag]) 36 | .GetTodo(description: "Retrieve already created to-do item", title) 37 | .DoneTodo(description: "Mark the to-do as done", title) 38 | .GetTodo(description: "Retrieve the to-do that has been done", title) 39 | .Build(); 40 | 41 | // assert 42 | await Verify(actual); 43 | } 44 | 45 | [Fact] 46 | public async Task ChangeTodoTitle() 47 | { 48 | // arrange 49 | var testCase = "FB4CBDC4-2E75-4B31-AD97-9AF922A6D24C"; 50 | var title = $"Land on the Mars {testCase}"; 51 | var newTitle = $"Terraform Mars {testCase}"; 52 | var tag = "astronomy"; 53 | 54 | // act 55 | var actual = await new TodosTestBuilder() 56 | .CreateTodo(description: "Set up a to-do", title, tags: [tag]) 57 | .GetTodo(description: "Retrieve already created to-do item", title) 58 | .ChangeTitle(description: "Change title from 'Land' to 'Terraform'", title, newTitle) 59 | .GetTodo(description: "Retrieve the to-do that has been changed", newTitle) 60 | .Build(); 61 | 62 | // assert 63 | await Verify(actual); 64 | } 65 | 66 | [Fact] 67 | public async Task ChangeTodoTags() 68 | { 69 | // arrange 70 | var testCase = "B1589CC6-5B47-428A-BF14-3A7B10D09B8B"; 71 | var title = $"Land on the Mars {testCase}"; 72 | string[] tags = ["astronomy"]; 73 | string[] newTags = ["astronomy", "practical"]; 74 | 75 | // act 76 | var actual = await new TodosTestBuilder() 77 | .CreateTodo(description: "Set up a to-do", title, tags) 78 | .GetTodo(description: "Retrieve already created to-do item", title) 79 | .ChangeTags(description: "Change tags by adding one more tag", title, newTags) 80 | .GetTodo(description: "Retrieve the to-do that has been changed", title) 81 | .Build(); 82 | 83 | // assert 84 | await Verify(actual); 85 | } 86 | 87 | [Fact] 88 | public async Task ChangeTagsAndMarkTodoAsDone() 89 | { 90 | // arrange 91 | var testCase = "A052551C-4577-4D84-9FFC-AA7227F11C54"; 92 | var theoryTitle = $"Define theory of everything {testCase}"; 93 | var flightTitle = $"Flight to Alpha Centauri {testCase}"; 94 | var astronomy = "astronomy"; 95 | var physics = "physics"; 96 | var theory = "theoretical"; 97 | var practice = "practical"; 98 | 99 | // act 100 | var actual = await new TodosTestBuilder() 101 | .CreateTodo(description: "Set up first theoretical to-do", theoryTitle, tags: [astronomy]) 102 | .CreateTodo(description: "Set up second practical to-do", flightTitle, tags: [astronomy]) 103 | .ChangeTags(description: "Change tags of the theory", theoryTitle, newTags: [physics, theory]) 104 | .ChangeTags(description: "Change tags of the practice", flightTitle, newTags: [astronomy, practice]) 105 | .DoneTodo(description: "Mark the theoretical to-do as done", theoryTitle) 106 | .GetTodo(description: "Retrieve the theoretical to-do that has been done", theoryTitle) 107 | .GetTodo(description: "Retrieve the practical that has not been done yet", flightTitle) 108 | .Build(); 109 | 110 | // assert 111 | await Verify(target: actual); 112 | } 113 | 114 | [Fact] 115 | public async Task GetTodosByTagsInQueryString() 116 | { 117 | // arrange 118 | var @case = "A658D7F2-5298-4145-A22F-404F71B10570"; 119 | var match = $"match-{@case}"; 120 | var physics = $"physics-{@case}"; 121 | var theory = $"theoretical-{@case}"; 122 | var exp = $"experimental-{@case}"; 123 | 124 | // act 125 | var actual = await new TodosTestBuilder() 126 | .CreateTodo( 127 | description: "Set up a math and theoretical", 128 | title: "Prove Riemann's hypothesis", tags: [match, theory]) 129 | .CreateTodo( 130 | description: "Set up a physics and theoretical", 131 | title: "Define theory of everything", tags: [physics, theory]) 132 | .CreateTodo( 133 | description: "Set up a physics and experimental", 134 | title: "Conduct a measurement of the gravitational wave", tags: [physics, exp]) 135 | .GetTodos( 136 | description: "Should only retrieve one item with math", tags: [match]) 137 | .GetTodos( 138 | description: "Should retrieve two items with physics", tags: [physics]) 139 | .GetTodos( 140 | description: "Should retrieve two items with theoretical", tags: [theory]) 141 | .GetTodos( 142 | description: "Should retrieve one item with experimental", tags: [exp]) 143 | .GetTodos( 144 | description: "Should retrieve one item with both math and theoretical", tags: [match, theory]) 145 | .GetTodos( 146 | description: "Should retrieve one item with both physics and theoretical", tags: [physics, theory]) 147 | .GetTodos( 148 | description: "Should retrieve one item with both physics and experimental", tags: [physics, exp]) 149 | .Build(); 150 | 151 | // assert 152 | await Verify(target: actual); 153 | } 154 | } -------------------------------------------------------------------------------- /src/TeoTodoApp/Modules/Todos/Core/Services/TodosService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.Caching.Memory; 3 | using NTeoTestBuildeR.Infra.ErrorHandling.Exceptions; 4 | using NTeoTestBuildeR.Modules.Todos.Api; 5 | using NTeoTestBuildeR.Modules.Todos.Core.DAL; 6 | using NTeoTestBuildeR.Modules.Todos.Core.Exceptions; 7 | 8 | namespace NTeoTestBuildeR.Modules.Todos.Core.Services; 9 | 10 | public sealed class TodosService( 11 | TeoAppDbContext db, 12 | CalendarClient calendarClient, 13 | IMemoryCache cache, 14 | StatsClient statsClient) 15 | { 16 | public async Task Create(CreateTodo cmd) 17 | { 18 | ValidateArgument(detail: "Invalid argument for creating a new todo", with: exception => 19 | { 20 | if (string.IsNullOrWhiteSpace(cmd.Dto.Title)) 21 | exception.WithError(code: $"{nameof(cmd.Dto.Title)}", 22 | values: ["Title is required, cannot be empty or white spaces"]); 23 | 24 | if (cmd.Dto.Tags.Length == 0) 25 | exception.WithError(code: $"{nameof(cmd.Dto.Tags)}", 26 | values: ["At least one tag is required"]); 27 | 28 | if (cmd.Dto.Tags.Any(tag => string.IsNullOrWhiteSpace(tag) || tag.Contains(' '))) 29 | exception.WithError(code: $"{nameof(cmd.Dto.Tags)}", 30 | values: ["Tags cannot be empty or contain spaces"]); 31 | }); 32 | 33 | if (cmd.Dto.Tags.Contains("calendar-event")) 34 | { 35 | ValidateArgument(detail: "Invalid additional tag argument for calendar-event tag", with: exception => 36 | { 37 | if (cmd.Dto.Tags.Length != 2) 38 | exception.WithError(code: $"{nameof(cmd.Dto.Tags)}", values: 39 | [ 40 | "Only two tags are allowed for calendar events, " + 41 | "first tag must be 'calendar-event' and the second tag must be a time tag" 42 | ]); 43 | else if (DateTime.TryParse(s: cmd.Dto.Tags[1], out _) == false) 44 | exception.WithError(code: $"{nameof(cmd.Dto.Tags)}", values: 45 | [ 46 | $"Second tag must be a valid date time but is '{cmd.Dto.Tags[1]}'" 47 | ]); 48 | }); 49 | 50 | var when = DateTime.Parse(cmd.Dto.Tags[1]).ToUniversalTime(); 51 | var createdEvent = await calendarClient.CreateEvent(cmd.Dto.Title, when); 52 | return new(createdEvent.Id); 53 | } 54 | 55 | var id = Guid.NewGuid(); 56 | await db.Todos.AddAsync(new() 57 | { 58 | Id = id, 59 | Title = cmd.Dto.Title, 60 | Tags = new() {Tags = cmd.Dto.Tags}, 61 | Done = false 62 | }); 63 | await db.SaveChangesAsync(); 64 | 65 | foreach (var dtoTag in cmd.Dto.Tags) 66 | await statsClient.AddStats(new(dtoTag)); 67 | 68 | return new(id); 69 | } 70 | 71 | public async Task Update(UpdateTodo cmd) 72 | { 73 | ValidateArgument(detail: "Invalid argument for updating existing todo", with: exception => 74 | { 75 | if (cmd.Dto.Tags.Contains("calendar-event")) 76 | exception.WithError(code: $"{nameof(cmd.Dto.Tags)}", 77 | values: ["Update calendar events is not not supported, use the calendar API instead"]); 78 | 79 | if (string.IsNullOrWhiteSpace(cmd.Dto.Title)) 80 | exception.WithError(code: $"{nameof(cmd.Dto.Title)}", 81 | values: ["Title is required, cannot be empty or white spaces"]); 82 | 83 | if (cmd.Dto.Tags.Length == 0) 84 | exception.WithError(code: $"{nameof(cmd.Dto.Tags)}", 85 | values: ["At least one tag is required"]); 86 | 87 | if (cmd.Dto.Tags.Any(tag => string.IsNullOrWhiteSpace(tag) || tag.Contains(' '))) 88 | exception.WithError(code: $"{nameof(cmd.Dto.Tags)}", 89 | values: ["Tags cannot be empty or contain spaces"]); 90 | }); 91 | 92 | var todo = await db.Todos 93 | .SingleOrDefaultAsync(item => item.Id == cmd.Id); 94 | 95 | if (todo is null) 96 | throw new TodoNotFoundException($"Todo with ID {cmd.Id} not found"); 97 | 98 | if (todo.Done) 99 | throw new TodoAlreadyDoneException("Cannot update a todo that is already done"); 100 | 101 | var oldTags = todo.Tags.Tags; 102 | var newTags = cmd.Dto.Tags; 103 | todo.Title = cmd.Dto.Title; 104 | todo.Tags = new() {Tags = newTags}; 105 | todo.Done = cmd.Dto.Done; 106 | 107 | await db.SaveChangesAsync(); 108 | 109 | var isOldAndNewTagsEqual = oldTags.Length == newTags.Length && oldTags.All(oldTag => newTags.Contains(oldTag)); 110 | if (!isOldAndNewTagsEqual) 111 | { 112 | foreach (var oldTag in oldTags) 113 | await statsClient.RemoveStats(new(oldTag)); 114 | 115 | foreach (var newTag in newTags) 116 | await statsClient.AddStats(new(newTag)); 117 | } 118 | } 119 | 120 | public async Task GetTodo(GetTodo query) 121 | { 122 | var todo = await db.Todos 123 | .AsNoTracking() 124 | .SingleOrDefaultAsync(item => item.Id == query.Dto.Id); 125 | 126 | if (todo is not null) 127 | return new(todo.Title, todo.Tags.Tags, todo.Done); 128 | 129 | var cachedEvent = cache.Get(query.Dto.Id); 130 | if (cachedEvent is not null) 131 | return cachedEvent; 132 | 133 | var @event = await calendarClient.GetEvent(query.Dto.Id); 134 | if (@event is null) 135 | throw new TodoNotFoundException($"Todo with ID {query.Dto.Id} not found"); 136 | 137 | var mapped = Map(eventItem: (query.Dto.Id, @event.Name, @event.Type, @event.When), DateTime.UtcNow); 138 | var result = new GetTodo.Response(mapped.Title, mapped.Tags, mapped.Done); 139 | return cache.Set(query.Dto.Id, result); 140 | } 141 | 142 | public async Task GetTodos(GetTodos.Query query) 143 | { 144 | if (query.Tags.Contains("calendar-event") && query.Tags.Length > 1) 145 | throw new AppArgumentException("Cannot mix calendar events with other to-do items"); 146 | 147 | if (query.Tags.Contains("calendar-event")) 148 | { 149 | var now = DateTime.UtcNow; 150 | 151 | var events = await calendarClient.GetEvents(); 152 | return new(events 153 | .Select(@event => 154 | { 155 | var item = Map(eventItem: (@event.Id, @event.Name, @event.Type, @event.When), now); 156 | return new GetTodos.Item(item.Id, item.Title, Done: item.Done, 157 | Tags: item.Tags.Select(tag => new GetTodos.Tag(tag, Count: null)).ToArray()); 158 | }) 159 | .ToArray()); 160 | } 161 | 162 | var todos = await db.Todos 163 | .AsNoTracking() 164 | .Where(todo => query.Tags.All(queryTag => todo.Tags.Tags.Contains(queryTag))) 165 | .ToListAsync(); 166 | 167 | var uniqueTags = todos 168 | .SelectMany(todo => todo.Tags.Tags) 169 | .ToArray(); 170 | 171 | var statsTags = await statsClient.GetStats(uniqueTags); 172 | 173 | return new(todos 174 | .Select(todo => 175 | { 176 | return new GetTodos.Item(todo.Id, todo.Title, Done: todo.Done, 177 | Tags: todo.Tags.Tags.Select(tag => 178 | { 179 | var stat = statsTags.Stats.Single(stat => stat.Tag == tag); 180 | return new GetTodos.Tag(tag, stat.Count); 181 | }).ToArray()); 182 | }) 183 | .ToArray()); 184 | } 185 | 186 | private static (Guid Id, string Title, string[] Tags, bool Done) Map( 187 | (Guid Id, string Name, string Type, DateTime When) eventItem, DateTime now) 188 | { 189 | if (eventItem.When < now) 190 | return (eventItem.Id, eventItem.Name, 191 | Tags: ["calendar-event"], Done: true); 192 | 193 | if (eventItem.When < now.AddHours(1)) 194 | return (eventItem.Id, eventItem.Name, 195 | Tags: ["calendar-event", "in-an-hour"], Done: false); 196 | 197 | if (eventItem.When < now.AddHours(24)) 198 | return (eventItem.Id, eventItem.Name, 199 | Tags: ["calendar-event", "in-a-day"], Done: false); 200 | 201 | if (eventItem.When < now.AddDays(7)) 202 | return (eventItem.Id, eventItem.Name, 203 | Tags: ["calendar-event", "in-a-week"], Done: false); 204 | 205 | return (eventItem.Id, eventItem.Name, 206 | Tags: ["calendar-event", "someday"], Done: false); 207 | } 208 | 209 | private static void ValidateArgument(string detail, Action with) 210 | { 211 | var exception = new InvalidTodoAppArgumentException(detail); 212 | 213 | with(exception); 214 | 215 | if (exception.HasErrors) 216 | throw exception; 217 | } 218 | } -------------------------------------------------------------------------------- /tests/TeoTests/Modules/TodosModule/TodosHappyPathTests.GetTodosByTagsInQueryString.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Set up a math and theoretical", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/todos", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | }, 12 | "Payload": { 13 | "Title": "Prove Riemann's hypothesis", 14 | "Tags": [ 15 | "match-Guid_1", 16 | "theoretical-Guid_1" 17 | ] 18 | } 19 | }, 20 | "Response": { 21 | "StatusCode": "Created", 22 | "Headers": { 23 | "Location": [ 24 | "/Todos/Guid_2" 25 | ] 26 | }, 27 | "Payload": { 28 | "Id": "Guid_2" 29 | } 30 | } 31 | }, 32 | { 33 | "Description": "Set up a physics and theoretical", 34 | "Request": { 35 | "Method": { 36 | "Method": "POST" 37 | }, 38 | "Path": "http://localhost/todos", 39 | "Headers": { 40 | "traceparent": "{Scrubbed}" 41 | }, 42 | "Payload": { 43 | "Title": "Define theory of everything", 44 | "Tags": [ 45 | "physics-Guid_1", 46 | "theoretical-Guid_1" 47 | ] 48 | } 49 | }, 50 | "Response": { 51 | "StatusCode": "Created", 52 | "Headers": { 53 | "Location": [ 54 | "/Todos/Guid_3" 55 | ] 56 | }, 57 | "Payload": { 58 | "Id": "Guid_3" 59 | } 60 | } 61 | }, 62 | { 63 | "Description": "Set up a physics and experimental", 64 | "Request": { 65 | "Method": { 66 | "Method": "POST" 67 | }, 68 | "Path": "http://localhost/todos", 69 | "Headers": { 70 | "traceparent": "{Scrubbed}" 71 | }, 72 | "Payload": { 73 | "Title": "Conduct a measurement of the gravitational wave", 74 | "Tags": [ 75 | "physics-Guid_1", 76 | "experimental-Guid_1" 77 | ] 78 | } 79 | }, 80 | "Response": { 81 | "StatusCode": "Created", 82 | "Headers": { 83 | "Location": [ 84 | "/Todos/Guid_4" 85 | ] 86 | }, 87 | "Payload": { 88 | "Id": "Guid_4" 89 | } 90 | } 91 | }, 92 | { 93 | "Description": "Should only retrieve one item with math", 94 | "Request": { 95 | "Method": { 96 | "Method": "GET" 97 | }, 98 | "Path": "http://localhost/todos?tags=match-Guid_1", 99 | "Headers": { 100 | "traceparent": "{Scrubbed}" 101 | } 102 | }, 103 | "Response": { 104 | "StatusCode": "OK", 105 | "Payload": { 106 | "Todos": [ 107 | { 108 | "Id": "Guid_2", 109 | "Title": "Prove Riemann's hypothesis", 110 | "Tags": [ 111 | { 112 | "Name": "match-Guid_1", 113 | "Count": 1 114 | }, 115 | { 116 | "Name": "theoretical-Guid_1", 117 | "Count": 2 118 | } 119 | ], 120 | "Done": false 121 | } 122 | ] 123 | } 124 | } 125 | }, 126 | { 127 | "Description": "Should retrieve two items with physics", 128 | "Request": { 129 | "Method": { 130 | "Method": "GET" 131 | }, 132 | "Path": "http://localhost/todos?tags=physics-Guid_1", 133 | "Headers": { 134 | "traceparent": "{Scrubbed}" 135 | } 136 | }, 137 | "Response": { 138 | "StatusCode": "OK", 139 | "Payload": { 140 | "Todos": [ 141 | { 142 | "Id": "Guid_4", 143 | "Title": "Conduct a measurement of the gravitational wave", 144 | "Tags": [ 145 | { 146 | "Name": "physics-Guid_1", 147 | "Count": 2 148 | }, 149 | { 150 | "Name": "experimental-Guid_1", 151 | "Count": 1 152 | } 153 | ], 154 | "Done": false 155 | }, 156 | { 157 | "Id": "Guid_3", 158 | "Title": "Define theory of everything", 159 | "Tags": [ 160 | { 161 | "Name": "physics-Guid_1", 162 | "Count": 2 163 | }, 164 | { 165 | "Name": "theoretical-Guid_1", 166 | "Count": 2 167 | } 168 | ], 169 | "Done": false 170 | } 171 | ] 172 | } 173 | } 174 | }, 175 | { 176 | "Description": "Should retrieve two items with theoretical", 177 | "Request": { 178 | "Method": { 179 | "Method": "GET" 180 | }, 181 | "Path": "http://localhost/todos?tags=theoretical-Guid_1", 182 | "Headers": { 183 | "traceparent": "{Scrubbed}" 184 | } 185 | }, 186 | "Response": { 187 | "StatusCode": "OK", 188 | "Payload": { 189 | "Todos": [ 190 | { 191 | "Id": "Guid_3", 192 | "Title": "Define theory of everything", 193 | "Tags": [ 194 | { 195 | "Name": "physics-Guid_1", 196 | "Count": 2 197 | }, 198 | { 199 | "Name": "theoretical-Guid_1", 200 | "Count": 2 201 | } 202 | ], 203 | "Done": false 204 | }, 205 | { 206 | "Id": "Guid_2", 207 | "Title": "Prove Riemann's hypothesis", 208 | "Tags": [ 209 | { 210 | "Name": "match-Guid_1", 211 | "Count": 1 212 | }, 213 | { 214 | "Name": "theoretical-Guid_1", 215 | "Count": 2 216 | } 217 | ], 218 | "Done": false 219 | } 220 | ] 221 | } 222 | } 223 | }, 224 | { 225 | "Description": "Should retrieve one item with experimental", 226 | "Request": { 227 | "Method": { 228 | "Method": "GET" 229 | }, 230 | "Path": "http://localhost/todos?tags=experimental-Guid_1", 231 | "Headers": { 232 | "traceparent": "{Scrubbed}" 233 | } 234 | }, 235 | "Response": { 236 | "StatusCode": "OK", 237 | "Payload": { 238 | "Todos": [ 239 | { 240 | "Id": "Guid_4", 241 | "Title": "Conduct a measurement of the gravitational wave", 242 | "Tags": [ 243 | { 244 | "Name": "physics-Guid_1", 245 | "Count": 2 246 | }, 247 | { 248 | "Name": "experimental-Guid_1", 249 | "Count": 1 250 | } 251 | ], 252 | "Done": false 253 | } 254 | ] 255 | } 256 | } 257 | }, 258 | { 259 | "Description": "Should retrieve one item with both math and theoretical", 260 | "Request": { 261 | "Method": { 262 | "Method": "GET" 263 | }, 264 | "Path": "http://localhost/todos?tags=match-Guid_1&tags=theoretical-Guid_1", 265 | "Headers": { 266 | "traceparent": "{Scrubbed}" 267 | } 268 | }, 269 | "Response": { 270 | "StatusCode": "OK", 271 | "Payload": { 272 | "Todos": [ 273 | { 274 | "Id": "Guid_2", 275 | "Title": "Prove Riemann's hypothesis", 276 | "Tags": [ 277 | { 278 | "Name": "match-Guid_1", 279 | "Count": 1 280 | }, 281 | { 282 | "Name": "theoretical-Guid_1", 283 | "Count": 2 284 | } 285 | ], 286 | "Done": false 287 | } 288 | ] 289 | } 290 | } 291 | }, 292 | { 293 | "Description": "Should retrieve one item with both physics and theoretical", 294 | "Request": { 295 | "Method": { 296 | "Method": "GET" 297 | }, 298 | "Path": "http://localhost/todos?tags=physics-Guid_1&tags=theoretical-Guid_1", 299 | "Headers": { 300 | "traceparent": "{Scrubbed}" 301 | } 302 | }, 303 | "Response": { 304 | "StatusCode": "OK", 305 | "Payload": { 306 | "Todos": [ 307 | { 308 | "Id": "Guid_3", 309 | "Title": "Define theory of everything", 310 | "Tags": [ 311 | { 312 | "Name": "physics-Guid_1", 313 | "Count": 2 314 | }, 315 | { 316 | "Name": "theoretical-Guid_1", 317 | "Count": 2 318 | } 319 | ], 320 | "Done": false 321 | } 322 | ] 323 | } 324 | } 325 | }, 326 | { 327 | "Description": "Should retrieve one item with both physics and experimental", 328 | "Request": { 329 | "Method": { 330 | "Method": "GET" 331 | }, 332 | "Path": "http://localhost/todos?tags=physics-Guid_1&tags=experimental-Guid_1", 333 | "Headers": { 334 | "traceparent": "{Scrubbed}" 335 | } 336 | }, 337 | "Response": { 338 | "StatusCode": "OK", 339 | "Payload": { 340 | "Todos": [ 341 | { 342 | "Id": "Guid_4", 343 | "Title": "Conduct a measurement of the gravitational wave", 344 | "Tags": [ 345 | { 346 | "Name": "physics-Guid_1", 347 | "Count": 2 348 | }, 349 | { 350 | "Name": "experimental-Guid_1", 351 | "Count": 1 352 | } 353 | ], 354 | "Done": false 355 | } 356 | ] 357 | } 358 | } 359 | } 360 | ] -------------------------------------------------------------------------------- /tests/TeoTests/Modules/StatsModule/StatsAppTests.CollectAndGetStatistics.verified.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Description": "Collect first work tag", 4 | "Request": { 5 | "Method": { 6 | "Method": "POST" 7 | }, 8 | "Path": "http://localhost/stats", 9 | "Headers": { 10 | "traceparent": "{Scrubbed}" 11 | } 12 | }, 13 | "Response": { 14 | "StatusCode": "NoContent" 15 | } 16 | }, 17 | { 18 | "Description": "Collect second work tag", 19 | "Request": { 20 | "Method": { 21 | "Method": "POST" 22 | }, 23 | "Path": "http://localhost/stats", 24 | "Headers": { 25 | "traceparent": "{Scrubbed}" 26 | } 27 | }, 28 | "Response": { 29 | "StatusCode": "NoContent" 30 | } 31 | }, 32 | { 33 | "Description": "Collect one taxes tag", 34 | "Request": { 35 | "Method": { 36 | "Method": "POST" 37 | }, 38 | "Path": "http://localhost/stats", 39 | "Headers": { 40 | "traceparent": "{Scrubbed}" 41 | } 42 | }, 43 | "Response": { 44 | "StatusCode": "NoContent" 45 | } 46 | }, 47 | { 48 | "Description": "Collect one holiday tag", 49 | "Request": { 50 | "Method": { 51 | "Method": "POST" 52 | }, 53 | "Path": "http://localhost/stats", 54 | "Headers": { 55 | "traceparent": "{Scrubbed}" 56 | } 57 | }, 58 | "Response": { 59 | "StatusCode": "NoContent" 60 | } 61 | }, 62 | { 63 | "Description": "Collect first car tag", 64 | "Request": { 65 | "Method": { 66 | "Method": "POST" 67 | }, 68 | "Path": "http://localhost/stats", 69 | "Headers": { 70 | "traceparent": "{Scrubbed}" 71 | } 72 | }, 73 | "Response": { 74 | "StatusCode": "NoContent" 75 | } 76 | }, 77 | { 78 | "Description": "Collect second car tag", 79 | "Request": { 80 | "Method": { 81 | "Method": "POST" 82 | }, 83 | "Path": "http://localhost/stats", 84 | "Headers": { 85 | "traceparent": "{Scrubbed}" 86 | } 87 | }, 88 | "Response": { 89 | "StatusCode": "NoContent" 90 | } 91 | }, 92 | { 93 | "Description": "Retrieve work stats", 94 | "Request": { 95 | "Method": { 96 | "Method": "GET" 97 | }, 98 | "Path": "http://localhost/stats?tags=work", 99 | "Headers": { 100 | "traceparent": "{Scrubbed}" 101 | } 102 | }, 103 | "Response": { 104 | "StatusCode": "OK", 105 | "Payload": { 106 | "stats": [ 107 | { 108 | "tag": "work", 109 | "count": 2 110 | } 111 | ] 112 | } 113 | } 114 | }, 115 | { 116 | "Description": "Retrieve taxes stats", 117 | "Request": { 118 | "Method": { 119 | "Method": "GET" 120 | }, 121 | "Path": "http://localhost/stats?tags=taxes", 122 | "Headers": { 123 | "traceparent": "{Scrubbed}" 124 | } 125 | }, 126 | "Response": { 127 | "StatusCode": "OK", 128 | "Payload": { 129 | "stats": [ 130 | { 131 | "tag": "taxes", 132 | "count": 1 133 | } 134 | ] 135 | } 136 | } 137 | }, 138 | { 139 | "Description": "Retrieve car stats", 140 | "Request": { 141 | "Method": { 142 | "Method": "GET" 143 | }, 144 | "Path": "http://localhost/stats?tags=car", 145 | "Headers": { 146 | "traceparent": "{Scrubbed}" 147 | } 148 | }, 149 | "Response": { 150 | "StatusCode": "OK", 151 | "Payload": { 152 | "stats": [ 153 | { 154 | "tag": "car", 155 | "count": 2 156 | } 157 | ] 158 | } 159 | } 160 | }, 161 | { 162 | "Description": "Retrieve holiday stats", 163 | "Request": { 164 | "Method": { 165 | "Method": "GET" 166 | }, 167 | "Path": "http://localhost/stats?tags=holiday", 168 | "Headers": { 169 | "traceparent": "{Scrubbed}" 170 | } 171 | }, 172 | "Response": { 173 | "StatusCode": "OK", 174 | "Payload": { 175 | "stats": [ 176 | { 177 | "tag": "holiday", 178 | "count": 1 179 | } 180 | ] 181 | } 182 | } 183 | }, 184 | { 185 | "Description": "Retrieve work and car stats", 186 | "Request": { 187 | "Method": { 188 | "Method": "GET" 189 | }, 190 | "Path": "http://localhost/stats?tags=work&tags=car", 191 | "Headers": { 192 | "traceparent": "{Scrubbed}" 193 | } 194 | }, 195 | "Response": { 196 | "StatusCode": "OK", 197 | "Payload": { 198 | "stats": [ 199 | { 200 | "tag": "work", 201 | "count": 2 202 | }, 203 | { 204 | "tag": "car", 205 | "count": 2 206 | } 207 | ] 208 | } 209 | } 210 | }, 211 | { 212 | "Description": "Retrieve work and taxes stats", 213 | "Request": { 214 | "Method": { 215 | "Method": "GET" 216 | }, 217 | "Path": "http://localhost/stats?tags=work&tags=taxes", 218 | "Headers": { 219 | "traceparent": "{Scrubbed}" 220 | } 221 | }, 222 | "Response": { 223 | "StatusCode": "OK", 224 | "Payload": { 225 | "stats": [ 226 | { 227 | "tag": "work", 228 | "count": 2 229 | }, 230 | { 231 | "tag": "taxes", 232 | "count": 1 233 | } 234 | ] 235 | } 236 | } 237 | }, 238 | { 239 | "Description": "Retrieve work, car, and taxes stats", 240 | "Request": { 241 | "Method": { 242 | "Method": "GET" 243 | }, 244 | "Path": "http://localhost/stats?tags=work&tags=car&tags=taxes", 245 | "Headers": { 246 | "traceparent": "{Scrubbed}" 247 | } 248 | }, 249 | "Response": { 250 | "StatusCode": "OK", 251 | "Payload": { 252 | "stats": [ 253 | { 254 | "tag": "work", 255 | "count": 2 256 | }, 257 | { 258 | "tag": "taxes", 259 | "count": 1 260 | }, 261 | { 262 | "tag": "car", 263 | "count": 2 264 | } 265 | ] 266 | } 267 | } 268 | }, 269 | { 270 | "Description": "Retrieve all stats", 271 | "Request": { 272 | "Method": { 273 | "Method": "GET" 274 | }, 275 | "Path": "http://localhost/stats?tags=work&tags=car&tags=taxes&tags=holiday", 276 | "Headers": { 277 | "traceparent": "{Scrubbed}" 278 | } 279 | }, 280 | "Response": { 281 | "StatusCode": "OK", 282 | "Payload": { 283 | "stats": [ 284 | { 285 | "tag": "work", 286 | "count": 2 287 | }, 288 | { 289 | "tag": "taxes", 290 | "count": 1 291 | }, 292 | { 293 | "tag": "holiday", 294 | "count": 1 295 | }, 296 | { 297 | "tag": "car", 298 | "count": 2 299 | } 300 | ] 301 | } 302 | } 303 | }, 304 | { 305 | "Description": "Retrieve all stats", 306 | "Request": { 307 | "Method": { 308 | "Method": "GET" 309 | }, 310 | "Path": "http://localhost/stats?tags=work&tags=car&tags=taxes&tags=holiday", 311 | "Headers": { 312 | "traceparent": "{Scrubbed}" 313 | } 314 | }, 315 | "Response": { 316 | "StatusCode": "OK", 317 | "Payload": { 318 | "stats": [ 319 | { 320 | "tag": "work", 321 | "count": 2 322 | }, 323 | { 324 | "tag": "taxes", 325 | "count": 1 326 | }, 327 | { 328 | "tag": "holiday", 329 | "count": 1 330 | }, 331 | { 332 | "tag": "car", 333 | "count": 2 334 | } 335 | ] 336 | } 337 | } 338 | }, 339 | { 340 | "Description": "Remove first work tag", 341 | "Request": { 342 | "Method": { 343 | "Method": "DELETE" 344 | }, 345 | "Path": "http://localhost/stats/work", 346 | "Headers": { 347 | "traceparent": "{Scrubbed}" 348 | } 349 | }, 350 | "Response": { 351 | "StatusCode": "NoContent" 352 | } 353 | }, 354 | { 355 | "Description": "Retrieve work tag (expected value is one)", 356 | "Request": { 357 | "Method": { 358 | "Method": "GET" 359 | }, 360 | "Path": "http://localhost/stats?tags=work", 361 | "Headers": { 362 | "traceparent": "{Scrubbed}" 363 | } 364 | }, 365 | "Response": { 366 | "StatusCode": "OK", 367 | "Payload": { 368 | "stats": [ 369 | { 370 | "tag": "work", 371 | "count": 1 372 | } 373 | ] 374 | } 375 | } 376 | }, 377 | { 378 | "Description": "Remove second work tag", 379 | "Request": { 380 | "Method": { 381 | "Method": "DELETE" 382 | }, 383 | "Path": "http://localhost/stats/work", 384 | "Headers": { 385 | "traceparent": "{Scrubbed}" 386 | } 387 | }, 388 | "Response": { 389 | "StatusCode": "NoContent" 390 | } 391 | }, 392 | { 393 | "Description": "Retrieve work tag (expected value is zero)", 394 | "Request": { 395 | "Method": { 396 | "Method": "GET" 397 | }, 398 | "Path": "http://localhost/stats?tags=work", 399 | "Headers": { 400 | "traceparent": "{Scrubbed}" 401 | } 402 | }, 403 | "Response": { 404 | "StatusCode": "OK", 405 | "Payload": { 406 | "stats": [ 407 | { 408 | "tag": "work", 409 | "count": 0 410 | } 411 | ] 412 | } 413 | } 414 | } 415 | ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NTeoTestBuildeR 2 | 3 | The repository was created for the talk I gave at the Krakow .NET Developers Group. The meeting took place on 17 April 2024 at 18:00 in the HEVRE club. For more details, check out our [KGD .NET Meetup Event](https://www.meetup.com/pl-PL/kgd-net/events/300191480/). 4 | 5 | ## Blog post 6 | 7 | > This [Blog Post](https://teo-vincent.blog/2024/05/19/%f0%9f%94%8a-asp-net-core-verify-wiremock-testcontainer-test-builder/) is a follow-up to the talk I gave at [KGD .NET](https://youtu.be/rAW89eaL0kQ). I have included everything I was unable to say during the talk. For those who were not able to attend live, I strongly recommend watching the recording of the talk after reading this article. This is the correct order. For those who attended the meeting, the post provides the missing introductory section, which can be crucial to fully understanding and motivating the solution presented. Please check out that blog post: [ASP .NET Core + Verify + Wiremock + Testcontainer + Test Builder](https://teo-vincent.blog/2024/05/19/%f0%9f%94%8a-asp-net-core-verify-wiremock-testcontainer-test-builder/) 8 | 9 | ## YouTube - 149th Meeting of the Krakow .NET User Group 10 | 11 | > On April 17, 2024, at 6:00 PM, at the HEVRE club, I delivered my first public presentation on [ASP .NET Core + Verify + Wiremock + Testcontainers + Test Builder](https://youtu.be/rAW89eaL0kQ). 12 | 13 | # To-do list 14 | 15 | The main goal of this repository is to present a test builder that makes it easy to write application tests. The strength of this repository lies in the test project, where a convenient test builder has been meticulously crafted. This builder seamlessly integrates technologies such as ASP.NET Core, Verify, Wiremock, Testcontainer and Test Builder, which together provide significant power and accelerate the writing of tests in a proper manner. 16 | 17 | > The To-Do List application provided here is a simple implementation, deliberately kept simple to facilitate understanding of the test engine we use. Its purpose is to facilitate the demonstration of our testing capabilities, rather than being the main focus of this repository. 18 | 19 | # 🔊 ASP .NET Core + Verify + Wiremock + Testcontainer + Test Builder 20 | 21 | Verify provides convenient assertion building based on snapshot testing. Wiremock effectively emulates third party API interfaces. Testcontainers simplifies database management. Test Builder is a sophisticated solution that integrates these tools into a cohesive whole, maximising the efficiency and clarity of the testing process. I provided a comprehensive solution that prevented the application from being cemented with mocks at lower layers. I ensured that the application run for testing purposes was executed in its entirety, exactly as it would be in production, without modifying the IoC container using mocks. This solution allows us to avoid the slowdown caused by having to adapt old test mocks to the new code. 22 | 23 | ## Commits Step by Step 24 | 25 | The code in the repository is structured to be able to navigate through commits one by one, progressing further and addressing new challenges while adding new functionalities to the test builder. Let's start by checking out all the commits step by step and comparing the differences in the code. 26 | 27 | --- 28 | 29 | Presentation of the first very simple TODO list application, still without payload validation. 30 | 31 | > $ git checkout 601c75f20396aa391d87e6a51eb7f5f31500f9e7 Simple TODO-list App Intro, No Payload Validation Yet 32 | 33 | --- 34 | The initial version of TestBuilder, which already includes a list of actions but operates on raw HttpResponseMessage objects. 35 | 36 | > $ git checkout 183f9047d03cbc4c503b1cbe92c7cac9c502ebb0 Initial version of TestBuilder, working with basic HttpResponseMessage 37 | 38 | --- 39 | A quick introduction to Verify (still separate from the TODO-list application) and how to set up the Verify. 40 | 41 | > $ git checkout fa333f63cf7b00218036ca3c0af790ee571906cf Quick introduction to Verify 42 | 43 | --- 44 | First use of Verify to test the app, but still a bit rough without deserializing the HttpResponseMessage. Either we don't do deserialization at all, or we deserialize the payload in the test and put it into Verify. The implementation is simple, too simple, lacking all the information. 45 | 46 | > $ git checkout c166e9ee08763eb6ef50c4ca345ea9c1f440391b First use of Verify for app testing, without HttpResponseMessage deserialisation yet 47 | 48 | --- 49 | We add the deserialisation of HttpResponseMessage at the Test Builder level, defining a helper object in which we enclose all the input and output arguments. We then pass this helper object to Verify. 50 | 51 | > $ git checkout baa9d55fde2e3263e2f5f0e964bcb8c6b1c576ae Define input and output arguments in a object for Verify 52 | 53 | --- 54 | I'm adding more happy path tests - laying the groundwork for the next scenario when I want to introduce a state object. 55 | 56 | > $ git checkout e5a7460aedf0aa95cf834c5a543ee69bddfb652d Change Title, Tags and Done To-Do Item Tests 57 | 58 | --- 59 | A complicated test that requires having the concept of a smarter state object. 60 | 61 | > $ git checkout e19b31dbfb3dfe4746473770e35b4b9a9e5f9f39 Complex test requiring smarter state object concept 62 | 63 | --- 64 | We're adding validation and including validation tests (naive implementation with repetitions at first, followed by a clever one without repetitions using extensions). 65 | 66 | > $ git checkout 8f6d54e4c4072187ff408e0e334664aaa16ae612 Validation and included validation tests (naive implementation with repetitions) 67 | 68 | > $ git checkout 073048b88dfaa03548f7c8dd6b6d6f9d163996b8 Smart extensions for mapping success or error for Verify 69 | 70 | --- 71 | Switching to a real database and adapting Testcontainers (EF, Postgres, Docker). 72 | 73 | > $ git checkout 65408b12b459ca01a26bcd770b41dcdefcb1e51e Setting up real DB (EF, Postgres, Docker) 74 | 75 | > $ git checkout 071132dd4f9726df076993db1d54c8b901d6cff7 Testcontainer with DB for testing 76 | 77 | --- 78 | Singleton for the test application builder factory so that the application starts up exactly once for all tests. 79 | 80 | > $ git checkout f2b0fed9b7459aaae91de92a408c353094b55af1 Single instance of app for all tests (singleton) 81 | 82 | --- 83 | Sort and manipulate results to always get the same result in the snapshot Verify for assertions. 84 | 85 | > $ git checkout 008fb2a9d37bcf15b464659953745fcc65eb9d55 Test for getting to-do's by tags in query string (without sorting) 86 | 87 | > $ git checkout f80dc9045791eff0ca64d053934cb2ec5925744c Sort/manipulate the actual result before passing it to Verify 88 | 89 | --- 90 | Presentation of the second external Calendar application with which we will integrate and which we will mock using Wiremock. 91 | 92 | > $ git checkout d417ccfab69944738674b1d4b45d804fcda82c0b 3rd-party Calendar app 93 | 94 | --- 95 | Integration with Calendar without Wiremock (no new tests, old tests are passing). Here, all tests that were not testing new functionality, so everything is still green. 96 | 97 | > $ git checkout bcd6c15b025e546413b93f29d22f226a56bf05cb Integration with 3rd-party Calendar app 98 | 99 | --- 100 | Setting up Wiremock with the first test, here you can also see Wiremock Inspection in action. 101 | 102 | > $ git checkout 7a353d5050a5f7748e81fc987f79fbeca0836b24 Test for retrieving items from the calendar (without Wiremock) 103 | 104 | > $ git checkout 7c943158e7480e68ff148a9593e127a5f9b2a3e3 Setup Wiremock and run first test with mock of HTTP 105 | 106 | --- 107 | I'm adding OpenTelemetry for Wiremock to differentiate which mock belongs to which test scenario. The whole solution works in the background. The programmer writing the tests doesn't need to know that OpenTelemetry exists. 108 | 109 | > $ git checkout af4581aa3570ef36d444a2a3172d581acb217e6f Second test with second mock leading to ambiguity in matching in Wiremock 110 | 111 | > $ git checkout 5e76d47670f769fc7a499a06737b4438d22b9c7d Add OpenTelemetry for clear identification of mocks in Wiremock 112 | 113 | --- 114 | Counting the invocations of Wiremock mocks to avoid leaving unused mocks that confuse the understanding of what the test does. We don't want to have mocks that are not involved in the test. We want the test to self-check that it uses all mocks. 115 | 116 | > $ git checkout a0d0e15108615a532b3c7557f6725f9d53f7073b More tests with external calendar 117 | 118 | > $ git checkout d5cb22203bb52d9ea6738427660843c80041fa19 Test self-verifies if a given Wiremock was used the expected number of times 119 | 120 | --- 121 | We're adding a cache to the application. Normally, we would have to provide a mock for the cache. However, in this solution, we use the cache without mocking it, just as it is in a real production application. 122 | 123 | > $ git checkout 3537363af8b47bc107311c94687914621eb9104e Cache feature and its handling, including tests 124 | 125 | --- 126 | We're adding a new Stats module - presenting its functionality. This application will serve as a simulation of the second module of our application, and then we'll want to test it in our tests. 127 | 128 | > $ git checkout 45be71072df8ac75a211de90b23cafe0db5bce85 Stats module 129 | 130 | --- 131 | We're adding a second Test Builder for the newly created second Stats Module. 132 | 133 | > $ git checkout 2c725f7f1a60e6f84ec277bb19d3453305ef39db Tests for Stats module 134 | 135 | --- 136 | Integration with the Stats module. Here the tests will fail because our application calls itself via an HTTP API. These are two separate modules, but in a monolithic solution, so it's a single deployment artefact, meaning that communication involves calling our own API. However, a difficulty arises here because our HTTP API is virtual and a simulation in the server's memory, so it doesn't have a reachable address that we could access. Therefore, we need to use a trick that will be presented in the next step. 137 | 138 | > 52d62f7802a07538cd31226a2029a8557a949d25 Integration with Stats module 139 | 140 | --- 141 | Provide a solution for calling itself without mocks in Wiremock. This addresses the problem outlined in the previous commit. Thanks to such a lazy function, we can get access to a virtual HTTP client that we can use inside the application (i.e. outside the tests - which is the opposite of what we've been doing, as we've been using the HTTP client outside the application inside the tests). 142 | 143 | > $ git checkout 2f27e38b5b8c39d1aa8d5c9d5e68ea6fb8f810c3 Register virtual HTTP client to self 144 | -------------------------------------------------------------------------------- /NTeoTestBuildeR.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | HINT 3 | HINT 4 | HINT 5 | HINT 6 | HINT 7 | HINT 8 | HINT 9 | HINT 10 | HINT 11 | HINT 12 | HINT 13 | HINT 14 | HINT 15 | HINT 16 | HINT 17 | HINT 18 | DO_NOT_SHOW 19 | DO_NOT_SHOW 20 | HINT 21 | DO_NOT_SHOW 22 | DO_NOT_SHOW 23 | DO_NOT_SHOW 24 | DO_NOT_SHOW 25 | DO_NOT_SHOW 26 | SUGGESTION 27 | Named 28 | Named 29 | Named 30 | True 31 | Named 32 | RequiredForMultiline 33 | RequiredForMultiline 34 | RequiredForMultiline 35 | NotRequiredForBoth 36 | Required 37 | RequiredForMultiline 38 | ExpressionBody 39 | ExpressionBody 40 | ExpressionBody 41 | public internal private required file new abstract volatile virtual async sealed protected extern unsafe readonly static override 42 | Arithmetic, Conditional 43 | Arithmetic, Shift, Bitwise 44 | True 45 | 0 46 | True 47 | TargetTyped 48 | 1 49 | False 50 | True 51 | True 52 | NEVER 53 | ALWAYS 54 | NEVER 55 | NEVER 56 | False 57 | NEVER 58 | False 59 | True 60 | False 61 | False 62 | CHOP_ALWAYS 63 | True 64 | CHOP_IF_LONG 65 | CHOP_ALWAYS 66 | CHOP_ALWAYS 67 | WRAP_IF_LONG 68 | False 69 | <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> 70 | <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> 71 | True 72 | False 73 | True 74 | True 75 | True 76 | <Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"> 77 | <TypePattern DisplayName="Non-reorderable types"> 78 | <TypePattern.Match> 79 | <Or> 80 | <And> 81 | <Kind Is="Interface" /> 82 | <Or> 83 | <HasAttribute Name="System.Runtime.InteropServices.InterfaceTypeAttribute" /> 84 | <HasAttribute Name="System.Runtime.InteropServices.ComImport" /> 85 | </Or> 86 | </And> 87 | <Kind Is="Struct" /> 88 | <HasAttribute Name="JetBrains.Annotations.NoReorderAttribute" /> 89 | <HasAttribute Name="JetBrains.Annotations.NoReorder" /> 90 | </Or> 91 | </TypePattern.Match> 92 | </TypePattern> 93 | 94 | <TypePattern DisplayName="xUnit.net Test Classes" RemoveRegions="All"> 95 | <TypePattern.Match> 96 | <And> 97 | <Kind Is="Class" /> 98 | <HasMember> 99 | <And> 100 | <Kind Is="Method" /> 101 | <HasAttribute Name="Xunit.FactAttribute" Inherited="True" /> 102 | <HasAttribute Name="Xunit.TheoryAttribute" Inherited="True" /> 103 | </And> 104 | </HasMember> 105 | </And> 106 | </TypePattern.Match> 107 | 108 | <Entry DisplayName="Setup/Teardown Methods"> 109 | <Entry.Match> 110 | <Or> 111 | <Kind Is="Constructor" /> 112 | <And> 113 | <Kind Is="Method" /> 114 | <ImplementsInterface Name="System.IDisposable" /> 115 | </And> 116 | </Or> 117 | </Entry.Match> 118 | 119 | <Entry.SortBy> 120 | <Kind> 121 | <Kind.Order> 122 | <DeclarationKind>Constructor</DeclarationKind> 123 | </Kind.Order> 124 | </Kind> 125 | </Entry.SortBy> 126 | </Entry> 127 | 128 | 129 | <Entry DisplayName="All other members" /> 130 | 131 | <Entry DisplayName="Test Methods" Priority="100"> 132 | <Entry.Match> 133 | <And> 134 | <Kind Is="Method" /> 135 | <HasAttribute Name="Xunit.FactAttribute" Inherited="false" /> 136 | <HasAttribute Name="Xunit.TheoryAttribute" Inherited="false" /> 137 | </And> 138 | </Entry.Match> 139 | 140 | <Entry.SortBy> 141 | <Name /> 142 | </Entry.SortBy> 143 | </Entry> 144 | </TypePattern> 145 | 146 | <TypePattern DisplayName="NUnit Test Fixtures" RemoveRegions="All"> 147 | <TypePattern.Match> 148 | <And> 149 | <Kind Is="Class" /> 150 | <Or> 151 | <HasAttribute Name="NUnit.Framework.TestFixtureAttribute" Inherited="true" /> 152 | <HasAttribute Name="NUnit.Framework.TestFixtureSourceAttribute" Inherited="true" /> 153 | <HasMember> 154 | <And> 155 | <Kind Is="Method" /> 156 | <HasAttribute Name="NUnit.Framework.TestAttribute" Inherited="false" /> 157 | <HasAttribute Name="NUnit.Framework.TestCaseAttribute" Inherited="false" /> 158 | <HasAttribute Name="NUnit.Framework.TestCaseSourceAttribute" Inherited="false" /> 159 | </And> 160 | </HasMember> 161 | </Or> 162 | </And> 163 | </TypePattern.Match> 164 | 165 | <Entry DisplayName="Setup/Teardown Methods"> 166 | <Entry.Match> 167 | <And> 168 | <Kind Is="Method" /> 169 | <Or> 170 | <HasAttribute Name="NUnit.Framework.SetUpAttribute" Inherited="true" /> 171 | <HasAttribute Name="NUnit.Framework.TearDownAttribute" Inherited="true" /> 172 | <HasAttribute Name="NUnit.Framework.TestFixtureSetUpAttribute" Inherited="true" /> 173 | <HasAttribute Name="NUnit.Framework.TestFixtureTearDownAttribute" Inherited="true" /> 174 | <HasAttribute Name="NUnit.Framework.OneTimeSetUpAttribute" Inherited="true" /> 175 | <HasAttribute Name="NUnit.Framework.OneTimeTearDownAttribute" Inherited="true" /> 176 | </Or> 177 | </And> 178 | </Entry.Match> 179 | </Entry> 180 | 181 | <Entry DisplayName="All other members" /> 182 | 183 | <Entry DisplayName="Test Methods" Priority="100"> 184 | <Entry.Match> 185 | <And> 186 | <Kind Is="Method" /> 187 | <HasAttribute Name="NUnit.Framework.TestAttribute" Inherited="false" /> 188 | <HasAttribute Name="NUnit.Framework.TestCaseAttribute" Inherited="false" /> 189 | <HasAttribute Name="NUnit.Framework.TestCaseSourceAttribute" Inherited="false" /> 190 | </And> 191 | </Entry.Match> 192 | 193 | <Entry.SortBy> 194 | <Name /> 195 | </Entry.SortBy> 196 | </Entry> 197 | </TypePattern> 198 | 199 | <TypePattern DisplayName="Default Pattern"> 200 | <Entry DisplayName="Public Delegates" Priority="100"> 201 | <Entry.Match> 202 | <And> 203 | <Access Is="Public" /> 204 | <Kind Is="Delegate" /> 205 | </And> 206 | </Entry.Match> 207 | 208 | <Entry.SortBy> 209 | <Name /> 210 | </Entry.SortBy> 211 | </Entry> 212 | 213 | <Entry DisplayName="Public Enums" Priority="100"> 214 | <Entry.Match> 215 | <And> 216 | <Access Is="Public" /> 217 | <Kind Is="Enum" /> 218 | </And> 219 | </Entry.Match> 220 | 221 | <Entry.SortBy> 222 | <Name /> 223 | </Entry.SortBy> 224 | </Entry> 225 | 226 | <Entry DisplayName="Static Fields and Constants"> 227 | <Entry.Match> 228 | <Or> 229 | <Kind Is="Constant" /> 230 | <And> 231 | <Kind Is="Field" /> 232 | <Static /> 233 | </And> 234 | </Or> 235 | </Entry.Match> 236 | 237 | <Entry.SortBy> 238 | <Kind> 239 | <Kind.Order> 240 | <DeclarationKind>Constant</DeclarationKind> 241 | <DeclarationKind>Field</DeclarationKind> 242 | </Kind.Order> 243 | </Kind> 244 | </Entry.SortBy> 245 | </Entry> 246 | 247 | <Entry DisplayName="Fields"> 248 | <Entry.Match> 249 | <And> 250 | <Kind Is="Field" /> 251 | <Not> 252 | <Static /> 253 | </Not> 254 | </And> 255 | </Entry.Match> 256 | 257 | <Entry.SortBy> 258 | <Readonly /> 259 | <Name /> 260 | </Entry.SortBy> 261 | </Entry> 262 | 263 | <Entry DisplayName="Properties, Indexers"> 264 | <Entry.Match> 265 | <Or> 266 | <Kind Is="Property"/> 267 | <Kind Is="Indexer"/> 268 | <And> 269 | <Kind Is="Property"/> 270 | <ImplementsInterface /> 271 | </And> 272 | </Or> 273 | </Entry.Match> 274 | </Entry> 275 | 276 | <Entry DisplayName="Constructors"> 277 | <Entry.Match> 278 | <Kind Is="Constructor" /> 279 | </Entry.Match> 280 | 281 | <Entry.SortBy> 282 | <Static/> 283 | </Entry.SortBy> 284 | </Entry> 285 | 286 | <Entry DisplayName="Interface Implementations" Priority="100"> 287 | <Entry.Match> 288 | <And> 289 | <Kind Is="Method" /> 290 | <ImplementsInterface /> 291 | </And> 292 | </Entry.Match> 293 | 294 | <Entry.SortBy> 295 | <ImplementsInterface Immediate="true" /> 296 | </Entry.SortBy> 297 | </Entry> 298 | 299 | <Entry DisplayName="All other members" /> 300 | 301 | <Entry DisplayName="Nested Types"> 302 | <Entry.Match> 303 | <Kind Is="Type" /> 304 | </Entry.Match> 305 | </Entry> 306 | </TypePattern> 307 | </Patterns> 308 | 309 | True 310 | True 311 | True 312 | True 313 | True 314 | True 315 | True 316 | True 317 | True 318 | --------------------------------------------------------------------------------