diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a1f317 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [ develop, main ] + pull_request: + branches: [ develop, main ] + +jobs: + build: + runs-on: ubuntu-latest + env: + ASPNETCORE_ENVIRONMENT: CI + services: + postgres: + image: postgres + env: + POSTGRES_DB: teacher_workout_test + POSTGRES_PASSWORD: docker + POSTGRES_USER: docker + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v2 + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 5.0.x + + - name: Install dependencies + run: dotnet tool install --global dotnet-ef + + - name: Restore dependencies + run: dotnet restore TeacherWorkout.sln + + - name: Build + run: dotnet build --no-restore /p:ContinuousIntegrationBuild=true TeacherWorkout.sln + + - name: Apply migrations + run: dotnet ef database update --no-build --project TeacherWorkout.Domain --startup-project TeacherWorkout.Api + + - name: Run tests + run: dotnet test --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover TeacherWorkout.sln diff --git a/.graphqlconfig b/.graphqlconfig new file mode 100644 index 0000000..175ed36 --- /dev/null +++ b/.graphqlconfig @@ -0,0 +1,15 @@ +{ + "name": "Untitled GraphQL Schema", + "schemaPath": "schema.graphql", + "extensions": { + "endpoints": { + "Default GraphQL Endpoint": { + "url": "http://localhost:8080/graphql", + "headers": { + "user-agent": "JS GraphQL" + }, + "introspect": false + } + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 114e1d0..3128b4a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,9 @@ Guide users through getting your code up and running on their own system. In thi Describe and show how to build your code and run the tests. +## Automatic tests +1. Run `$ dotnet test` + ## Feedback * Request a new feature on GitHub. diff --git a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs index ad78c34..a353be0 100644 --- a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs +++ b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs @@ -5,12 +5,14 @@ using TeacherWorkout.Api.GraphQL.Types.Payloads; using TeacherWorkout.Domain.Lessons; using TeacherWorkout.Domain.Models.Inputs; +using TeacherWorkout.Domain.Themes; namespace TeacherWorkout.Api.GraphQL { public class TeacherWorkoutMutation : ObjectGraphType { - public TeacherWorkoutMutation(CompleteStep completeStep) + public TeacherWorkoutMutation(CompleteStep completeStep, + CreateTheme createTheme) { Name = "Mutation"; @@ -32,8 +34,19 @@ public TeacherWorkoutMutation(CompleteStep completeStep) ), resolve: context => { - var stepComplete = context.GetArgument("input"); - return completeStep.Execute(stepComplete); + var input = context.GetArgument("input"); + return completeStep.Execute(input); + }); + + Field( + "themeCreate", + arguments: new QueryArguments( + new QueryArgument> { Name = "input" } + ), + resolve: context => + { + var input = context.GetArgument("input"); + return createTheme.Execute(input); }); } } diff --git a/TeacherWorkout.Api/GraphQL/TeacherWorkoutSchema.cs b/TeacherWorkout.Api/GraphQL/TeacherWorkoutSchema.cs index 62eba6f..3547bdd 100644 --- a/TeacherWorkout.Api/GraphQL/TeacherWorkoutSchema.cs +++ b/TeacherWorkout.Api/GraphQL/TeacherWorkoutSchema.cs @@ -24,6 +24,7 @@ private void AddTypeMappings() var classTypes = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(t => t.GetTypes()) .Where(t => t.IsClass || t.IsEnum) + .Where(t => t.Namespace != null && t.Namespace.StartsWith("TeacherWorkout")) .ToList(); classTypes.Where(t => t.Namespace == "TeacherWorkout.Domain.Models") diff --git a/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs b/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs new file mode 100644 index 0000000..77689dc --- /dev/null +++ b/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs @@ -0,0 +1,16 @@ +using GraphQL.Types; +using TeacherWorkout.Domain.Models.Inputs; + +namespace TeacherWorkout.Api.GraphQL.Types.Inputs +{ + public class ThemeCreateInputType : InputObjectGraphType + { + public ThemeCreateInputType() + { + Name = "ThemeCreateInput"; + + Field(x => x.ThumbnailId, type: typeof(IdGraphType)); + Field(x => x.Title); + } + } +} diff --git a/TeacherWorkout.Api/GraphQL/Types/Payloads/ThemeCreatePayloadType.cs b/TeacherWorkout.Api/GraphQL/Types/Payloads/ThemeCreatePayloadType.cs new file mode 100644 index 0000000..f383b66 --- /dev/null +++ b/TeacherWorkout.Api/GraphQL/Types/Payloads/ThemeCreatePayloadType.cs @@ -0,0 +1,15 @@ +using GraphQL.Types; +using TeacherWorkout.Domain.Models.Payloads; + +namespace TeacherWorkout.Api.GraphQL.Types.Payloads +{ + public class ThemeCreatePayloadType : ObjectGraphType + { + public ThemeCreatePayloadType() + { + Name = "ThemeCreatePayload"; + + Field(x => x.Theme).Description("The Theme."); + } + } +} diff --git a/TeacherWorkout.Api/Startup.cs b/TeacherWorkout.Api/Startup.cs index 4509059..39034a7 100644 --- a/TeacherWorkout.Api/Startup.cs +++ b/TeacherWorkout.Api/Startup.cs @@ -49,7 +49,7 @@ public void ConfigureServices(IServiceCollection services) services.AddHttpContextAccessor(); services.AddGraphQL(options => { - options.EnableMetrics = true; + options.EnableMetrics = false; }) .AddErrorInfoProvider(opt => opt.ExposeExceptionStackTrace = true) .AddSystemTextJson() @@ -111,7 +111,7 @@ private static void EnsureReferencedAssembliesAreLoaded() { // We need to reference something in the assembly to make it load // otherwise the Compiler will not include it in the output package - new List { typeof(MockData.Repositories.LessonRepository).Assembly }; + new List { typeof(MockData.Repositories.StepRepository).Assembly }; } } } diff --git a/TeacherWorkout.Api/TeacherWorkout.Api.csproj b/TeacherWorkout.Api/TeacherWorkout.Api.csproj index 98acc64..9e17669 100644 --- a/TeacherWorkout.Api/TeacherWorkout.Api.csproj +++ b/TeacherWorkout.Api/TeacherWorkout.Api.csproj @@ -11,6 +11,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/TeacherWorkout.Api/WeatherForecast.cs b/TeacherWorkout.Api/WeatherForecast.cs deleted file mode 100644 index 01b8f14..0000000 --- a/TeacherWorkout.Api/WeatherForecast.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace TeacherWorkout.Api -{ - public class WeatherForecast - { - public DateTime Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string Summary { get; set; } - } -} diff --git a/TeacherWorkout.Api/appsettings.CI.json b/TeacherWorkout.Api/appsettings.CI.json new file mode 100644 index 0000000..a0c4f93 --- /dev/null +++ b/TeacherWorkout.Api/appsettings.CI.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "TeacherWorkoutContext": "host=localhost;Port=5432;Database=teacher_workout_test;User Id=docker;Password=docker;" + } +} diff --git a/TeacherWorkout.Api/appsettings.Test.json b/TeacherWorkout.Api/appsettings.Test.json new file mode 100644 index 0000000..a0c4f93 --- /dev/null +++ b/TeacherWorkout.Api/appsettings.Test.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "TeacherWorkoutContext": "host=localhost;Port=5432;Database=teacher_workout_test;User Id=docker;Password=docker;" + } +} diff --git a/TeacherWorkout.Data/Migrations/20210827110928_SeedDummyData.Designer.cs b/TeacherWorkout.Data/Migrations/20210827110928_SeedDummyData.Designer.cs deleted file mode 100644 index 852f0bf..0000000 --- a/TeacherWorkout.Data/Migrations/20210827110928_SeedDummyData.Designer.cs +++ /dev/null @@ -1,115 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using TeacherWorkout.Data; - -namespace TeacherWorkout.Data.Migrations -{ - [DbContext(typeof(TeacherWorkoutContext))] - [Migration("20210827110928_SeedDummyData")] - partial class SeedDummyData - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("Relational:MaxIdentifierLength", 63) - .HasAnnotation("ProductVersion", "5.0.8") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - - modelBuilder.Entity("TeacherWorkout.Domain.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("text"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Url") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Image"); - }); - - modelBuilder.Entity("TeacherWorkout.Domain.Models.Lesson", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("text"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Duration") - .HasColumnType("integer"); - - b.Property("ThemeId") - .HasColumnType("text"); - - b.Property("ThumbnailId") - .HasColumnType("text"); - - b.Property("Title") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("ThemeId"); - - b.HasIndex("ThumbnailId"); - - b.ToTable("Lessons"); - }); - - modelBuilder.Entity("TeacherWorkout.Domain.Models.Theme", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("text"); - - b.Property("ThumbnailId") - .HasColumnType("text"); - - b.Property("Title") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("ThumbnailId"); - - b.ToTable("Themes"); - }); - - modelBuilder.Entity("TeacherWorkout.Domain.Models.Lesson", b => - { - b.HasOne("TeacherWorkout.Domain.Models.Theme", "Theme") - .WithMany() - .HasForeignKey("ThemeId"); - - b.HasOne("TeacherWorkout.Domain.Models.Image", "Thumbnail") - .WithMany() - .HasForeignKey("ThumbnailId"); - - b.Navigation("Theme"); - - b.Navigation("Thumbnail"); - }); - - modelBuilder.Entity("TeacherWorkout.Domain.Models.Theme", b => - { - b.HasOne("TeacherWorkout.Domain.Models.Image", "Thumbnail") - .WithMany() - .HasForeignKey("ThumbnailId"); - - b.Navigation("Thumbnail"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/TeacherWorkout.Data/Migrations/20210827110928_SeedDummyData.cs b/TeacherWorkout.Data/Migrations/20210827110928_SeedDummyData.cs deleted file mode 100644 index 249cb10..0000000 --- a/TeacherWorkout.Data/Migrations/20210827110928_SeedDummyData.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace TeacherWorkout.Data.Migrations -{ - public partial class SeedDummyData : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - var imageList = new object[,] - { - { - NewId(), - "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Felis_catus-cat_on_snow.jpg/640px-Felis_catus-cat_on_snow.jpg", - "Cat Photo" - }, - { - NewId(), - "https://static.toiimg.com/thumb/msid-67586673,width-800,height-600,resizemode-75,imgsize-3918697,pt-32,y_pad-40/67586673.jpg", - "Another Cat Photo" - }, - { - NewId(), - "https://cdn.shopify.com/s/files/1/1149/5008/articles/why-cat-looking-at-wall-or-nothing.jpg?v=1551321728", - "YACP" - }, - { - NewId(), - "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcReLDHsAIhgLgFpupyZg0CtevFcI2NY9WkoOQ&usqp=CAU", - "More Cat Photos" - }, - { - NewId(), "https://upload.wikimedia.org/wikipedia/commons/e/e6/10_years_old_american_staff.jpg", - "Dog Photos" - }, - { - NewId(), "https://upload.wikimedia.org/wikipedia/commons/f/f6/11.10.2015_Samoyed_%28cropped%29.jpg", - "More Dog Photos" - }, - { - NewId(), - "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/AiredaleDog.jpg/386px-AiredaleDog.jpg", - "Cute Dog photo" - }, - { - NewId(), - "https://commons.wikimedia.org/wiki/Category:Sitting_dogs#/media/File:Cachorro_ra%C3%A7a_Lhasa_Apso_posando_para_book_canino_perfil.JPG", - "Nice dog photo" - }, - {NewId(), "https://upload.wikimedia.org/wikipedia/commons/9/9a/Paisible.JPG", "Very nice dog photo"}, - { - NewId(), - "https://commons.wikimedia.org/wiki/Category:Dogs_tilting_head#/media/File:Yumi_19mois2.jpg", - "Small dog photo" - }, - { - NewId(), - "https://commons.wikimedia.org/wiki/Category:Quality_images_of_dogs#/media/File:Canis_lupus_PO.jpg", - "Beautiful dog photo" - } - }; - - var themeList = new object[,] - { - {NewId(), "Lorem Ipsum", imageList[0, 0]}, - {NewId(), "Dolor sit amet", imageList[1, 0]}, - {NewId(), "Consectetur adipiscing elit", imageList[2, 0]}, - {NewId(), "Fusce tempor", imageList[3, 0]}, - {NewId(), "Doloremque laudantium", imageList[4, 0]}, - {NewId(), "Quasi architecto beatae vitae", imageList[5, 0]}, - {NewId(), "Nemo enim ipsam voluptatem", imageList[6, 0]}, - {NewId(), "Sed quia consequuntur magni dolores eos", imageList[7, 0]}, - {NewId(), "Ratione voluptatem sequi nesciunt", imageList[8, 0]}, - {NewId(), "Neque porro quisquam est", imageList[9, 0]}, - {NewId(), "Accusamus et iusto", imageList[10, 0]} - }; - - migrationBuilder.InsertData( - table: "Image", - columns: new[] { "Id", "Url", "Description" }, - values: imageList); - - migrationBuilder.InsertData( - table: "Themes", - columns: new[] { "Id", "Title", "ThumbnailId" }, - values: themeList); - - for (var themeIndex = 0; themeIndex < 11; themeIndex++) - { - var themeId = themeList[themeIndex, 0]; - - migrationBuilder.InsertData( - table: "Lessons", - columns: new[] { "Id", "Title", "ThemeId", "Duration", "Description", "ThumbnailId"}, - values: new object[,] - { - {NewId(), "Lorem Ipsum", themeId, 45 * 60, "Lorem Ipsum Lorem Ipsum", imageList[0,0]}, - {NewId(), "Dolor sit amet", themeId, 32 * 60, "Dolor sit amet Dolor sit amet", imageList[1,0]}, - {NewId(), "Consectetur adipiscing elit", themeId, 55 * 60, "Consectetur adipiscing elit Consectetur adipiscing elit", imageList[2,0]}, - {NewId(), "Fusce tempor", themeId, 37 * 60, "Fusce tempor Fusce tempor", imageList[3,0]}, - {NewId(), "Doloremque laudantium", themeId, 39 * 60, "Doloremque laudantium Doloremque laudantium", imageList[4,0]}, - {NewId(), "Quasi architecto beatae vitae", themeId, 42 * 60, "Quasi architecto beatae vitae Quasi architecto beatae vitae", imageList[5,0]}, - {NewId(), "Nemo enim ipsam voluptatem", themeId, 49 * 49, "Nemo enim ipsam voluptatem Nemo enim ipsam voluptatem", imageList[6,0]} - }); - } - } - - private string NewId() - { - return Guid.NewGuid().ToString(); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql("DELETE FROM Lessons"); - migrationBuilder.Sql("DELETE FROM Themes"); - migrationBuilder.Sql("DELETE FROM Image"); - } - } -} diff --git a/TeacherWorkout.Data/Repositories/ThemeRepository.cs b/TeacherWorkout.Data/Repositories/ThemeRepository.cs index dfe84cd..c86284f 100644 --- a/TeacherWorkout.Data/Repositories/ThemeRepository.cs +++ b/TeacherWorkout.Data/Repositories/ThemeRepository.cs @@ -26,5 +26,11 @@ public PaginatedResult PaginatedList(PaginationFilter pagination) Items = result.ToList() }; } + + public void Insert(Theme theme) + { + _context.Themes.Add(theme); + _context.SaveChanges(); + } } } diff --git a/TeacherWorkout.Domain/Images/IImageRepository.cs b/TeacherWorkout.Domain/Images/IImageRepository.cs new file mode 100644 index 0000000..29232dd --- /dev/null +++ b/TeacherWorkout.Domain/Images/IImageRepository.cs @@ -0,0 +1,9 @@ +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.Domain.Images +{ + public interface IImageRepository + { + Image Find(string id); + } +} \ No newline at end of file diff --git a/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs b/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs new file mode 100644 index 0000000..dcc91cf --- /dev/null +++ b/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TeacherWorkout.Domain.Models.Inputs +{ + public class ThemeCreateInput + { + public string Title { get; set; } + + public string ThumbnailId { get; set; } + } +} diff --git a/TeacherWorkout.Domain/Models/Payloads/ThemeCreatePayload.cs b/TeacherWorkout.Domain/Models/Payloads/ThemeCreatePayload.cs new file mode 100644 index 0000000..a6ac485 --- /dev/null +++ b/TeacherWorkout.Domain/Models/Payloads/ThemeCreatePayload.cs @@ -0,0 +1,7 @@ +namespace TeacherWorkout.Domain.Models.Payloads +{ + public class ThemeCreatePayload + { + public Theme Theme { get; set; } + } +} diff --git a/TeacherWorkout.Domain/Themes/CreateTheme.cs b/TeacherWorkout.Domain/Themes/CreateTheme.cs new file mode 100644 index 0000000..80a6159 --- /dev/null +++ b/TeacherWorkout.Domain/Themes/CreateTheme.cs @@ -0,0 +1,37 @@ +using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.Images; +using TeacherWorkout.Domain.Models; +using TeacherWorkout.Domain.Models.Inputs; +using TeacherWorkout.Domain.Models.Payloads; + +namespace TeacherWorkout.Domain.Themes +{ + public class CreateTheme : IOperation + { + private readonly IThemeRepository _themeRepository; + private readonly IImageRepository _imageRepository; + + public CreateTheme(IThemeRepository themeRepository, + IImageRepository imageRepository) + { + _themeRepository = themeRepository; + _imageRepository = imageRepository; + } + + public ThemeCreatePayload Execute(ThemeCreateInput input) + { + var theme = new Theme + { + Title = input.Title, + Thumbnail = _imageRepository.Find(input.ThumbnailId) + }; + + _themeRepository.Insert(theme); + + return new ThemeCreatePayload + { + Theme = theme + }; + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Domain/Themes/IThemeRepository.cs b/TeacherWorkout.Domain/Themes/IThemeRepository.cs index fb2cace..b810574 100644 --- a/TeacherWorkout.Domain/Themes/IThemeRepository.cs +++ b/TeacherWorkout.Domain/Themes/IThemeRepository.cs @@ -6,5 +6,7 @@ namespace TeacherWorkout.Domain.Themes public interface IThemeRepository { PaginatedResult PaginatedList(PaginationFilter pagination); + + void Insert(Theme theme); } } \ No newline at end of file diff --git a/TeacherWorkout.MockData/Repositories/ImageRepository.cs b/TeacherWorkout.MockData/Repositories/ImageRepository.cs new file mode 100644 index 0000000..be5d454 --- /dev/null +++ b/TeacherWorkout.MockData/Repositories/ImageRepository.cs @@ -0,0 +1,13 @@ +using TeacherWorkout.Domain.Images; +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.MockData.Repositories +{ + public class ImageRepository : IImageRepository + { + public Image Find(string id) + { + return null; + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.MockData/Repositories/ThemeRepository.cs b/TeacherWorkout.MockData/Repositories/ThemeRepository.cs index d1e9423..dc4119e 100644 --- a/TeacherWorkout.MockData/Repositories/ThemeRepository.cs +++ b/TeacherWorkout.MockData/Repositories/ThemeRepository.cs @@ -136,5 +136,9 @@ public PaginatedResult PaginatedList(PaginationFilter pagination) } }; } + + public void Insert(Theme theme) + { + } } } \ No newline at end of file diff --git a/TeacherWorkout.Specs/Drivers/Driver.cs b/TeacherWorkout.Specs/Drivers/Driver.cs new file mode 100644 index 0000000..2c4b130 --- /dev/null +++ b/TeacherWorkout.Specs/Drivers/Driver.cs @@ -0,0 +1,8 @@ +using System; + +namespace TeacherWorkout.Specs.Drivers +{ + public class Driver + { + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/Extensions/FluentExtensions.cs b/TeacherWorkout.Specs/Extensions/FluentExtensions.cs new file mode 100644 index 0000000..9daf19f --- /dev/null +++ b/TeacherWorkout.Specs/Extensions/FluentExtensions.cs @@ -0,0 +1,57 @@ +using System.IO; +using System.Linq; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; +using FluentAssertions.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace TeacherWorkout.Specs.Extensions +{ + public static class FluentExtensions + { + public static void MatchResponse(this ObjectAssertions assertions, string path) + { + var generateNew = !GlobalSettings.IsCi; + var actual = assertions.Subject; + + Execute.Assertion + .ForCondition(!string.IsNullOrEmpty(path)) + .FailWith("You need to pass in a path to a sample response") + .Then + .ForCondition(File.Exists(path) || generateNew) + .FailWith("Sample does not exist"); + + if (!File.Exists(path) && generateNew) + { + var currentNamespace = "TeacherWorkout.Specs"; + var currentDirectoryPath = Directory.GetCurrentDirectory(); + var targetFilePath = Path.Join(currentDirectoryPath.Split(currentNamespace).First(), currentNamespace, path); + var targetDirectoryPath = Path.GetDirectoryName(targetFilePath); + + if (!Directory.Exists(targetDirectoryPath)) + { + Directory.CreateDirectory(targetDirectoryPath); + } + + File.WriteAllText(targetFilePath, actual.ToJson()); + path = targetFilePath; + } + + var expectedToken = JToken.Parse(File.ReadAllText(path)); + assertions.Subject.ToJToken().Should().BeEquivalentTo(expectedToken); + } + + private static JToken ToJToken(this object obj) + { + return JToken.Parse(obj.ToJson()); + } + + public static string ToJson(this object obj) + { + dynamic parsedJson = obj is string s ? JsonConvert.DeserializeObject(s) : obj; + + return JsonConvert.SerializeObject(parsedJson, Formatting.Indented); + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/Features/Themes.feature b/TeacherWorkout.Specs/Features/Themes.feature new file mode 100644 index 0000000..71c6a60 --- /dev/null +++ b/TeacherWorkout.Specs/Features/Themes.feature @@ -0,0 +1,15 @@ +Feature: Themes + As a user + I want to be able to list themes + +Scenario: Admin user can create a theme + Given Ion is an admin + When Ion creates a theme + Then the theme was created successfully + +Scenario: Anonymous user can list themes + Given Ion is an admin + And Vasile is an anonymous user + And Ion creates a theme + When Vasile requests themes + Then Vasile receives the theme diff --git a/TeacherWorkout.Specs/Features/Themes.feature.cs b/TeacherWorkout.Specs/Features/Themes.feature.cs new file mode 100644 index 0000000..148be12 --- /dev/null +++ b/TeacherWorkout.Specs/Features/Themes.feature.cs @@ -0,0 +1,189 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by SpecFlow (https://www.specflow.org/). +// SpecFlow Version:3.8.0.0 +// SpecFlow Generator Version:3.8.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +namespace TeacherWorkout.Specs.Features +{ + using TechTalk.SpecFlow; + using System; + using System.Linq; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.8.0.0")] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public partial class ThemesFeature : object, Xunit.IClassFixture, System.IDisposable + { + + private static TechTalk.SpecFlow.ITestRunner testRunner; + + private string[] _featureTags = ((string[])(null)); + + private Xunit.Abstractions.ITestOutputHelper _testOutputHelper; + +#line 1 "Themes.feature" +#line hidden + + public ThemesFeature(ThemesFeature.FixtureData fixtureData, TeacherWorkout_Specs_XUnitAssemblyFixture assemblyFixture, Xunit.Abstractions.ITestOutputHelper testOutputHelper) + { + this._testOutputHelper = testOutputHelper; + this.TestInitialize(); + } + + public static void FeatureSetup() + { + testRunner = TechTalk.SpecFlow.TestRunnerManager.GetTestRunner(); + TechTalk.SpecFlow.FeatureInfo featureInfo = new TechTalk.SpecFlow.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "Features", "Themes", "\tAs a user\n\tI want to be able to list themes", ProgrammingLanguage.CSharp, ((string[])(null))); + testRunner.OnFeatureStart(featureInfo); + } + + public static void FeatureTearDown() + { + testRunner.OnFeatureEnd(); + testRunner = null; + } + + public virtual void TestInitialize() + { + } + + public virtual void TestTearDown() + { + testRunner.OnScenarioEnd(); + } + + public virtual void ScenarioInitialize(TechTalk.SpecFlow.ScenarioInfo scenarioInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testOutputHelper); + } + + public virtual void ScenarioStart() + { + testRunner.OnScenarioStart(); + } + + public virtual void ScenarioCleanup() + { + testRunner.CollectScenarioErrors(); + } + + void System.IDisposable.Dispose() + { + this.TestTearDown(); + } + + [Xunit.SkippableFactAttribute(DisplayName="Admin user can create a theme")] + [Xunit.TraitAttribute("FeatureTitle", "Themes")] + [Xunit.TraitAttribute("Description", "Admin user can create a theme")] + public virtual void AdminUserCanCreateATheme() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Admin user can create a theme", null, tagsOfScenario, argumentsOfScenario, this._featureTags); +#line 5 +this.ScenarioInitialize(scenarioInfo); +#line hidden + bool isScenarioIgnored = default(bool); + bool isFeatureIgnored = default(bool); + if ((tagsOfScenario != null)) + { + isScenarioIgnored = tagsOfScenario.Where(__entry => __entry != null).Where(__entry => String.Equals(__entry, "ignore", StringComparison.CurrentCultureIgnoreCase)).Any(); + } + if ((this._featureTags != null)) + { + isFeatureIgnored = this._featureTags.Where(__entry => __entry != null).Where(__entry => String.Equals(__entry, "ignore", StringComparison.CurrentCultureIgnoreCase)).Any(); + } + if ((isScenarioIgnored || isFeatureIgnored)) + { + testRunner.SkipScenario(); + } + else + { + this.ScenarioStart(); +#line 6 + testRunner.Given("Ion is an admin", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); +#line hidden +#line 7 + testRunner.When("Ion creates a theme", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); +#line hidden +#line 8 + testRunner.Then("the theme was created successfully", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); +#line hidden + } + this.ScenarioCleanup(); + } + + [Xunit.SkippableFactAttribute(DisplayName="Anonymous user can list themes")] + [Xunit.TraitAttribute("FeatureTitle", "Themes")] + [Xunit.TraitAttribute("Description", "Anonymous user can list themes")] + public virtual void AnonymousUserCanListThemes() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Anonymous user can list themes", null, tagsOfScenario, argumentsOfScenario, this._featureTags); +#line 10 +this.ScenarioInitialize(scenarioInfo); +#line hidden + bool isScenarioIgnored = default(bool); + bool isFeatureIgnored = default(bool); + if ((tagsOfScenario != null)) + { + isScenarioIgnored = tagsOfScenario.Where(__entry => __entry != null).Where(__entry => String.Equals(__entry, "ignore", StringComparison.CurrentCultureIgnoreCase)).Any(); + } + if ((this._featureTags != null)) + { + isFeatureIgnored = this._featureTags.Where(__entry => __entry != null).Where(__entry => String.Equals(__entry, "ignore", StringComparison.CurrentCultureIgnoreCase)).Any(); + } + if ((isScenarioIgnored || isFeatureIgnored)) + { + testRunner.SkipScenario(); + } + else + { + this.ScenarioStart(); +#line 11 + testRunner.Given("Ion is an admin", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); +#line hidden +#line 12 + testRunner.And("Vasile is an anonymous user", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); +#line hidden +#line 13 + testRunner.And("Ion creates a theme", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); +#line hidden +#line 14 + testRunner.When("Vasile requests themes", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); +#line hidden +#line 15 + testRunner.Then("Vasile receives the theme", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); +#line hidden + } + this.ScenarioCleanup(); + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.8.0.0")] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class FixtureData : System.IDisposable + { + + public FixtureData() + { + ThemesFeature.FeatureSetup(); + } + + void System.IDisposable.Dispose() + { + ThemesFeature.FeatureTearDown(); + } + } + } +} +#pragma warning restore +#endregion diff --git a/TeacherWorkout.Specs/GlobalSettings.cs b/TeacherWorkout.Specs/GlobalSettings.cs new file mode 100644 index 0000000..c472b38 --- /dev/null +++ b/TeacherWorkout.Specs/GlobalSettings.cs @@ -0,0 +1,9 @@ +using System; + +namespace TeacherWorkout.Specs +{ + public class GlobalSettings + { + public static bool IsCi => Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "CI"; + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/GraphQL/Mutation/ThemeCreate.graphql b/TeacherWorkout.Specs/GraphQL/Mutation/ThemeCreate.graphql new file mode 100644 index 0000000..a8125bf --- /dev/null +++ b/TeacherWorkout.Specs/GraphQL/Mutation/ThemeCreate.graphql @@ -0,0 +1,7 @@ +mutation ThemeCreate($input: ThemeCreateInput!) { + themeCreate(input: $input) { + theme { + title + } + } +} diff --git a/TeacherWorkout.Specs/GraphQL/Query/Themes.graphql b/TeacherWorkout.Specs/GraphQL/Query/Themes.graphql new file mode 100644 index 0000000..808bb8d --- /dev/null +++ b/TeacherWorkout.Specs/GraphQL/Query/Themes.graphql @@ -0,0 +1,7 @@ +query Themes { + themes { + items { + title + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/GraphQLServer.cs b/TeacherWorkout.Specs/GraphQLServer.cs new file mode 100644 index 0000000..1906ddb --- /dev/null +++ b/TeacherWorkout.Specs/GraphQLServer.cs @@ -0,0 +1,38 @@ +using System.Net.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TeacherWorkout.Api; +using TeacherWorkout.Data; + +namespace TeacherWorkout.Specs +{ + public class GraphQLServer + { + private readonly WebApplicationFactory _factory; + + public HttpClient Client => _factory.CreateClient(); + + public WebApplicationFactory Factory => _factory; + + public GraphQLServer(WebApplicationFactory factory) + { + _factory = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddSingleton(p => + { + var configuration = p.GetService(); + var options = new DbContextOptionsBuilder() + .UseNpgsql(configuration.GetConnectionString("TeacherWorkoutContext")) + .Options; + return new TeacherWorkoutContext(options); + }); + }); + }); + _factory.Server.PreserveExecutionContext = true; + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/Hooks/DatabaseHooks.cs b/TeacherWorkout.Specs/Hooks/DatabaseHooks.cs new file mode 100644 index 0000000..72d76cf --- /dev/null +++ b/TeacherWorkout.Specs/Hooks/DatabaseHooks.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using TeacherWorkout.Data; +using TechTalk.SpecFlow; + +namespace TeacherWorkout.Specs.Hooks +{ + [Binding] + public class DatabaseHooks + { + private ScenarioContext _scenarioContext; + private readonly GraphQLServer _server; + + public DatabaseHooks(ScenarioContext scenarioContext, GraphQLServer server) + { + _scenarioContext = scenarioContext; + _server = server; + } + + [BeforeScenario] + public void BeforeScenario() + { + var dbContext = _server.Factory.Services.GetService(); + var transaction = dbContext.Database.BeginTransaction(); + + _scenarioContext["transaction"] = transaction; + _scenarioContext["dbContext"] = dbContext; + } + + [AfterScenario] + public void AfterScenario() + { + ((IDbContextTransaction)_scenarioContext["transaction"]).Rollback(); + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/Responses/Mutation/ThemeCreate/Success.json b/TeacherWorkout.Specs/Responses/Mutation/ThemeCreate/Success.json new file mode 100644 index 0000000..f32c182 --- /dev/null +++ b/TeacherWorkout.Specs/Responses/Mutation/ThemeCreate/Success.json @@ -0,0 +1,9 @@ +{ + "data": { + "themeCreate": { + "theme": { + "title": "foo" + } + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/Responses/Query/Themes/VisibleToVasileAsAnonymous.json b/TeacherWorkout.Specs/Responses/Query/Themes/VisibleToVasileAsAnonymous.json new file mode 100644 index 0000000..a7bda6e --- /dev/null +++ b/TeacherWorkout.Specs/Responses/Query/Themes/VisibleToVasileAsAnonymous.json @@ -0,0 +1,11 @@ +{ + "data": { + "themes": { + "items": [ + { + "title": "foo" + } + ] + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs b/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs new file mode 100644 index 0000000..c9453e5 --- /dev/null +++ b/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs @@ -0,0 +1,47 @@ +using System.Threading.Tasks; +using FluentAssertions; +using TeacherWorkout.Specs.Extensions; +using TechTalk.SpecFlow; + +namespace TeacherWorkout.Specs.Steps +{ + [Binding] + public class ThemeStepDefinitions + { + private readonly ScenarioContext _scenarioContext; + + public ThemeStepDefinitions(ScenarioContext scenarioContext) + { + _scenarioContext = scenarioContext; + } + + [Given(@"Ion creates a theme")] + [When(@"Ion creates a theme")] + public async Task GivenIonCreatesATheme() + { + _scenarioContext["theme-create-response"] = await ((TeacherWorkoutApiClient) _scenarioContext["Ion"]).ThemeCreateAsync(); + } + + [When(@"Vasile requests themes")] + public async Task WhenVasileRequestsThemes() + { + _scenarioContext["themes"] = await ((TeacherWorkoutApiClient) _scenarioContext["Vasile"]).ThemesAsync(); + } + + [Then(@"Vasile receives the theme")] + public void ThenVasileReceivesTheTheme() + { + _scenarioContext["themes"] + .Should() + .MatchResponse("Responses/Query/Themes/VisibleToVasileAsAnonymous.json"); + } + + [Then(@"the theme was created successfully")] + public void ThenTheThemeWasCreatedSuccessfully() + { + _scenarioContext["theme-create-response"] + .Should() + .MatchResponse("Responses/Mutation/ThemeCreate/Success.json"); + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/Steps/UserStepDefinitions.cs b/TeacherWorkout.Specs/Steps/UserStepDefinitions.cs new file mode 100644 index 0000000..622a24e --- /dev/null +++ b/TeacherWorkout.Specs/Steps/UserStepDefinitions.cs @@ -0,0 +1,29 @@ +using TechTalk.SpecFlow; + +namespace TeacherWorkout.Specs.Steps +{ + [Binding] + public class UserStepDefinitions + { + private readonly ScenarioContext _scenarioContext; + private readonly GraphQLServer _server; + + public UserStepDefinitions(ScenarioContext scenarioContext, GraphQLServer server) + { + _scenarioContext = scenarioContext; + _server = server; + } + + [Given(@"Ion is an admin")] + public void GivenIonIsAnAdmin() + { + _scenarioContext["Ion"] = new TeacherWorkoutApiClient(_server.Client); + } + + [Given(@"Vasile is an anonymous user")] + public void GivenVasileIsAnAnonymousUser() + { + _scenarioContext["Vasile"] = new TeacherWorkoutApiClient(_server.Client); + } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Specs/TeacherWorkout.Specs.csproj b/TeacherWorkout.Specs/TeacherWorkout.Specs.csproj new file mode 100644 index 0000000..6ce27d0 --- /dev/null +++ b/TeacherWorkout.Specs/TeacherWorkout.Specs.csproj @@ -0,0 +1,40 @@ + + + + net5.0 + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + + + + + + + diff --git a/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs b/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs new file mode 100644 index 0000000..3a0f3c2 --- /dev/null +++ b/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs @@ -0,0 +1,66 @@ +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using TeacherWorkout.Specs.Extensions; + +namespace TeacherWorkout.Specs +{ + public class TeacherWorkoutApiClient + { + enum Queries + { + Themes + } + + enum Mutations + { + ThemeCreate + } + + private readonly HttpClient _client; + + public TeacherWorkoutApiClient(HttpClient client) + { + _client = client; + } + + public async Task ThemesAsync() + { + return await SendRequest(QueryFor(Queries.Themes), new {}); + } + + public async Task ThemeCreateAsync() + { + return await SendRequest(MutationFor(Mutations.ThemeCreate), new + { + input = new + { + title = "foo" + } + }); + } + + private string QueryFor(Queries query) + { + return GraphQL("Query", query.ToString()); + } + + private string MutationFor(Mutations mutation) + { + return GraphQL("Mutation", mutation.ToString()); + } + + private string GraphQL(string category, string name) + { + return File.ReadAllText($"GraphQL/{category}/{name}.graphql"); + } + + private async Task SendRequest(string query, object variables) + { + var content = new StringContent(new {query, variables}.ToJson(), Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("http://localhost/graphql", content); + return await response.Content.ReadAsStringAsync(); + } + } +} diff --git a/TeacherWorkout.sln b/TeacherWorkout.sln index 5625f18..89fa1f2 100644 --- a/TeacherWorkout.sln +++ b/TeacherWorkout.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.30114.105 @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeacherWorkout.MockData", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeacherWorkout.Data", "TeacherWorkout.Data\TeacherWorkout.Data.csproj", "{C4441C07-C318-4077-B917-1516D3926D5A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeacherWorkout.Specs", "TeacherWorkout.Specs\TeacherWorkout.Specs.csproj", "{5390BAF3-9620-4E85-B5FC-23DC96FA915B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -72,5 +74,17 @@ Global {C4441C07-C318-4077-B917-1516D3926D5A}.Release|x64.Build.0 = Release|Any CPU {C4441C07-C318-4077-B917-1516D3926D5A}.Release|x86.ActiveCfg = Release|Any CPU {C4441C07-C318-4077-B917-1516D3926D5A}.Release|x86.Build.0 = Release|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Debug|x64.ActiveCfg = Debug|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Debug|x64.Build.0 = Debug|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Debug|x86.ActiveCfg = Debug|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Debug|x86.Build.0 = Debug|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Release|Any CPU.Build.0 = Release|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Release|x64.ActiveCfg = Release|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Release|x64.Build.0 = Release|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Release|x86.ActiveCfg = Release|Any CPU + {5390BAF3-9620-4E85-B5FC-23DC96FA915B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal