Manipuler une collection de sous-objets dans une vue ASP.Net MVC

icon Tags de l'article : , , ,

Aout 13, 2018
Hello les devs !

Aujourd'hui on va parler d'une problématique assez commune : envoyer des sous-objets au serveur depuis une vue CSHTML.

En effet, quand on développe un site avec ASP.Net MVC, il peut arriver qu'on ait besoin, dans notre vue, de manipuler des objets qui sont en fait... une collection d'objets liés à notre objet principal.

Exemple : ma classe Guerrier qui a une propriété "Inventaire" contenant une collection d'"Objet".

Dans ma vue, j'aimerais donc pouvoir voir les objets dans l'inventaire, et si besoin... en ajouter/supprimer.

Comment faire ça ?

Et bien en fait c'est ultra simple : le moteur d'ASP.Net MVC sait tout à fait mapper des sous-objets à partir de nommages façon "tableau".

Exemple : <input type="text" name="Guerrier.Inventaire[0].Nom" value="Epée des mille vérités" />

Grâce à la syntaxe "Inventaire[0].Nom", le moteur d'ASP.Net va comprendre de lui-même qu'il s'agit du premier objet dans la liste "Inventaire" et va essayer de le créer et mapper ses propriétés.

Quelques choses à noter tout de même :
  • Attention à la numérotation, si vous avez juste un sous-objet numéroté [1], il ne sera pas mappé et récupéré.
  • Côté serveur vous ne récupérerez que ce qui a été envoyé par le client. S'il s'agit de données à sauvegarder, vous devrez faire une comparaison entre ce que vous avez en base et ce que l'utilisateur a envoyé.
  • Si vous n'avez pas de valeurs, vous n'aurez pas d'objets.
  • Si vous n'avez pas d'objets, vous ne récupérerez pas une collection vide, mais null.
  • Si jamais les champs sont disabled, ils ne seront ni récupérés, ni mappés (pratique pour activer/désactiver un objet en javascript).

Voilà,

Bonne semaine et bon dev à tous/toutes !

Ecrire des tests unitaires pour une méthode de contrôleur d'API qui attend un fichier (ou comment mocker HttpContext)

icon Tags de l'article : , ,

Juillet 02, 2018
Hello tout le monde,

Aujourd'hui un cas de dev très spécifique : comment tester unitairement une méthode de contrôleur MVC / d'API qui attend un fichier ?

Exemple de méthode :

[HttpPost]
[Route("fromDocument")]
public IHttpActionResult CreateFromDocument()
{
	if (!Request.Content.IsMimeMultipartContent())
	{
		return InternalServerError(Resource.Error_RequestNotMimeMultipartContent);
	}

	if (HttpContext.Current.Request.Files.Count != 1)
	{
		return BadRequest(Resource.Error_FileContentIncorrect);
	}

	// We will only consider the first file
	HttpPostedFile uploadedFile = HttpContext.Current.Request.Files[0];

	if (uploadedFile.ContentLength > ConstantApplicationSettings.UploadFileSizeLimitInBytes)
	{
		return BadRequest();
	}

	uploadedFile.InputStream.Position = 0;

	string originFileName = uploadedFile.FileName.Replace("\"", string.Empty);
	_itemBusinessLogic.CreateDocument(item.Id, originFileName, uploadedFile.InputStream, CurrentUserId);

	return Ok();
}

Comment tester cette méthode ? En effet, de l'extérieur le HttpContext n'est pas accessible. Du coup comment faire croire au contrôleur qu'il recoit bien un fichier alors que ce n'est pas le cas ?

Pour ça, il faut passer par plusieurs étapes.

Déjà, la première chose importante à assimiler : il ne faut PAS mocker HttpContext.
La classe est vraiment énorme et embarque énormément de choses. La mocker ou la stubber c'est prendre le risque d'avoir du code qui casse en production sans qu'on s'en rende compte, malgré des tests unitaires parfaits.

Non, la vraie bonne pratique c'est d'interfacer nos besoins d'objets liés à HttpContext. Nous allons donc créer une interface IHttpContextAdapter :

public interface IHttpContextAdapter
{
	HttpFileCollectionBase GetFiles();
}

Comme vous le voyez, nous allons renvoyer un HttpFileCollectionBase. Pas un HttpFileCollection car il s'agit d'une classe sealed, et donc impossible à mock sans passer par de l'interception.

Du coup, implémentons cette interface dans la classe qui nous servira dans nos contrôleurs d'API :

public class HttpContextAdapter : IHttpContextAdapter
{
    public HttpFileCollectionBase GetFiles()
    {
        return new HttpFileCollectionWrapper(HttpContext.Current.Request.Files);
    }
}

Voilà. Comme vous l'avez compris, le HttpFileCollectionWrapper va permettre de transformer le HttpFileCollection en HttpFileCollectionBase, classe qu'on peut mocker / stubber car non sealed.

