Updated to current conventions (#11)

* Updated to current conventions

-Changed startup and testing to generic host.
-Changed exception handling from GlobalExceptionFilter to startup middleware.
-Changed DTO validation to IValidatableObject.
-Replaced TestStartup with generic host web application factory.
-Implemented current testing conventions, folder structure, integration tests, unit tests, etc.
-Implemented DTO validation tests.
-Created Constants class.
-General code cleanup.

* Docker file and client updates

-Created dockerfile to build client and service.
-Added @fluentui/react package to client.

* Completed upgrade to fluent and packages updates

-Completely updated App.tsx to fluent by replacing Customizer with a React.Fragment.
-Removed office-ui-fabric-core and office-ui-fabric-react packages which are now rolled into the @fluentui/react package.
-Updated to latest packages with npm update.

* Updated dependencies for deployment and dockerfile

-Moved package dependencies into devDependencies to optimize deployment.
-Fixed dockerfile comment.

Co-authored-by: Ben Werle <v-bewerl@microsoft.com>
This commit is contained in:
Ben Werle 2020-07-23 19:30:20 -07:00 коммит произвёл GitHub
Родитель 711d7441ca
Коммит a61bc99e04
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
62 изменённых файлов: 3565 добавлений и 1594 удалений

11
.dockerignore Normal file
Просмотреть файл

@ -0,0 +1,11 @@
.dockerignore
.env
.git
.gitignore
.vs
.vscode
docker-compose.yml
docker-compose.*.yml
*/bin
*/obj
*/node_modules

3011
client/package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -3,77 +3,18 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@babel/core": "7.4.3",
"@svgr/webpack": "4.1.0",
"@types/jest": "24.0.13",
"@types/node": "12.0.2",
"@types/node-fetch": "^2.3.7",
"@types/react": "16.8.18",
"@types/react-adal": "^0.4.1",
"@types/react-dom": "16.8.4",
"@types/react-redux": "^7.1.1",
"@types/react-router-dom": "^4.3.4",
"@typescript-eslint/eslint-plugin": "1.6.0",
"@typescript-eslint/parser": "1.6.0",
"@uifabric/fluent-theme": "^7.0.2",
"babel-eslint": "10.0.1",
"babel-jest": "^24.8.0",
"babel-loader": "8.0.5",
"babel-plugin-named-asset-import": "^0.3.2",
"babel-preset-react-app": "^9.0.0",
"@fluentui/react": "^7.123.7",
"@uifabric/fluent-theme": "^7.1.115",
"camelcase": "^5.2.0",
"case-sensitive-paths-webpack-plugin": "2.2.0",
"css-loader": "^1.0.1",
"dotenv": "6.2.0",
"dotenv-expand": "4.2.0",
"eslint": "^5.16.0",
"eslint-config-react-app": "^4.0.1",
"eslint-loader": "2.1.2",
"eslint-plugin-flowtype": "2.50.1",
"eslint-plugin-import": "2.16.0",
"eslint-plugin-jsx-a11y": "6.2.1",
"eslint-plugin-react": "7.12.4",
"eslint-plugin-react-hooks": "^1.5.0",
"file-loader": "3.0.1",
"fs-extra": "7.0.1",
"html-webpack-plugin": "4.0.0-beta.5",
"identity-obj-proxy": "3.0.0",
"is-wsl": "^1.1.0",
"jest": "24.7.1",
"jest-environment-jsdom-fourteen": "0.1.0",
"jest-resolve": "24.7.1",
"jest-watch-typeahead": "0.3.0",
"mini-css-extract-plugin": "0.5.0",
"node-sass": "^4.12.0",
"office-ui-fabric-core": "^10.1.0",
"office-ui-fabric-react": "^7.7.0",
"optimize-css-assets-webpack-plugin": "5.0.1",
"pnp-webpack-plugin": "1.2.1",
"postcss-flexbugs-fixes": "4.1.0",
"postcss-loader": "3.0.0",
"postcss-normalize": "7.0.1",
"postcss-preset-env": "6.6.0",
"postcss-safe-parser": "4.0.1",
"react": "^16.8.6",
"react": "^16.13.1",
"react-adal": "^0.4.24",
"react-app-polyfill": "^1.0.1",
"react-dev-utils": "^9.0.1",
"react-dom": "^16.8.6",
"react-redux": "^7.1.0",
"react-router-dom": "^4.3.1",
"resolve": "1.10.0",
"sass-loader": "^7.1.0",
"semver": "6.0.0",
"style-loader": "0.23.1",
"terser-webpack-plugin": "1.2.3",
"ts-pnp": "1.1.2",
"typescript": "3.4.5",
"typings-for-css-modules-loader": "^1.7.0",
"url-loader": "1.1.2",
"webpack": "4.29.6",
"webpack-dev-server": "3.2.1",
"webpack-manifest-plugin": "2.0.4",
"workbox-webpack-plugin": "4.2.0"
"react-app-polyfill": "^1.0.6",
"react-dom": "^16.13.1",
"react-redux": "^7.2.0",
"react-router-dom": "^4.3.1"
},
"scripts": {
"start": "node scripts/start.js",
@ -147,6 +88,64 @@
]
},
"devDependencies": {
"ts-jest": "^24.0.2"
"@babel/core": "7.4.3",
"@svgr/webpack": "4.1.0",
"@types/jest": "24.0.13",
"@types/node": "12.0.2",
"@types/node-fetch": "^2.5.7",
"@types/react": "16.8.18",
"@types/react-adal": "^0.4.2",
"@types/react-dom": "16.8.4",
"@types/react-redux": "^7.1.9",
"@types/react-router-dom": "^4.3.5",
"@typescript-eslint/eslint-plugin": "1.6.0",
"@typescript-eslint/parser": "1.6.0",
"babel-eslint": "10.0.1",
"babel-jest": "^24.9.0",
"babel-loader": "8.0.5",
"babel-plugin-named-asset-import": "^0.3.6",
"babel-preset-react-app": "^9.1.2",
"case-sensitive-paths-webpack-plugin": "2.2.0",
"css-loader": "^1.0.1",
"eslint": "^5.16.0",
"eslint-config-react-app": "^4.0.1",
"eslint-loader": "2.1.2",
"eslint-plugin-flowtype": "2.50.1",
"eslint-plugin-import": "2.16.0",
"eslint-plugin-jsx-a11y": "6.2.1",
"eslint-plugin-react": "7.12.4",
"eslint-plugin-react-hooks": "^1.7.0",
"file-loader": "3.0.1",
"html-webpack-plugin": "4.0.0-beta.5",
"identity-obj-proxy": "3.0.0",
"is-wsl": "^1.1.0",
"jest": "24.7.1",
"jest-environment-jsdom-fourteen": "0.1.0",
"jest-resolve": "24.7.1",
"jest-watch-typeahead": "0.3.0",
"mini-css-extract-plugin": "0.5.0",
"node-sass": "^4.14.1",
"optimize-css-assets-webpack-plugin": "5.0.1",
"pnp-webpack-plugin": "1.2.1",
"postcss-flexbugs-fixes": "4.1.0",
"postcss-loader": "3.0.0",
"postcss-normalize": "7.0.1",
"postcss-preset-env": "6.6.0",
"postcss-safe-parser": "4.0.1",
"react-dev-utils": "^9.1.0",
"resolve": "1.10.0",
"sass-loader": "^7.3.1",
"semver": "6.0.0",
"style-loader": "0.23.1",
"terser-webpack-plugin": "1.2.3",
"ts-jest": "^24.3.0",
"ts-pnp": "1.1.2",
"typescript": "3.4.5",
"typings-for-css-modules-loader": "^1.7.0",
"url-loader": "1.1.2",
"webpack": "4.29.6",
"webpack-dev-server": "3.2.1",
"webpack-manifest-plugin": "2.0.4",
"workbox-webpack-plugin": "4.2.0"
}
}

Просмотреть файл

