0

I followed the Aqueduct tutorial for creating tests, but it was missing one example that I am in a dire need; I am unable to test a file uploading endpoint with my controller.

I have implemented a controller as such:

class FileController extends ResourceController {

  FileController() {
    acceptedContentTypes = [ContentType("multipart", "form-data")];
  }

  @Operation.post()
  Future<Response> postForm() async {

    final transformer = MimeMultipartTransformer(request.raw.headers.contentType.parameters["boundary"]);
    final bodyStream = Stream.fromIterable([await request.body.decode<List<int>>()]);
    final parts = await transformer.bind(bodyStream).toList();

    for (var part in parts) {
      final headers = part.headers;

      HttpMultipartFormData multipart = HttpMultipartFormData.parse(part);
      final content = multipart.cast<List<int>>();

      final filePath = "uploads/test.txt";

      await new File(filePath).create(recursive: true);

      IOSink sink = File(filePath).openWrite();
      await content.forEach(sink.add);

      await sink.flush();
      await sink.close();
    }

    return Response.ok({});   
  }
}

And it works fine when using Postman for a file upload.

Now I am trying to write a test for this endpoint:

test("POST /upload-file uploads a file to the server", () async {

    final file = File('test.txt');
    final sink = file.openWrite();
    sink.write('test');
    await sink.close();

    final bytes = file.readAsBytesSync();

    harness.agent.headers['Content-Type'] = 'multipart/form-data; boundary=MultipartBoundry';
    harness.agent.headers['Content-Disposition'] = 'form-data; name="file"; filename="test.txt"';


    final response = await harness.agent.post("/upload-file", body: bytes);

    expectResponse(response, 200);
  });

And get this in the vscode debugger:

Expected: --- HTTP Response ---
          - Status code must be 200
          - Headers can be anything
          - Body can be anything
          ---------------------
  Actual: TestResponse:<-----------
          - Status code is 415
          - Headers are the following:
            - x-frame-options: SAMEORIGIN
            - x-xss-protection: 1; mode=block
            - x-content-type-options: nosniff
            - server: aqueduct/1
            - content-length: 0
          - Body is empty
          -------------------------
          >
   Which: Status codes are different. Expected: 200. Actual: 415
JereK
  • 83
  • 9

2 Answers2

0

The 415 status code response would indicate that the ResourceController has rejected the content-type of the request. You have correctly set the acceptedContentTypes, however, there is a (admittedly confusing) nuance to the test agent that is buried in the documentation of Agent.headers:

Default headers to be added to requests made by this agent.

By default, this value is the empty map.

Do not provide a 'content-type' key. If the key 'content-type' is present, it will be removed prior to sending the request. It is replaced by the value of TestRequest.contentType, which also controls body encoding.

See also setBasicAuthorization, bearerAuthorization, accept, contentType for setting common headers.

See the API reference here. As to why this exists this way: like your Responses, the content type of a TestRequest (which is the object created and executed when you use an agent to make a request) determines which Codec from the CodecRegistry to use as an encoder. This allows you to always deal with 'Dart objects' and let Aqueduct handle encoding/decoding.

Joe Conway
  • 1,566
  • 9
  • 8
  • Thank you for the answer. I have been wrestling with this on/off for some time now. It seems that `ContentType` class does not provide a property for `multipart/form-data`. So what property should I be using? – JereK Nov 22 '19 at 13:41
  • Ok I was able to do this in tests: `harness.agent.contentType = ContentType("multipart", "form-data");` and that eradicated my Unsupported Media Type problem, but now the implementation fails with another error message, which might be out of scope for this question. Was my approach correct? – JereK Nov 22 '19 at 14:13
0

I wrote a bunch of classes to simplify and clarify multipart requests testing. So, if anyone still struggle with this, be welcome to try my solution:

test

import 'multipart_body_parser.dart';
//[...]
    test('POST /upload-file uploads a file to the server', () async {
      final boundary = '7d82a244f2ea5xd0s046';
      final file = File('test.txt');

      var encodedBody = MultipartBodyParser(boundary).parse([
        FileBodyPart(
          'file',
          'test.txt',
          File('test.txt'),
        ),
      ]);

      final response = await harness.agent.post(
        '/upload-file',
        body: encodedBody,
      );

      expectResponse(response, 200);
    });

multipart_body_parser.dart

import 'dart:convert';
import 'dart:io';

class MultipartBodyParser {
  final String boundary;

  MultipartBodyParser(this.boundary)
      : assert(
          boundary != null,
          'The boundary is empty. Please set it ' +
              'and keep on mind that it MUST NOT appear inside any of the ' +
              'encapsulated parts. Example: "sampleBoundary7da24f2e50046".',
        );

  List<int> get encodedNonLastBoundary =>
      ascii.encode('\r\n--' + boundary + '\r\n');

  List<int> get encodedLastBoundary =>
      ascii.encode('\r\n--' + boundary + '--\r\n\r\n');

  List<int> parse(List<_BodyPart> parts) {
    if (parts == null || parts.isEmpty) {
      throw MultipartBodyParserException(
        'Parts CAN NOT be empty. Please set at least one part of body.',
      );
    }
    var body = encodedNonLastBoundary;
    parts.forEach((part) {
      body += part.parse();
      if (parts.last != part) {
        body += encodedNonLastBoundary;
      }
    });
    body += encodedLastBoundary;
    return body;
  }
}

class TextBodyPart extends _BodyPart {
  final String content;

  TextBodyPart(formFieldName, _content)
      : content = _content ?? '',
        super(
          _ContentDisposition(
            formFieldName,
            'form-data',
          ),
          _ContentType(),
        );

  @override
  List<int> get encodedContent => ascii.encode(content);
}

class FileBodyPart extends _BodyPart {
  final File file;
  final String fileName;

  FileBodyPart(formFieldName, this.fileName, this.file)
      : super(
          _ContentDisposition(
            formFieldName,
            'form-data',
            '; filename="$fileName"',
          ),
          _ContentType('application/octet-stream'),
        );

  @override
  List<int> get encodedContent => file.readAsBytesSync();
}

abstract class _BodyPart {
  final _ContentDisposition contentDisposition;
  final _ContentType contentType;

  _BodyPart(this.contentDisposition, this.contentType)
      : assert(contentDisposition != null),
        assert(contentType != null);

  String get partHeader =>
      contentDisposition.toString() + contentType.toString();

  List<int> get encodedContent;

  List<int> parse() => ascii.encode(partHeader) + encodedContent;
}

class _ContentDisposition {
  final String formFieldName;
  final String formFieldType;
  final String additionalParams;
  _ContentDisposition(this.formFieldName, [_formFieldType, _additionalParams])
      : formFieldType = _formFieldType ?? 'form-data',
        additionalParams = _additionalParams ?? '',
        assert(formFieldName != null);

  @override
  String toString() =>
      'content-disposition: $formFieldType; name="$formFieldName"$additionalParams\r\n';
}

class _ContentType {
  final String type;
  _ContentType([this.type = 'text/plain']) : assert(type != null);

  @override
  String toString() => 'content-type: $type\r\n\r\n';
}

class MultipartBodyParserException implements Exception {
  final String message;

  const MultipartBodyParserException([this.message]);
}

Owczar
  • 2,335
  • 1
  • 17
  • 24