Du coup, injectons maintenant dans notre constructeur cette interface, et utilisons la :

public class ItemService : ApiControllerBase, IItemService
{
	public readonly IItemBusinessLogic _itemBusinessLogic;
	public readonly IHttpContextAdapter _httpContextAdapter;

    public ItemService(IItemBusinessLogic itemBusinessLogic, IHttpContextAdapter httpContextAdapter)
    {
		_itemBusinessLogic = itemBusinessLogic;
        _httpContextAdapter = httpContextAdapter;
    }
	
	[...]
}

Voilà. Et du coup, au lieu d'appeler HttpContext.Current, nous allons appeler notre _httpContextAdapter :

// avant modification
if (HttpContext.Current.Request.Files.Count != 1)
[...]
HttpPostedFile uploadedFile = HttpContext.Current.Request.Files[0];

// après modification
if (_httpContextAdapter.GetFiles().Count != 1)
[...]
HttpPostedFileBase uploadedFile = _httpContextAdapter.GetFiles().Get(0);

Ca avance, ça avance.

Mais on a toujours un problème : lorsque le contrôleur va vérifier le fichier censé être en entrée... il va faire un Request.Content.IsMimeMultipartContent()...
Or... le mock de notre HttpContext ne suffira pas à faire passer cette méthode.

Heureusement, j'ai trouvé une méthode d'extension qui permet de faire ça !
Elle va tout simplement réinitialiser le ControllerContext de notre ApiController en ajoutant un "faux" fichier de 100 bytes, avec les bons headers :

public static void InitializeMimeMultipartContent(this ApiController controller, string fileName = "filename.txt")
{
	var user = controller.User;
	Uri uri = controller.Request.RequestUri;

	var configuration = new HttpConfiguration();
	var request = new HttpRequestMessage(HttpMethod.Post, string.Empty);
	var content = new MultipartFormDataContent();

	var fileContent = new ByteArrayContent(new byte[100]);
	fileContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
	{
		FileName = fileName
	};
	content.Add(fileContent);
	request.Content = content;
	request.Properties[HttpPropertyKeys.HttpConfigurationKey] = configuration;

	controller.ControllerContext = new HttpControllerContext(new HttpConfiguration(), new HttpRouteData(new HttpRoute(string.Empty)), request);

	controller.User = user;
	controller.Request.RequestUri = uri;
}

La seule chose à noter est que, comme on va réinitialiser le ControllerContext, on perdra des données qui sont probablement actuellement utilisées (l'utilisateur courant, l'url courante, etc.), d'où le fait qu'on mette de côté l'utilisateur et l'url avant la réinitialisation, pour les remettre dans le contrôleur ensuite.

Et voilà, nous avons tous les outils pour pouvoir tester efficacement notre méthode !

Voici donc un test unitaire commenté utilisant tout ce qu'on vient de coder (avec RhinoMocks, mais vous devriez pouvoir adapter ce code à votre moteur de mocking) :

[Test]
public void ItemController_CreateFromDocument_WithValidData_Ok()
{
	string fileName = "file.png";
	
	// on va d'abord initialiser le contrôleur pour faire croire qu'il s'agit d'un MimeMultipartContent
	_itemController.InitializeMimeMultipartContent(fileName);

	// ensuite on va mocker les appels à GetFile() de IHttpContextAdapter
	HttpFileCollectionBase files = MockRepository.GenerateStub<HttpFileCollectionBase>();
    HttpPostedFileBase file = MockRepository.GenerateStub<HttpPostedFileBase>();
	_httpContextAdapter.Stub(p => p.GetFiles()).Return(files);
	files.Stub(p => p.Get(0)).Return(file);

	// ici on mocke le "nombre" de fichiers envoyés
	files.Stub(p => p.Count).Return(1);
	
	// maintenant, on mocke le fichier "envoyé", avec les propriétés checkées par notre contrôleur
	file.Stub(p => p.FileName).Return(fileName);
	file.Stub(p => p.ContentLength).Return(50000);
	
	// on pense aussi à renvoyer un stub pour le stream (pour éviter que les appels au stream ne plantent)
	file.Stub(p => p.InputStream).Return(MockRepository.GenerateStub<Stream>());

	// Une fois le test prêt, on appelle notre méthode de contrôleur
	HttpResponseMessage httpResponseMessage = _itemController.CreateFromDocument().GetResponse();

	// Et on n'a plus qu'à vérifier que la réponse est bien celle attendue, et que la couche métier a été appelé
	ApiControllerAssert.IsOk(httpResponseMessage);
	_itemBusinessLogic.AssertWasCalled(p => p.CreateDocument(
		Arg<string>.Is.Equal(fileName),
		Arg<Stream>.Is.NotNull,
		Arg<int>.Is.Equal(CurrentUserId)));
}

En espérant que ce soit utile à quelqu'un :)

Bon dev tout le monde !