@ -1,15 +1,13 @@
import 'office-ui-fabric-core/dist/css/fabric.css';
import 'office-ui-fabric-react/dist/css/fabric.css';
import { FluentCustomizations } from '@uifabric/fluent-theme';
import {
Customizer,
IButtonProps,
Icon,
Image,
initializeIcons,
Nav,
Text
} from 'office-ui-fabric-react';
} from '@fluentui/react';
import React from 'react';
import { BrowserRouter, Link, Route } from 'react-router-dom';
@ -24,7 +22,7 @@ initializeIcons();
const App: React.FC = () => {
return (
<BrowserRouter>
<Customizer {...FluentCustomizations}>
<React.Fragment>
<div className="ms-Grid" dir="ltr">
<div className="ms-Grid-row">
<div className={styles.header}>
@ -66,7 +64,7 @@ const App: React.FC = () => {
</div>
</div>
</div>
</Customizer>
</React.Fragment>
</BrowserRouter>
);
};

Просмотреть файл

@ -6,7 +6,7 @@ import {
DetailsList,
DetailsListLayoutMode,
SpinnerSize
} from 'office-ui-fabric-react';
} from '@fluentui/react';
import { IGroupDto, ApiClient } from '../../generated/backend';
const Groups: React.FC = () => {

44
dockerfile Normal file
Просмотреть файл

@ -0,0 +1,44 @@
# to build local image from PowerShell (e.g. when you're authoring this dockerfile):
# docker build -f .\dockerfile . --build-arg BUILDCONFIG=dev
# build backend
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS serviceBuild
# copy backend files into 'service' folder in prep for dotnet commands
COPY service /service
# ensure tests are passing
ARG ASPNETCORE_ENVIRONMENT
WORKDIR /service/Microsoft.DSX.ProjectTemplate.API
RUN dotnet build
# setup frontend
FROM node:12.16.1 AS clientBuild
ARG APP_ENV
RUN echo APP_ENV = ${APP_ENV}
RUN npm config set unsafe-perm true
COPY client /client
# copy auto-generated TS files from API bulid
COPY --from=serviceBuild /client/src/generated/. client/src/generated/
# build frontend
WORKDIR /client
RUN npm i
ENV REACT_APP_ENV=${APP_ENV}
RUN npm run build
# copy our frontend into published app's wwwroot folder
FROM serviceBuild AS publisher
COPY --from=clientBuild /client/build /app/wwwroot/
# build & publish our API projectARG ClientId
ARG ASPNETCORE_ENVIRONMENT
RUN dotnet publish /service/Microsoft.DSX.ProjectTemplate.API/Microsoft.DSX.ProjectTemplate.API.csproj -c Release -o /app
# build runtime image (contains full stack)
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
EXPOSE 80
COPY --from=publisher /app ./
ENTRYPOINT ["dotnet", "Microsoft.DSX.ProjectTemplate.API.dll"]

Просмотреть файл

@ -1,13 +1,21 @@
namespace Microsoft.DSX.ProjectTemplate.API.Controllers
{
using MediatR;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace Microsoft.DSX.ProjectTemplate.API.Controllers
{
/// <summary>
/// Base controller for our web API.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public abstract class BaseController : Controller
{
/// <summary>
/// Gets injected Mediator instance.
/// </summary>
protected IMediator Mediator { get; }
/// <summary>
/// Initializes a new instance of the <see cref="BaseController"/> class.
/// </summary>
@ -17,10 +25,5 @@
{
Mediator = mediator;
}
/// <summary>
/// Gets injected Mediator instance.
/// </summary>
protected IMediator Mediator { get; }
}
}

Просмотреть файл

@ -1,43 +1,66 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using MediatR;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.DSX.ProjectTemplate.Command.Group;
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.API.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Produces("application/json")]
/// <summary>
/// Controller for Group APIs.
/// </summary>
public class GroupsController : BaseController
{
/// <summary>
/// Initializes a new instance of the <see cref="GroupsController"/> class.
/// </summary>
/// <param name="mediator">Mediator instance from dependency injection.</param>
public GroupsController(IMediator mediator) : base(mediator) { }
/// <summary>
/// Get all Groups.
/// </summary>
[HttpGet]
public async Task<ActionResult<IEnumerable<GroupDto>>> GetAllGroups()
{
return Ok(await Mediator.Send(new GetAllGroupsQuery()));
}
/// <summary>
/// Get a Group by its Id.
/// </summary>
/// <param name="id">ID of the Group to get.</param>
[HttpGet("{id}")]
public async Task<ActionResult<GroupDto>> GetGroup(int id)
{
return Ok(await Mediator.Send(new GetGroupByIdQuery() { GroupId = id }));
}
/// <summary>
/// Create a new Group.
/// </summary>
/// <param name="dto">A Group DTO.</param>
[HttpPost]
public async Task<ActionResult<GroupDto>> CreateGroup([FromBody] GroupDto dto)
{
return Ok(await Mediator.Send(new CreateGroupCommand() { Group = dto }));
}
/// <summary>
/// Update an existing Group.
/// </summary>
/// <param name="dto">Updated Group DTO.</param>
[HttpPut]
public async Task<ActionResult<GroupDto>> UpdateGroup([FromBody] GroupDto dto)
{
return Ok(await Mediator.Send(new UpdateGroupCommand() { Group = dto }));
}
/// <summary>
/// Delete an existing Group.
/// </summary>
/// <param name="id">Id of the Group to be deleted.</param>
[HttpDelete("{id}")]
public async Task<ActionResult<GroupDto>> DeleteGroup([FromRoute] int id)
{

Просмотреть файл

@ -1,70 +0,0 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using Microsoft.DSX.ProjectTemplate.Data.Exceptions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using System;
namespace Microsoft.DSX.ProjectTemplate.API
{
/// <summary>
/// This filter will capture unhandled exceptions that occur in
/// controller creation, model binding, action filters, or action methods.
/// </summary>
/// <remarks>https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/filters#exception-filters</remarks>
public class GlobalExceptionFilter : IExceptionFilter
{
private readonly IHostEnvironment _hostingEnvironment;
private readonly ILogger<GlobalExceptionFilter> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="GlobalExceptionFilter"/> class.
/// </summary>
public GlobalExceptionFilter(IHostEnvironment hostingEnvironment, ILogger<GlobalExceptionFilter> logger)
{
_hostingEnvironment = hostingEnvironment;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public void OnException(ExceptionContext context)
{
// transform the exception into JSON
if (context.Exception is ExceptionBase)
{
_logger.LogInformation(context.Exception, context.ActionDescriptor.DisplayName);
ExceptionBase customException = context.Exception as ExceptionBase;
var exceptionJson = new ErrorResponseDto
{
Message = customException.Message,
InnerExceptionMessage = customException.InnerException?.Message,
};
switch (_hostingEnvironment.EnvironmentName.ToLowerInvariant())
{
case "local":
case "dev":
case "development":
exceptionJson.StackTrace = customException.StackTrace;
break;
default:
break;
}
context.Result = new JsonResult(exceptionJson) { StatusCode = (int)customException.StatusCode };
context.ExceptionHandled = true;
}
else
{
// unexpected exception so log to _logger (which probably will be Application Insights)
_logger.LogCritical(context.Exception, context.ActionDescriptor.DisplayName);
// unhandled exception keeps percolating and will be handled by ASP.NET Core
}
}
}
}

Просмотреть файл

@ -1,22 +1,24 @@
using System;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.DSX.ProjectTemplate.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
namespace Microsoft.DSX.ProjectTemplate.API
{
/// <summary>
/// The main class of the application.
/// </summary>
public class Program
{
{
/// <summary>
/// Main method where execution begins.
/// </summary>
/// <param name="args">Injected by the operating system.</param>
public static void Main(string[] args)
{
//var webHost = CreateWebHostBuilder(args).Build();
IHost host = CreateHostBuilder(args).Build();
var logger = host.Services.GetRequiredService<ILogger<Program>>();
@ -29,27 +31,35 @@ namespace Microsoft.DSX.ProjectTemplate.API
{
logger.LogCritical(ex, ex.Message);
throw;
}
}
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
/// <summary>
/// Creates a host builder for this API.
/// </summary>
/// <param name="args">Arguments for the web host builder.</param>
/// <returns>A fully configured host builder.</returns>
public static IHostBuilder CreateHostBuilder(string[] args)
{
return Host
.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
private static void RunDatabaseMigrations(IHost host, ILogger logger)
{
logger.LogInformation($"Running database migrations");
logger.LogInformation($"Running database migrations.");
using (var serviceScope = host.Services.CreateScope())
{
var context = serviceScope.ServiceProvider.GetRequiredService<ProjectTemplateDbContext>();
context.Database.Migrate();
}
logger.LogInformation($"Completed database migrations");
logger.LogInformation($"Completed database migrations.");
}
}
}

Просмотреть файл

@ -0,0 +1,138 @@
using AutoMapper;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.DSX.ProjectTemplate.Data;
using Microsoft.DSX.ProjectTemplate.Data.Abstractions;
using Microsoft.DSX.ProjectTemplate.Data.Exceptions;
using Microsoft.DSX.ProjectTemplate.Data.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace Microsoft.DSX.ProjectTemplate.API
{
/// <summary>
/// Startup extensions.
/// </summary>
public static class StartupExtensions
{
/// <summary>
/// Register the database connections used by the API with DI.
/// </summary>
public static IServiceCollection AddDbConnections(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment)
{
/*
* NOTE: All database queries against strings are case-insensitive unless collation is set against
* the database, table, or column. This cannot be overridden by the client and the results are
* evaluated locally (e.g. .Equals(string, comparison)).
* We should avoid client evaluation where possible!
* See - https://github.com/aspnet/EntityFrameworkCore/issues/1222#issuecomment-443119582
*/
if (environment.IsEnvironment(Constants.Environments.Test))
{
return services;
}
return services.AddDbContext<ProjectTemplateDbContext>(options => options
.UseSqlServer(configuration.GetConnectionString("Database")));
}
/// <summary>
/// Configure the mapping profiles.
/// </summary>
public static IServiceCollection AddAutoMapperProfiles(this IServiceCollection services)
{
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfile>());
return services.AddSingleton(config.CreateMapper());
}
/// <summary>
/// Register the services used by the API with DI
/// </summary>
public static IServiceCollection AddServices(this IServiceCollection services)
{
return services
.AddOptions()
.AddSingleton<IEmailService, EmailService>();
}
/// <summary>
/// Configure Cross-Origin Request Sharing (CORS) options.
/// </summary>
public static IServiceCollection AddCors(this IServiceCollection services)
{
return services.AddCors(options =>
{
options.AddPolicy("CorsPolicy",
builder =>
{
builder
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
}
/// <summary>
/// Configure project template swagger document.
/// </summary>
public static IServiceCollection AddSwaggerDocument(this IServiceCollection services)
{
return services
.AddSwaggerDocument(config =>
{
config.PostProcess = document =>
{
document.Info.Version = "v1";
document.Info.Title = "Project Template API";
document.Info.Contact = new NSwag.OpenApiContact
{
Name = "Devices Software Experiences"
};
};
});
}
/// <summary>
/// Startup extension method to add our custom exception handling.
/// </summary>
public static IApplicationBuilder UseExceptionHandling(this IApplicationBuilder app)
{
return app.UseExceptionHandler(options =>
{
options.Run(async context =>
{
var pathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
using var scope = context.RequestServices.CreateScope();
var hostEnvironment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger(pathFeature?.Error.GetType());
logger.LogError(pathFeature?.Error, pathFeature?.Path);
object exceptionJson = hostEnvironment.EnvironmentName.ToLower() switch
{
var env when
env == Constants.Environments.Local ||
env == Constants.Environments.Test ||
env == Constants.Environments.Dev ||
env == Constants.Environments.Development => new { pathFeature?.Error.Message, pathFeature?.Error.StackTrace },
_ => new { pathFeature?.Error.Message }
};
// if we threw the exception, ensure the response contains the right status code
context.Response.StatusCode = pathFeature?.Error is ExceptionBase ? (int)(pathFeature.Error as ExceptionBase)?.StatusCode : 500;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(JsonSerializer.Serialize(exceptionJson), context.RequestAborted);
});
});
}
}
}

Просмотреть файл

@ -1,13 +1,8 @@
using AutoMapper;
using MediatR;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.DSX.ProjectTemplate.Command;
using Microsoft.DSX.ProjectTemplate.Data;
using Microsoft.DSX.ProjectTemplate.Data.Abstractions;
using Microsoft.DSX.ProjectTemplate.Data.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@ -15,76 +10,48 @@ using Microsoft.Extensions.Hosting;
[assembly: ApiConventionType(typeof(DefaultApiConventions))]
namespace Microsoft.DSX.ProjectTemplate.API
{
/// <summary>
/// Class that initializes our API.
/// </summary>
public class Startup
{
public Startup(IConfiguration configuration, IHostEnvironment hostingEnvironment)
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _environment;
/// <summary>
/// Initializes a new instance of the <see cref="Startup"/> class.
/// </summary>
/// <param name="configuration">Configuration of the web API.</param>
/// <param name="environment">Hosting environment.</param>
public Startup(IConfiguration configuration, IHostEnvironment environment)
{
Configuration = configuration;
HostingEnvironment = hostingEnvironment;
_configuration = configuration;
_environment = environment;
}
readonly string CorsPolicy = "CorsPolicy";
public IConfiguration Configuration { get; }
public IHostEnvironment HostingEnvironment { get; }
// This method gets called by the runtime. Use this method to add services to the container.
/// <summary>
/// This method gets called by the runtime and is used to add services to the DI container.
/// </summary>
/// <param name="services">Collection of services to be provided by DI.</param>
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<GlobalExceptionFilter>();
services
.AddCors(options =>
{
options.AddPolicy(CorsPolicy,
builder => builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
})
.AddDbConnections(_configuration, _environment)
.AddAutoMapperProfiles()
.AddServices()
.AddMediatR(typeof(HandlerBase))
.AddCors()
.AddSwaggerDocument()
.AddControllers();
// Register Entity Framework Core
ConfigureDatabase(services);
// Register MediatR and handlers
services.AddMediatR(typeof(HandlerBase));
// Register AutoMapper
var mapperConfig = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfile>());
services.AddSingleton(mapperConfig.CreateMapper());
// Register our custom services.
services.AddSingleton<IEmailService, EmailService>();
// Register the Swagger services
services.AddSwaggerDocument(config =>
{
config.PostProcess = document =>
{
document.Info.Version = "v1";
document.Info.Title = "Project Template API";
document.Info.Contact = new NSwag.OpenApiContact
{
Name = "Devices Software Experiences",
Email = "dsx@microsoft.com",
Url = "https://deviceswiki.com/wiki/DSX"
};
};
});
}
protected virtual void ConfigureDatabase(IServiceCollection services)
/// <summary>
/// This method gets called by the runtime and is used to configure the HTTP request pipeline.
/// </summary>
/// <param name="app">Application builder.</param>
public virtual void Configure(IApplicationBuilder app)
{
services.AddDbContext<ProjectTemplateDbContext>(options =>
{
options.UseSqlServer(Configuration.GetConnectionString("Database"));
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public virtual void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
if (_environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
@ -94,25 +61,14 @@ namespace Microsoft.DSX.ProjectTemplate.API
app.UseHsts();
}
// Register the Swagger generator and the Swagger UI middlewares
app.UseOpenApi();
app.UseSwaggerUi3();
if (!env.IsEnvironment("Test"))
{
app.UseHttpsRedirection();
}
app.UseRouting();
app.UseCors(CorsPolicy);
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
app
.UseExceptionHandling()
.UseOpenApi()
.UseSwaggerUi3()
.UseRouting()
.UseCors("CorsPolicy")
.UseAuthorization()
.UseEndpoints(endpoints => endpoints.MapControllers());
}
}
}

Просмотреть файл

@ -1,42 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.DSX.ProjectTemplate.Data;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Microsoft.DSX.ProjectTemplate.API
{
public class TestStartup : Startup
{
public TestStartup(IConfiguration configuration, IHostEnvironment hostingEnvironment)
: base(configuration, hostingEnvironment)
{
}
protected override void ConfigureDatabase(IServiceCollection services)
{
services
.AddDbContext<ProjectTemplateDbContext>(options =>
options.UseInMemoryDatabase("InMemoryTestDatabase"), ServiceLifetime.Singleton);
services
.AddTransient<TestDataSeeder>();
}
public override void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// perform all configuration in the normal startup
base.Configure(app, env);
// seed the database with test data
using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
{
var seeder = serviceScope.ServiceProvider.GetService<TestDataSeeder>();
seeder.SeedTestData();
}
}
}
}

Просмотреть файл

@ -6,7 +6,7 @@ using Microsoft.DSX.ProjectTemplate.Data;
namespace Microsoft.DSX.ProjectTemplate.Command
{
/// <summary>
/// Base class that all command handlers inherit from
/// Base class of all command handlers.
/// </summary>
public abstract class CommandHandlerBase : HandlerBase
{

Просмотреть файл

@ -30,23 +30,13 @@ namespace Microsoft.DSX.ProjectTemplate.Command.Group
public async Task<GroupDto> Handle(CreateGroupCommand request, CancellationToken cancellationToken)
{
if (request.Group == null)
{
throw new BadRequestException($"A valid {nameof(Data.Models.Group)} must be provided");
}
if (!request.Group.IsValid())
{
throw new BadRequestException(request.Group.GetValidationErrors());
}
var dto = request.Group;
bool nameAlreadyUsed = await Database.Groups
.AnyAsync(e => e.Name.Trim() == dto.Name.Trim());
if (nameAlreadyUsed)
{
throw new BadRequestException($"{nameof(dto.Name)} '{dto.Name}' already used");
throw new BadRequestException($"{nameof(dto.Name)} '{dto.Name}' already used.");
}
var model = new Data.Models.Group()

Просмотреть файл

@ -30,14 +30,14 @@ namespace Microsoft.DSX.ProjectTemplate.Command.Group
{
if (request.GroupId <= 0)
{
throw new BadRequestException($"A valid {nameof(Data.Models.Group)} Id must be provided");
throw new BadRequestException($"A valid {nameof(Data.Models.Group)} Id must be provided.");
}
var group = await Database.Groups.FindAsync(request.GroupId);
if (group == null)
{
throw new EntityNotFoundException($"{nameof(Data.Models.Group)} not found");
throw new EntityNotFoundException($"{nameof(Data.Models.Group)} not found.");
}
Database.Groups.Remove(group);

Просмотреть файл

@ -45,7 +45,7 @@ namespace Microsoft.DSX.ProjectTemplate.Command.Group
{
if (request.GroupId <= 0)
{
throw new BadRequestException($"A valid {nameof(Data.Models.Group)} Id must be provided");
throw new BadRequestException($"A valid {nameof(Data.Models.Group)} Id must be provided.");
}
var innerResult = await Database.Groups
@ -53,7 +53,7 @@ namespace Microsoft.DSX.ProjectTemplate.Command.Group
if (innerResult == null)
{
throw new EntityNotFoundException($"{nameof(Data.Models.Group)} with Id {request.GroupId} cannot be found");
throw new EntityNotFoundException($"{nameof(Data.Models.Group)} with Id {request.GroupId} cannot be found.");
}
return Mapper.Map<GroupDto>(innerResult);

Просмотреть файл

@ -34,11 +34,11 @@ namespace Microsoft.DSX.ProjectTemplate.Command.Group
// As a subscriber, we run on the thread pool so we need to handle our own failures appropriately.
try
{
await _emailService.SendEmailAsync("a@microsoft.com", "b@microsoft.com", $"New group '{notification.Group.Name}' was created", "lorem ipsum");
await _emailService.SendEmailAsync("a@microsoft.com", "b@microsoft.com", $"New group '{notification.Group.Name}' was created.", "lorem ipsum");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email");
_logger.LogError(ex, "Failed to send email.");
}
}
}

Просмотреть файл

@ -30,16 +30,6 @@ namespace Microsoft.DSX.ProjectTemplate.Command.Group
public async Task<GroupDto> Handle(UpdateGroupCommand request, CancellationToken cancellationToken)
{
if (request.Group == null)
{
throw new BadRequestException($"A valid {nameof(Data.Models.Group)} object must be provided");
}
if (!request.Group.IsValid())
{
throw new BadRequestException(request.Group.GetValidationErrors());
}
var dto = request.Group;
if (dto.Id <= 0)
@ -53,7 +43,7 @@ namespace Microsoft.DSX.ProjectTemplate.Command.Group
if (model == null)
{
throw new EntityNotFoundException($"{nameof(Data.Models.Group)} with Id {dto.Id} not found");
throw new EntityNotFoundException($"{nameof(Data.Models.Group)} with Id {dto.Id} not found.");
}
// ensure uniqueness of name
@ -61,7 +51,7 @@ namespace Microsoft.DSX.ProjectTemplate.Command.Group
.AnyAsync(e => e.Name.Trim() == dto.Name.Trim()) && dto.Name != (model.Name);
if (nameAlreadyUsed)
{
throw new BadRequestException($"{nameof(dto.Name)} {dto.Name} already used");
throw new BadRequestException($"{nameof(dto.Name)} {dto.Name} already used.");
}
model.Name = dto.Name;

Просмотреть файл

@ -6,7 +6,7 @@ using Microsoft.DSX.ProjectTemplate.Data;
namespace Microsoft.DSX.ProjectTemplate.Command
{
/// <summary>
/// Base class that all handlers inherit from
/// Base class of all handlers.
/// </summary>
public abstract class HandlerBase
{

Просмотреть файл

@ -6,7 +6,7 @@ using Microsoft.DSX.ProjectTemplate.Data;
namespace Microsoft.DSX.ProjectTemplate.Command
{
/// <summary>
/// Base class that all query handlers inherit from
/// Base class of all query handlers.
/// </summary>
public abstract class QueryHandlerBase : HandlerBase
{

Просмотреть файл

@ -0,0 +1,18 @@
namespace Microsoft.DSX.ProjectTemplate.Data
{
public static class Constants
{
public static class Environments
{
public const string Local = "local";
public const string Test = "test";
public const string Dev = "dev";
public const string Development = "development";
}
public static class MaximumLengths
{
public const int StringColumn = 512;
}
}
}

Просмотреть файл

@ -5,6 +5,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.DTOs
public abstract class AuditDto<TType> : BaseDto<TType>
{
public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; }
}
}

Просмотреть файл

@ -1,25 +1,15 @@
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Text;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Microsoft.DSX.ProjectTemplate.Data.DTOs
{
public abstract class BaseDto<TType>
public abstract class BaseDto<TType> : IValidatableObject
{
public TType Id { get; set; }
protected ModelStateDictionary ModelState { get; } = new ModelStateDictionary();
public abstract bool IsValid();
public virtual string GetValidationErrors()
{
StringBuilder sb = new StringBuilder();
foreach (var error in ModelState)
{
sb.AppendLine($"{error.Key} : {error.Value}");
}
return sb.ToString();
}
protected ModelStateDictionary ModelState { get; } = new ModelStateDictionary();
public abstract IEnumerable<ValidationResult> Validate(ValidationContext validationContext);
}
}

Просмотреть файл

@ -1,19 +1,27 @@
namespace Microsoft.DSX.ProjectTemplate.Data.DTOs
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Microsoft.DSX.ProjectTemplate.Data.DTOs
{
public class GroupDto : AuditDto<int>
{
public string Name { get; set; }
public bool IsActive { get; set; }
public override bool IsValid()
{
if (string.IsNullOrWhiteSpace(Name))
{
ModelState.AddModelError(nameof(Name), $"{nameof(Name)} cannot be null or empty.");
public override IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!string.IsNullOrWhiteSpace(Name))
{
if (Name.Length > Constants.MaximumLengths.StringColumn)
{
yield return new ValidationResult($"{nameof(Name)} must be less than {Constants.MaximumLengths.StringColumn} characters.", new[] { nameof(Name) });
}
}
return ModelState.IsValid;
}
else
{
yield return new ValidationResult($"{nameof(Name)} cannot be null or empty.", new[] { nameof(Name) });
}
}
}
}

Просмотреть файл

@ -5,11 +5,11 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Events
{
public class GroupCreatedDomainEvent : INotification
{
public Group Group { get; }
public GroupCreatedDomainEvent(Group group)
{
Group = group;
}
public Group Group { get; }
}
}

Просмотреть файл

@ -6,9 +6,9 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Exceptions
{
private static string DefaultMessageHeader => "Bad Request";
public override HttpStatusCode StatusCode => HttpStatusCode.BadRequest;
public BadRequestException(string message, string messageHeader = null)
: base(message, messageHeader ?? DefaultMessageHeader) { }
public override HttpStatusCode StatusCode => HttpStatusCode.BadRequest;
}
}

Просмотреть файл

@ -6,9 +6,9 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Exceptions
{
private static string DefaultMessageHeader => "Not found";
public override HttpStatusCode StatusCode => HttpStatusCode.NotFound;
public EntityNotFoundException(string message, string messageHeader = null)
: base(message, messageHeader ?? DefaultMessageHeader) { }
public override HttpStatusCode StatusCode => HttpStatusCode.NotFound;
}
}

Просмотреть файл

@ -5,14 +5,14 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Exceptions
{
public abstract class ExceptionBase : Exception
{
public abstract HttpStatusCode StatusCode { get; }
public string MessageHeader { get; }
protected ExceptionBase(string message, string messageHeader = null)
: base(message)
{
MessageHeader = messageHeader;
}
public abstract HttpStatusCode StatusCode { get; }
public string MessageHeader { get; }
}
}

Просмотреть файл

@ -35,7 +35,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<DateTime>("UpdatedDate");
@ -84,7 +84,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<DateTime>("CreatedDate");
b.Property<string>("Name")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<DateTime>("UpdatedDate");
@ -111,7 +111,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<int?>("OwnerId");
@ -140,7 +140,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<int>("GroupId");
b.Property<string>("Name")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<DateTime>("UpdatedDate");
@ -160,7 +160,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<DateTime>("CreatedDate");
b.Property<string>("DisplayName")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<string>("Metadata");
@ -187,22 +187,22 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b1.Property<string>("LocationAddressLine1")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationAddressLine2")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationCity")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationCountry")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationStateProvince")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationZipCode")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.HasKey("LibraryId");

Просмотреть файл

@ -16,13 +16,13 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
.Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn),
CreatedDate = table.Column<DateTime>(nullable: false),
UpdatedDate = table.Column<DateTime>(nullable: false),
Name = table.Column<string>(maxLength: 512, nullable: true),
Address_LocationAddressLine1 = table.Column<string>(maxLength: 512, nullable: true),
Address_LocationAddressLine2 = table.Column<string>(maxLength: 512, nullable: true),
Address_LocationCity = table.Column<string>(maxLength: 512, nullable: true),
Address_LocationStateProvince = table.Column<string>(maxLength: 512, nullable: true),
Address_LocationZipCode = table.Column<string>(maxLength: 512, nullable: true),
Address_LocationCountry = table.Column<string>(maxLength: 512, nullable: true)
Name = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: true),
Address_LocationAddressLine1 = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: true),
Address_LocationAddressLine2 = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: true),
Address_LocationCity = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: true),
Address_LocationStateProvince = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: true),
Address_LocationZipCode = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: true),
Address_LocationCountry = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: true)
},
constraints: table =>
{
@ -37,7 +37,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
.Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn),
CreatedDate = table.Column<DateTime>(nullable: false),
UpdatedDate = table.Column<DateTime>(nullable: false),
DisplayName = table.Column<string>(maxLength: 512, nullable: true),
DisplayName = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: true),
Metadata = table.Column<string>(nullable: true)
},
constraints: table =>
@ -53,7 +53,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
.Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn),
CreatedDate = table.Column<DateTime>(nullable: false),
UpdatedDate = table.Column<DateTime>(nullable: false),
Name = table.Column<string>(maxLength: 512, nullable: false),
Name = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: false),
IsActive = table.Column<bool>(nullable: false),
DefaultLibraryId = table.Column<int>(nullable: true)
},
@ -76,7 +76,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
.Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn),
CreatedDate = table.Column<DateTime>(nullable: false),
UpdatedDate = table.Column<DateTime>(nullable: false),
Name = table.Column<string>(maxLength: 512, nullable: false),
Name = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: false),
IsActive = table.Column<bool>(nullable: false),
GroupId = table.Column<int>(nullable: false),
OwnerId = table.Column<int>(nullable: true)
@ -106,7 +106,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
.Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn),
CreatedDate = table.Column<DateTime>(nullable: false),
UpdatedDate = table.Column<DateTime>(nullable: false),
Name = table.Column<string>(maxLength: 512, nullable: true),
Name = table.Column<string>(maxLength: Constants.MaximumLengths.StringColumn, nullable: true),
GroupId = table.Column<int>(nullable: false)
},
constraints: table =>

Просмотреть файл

@ -33,7 +33,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<DateTime>("UpdatedDate");
@ -82,7 +82,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<DateTime>("CreatedDate");
b.Property<string>("Name")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<DateTime>("UpdatedDate");
@ -109,7 +109,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<int?>("OwnerId");
@ -138,7 +138,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<int>("GroupId");
b.Property<string>("Name")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<DateTime>("UpdatedDate");
@ -158,7 +158,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
b.Property<DateTime>("CreatedDate");
b.Property<string>("DisplayName")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b.Property<string>("Metadata");
@ -185,22 +185,22 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Migrations
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b1.Property<string>("LocationAddressLine1")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationAddressLine2")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationCity")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationCountry")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationStateProvince")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.Property<string>("LocationZipCode")
.HasMaxLength(512);
.HasMaxLength(Constants.MaximumLengths.StringColumn);
b1.HasKey("LibraryId");

Просмотреть файл

@ -1,27 +1,27 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
namespace Microsoft.DSX.ProjectTemplate.Data.Models
{
[Owned]
public class Address
{
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string LocationAddressLine1 { get; set; }
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string LocationAddressLine2 { get; set; }
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string LocationCity { get; set; }
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string LocationStateProvince { get; set; }
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string LocationZipCode { get; set; }
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string LocationCountry { get; set; }
}
}

Просмотреть файл

@ -6,7 +6,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Models
public class Group : AuditModel<int>
{
[Required]
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string Name { get; set; }
public bool IsActive { get; set; }

Просмотреть файл

@ -4,7 +4,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Models
{
public class Library : AuditModel<int>
{
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string Name { get; set; }
public Address Address { get; set; }

Просмотреть файл

@ -5,7 +5,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Models
public class Project : AuditModel<int>
{
[Required]
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string Name { get; set; }
public bool IsActive { get; set; }

Просмотреть файл

@ -4,7 +4,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Models
{
public class Team : AuditModel<int>
{
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string Name { get; set; }
public virtual Group Group { get; set; }

Просмотреть файл

@ -5,7 +5,7 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Models
{
public class User : AuditModel<int>
{
[MaxLength(512)]
[MaxLength(Constants.MaximumLengths.StringColumn)]
public string DisplayName { get; set; }
public Dictionary<string, string> Metadata { get; set; }

Просмотреть файл

@ -1,12 +1,11 @@
using Microsoft.DSX.ProjectTemplate.Data.Models;
using Microsoft.EntityFrameworkCore;
//using Microsoft.AspNetCore.Mvc.NewtonsoftJson;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace Microsoft.DSX.ProjectTemplate.Data
{

Просмотреть файл

@ -15,13 +15,13 @@ namespace Microsoft.DSX.ProjectTemplate.Data.Utilities
public void SeedTestData()
{
_logger.LogInformation("Database seeding started");
_logger.LogInformation("Database seeding started.");
SeedGroups(10);
SeedProjects(10);
_logger.LogInformation("Database seeding completed");
_logger.LogInformation("Database seeding completed.");
}
private void SeedGroups(int entityCount)

Просмотреть файл

@ -1,25 +0,0 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Microsoft.DSX.ProjectTemplate.Test
{
public class CustomWebApplicationFactory<TStartup>
: WebApplicationFactory<TStartup>
where TStartup : class
{
/// <summary>
/// Code inside this method runs _before_ <see cref="TStartup"/> does.
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder
.UseEnvironment("Test")
.UseStartup<TStartup>();
}
}
}

Просмотреть файл

@ -1,105 +0,0 @@
using FluentAssertions;
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Group
{
[TestClass]
[TestCategory("Group")]
public class GroupControllerTests : IntegrationTest
{
[TestMethod]
public async Task GetAll_Valid_Success()
{
// Arrange
var client = Factory.CreateClient();
// Act
var response = await client.GetAsync("/api/groups");
// Assert
var result = await EnsureObject<IEnumerable<GroupDto>>(response);
result.Should().HaveCountGreaterThan(0);
}
[DataTestMethod]
[DataRow(1)]
public async Task GetById_Valid_Success(int groupId)
{
// Arrange
var client = Factory.CreateClient();
// Act
var response = await client.GetAsync($"/api/groups/{groupId}");
// Assert
var result = await EnsureObject<GroupDto>(response);
result.Should().NotBeNull();
result.Id.Should().Be(groupId);
result.IsValid().Should().BeTrue();
}
[TestMethod]
public async Task Create_Valid_Success()
{
// Arrange
var client = Factory.CreateClient();
var dto = SetupGroupDto();
// Act
var response = await client.PostAsJsonAsync("/api/groups", dto);
// Assert
var result = await EnsureObject<GroupDto>(response);
result.Id.Should().BeGreaterThan(0);
result.Name.Should().Be(dto.Name);
result.IsActive.Should().Be(dto.IsActive);
}
[TestMethod]
public async Task Update_Valid_Success()
{
// Arrange
var client = Factory.CreateClient();
var dto = SetupGroupDto();
dto.Id = 4;
// Act
var response = await client.PutAsJsonAsync("/api/groups", dto);
// Assert
var result = await EnsureObject<GroupDto>(response);
result.Id.Should().Be(dto.Id);
result.Name.Should().Be(dto.Name);
result.IsActive.Should().Be(dto.IsActive);
}
[DataTestMethod]
[DataRow(4)]
public async Task Delete_Valid_Success(int groupId)
{
// Arrange
var client = Factory.CreateClient();
// Act
var response = await client.DeleteAsync($"/api/groups/{groupId}");
// Assert
var result = await EnsureObject<bool>(response);
result.Should().BeTrue();
}
private GroupDto SetupGroupDto()
{
return new GroupDto()
{
Name = RandomFactory.GetCompanyName(),
IsActive = RandomFactory.GetBoolean()
};
}
}
}

Просмотреть файл

@ -1,76 +0,0 @@
using Microsoft.DSX.ProjectTemplate.Command.Group;
using Microsoft.DSX.ProjectTemplate.Data.Events;
using Microsoft.DSX.ProjectTemplate.Data.Exceptions;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Group
{
[TestClass]
[TestCategory("Group")]
public class GroupCreateCommandHandlerTests : UnitTest
{
[DataTestMethod]
[ExpectedException(typeof(BadRequestException))]
[DataRow(null)]
[DataRow("")]
public async Task Create_MissingName_BadRequestException(string name)
{
await ExecuteWithDb((db) =>
{
var handler = new CreateGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
var dto = SeedHelper.CreateValidNewGroupDto(db, Mapper);
dto.Name = name;
return handler.Handle(new CreateGroupCommand() { Group = dto }, default);
});
}
[TestMethod]
[ExpectedException(typeof(BadRequestException))]
public async Task Create_NameAlreadyUsed_BadRequestException()
{
await ExecuteWithDb((db) =>
{
var handler = new CreateGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
var existingGroup = SeedHelper.GetRandomGroup(db);
var dto = SeedHelper.CreateValidNewGroupDto(db, Mapper, existingGroup.Name);
return handler.Handle(new CreateGroupCommand() { Group = dto }, default);
});
}
[TestMethod]
public async Task Create_Valid_PublishesGroupCreatedDomainEvent()
{
await ExecuteWithDb((db) =>
{
var handler = new CreateGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
var dto = SeedHelper.CreateValidNewGroupDto(db, Mapper);
return handler.Handle(new CreateGroupCommand() { Group = dto }, default);
}, (result, db) =>
{
MockMediator.Verify(x => x.Publish(It.IsAny<GroupCreatedDomainEvent>(), It.IsAny<CancellationToken>()), Times.Once);
});
}
}
}

Просмотреть файл

@ -1,40 +0,0 @@
using Microsoft.DSX.ProjectTemplate.Command.Group;
using Microsoft.DSX.ProjectTemplate.Data.Events;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Group
{
[TestClass]
[TestCategory("Group")]
public class GroupCreatedEventHandlerTests : UnitTest
{
[DataTestMethod]
[DataRow("Alpha")]
public async Task Event_Created_SendsEmail(string groupName)
{
await ExecuteWithDb(async (db) =>
{
var handler = new GroupCreatedEventHandler(MockMediator.Object, db, Mapper,
MockAuthorizationService.Object, MockEmailService.Object,
LoggerFactory.CreateLogger<GroupCreatedEventHandler>());
var group = SeedHelper.CreateValidNewGroup(db, groupName);
var notification = new GroupCreatedDomainEvent(group);
await handler.Handle(notification, default);
return Task.CompletedTask;
}, (_, db) =>
{
MockEmailService.Verify(x => x.SendEmailAsync(It.IsNotNull<string>(),
It.IsNotNull<string>(),
It.Is<string>(subject => subject.Contains(groupName)),
It.IsNotNull<string>()),
Times.Once);
});
}
}
}

Просмотреть файл

@ -1,50 +0,0 @@
using Microsoft.DSX.ProjectTemplate.Command.Group;
using Microsoft.DSX.ProjectTemplate.Data.Exceptions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Group
{
[TestClass]
[TestCategory("Group")]
public class GroupDeleteCommandHandlerTests : UnitTest
{
[DataTestMethod]
[ExpectedException(typeof(BadRequestException))]
[DataRow(0)]
[DataRow(-1)]
public async Task Delete_InvalidId_BadRequestException(int id)
{
await ExecuteWithDb((db) =>
{
var handler = new DeleteGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
return handler.Handle(new DeleteGroupCommand() { GroupId = id }, default(CancellationToken));
});
}
[TestMethod]
[ExpectedException(typeof(EntityNotFoundException))]
public async Task Delete_IdNotFound_EntityNotFoundException()
{
await ExecuteWithDb((db) =>
{
var handler = new DeleteGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
var lastGroup = db.Groups.OrderBy(o => o.Id).Last();
return handler.Handle(new DeleteGroupCommand() { GroupId = (lastGroup.Id + 1) }, default(CancellationToken));
});
}
}
}

Просмотреть файл

@ -1,78 +0,0 @@
using FluentAssertions;
using Microsoft.DSX.ProjectTemplate.Command.Group;
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using Microsoft.DSX.ProjectTemplate.Data.Exceptions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Group
{
[TestClass]
[TestCategory("Group")]
public class GroupQueryHandlerTests : UnitTest
{
[TestMethod]
public async Task GetAll_Valid_Success()
{
DateTime dtStart = DateTime.Now;
await ExecuteWithDb((db) =>
{
var handler = new GroupQueryHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
return handler.Handle(new GetAllGroupsQuery(), default(CancellationToken));
}, (result, db) =>
{
result.Should().NotBeNull();
result.Should().BeAssignableTo<IEnumerable<GroupDto>>();
result.Should().HaveCountGreaterThan(0);
foreach (var group in result)
{
group.Id.Should().BeGreaterThan(0);
group.Name.Should().NotBeNullOrWhiteSpace();
group.CreatedDate.Should().BeOnOrAfter(dtStart);
group.UpdatedDate.Should().BeOnOrAfter(dtStart);
}
});
}
[DataTestMethod]
[ExpectedException(typeof(BadRequestException))]
[DataRow(0)]
[DataRow(-1)]
public async Task GetById_InvalidId_BadRequestException(int id)
{
await ExecuteWithDb((db) =>
{
var handler = new GroupQueryHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
return handler.Handle(new GetGroupByIdQuery() { GroupId = id }, default(CancellationToken));
});
}
[DataTestMethod]
[ExpectedException(typeof(EntityNotFoundException))]
[DataRow(int.MaxValue)]
public async Task GetById_IdNotFound_EntityNotFoundException(int id)
{
await ExecuteWithDb((db) =>
{
var handler = new GroupQueryHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
return handler.Handle(new GetGroupByIdQuery() { GroupId = id }, default(CancellationToken));
});
}
}
}

Просмотреть файл

@ -1,123 +0,0 @@
using Microsoft.DSX.ProjectTemplate.Command.Group;
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using Microsoft.DSX.ProjectTemplate.Data.Exceptions;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Group
{
[TestClass]
[TestCategory("Group")]
public class GroupUpdateCommandHandlerTests : UnitTest
{
[DataTestMethod]
[ExpectedException(typeof(BadRequestException))]
[DataRow(0)]
[DataRow(-1)]
public async Task Update_InvalidId_BadRequestException(int id)
{
await ExecuteWithDb(async (db) =>
{
var handler = new UpdateGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
var randomGroup = SeedHelper.GetRandomGroup(db);
var dto = Mapper.Map<GroupDto>(randomGroup);
dto.Id = id;
return await handler.Handle(new UpdateGroupCommand() { Group = dto }, default(CancellationToken));
});
}
[DataTestMethod]
[ExpectedException(typeof(BadRequestException))]
[DataRow(null)]
[DataRow("")]
public async Task Update_MissingName_BadRequestException(string name)
{
await ExecuteWithDb(async (db) =>
{
var handler = new UpdateGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
var randomGroup = SeedHelper.GetRandomGroup(db);
var dto = Mapper.Map<GroupDto>(randomGroup);
dto.Name = name;
return await handler.Handle(new UpdateGroupCommand() { Group = dto }, default(CancellationToken));
});
}
[TestMethod]
public async Task Update_NoChanges_Success()
{
await ExecuteWithDb((db) =>
{
var handler = new UpdateGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
var existingGroup = SeedHelper.GetRandomGroup(db);
var dto = Mapper.Map<GroupDto>(existingGroup);
return handler.Handle(new UpdateGroupCommand() { Group = dto }, default(CancellationToken));
});
}
[TestMethod]
[ExpectedException(typeof(BadRequestException))]
public async Task Update_NameAlreadyUsed_BadRequestException()
{
await ExecuteWithDb((db) =>
{
var handler = new UpdateGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
var existingGroup = SeedHelper.GetRandomGroup(db);
var differentGroup = db.Groups
.Where(x => x.Id != existingGroup.Id)
.OrderBy(x => Guid.NewGuid())
.First();
var dto = Mapper.Map<GroupDto>(existingGroup);
dto.Name = differentGroup.Name;
return handler.Handle(new UpdateGroupCommand() { Group = dto }, default(CancellationToken));
});
}
[TestMethod]
[ExpectedException(typeof(EntityNotFoundException))]
public async Task Update_IdNotFound_EntityNotFoundException()
{
await ExecuteWithDb((db) =>
{
var handler = new UpdateGroupCommandHandler(
MockMediator.Object,
db,
Mapper,
MockAuthorizationService.Object);
var dto = SeedHelper.CreateValidNewGroupDto(db, Mapper);
dto.Id = int.MaxValue;
return handler.Handle(new UpdateGroupCommand() { Group = dto }, default(CancellationToken));
});
}
}
}

Просмотреть файл

@ -0,0 +1,27 @@
using Microsoft.DSX.ProjectTemplate.Data.Abstractions;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Infrastructure
{
internal class FakeEmailService : IEmailService
{
private int m_sentCount;
public FakeEmailService()
{
m_sentCount = 0;
}
public Task SendEmailAsync(string from, string to, string subject, string body)
{
m_sentCount++;
return Task.CompletedTask;
}
public int GetSentCount()
{
return m_sentCount;
}
}
}

Просмотреть файл

@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.DSX.ProjectTemplate.Data;
using Microsoft.DSX.ProjectTemplate.Data.Abstractions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
namespace Microsoft.DSX.ProjectTemplate.Test.Infrastructure
{
/// <summary>
/// Factory for bootstrapping an application into memory for functional end-to-end testing.
/// </summary>
public class ProjectTemplateWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
private readonly Guid _dbGuid = Guid.NewGuid();
/// <summary>
/// Creates an instance of an <see cref="IHostBuilder"/> and adds to its pre-configured defaults
/// </summary>
protected override IHostBuilder CreateHostBuilder()
{
return Host
.CreateDefaultBuilder()
.UseEnvironment(Constants.Environments.Test)
.ConfigureLogging(loggingBuilder =>
{
loggingBuilder
.ClearProviders()
.AddConsole()
.SetMinimumLevel(LogLevel.Trace);
})
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder
.UseSetting(WebHostDefaults.DetailedErrorsKey, "true")
.CaptureStartupErrors(true)
.UseStartup<TStartup>();
});
}
/// <summary>
/// Configures the <see cref="IWebHostBuilder"/> services and sets up the in-memory database
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder
.ConfigureTestServices(services =>
{
services
.AddSingleton<IEmailService, FakeEmailService>()
.RemoveAll(typeof(IHostedService));
})
.ConfigureServices((_, services) =>
{
var serviceProvider = new ServiceCollection()
.AddEntityFrameworkInMemoryDatabase()
.BuildServiceProvider();
services
.AddDbContext<ProjectTemplateDbContext>(options =>
{
options
.ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.UseInMemoryDatabase(_dbGuid.ToString())
.EnableSensitiveDataLogging();
});
var sp = services.BuildServiceProvider();
Utilities.SetupDatabase<ProjectTemplateWebApplicationFactory<TStartup>>(sp);
});
}
}
}

Просмотреть файл

@ -0,0 +1,66 @@
using Microsoft.DSX.ProjectTemplate.Data;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Infrastructure
{
internal static class Utilities
{
/// <summary>
/// Ensure the database is created and seeded with both real and test data.
/// </summary>
/// <typeparam name="TInitializer">The class that initialized the setup, for relevant logging.</typeparam>
public static void SetupDatabase<TInitializer>(IServiceProvider serviceProvider)
{
using IServiceScope scope = serviceProvider.CreateScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<TInitializer>();
try
{
logger.LogInformation("Database configuration started");
var db = scope.ServiceProvider.GetRequiredService<ProjectTemplateDbContext>();
db.Database.EnsureCreated();
(new TestDataSeeder(db, loggerFactory.CreateLogger<TestDataSeeder>())).SeedTestData();
logger.LogInformation("Database configuration completed");
}
catch (Exception ex)
{
logger.LogError(ex, $"An error occurred setting up the database. Error: {ex.Message}");
}
}
/// <summary>
/// Asynchronously resolves a <see cref="ProjectTemplateDbContext"/> service scope and executes a delegate function.
/// The scoped DbContext is disposed after the delegate is executed.
/// </summary>
/// <param name="func">The delegate <see cref="Func{T, TResult}"/> to execute with the scoped <see cref="ProjectTemplateDbContext"/></param>
public static async Task<TResult> ExecuteWithDbScope<TResult>(this IServiceProvider provider, Func<ProjectTemplateDbContext, Task<TResult>> func)
{
using (IServiceScope scope = provider.CreateScope())
{
ProjectTemplateDbContext db = scope.ServiceProvider.GetRequiredService<ProjectTemplateDbContext>();
return await func.Invoke(db).ConfigureAwait(false);
}
}
/// <summary>
/// Resolves a <see cref="ProjectTemplateDbContext"/> service scope and executes a delegate action.
/// The scoped DbContext is disposed after the delegate is executed.
/// </summary>
/// <param name="action">The delegate <see cref="Action"/> to execute with the scoped <see cref="ProjectTemplateDbContext"/></param>
public static void ExecuteWithDbScope(this IServiceProvider provider, Action<ProjectTemplateDbContext> action)
{
using (IServiceScope scope = provider.CreateScope())
{
ProjectTemplateDbContext db = scope.ServiceProvider.GetRequiredService<ProjectTemplateDbContext>();
action.Invoke(db);
}
}
}
}

Просмотреть файл

@ -1,27 +0,0 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.DSX.ProjectTemplate.API;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.DSX.ProjectTemplate.Test
{
/// <summary>
/// Each integration test is truly isolated because the test has private instances of:
/// - <see cref="Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory{TEntryPoint}"/>
/// - <see cref="Microsoft.AspNetCore.TestHost.TestServer"/>
/// - <see cref="Microsoft.EntityFrameworkCore.InMemory"/> database
/// </summary>
[TestCategory("Integration")]
[TestClass]
public abstract class IntegrationTest : BaseTest
{
/// <summary>
/// Gets factory that creates <see cref="System.Net.Http.HttpClient"/> instances for sending HTTP requests to.
/// </summary>
protected WebApplicationFactory<TestStartup> Factory { get; }
protected IntegrationTest()
{
Factory = new CustomWebApplicationFactory<TestStartup>();
}
}
}

Просмотреть файл

@ -5,7 +5,7 @@ using System.Net.Http;
using System.Threading.Tasks;
[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)]
namespace Microsoft.DSX.ProjectTemplate.Test
namespace Microsoft.DSX.ProjectTemplate.Test.Tests
{
public abstract class BaseTest
{

Просмотреть файл

@ -0,0 +1,64 @@
using AutoMapper;
using Microsoft.DSX.ProjectTemplate.API;
using Microsoft.DSX.ProjectTemplate.Data.Abstractions;
using Microsoft.DSX.ProjectTemplate.Test.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Net.Http;
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Integration
{
/// <summary>
/// Each integration test is truly isolated because the test has private instances of:
/// - <see cref="Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory{TEntryPoint}"/>
/// - <see cref="Microsoft.AspNetCore.TestHost.TestServer"/>
/// - <see cref="Microsoft.EntityFrameworkCore.InMemory"/> database
/// </summary>
[TestCategory("Integration")]
[TestClass]
public abstract class BaseIntegrationTest : BaseTest
{
/// <summary>
/// Gets factory that creates <see cref="System.Net.Http.HttpClient"/> instances for sending HTTP requests to.
/// </summary>
protected ProjectTemplateWebApplicationFactory<Startup> _factory;
protected IServiceProvider ServiceProvider { get; }
protected HttpClient Client { get; }
protected IMapper Mapper { get; }
protected BaseIntegrationTest()
{
_factory = new ProjectTemplateWebApplicationFactory<Startup>();
ServiceProvider = _factory.Services;
Client = _factory.CreateClient();
Client.DefaultRequestVersion = new Version(2, 0);
Mapper = _factory.Services.GetRequiredService<IMapper>();
}
[TestCleanup]
public void Cleanup()
{
Client.Dispose();
_factory.Dispose();
}
/// <summary>
/// Resolves a <see cref="IEmailService"/> service scope and gets the count of sent emails.
/// </summary>
/// <returns>Count of emails sent.</returns>
protected int GetSentCount()
{
using (IServiceScope scope = ServiceProvider.CreateScope())
{
var emailService = (FakeEmailService)scope.ServiceProvider.GetRequiredService<IEmailService>();
return emailService.GetSentCount();
}
}
}
}

Просмотреть файл

@ -0,0 +1,17 @@
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Integration.Group
{
public abstract class BaseGroupTest : BaseIntegrationTest
{
protected GroupDto GetGroupDto()
{
return new GroupDto()
{
Name = RandomFactory.GetCompanyName(),
IsActive = RandomFactory.GetBoolean()
};
}
}
}

Просмотреть файл

@ -0,0 +1,99 @@
using FluentAssertions;
using Microsoft.DSX.ProjectTemplate.Data;
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
using Microsoft.DSX.ProjectTemplate.Test.Infrastructure;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Integration.Group
{
[TestClass]
[TestCategory("Groups - Create")]
public class CreateGroupTests : BaseGroupTest
{
[TestMethod]
public async Task CreateGroup_ValidDto_Success()
{
// Arrange
var dto = GetGroupDto();
// Act
var response = await Client.PostAsJsonAsync("/api/groups", dto);
// Assert
var result = await EnsureObject<GroupDto>(response);
result.Id.Should().BeGreaterThan(0);
result.Name.Should().Be(dto.Name);
result.IsActive.Should().Be(dto.IsActive);
}
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("\t")]
public async Task CreateGroup_MissingName_BadRequest(string name)
{
// Arrange
var dto = GetGroupDto();
dto.Name = name;
// Act
var response = await Client.PostAsJsonAsync("/api/groups", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[TestMethod]
public async Task CreateGroup_NameTooLong_BadRequest()
{
// Arrange
var dto = GetGroupDto();
dto.Name = RandomFactory.GetAlphanumericString(Constants.MaximumLengths.StringColumn + 1);
// Act
var response = await Client.PostAsJsonAsync("/api/groups", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[TestMethod]
public async Task CreateGroup_NameAlreadyExists_BadRequest()
{
// Arrange
Data.Models.Group randomGroup = null;
ServiceProvider.ExecuteWithDbScope(db =>
{
randomGroup = SeedHelper.GetRandomGroup(db);
});
var dto = GetGroupDto();
dto.Name = randomGroup.Name;
// Act
var response = await Client.PostAsJsonAsync("/api/groups", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[TestMethod]
public async Task CreateGroup_ValidDto_SendsEmail()
{
// Arrange
var dto = GetGroupDto();
// Act
var response = await Client.PostAsJsonAsync("/api/groups", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
GetSentCount().Should().Be(1);
}
}
}

Просмотреть файл

@ -0,0 +1,53 @@
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Net;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Integration.Group
{
[TestClass]
[TestCategory("Groups - Delete")]
public class DeleteGroupTests : BaseGroupTest
{
[DataTestMethod]
[DataRow(5)]
public async Task DeleteGroup_ValidId_Success(int groupId)
{
// Arrange
// Act
var response = await Client.DeleteAsync($"/api/groups/{groupId}");
// Assert
var result = await EnsureObject<bool>(response);
result.Should().BeTrue();
}
[DataTestMethod]
[DataRow(0)]
[DataRow(-1)]
public async Task DeleteGroup_InvalidId_BadRequest(int groupId)
{
// Arrange
// Act
var response = await Client.DeleteAsync($"/api/groups/{groupId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[DataTestMethod]
[DataRow(int.MaxValue)]
public async Task DeleteGroup_IdDoesNotExist_NotFound(int groupId)
{
// Arrange
// Act
var response = await Client.DeleteAsync($"/api/groups/{groupId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

Просмотреть файл

@ -0,0 +1,69 @@
using FluentAssertions;
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Integration.Group
{
[TestClass]
[TestCategory("Groups - Get")]
public class GetGroupTests : BaseGroupTest
{
[TestMethod]
public async Task GetAllGroup_Valid_Success()
{
// Arrange
// Act
var response = await Client.GetAsync("/api/groups");
// Assert
var result = await EnsureObject<IEnumerable<GroupDto>>(response);
result.Should().HaveCountGreaterThan(0);
}
[DataTestMethod]
[DataRow(1)]
public async Task GetByIdGroup_ValidId_Success(int groupId)
{
// Arrange
// Act
var response = await Client.GetAsync($"/api/groups/{groupId}");
// Assert
var result = await EnsureObject<GroupDto>(response);
result.Should().NotBeNull();
result.Id.Should().Be(groupId);
}
[DataTestMethod]
[DataRow(0)]
[DataRow(-1)]
public async Task GetByIdGroup_InvalidId_BadRequest(int groupId)
{
// Arrange
// Act
var response = await Client.GetAsync($"/api/groups/{groupId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[DataTestMethod]
[DataRow(int.MaxValue)]
public async Task GetByIdGroup_IdDoesNotExist_NotFound(int groupId)
{
// Arrange
// Act
var response = await Client.GetAsync($"/api/groups/{groupId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
}

Просмотреть файл

@ -0,0 +1,144 @@
using FluentAssertions;
using Microsoft.DSX.ProjectTemplate.Data;
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
using Microsoft.DSX.ProjectTemplate.Test.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Integration.Group
{
[TestClass]
[TestCategory("Groups - Update")]
public class UpdateGroupTests : BaseGroupTest
{
[TestMethod]
public async Task UpdateGroup_ValidDto_Success()
{
// Arrange
var dto = GetGroupDto();
dto.Id = 4;
// Act
var response = await Client.PutAsJsonAsync("/api/groups", dto);
// Assert
var result = await EnsureObject<GroupDto>(response);
result.Id.Should().Be(dto.Id);
result.Name.Should().Be(dto.Name);
result.IsActive.Should().Be(dto.IsActive);
}
[DataTestMethod]
[DataRow(0)]
[DataRow(-1)]
public async Task UpdateGroup_InvalidId_BadRequest(int id)
{
// Arrange
Data.Models.Group randomGroup = null;
ServiceProvider.ExecuteWithDbScope(db =>
{
randomGroup = SeedHelper.GetRandomGroup(db);
});
var dto = Mapper.Map<GroupDto>(randomGroup);
dto.Id = id;
// Act
var response = await Client.PutAsJsonAsync("/api/groups", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[DataTestMethod]
[DataRow(int.MaxValue)]
public async Task UpdateGroup_IdDoesNotExist_NotFound(int id)
{
// Arrange
Data.Models.Group randomGroup = null;
ServiceProvider.ExecuteWithDbScope(db =>
{
randomGroup = SeedHelper.GetRandomGroup(db);
});
var dto = Mapper.Map<GroupDto>(randomGroup);
dto.Id = id;
// Act
var response = await Client.PutAsJsonAsync("/api/groups", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
[DataRow("\t")]
public async Task UpdateGroup_MissingName_BadRequest(string name)
{
// Arrange
Data.Models.Group randomGroup = null;
ServiceProvider.ExecuteWithDbScope(db =>
{
randomGroup = SeedHelper.GetRandomGroup(db);
});
var dto = Mapper.Map<GroupDto>(randomGroup);
dto.Name = name;
// Act
var response = await Client.PutAsJsonAsync("/api/groups", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[TestMethod]
public async Task UpdateGroup_NameTooLong_BadRequest()
{
// Arrange
Data.Models.Group randomGroup = null;
ServiceProvider.ExecuteWithDbScope(db =>
{
randomGroup = SeedHelper.GetRandomGroup(db);
});
var dto = Mapper.Map<GroupDto>(randomGroup);
dto.Name = RandomFactory.GetAlphanumericString(Constants.MaximumLengths.StringColumn + 1);
// Act
var response = await Client.PutAsJsonAsync("/api/groups", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[TestMethod]
public async Task UpdateGroup_NameAlreadyExists_BadRequest()
{
// Arrange
Data.Models.Group randomGroup = null;
Data.Models.Group differentGroup = null;
ServiceProvider.ExecuteWithDbScope(async db =>
{
randomGroup = SeedHelper.GetRandomGroup(db);
differentGroup = await db.Groups
.Where(x => x.Id != randomGroup.Id)
.OrderBy(x => Guid.NewGuid())
.FirstAsync();
});
var dto = Mapper.Map<GroupDto>(randomGroup);
dto.Name = differentGroup.Name;
// Act
var response = await Client.PutAsJsonAsync("/api/groups", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}
}

Просмотреть файл

@ -16,10 +16,10 @@ using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Unit
{
[TestCategory("Unit")]
public abstract class UnitTest : BaseTest
public abstract class BaseUnitTest : BaseTest
{
protected ILoggerFactory LoggerFactory { get; set; }
@ -31,7 +31,7 @@ namespace Microsoft.DSX.ProjectTemplate.Test
protected Mock<IEmailService> MockEmailService { get; set; } = new Mock<IEmailService>();
protected UnitTest()
protected BaseUnitTest()
{
// redirect all logging to console
var services = new ServiceCollection();

Просмотреть файл

@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Unit.DtoValidation
{
public abstract class BaseDtoTest : BaseUnitTest
{
protected ValidationResult FindMember(IEnumerable<ValidationResult> validationResults, string memberNameToFind)
{
return validationResults
.FirstOrDefault(validationResult => validationResult.MemberNames.Any(
memberName => memberName.Equals(memberNameToFind)));
}
}
}

Просмотреть файл

@ -0,0 +1,65 @@
using FluentAssertions;
using Microsoft.DSX.ProjectTemplate.Data;
using Microsoft.DSX.ProjectTemplate.Data.DTOs;
using Microsoft.DSX.ProjectTemplate.Data.Utilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Unit.DtoValidation
{
[TestClass]
[TestCategory("Group")]
public class GroupDtoTests : BaseDtoTest
{
[TestMethod]
public void GroupDtoValidation_Valid_NoErrors()
{
var dto = new GroupDto
{
Name = RandomFactory.GetCompanyName(),
};
var validationContext = new ValidationContext(dto);
var validationResults = dto.Validate(validationContext);
validationResults.Should().HaveCount(0);
}
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
[DataRow(" ")]
public void GroupDtoValidation_MissingName_HasValidationErrors(string name)
{
var dto = new GroupDto
{
Name = name,
};
var validationContext = new ValidationContext(dto);
var validationResults = dto.Validate(validationContext);
validationResults.Should().HaveCountGreaterThan(0);
validationResults.FirstOrDefault(validationResult => validationResult.MemberNames.Any(memberName => memberName.Equals(nameof(GroupDto.Name)))).Should().NotBeNull();
}
[TestMethod]
public void GroupDtoValidation_NameTooLong_HasValidationErrors()
{
var dto = new GroupDto
{
Name = RandomFactory.GetAlphanumericString(Constants.MaximumLengths.StringColumn + 1),
};
var validationContext = new ValidationContext(dto);
var validationResults = dto.Validate(validationContext);
validationResults.Should().HaveCountGreaterThan(0);
validationResults.FirstOrDefault(validationResult => validationResult.MemberNames.Any(memberName => memberName.Equals(nameof(GroupDto.Name)))).Should().NotBeNull();
}
}
}

Просмотреть файл

@ -1,10 +1,11 @@
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Threading.Tasks;
namespace Microsoft.DSX.ProjectTemplate.Test.Infrastructure
namespace Microsoft.DSX.ProjectTemplate.Test.Tests.Unit.Infrastructure
{
[TestClass]
public class InfrastructureTests : UnitTest
[TestCategory("Infrastructure")]
public class InfrastructureTests : BaseUnitTest
{
[TestMethod]
public async Task Infrastructure_AutoMapper_ConfigurationIsValid()