← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:filebug-data-parser-unittest into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:filebug-data-parser-unittest into launchpad:master.

Commit message:
Convert filebug-data-parser.txt to unittest

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/395358
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:filebug-data-parser-unittest into launchpad:master.
diff --git a/lib/lp/bugs/doc/filebug-data-parser.txt b/lib/lp/bugs/doc/filebug-data-parser.txt
deleted file mode 100644
index 39e7c38..0000000
--- a/lib/lp/bugs/doc/filebug-data-parser.txt
+++ /dev/null
@@ -1,405 +0,0 @@
-= Filebug Data Parser =
-
-An application like Apport can upload data to Launchpad, and have the
-information added to the bug report that the user will file. The
-information is uploaded as a MIME multipart message, where the different
-headers tells Launchpad what kind of information it is.
-
-
-== FileBugDataParser Internals ==
-
-FileBugDataParser is used to parse the MIME message with the information
-to be added to the bug report. The information is passed as a file
-object to the constructor.
-
-    >>> from io import BytesIO
-    >>> from lp.bugs.utilities.filebugdataparser import FileBugDataParser
-    >>> parser = FileBugDataParser(BytesIO(b'123456789'))
-
-To make parsing easier and more efficient, it has a buffer where it
-stores the next few bytes of the file. To begin with, it's empty.
-
-    >>> parser._buffer
-    ''
-
-Whenever it needs to read some bytes of the file, it will read a fixed
-number of bytes into the buffer. The number of bytes is specified by the
-BUFFER_SIZE variable.
-
-    >>> parser.BUFFER_SIZE = 3
-
-There is helper method, _consumeBytes(), which will read from the file
-until a certain delimiter string is encountered.
-
-    >>> parser._consumeBytes(b'4')
-    '1234'
-
-In order to find the delimiter string, it had to read '123456' into
-the buffer. Up to the delimiter string is read, but the rest of the
-string is kept in the buffer.
-
-    >>> parser._buffer
-    '56'
-
-The delimiter string isn't limited to one character.
-
-    >>> parser._consumeBytes(b'67')
-    '567'
-
-    >>> parser._buffer
-    '89'
-
-If the delimiter isn't found in the file, the rest of the file is
-returned.
-
-    >>> parser._consumeBytes(b'0')
-    '89'
-    >>> parser._buffer
-    ''
-
-Subsequent reads will result in the empty string.
-
-    >>> parser._consumeBytes(b'0')
-    ''
-    >>> parser._consumeBytes(b'0')
-    ''
-
-
-=== readLine() ===
-
-readLine() is a helper method to read a single line of the file.
-
-    >>> parser = FileBugDataParser(BytesIO(b'123\n456\n789'))
-    >>> parser.readLine()
-    '123\n'
-    >>> parser.readLine()
-    '456\n'
-    >>> parser.readLine()
-    '789'
-
-If we try to read past the end of the file an AssertionError will be
-raised. This is to ensure that invalid messages won't cause an infinite
-loop, or something like that.
-
-    >>> parser.readLine()
-    Traceback (most recent call last):
-    ...
-    AssertionError: End of file reached.
-
-
-=== readHeaders() ====
-
-readHeaders() reads the headers of a MIME message. It reads all the
-headers, untils it sees a blank line.
-
-    >>> msg = b"""Header: value
-    ... Space-Folded-Header: this header
-    ...  is folded with a space.
-    ... Tab-Folded-Header: this header
-    ... \tis folded with a tab.
-    ... Another-header: another-value
-    ...
-    ... Not-A-Header: not-a-value
-    ... """
-    >>> parser = FileBugDataParser(BytesIO(msg))
-    >>> headers = parser.readHeaders()
-    >>> headers['Header']
-    'value'
-    >>> headers['Space-Folded-Header']
-    'this header\n is folded with a space.'
-    >>> headers['Tab-Folded-Header']
-    'this header\n\tis folded with a tab.'
-    >>> headers['Another-Header']
-    'another-value'
-    >>> 'Not-A-Header' in headers
-    False
-
-
-== Parsing the data ==
-
-The parse() method returns a FileBugData object, with the information
-from the message as attributes.
-
-
-=== Headers ===
-
-The headers are processed by the _setDataFromHeaders() method. It
-accepts a FileBugData object and a dictionary of the headers.
-
-
-==== Subject ====
-
-The Subject header is available in the initial_summary attribute.
-
-    >>> from lp.bugs.browser.bugtarget import FileBugData
-    >>> data = FileBugData()
-    >>> parser = FileBugDataParser(None)
-    >>> parser._setDataFromHeaders(data, {'Subject': 'Bug Subject'})
-    >>> data.initial_summary
-    u'Bug Subject'
-
-
-==== Tags ====
-
-The Tags headers is translated into a list of strings as the
-initial_tags attributes. The tags are translated to lower case
-automatically.
-
-    >>> data = FileBugData()
-    >>> parser._setDataFromHeaders(data, {'Tags': 'Tag-One Tag-Two'})
-    >>> sorted(data.initial_tags)
-    [u'tag-one', u'tag-two']
-
-
-==== Private ====
-
-The Private header gets translated into a boolean, as the private
-attribute. The values "yes" and "no" are accepted, which get translated
-into True and False.
-
-    >>> data = FileBugData()
-    >>> parser._setDataFromHeaders(data, {'Private': 'yes'})
-    >>> data.private
-    True
-    >>> data = FileBugData()
-    >>> parser._setDataFromHeaders(data, {'Private': 'no'})
-    >>> data.private
-    False
-
-We're in no position of presenting a good error message to the user at
-this point, so invalid values get ignored.
-
-    >>> data = FileBugData()
-    >>> parser._setDataFromHeaders(data, {'Private': 'not-valid'})
-    >>> print(data.private)
-    None
-
-
-==== Subscribers ====
-
-The Subscribers header is turned into a list of strings, available
-through the subscribers attribute. The strings get lowercased
-automatically.
-
-    >>> data = FileBugData()
-    >>> parser._setDataFromHeaders(data, {'Subscribers': 'sub-one SUB-TWO'})
-    >>> sorted(data.subscribers)
-    [u'sub-one', u'sub-two']
-
-
-=== Message Parts ===
-
-Different parts of the message gets treated differently. In short, we
-look at the Content-Disposition header. If it's inline, it's a comment,
-if it's attachment, it's an attachment.
-
-
-==== Inline parts ====
-
-The first inline part is special. Instead of being treated as a comment,
-it gets appended to the bug description. It's available through the
-extra_description attribute.
-
-    >>> used_parsers = []
-    >>> def parse_message(message):
-    ...     parser = FileBugDataParser(BytesIO(message))
-    ...     used_parsers.append(parser)
-    ...     return parser.parse()
-
-    >>> debug_data = b"""MIME-Version: 1.0
-    ... Content-type: multipart/mixed; boundary=boundary
-    ...
-    ... --boundary
-    ... Content-disposition: inline
-    ... Content-type: text/plain; charset=utf-8
-    ...
-    ... This should be added to the description.
-    ...
-    ... Another line.
-    ...
-    ... --boundary--
-    ... """
-    >>> data = parse_message(debug_data)
-    >>> data.extra_description
-    u'This should be added to the description.\n\nAnother line.'
-
-
-The text can also be base64 decoded.
-
-    >>> encoded_text = b"""VGhpcyBzaG91bGQgYmUgYWRkZWQgdG8g
-    ... dGhlIGRlc2NyaXB0aW9uLgoKQW5vdGhl
-    ... ciBsaW5lLg=="""
-    >>> encoded_text.decode('base64')
-    'This should be added to the description.\n\nAnother line.'
-
-    >>> debug_data = b"""MIME-Version: 1.0
-    ... Content-type: multipart/mixed; boundary=boundary
-    ...
-    ... --boundary
-    ... Content-disposition: inline
-    ... Content-type: text/plain; charset=utf-8
-    ... Content-transfer-encoding: base64
-    ...
-    ... %s
-    ...
-    ... --boundary--
-    ... """ % encoded_text
-    >>> data = parse_message(debug_data)
-    >>> data.extra_description
-    u'This should be added to the description.\n\nAnother line.'
-
-
-==== Other inline parts ====
-
-If there are more than one inline part, those will be added as comments
-to the bug. The comments are simple ext strings, accessible through the
-comments attribute.
-
-    >>> debug_data = b"""MIME-Version: 1.0
-    ... Content-type: multipart/mixed; boundary=boundary
-    ...
-    ... --boundary
-    ... Content-disposition: inline
-    ... Content-type: text/plain; charset=utf-8
-    ...
-    ... This should be added to the description.
-    ...
-    ... --boundary
-    ... Content-disposition: inline
-    ... Content-type: text/plain; charset=utf-8
-    ...
-    ... This should be added as a comment.
-    ...
-    ... --boundary
-    ... Content-disposition: inline
-    ... Content-type: text/plain; charset=utf-8
-    ...
-    ... This should be added as another comment.
-    ...
-    ... Line 2.
-    ...
-    ... --boundary--
-    ... """
-    >>> data = parse_message(debug_data)
-    >>> len(data.comments)
-    2
-    >>> data.comments[0]
-    u'This should be added as a comment.'
-    >>> data.comments[1]
-    u'This should be added as another comment.\n\nLine 2.'
-
-
-=== Attachment parts ===
-
-All the parts that have a 'Content-disposition: attachment' header
-will get added as attachments to the bug. The attachment description can
-be specified using a Content-description header, but it's not required.
-
-    >>> debug_data = b"""MIME-Version: 1.0
-    ... Content-type: multipart/mixed; boundary=boundary
-    ...
-    ... --boundary
-    ... Content-disposition: attachment; filename='attachment1'
-    ... Content-type: text/plain; charset=utf-8
-    ...
-    ... This is an attachment.
-    ...
-    ... Another line.
-    ...
-    ... --boundary
-    ... Content-disposition: attachment; filename='attachment2'
-    ... Content-description: Attachment description.
-    ... Content-type: text/plain; charset=ISO-8859-1
-    ...
-    ... This is another attachment, with a description.
-    ... --boundary--
-    ... """
-    >>> data = parse_message(debug_data)
-    >>> len(data.attachments)
-    2
-    >>> first_attachment, second_attachment = data.attachments
-
-The filename is copied into the 'filename' item.
-
-    >>> first_attachment['filename']
-    u'attachment1'
-    >>> second_attachment['filename']
-    u'attachment2'
-
-The Content-Type header is copied as is.
-
-    >>> first_attachment['content_type']
-    u'text/plain; charset=utf-8'
-    >>> second_attachment['content_type']
-    u'text/plain; charset=ISO-8859-1'
-
-If there is a Content-Description header, it's accessible as
-'description'.
-
-    >>> second_attachment['description']
-    u'Attachment description.'
-
-If there isn't any Content-Description header, the file name is used
-instead.
-
-    >>> first_attachment['description']
-    u'attachment1'
-
-The contents of the attachments are stored in a file.
-
-    >>> first_file = first_attachment['content']
-    >>> first_file.read()
-    'This is an attachment.\n\nAnother line.\n\n'
-    >>> first_file.close()
-
-    >>> second_file = second_attachment['content']
-    >>> second_file.read()
-    'This is another attachment, with a description.\n'
-    >>> second_file.close()
-
-Binary files are base64 encoded. They are decoded automatically.
-
-    >>> binary_data = b'\n'.join([b'\x00'*5, b'\x01'*5])
-    >>> debug_data = b"""MIME-Version: 1.0
-    ... Content-type: multipart/mixed; boundary=boundary
-    ...
-    ... --boundary
-    ... Content-disposition: attachment; filename='attachment1'
-    ... Content-type: application/octet-stream
-    ... Content-transfer-encoding: base64
-    ...
-    ... %s
-    ... --boundary--
-    ... """ % binary_data.encode('base64')
-    >>> data = parse_message(debug_data)
-    >>> len(data.attachments)
-    1
-
-    >>> contents = data.attachments[0]['content']
-    >>> contents.read()
-    '\x00\x00\x00\x00\x00\n\x01\x01\x01\x01\x01'
-    >>> contents.close()
-
-
-== Invalid messages ==
-
-If someone gives an invalid message, for example one that doesn't have
-an end boundary, and AssertionError will be raised. This is mainly to
-assert that nothing bad will happen if we can't parse the message. We
-don't care about giving the user a good error message, since the format
-is well-known.
-
-    >>> debug_data = b"""MIME-Version: 1.0
-    ... Content-type: multipart/mixed; boundary=boundary
-    ...
-    ... --boundary
-    ... Content-disposition: attachment; filename='attachment1'
-    ... Content-type: text/plain; charset=utf-8
-    ...
-    ... This is an attachment.
-    ...
-    ... Another line."""
-    >>> data = parse_message(debug_data)
-    Traceback (most recent call last):
-    ...
-    AssertionError: End of file reached.
diff --git a/lib/lp/bugs/tests/test_doc.py b/lib/lp/bugs/tests/test_doc.py
index f22cd6f..f7a1e78 100644
--- a/lib/lp/bugs/tests/test_doc.py
+++ b/lib/lp/bugs/tests/test_doc.py
@@ -444,10 +444,6 @@ special = {
         tearDown=tearDown,
         layer=LaunchpadZopelessLayer
         ),
-    'filebug-data-parser.txt': LayeredDocFileSuite(
-        '../doc/filebug-data-parser.txt',
-        setUp=lambda test: setGlobs(test, future=True),
-        ),
     'product-update-remote-product.txt': LayeredDocFileSuite(
         '../doc/product-update-remote-product.txt',
         setUp=updateRemoteProductSetup,
diff --git a/lib/lp/bugs/utilities/tests/__init__.py b/lib/lp/bugs/utilities/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/bugs/utilities/tests/__init__.py
diff --git a/lib/lp/bugs/utilities/tests/test_filebugdataparser.py b/lib/lp/bugs/utilities/tests/test_filebugdataparser.py
new file mode 100644
index 0000000..10858f7
--- /dev/null
+++ b/lib/lp/bugs/utilities/tests/test_filebugdataparser.py
@@ -0,0 +1,310 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""FileBugDataParser tests.
+
+An application like Apport can upload data to Launchpad, and have the
+information added to the bug report that the user will file.  The
+information is uploaded as a MIME multipart message, where the different
+headers tells Launchpad what kind of information it is.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+import base64
+import io
+from textwrap import dedent
+
+from lp.bugs.model.bug import FileBugData
+from lp.bugs.utilities.filebugdataparser import FileBugDataParser
+from lp.testing import TestCase
+
+
+class TestFileBugDataParser(TestCase):
+
+    def test_initial_buffer(self):
+        # The parser's buffer starts out empty.
+        parser = FileBugDataParser(io.BytesIO(b"123456789"))
+        self.assertEqual(b"", parser._buffer)
+
+    def test__consumeBytes(self):
+        # _consumeBytes reads from the file until a delimiter is
+        # encountered.
+        parser = FileBugDataParser(io.BytesIO(b"123456789"))
+        parser.BUFFER_SIZE = 3
+        self.assertEqual(b"1234", parser._consumeBytes(b"4"))
+        # In order to find the delimiter string, it had to read b"123456"
+        # into the buffer, so two bytes remain.
+        self.assertEqual(b"56", parser._buffer)
+        # The delimiter string isn't limited to one character.
+        self.assertEqual(b"567", parser._consumeBytes(b"67"))
+        self.assertEqual(b"89", parser._buffer)
+        # If the delimiter isn't found in the file, the rest of the file is
+        # returned.
+        self.assertEqual(b"89", parser._consumeBytes(b"0"))
+        self.assertEqual(b"", parser._buffer)
+        # Subsequent reads result in the empty string.
+        self.assertEqual(b"", parser._consumeBytes(b"0"))
+        self.assertEqual(b"", parser._consumeBytes(b"0"))
+
+    def test_readLine(self):
+        # readLine reads a single line of the file.
+        parser = FileBugDataParser(io.BytesIO(b"123\n456\n789"))
+        self.assertEqual(b"123\n", parser.readLine())
+        self.assertEqual(b"456\n", parser.readLine())
+        self.assertEqual(b"789", parser.readLine())
+        # If we try to read past the end of the file, an AssertionError is
+        # raised.  This ensures that invalid messages don't cause an
+        # infinite loop or similar.
+        self.assertRaisesWithContent(
+            AssertionError, "End of file reached.", parser.readLine)
+
+    def test_readHeaders(self):
+        # readHeaders reads the headers of a MIME message.  It reads all the
+        # headers until it sees a blank line.
+        msg = dedent("""\
+            Header: value
+            Space-Folded-Header: this header
+             is folded with a space.
+            Tab-Folded-Header: this header
+            \tis folded with a tab.
+            Another-header: another-value
+
+            Not-A-Header: not-a-value
+            """).encode("ASCII")
+        parser = FileBugDataParser(io.BytesIO(msg))
+        headers = parser.readHeaders()
+        self.assertEqual("value", headers["Header"])
+        self.assertEqual(
+            "this header\n is folded with a space.",
+            headers["Space-Folded-Header"])
+        self.assertEqual(
+            "this header\n\tis folded with a tab.",
+            headers["Tab-Folded-Header"])
+        self.assertEqual("another-value", headers["Another-Header"])
+        self.assertNotIn("Not-A-Header", headers)
+
+    def test__setDataFromHeaders_subject(self):
+        # _setDataFromHeaders makes the Subject header available in
+        # FileBugData.initial_summary.
+        data = FileBugData()
+        parser = FileBugDataParser(None)
+        parser._setDataFromHeaders(data, {"Subject": "Bug Subject"})
+        self.assertEqual("Bug Subject", data.initial_summary)
+
+    def test__setDataFromHeaders_tags(self):
+        # _setDataFromHeaders translates the Tags header into a list of
+        # lower-case strings as FileBugData.initial_tags.
+        data = FileBugData()
+        parser = FileBugDataParser(None)
+        parser._setDataFromHeaders(data, {"Tags": "Tag-One Tag-Two"})
+        self.assertContentEqual(["tag-one", "tag-two"], data.initial_tags)
+
+    def test__setDataFromHeaders_private(self):
+        # _setDataFromHeaders translates the Private header into a boolean
+        # as FileBugData.private.  It accepts "yes" for True and "no" for
+        # False.
+        data = FileBugData()
+        parser = FileBugDataParser(None)
+        parser._setDataFromHeaders(data, {"Private": "yes"})
+        self.assertIs(True, data.private)
+        data = FileBugData()
+        parser._setDataFromHeaders(data, {"Private": "no"})
+        self.assertIs(False, data.private)
+        # We're in no position to present a good error message to the user
+        # at this point, so invalid values get ignored.
+        data = FileBugData()
+        parser._setDataFromHeaders(data, {"Private": "not-valid"})
+        self.assertIsNone(data.private)
+
+    def test__setDataFromHeaders_subscribers(self):
+        # _setDataFromHeaders translates the Subscriber header into a list
+        # of lower-case strings as FileBugData.subscribers.
+        data = FileBugData()
+        parser = FileBugDataParser(None)
+        parser._setDataFromHeaders(data, {"Subscribers": "sub-one SUB-TWO"})
+        self.assertContentEqual(["sub-one", "sub-two"], data.subscribers)
+
+    def test_parse_first_inline_part(self):
+        # The first inline part is special.  Instead of being treated as a
+        # comment, it gets appended to the bug description.  It's available
+        # as FileBugData.extra_description.
+        message = dedent("""\
+            MIME-Version: 1.0
+            Content-type: multipart/mixed; boundary=boundary
+
+            --boundary
+            Content-disposition: inline
+            Content-type: text/plain; charset=utf-8
+
+            This should be added to the description.
+
+            Another line.
+
+            --boundary--
+            """).encode("ASCII")
+        parser = FileBugDataParser(io.BytesIO(message))
+        data = parser.parse()
+        self.assertEqual(
+            "This should be added to the description.\n\nAnother line.",
+            data.extra_description)
+
+    def test_parse_first_inline_part_base64(self):
+        # An inline part can be base64-encoded.
+        encoded_text = base64.b64encode(
+            b"This should be added to the description.\n\n"
+            b"Another line.").decode("ASCII")
+        message = dedent("""\
+            MIME-Version: 1.0
+            Content-type: multipart/mixed; boundary=boundary
+
+            --boundary
+            Content-disposition: inline
+            Content-type: text/plain; charset=utf-8
+            Content-transfer-encoding: base64
+
+            %s
+
+            --boundary--
+            """ % encoded_text).encode("ASCII")
+        parser = FileBugDataParser(io.BytesIO(message))
+        data = parser.parse()
+        self.assertEqual(
+            "This should be added to the description.\n\nAnother line.",
+            data.extra_description)
+
+    def test_parse_other_inline_parts(self):
+        # If there is more than one inline part, the second and subsequent
+        # parts are added as comments to the bug.  These are simple text
+        # strings, available as FileBugData.comments.
+        message = dedent("""\
+            MIME-Version: 1.0
+            Content-type: multipart/mixed; boundary=boundary
+
+            --boundary
+            Content-disposition: inline
+            Content-type: text/plain; charset=utf-8
+
+            This should be added to the description.
+
+            --boundary
+            Content-disposition: inline
+            Content-type: text/plain; charset=utf-8
+
+            This should be added as a comment.
+
+            --boundary
+            Content-disposition: inline
+            Content-type: text/plain; charset=utf-8
+
+            This should be added as another comment.
+
+            Line 2.
+
+            --boundary--
+            """).encode("ASCII")
+        parser = FileBugDataParser(io.BytesIO(message))
+        data = parser.parse()
+        self.assertEqual(
+            ["This should be added as a comment.",
+             "This should be added as another comment.\n\nLine 2."],
+            data.comments)
+
+    def test_parse_text_attachments(self):
+        # Parts with a "Content-Disposition: attachment" header are added as
+        # attachments to the bug.  The attachment description can be
+        # specified using a Content-Description header, but it's not
+        # required.
+        message = dedent("""\
+            MIME-Version: 1.0
+            Content-type: multipart/mixed; boundary=boundary
+
+            --boundary
+            Content-disposition: attachment; filename='attachment1'
+            Content-type: text/plain; charset=utf-8
+
+            This is an attachment.
+
+            Another line.
+
+            --boundary
+            Content-disposition: attachment; filename='attachment2'
+            Content-description: Attachment description.
+            Content-type: text/plain; charset=ISO-8859-1
+
+            This is another attachment, with a description.
+            --boundary--
+            """).encode("ASCII")
+        parser = FileBugDataParser(io.BytesIO(message))
+        data = parser.parse()
+        self.assertEqual(2, len(data.attachments))
+        # The filename is copied into the "filename" item.
+        self.assertEqual("attachment1", data.attachments[0]["filename"])
+        self.assertEqual("attachment2", data.attachments[1]["filename"])
+        # The Content-Type header is copied as is.
+        self.assertEqual(
+            "text/plain; charset=utf-8",
+            data.attachments[0]["content_type"])
+        self.assertEqual(
+            "text/plain; charset=ISO-8859-1",
+            data.attachments[1]["content_type"])
+        # If there is a Content-Description header, it's accessible as
+        # "description".  If there isn't any, the file name is used instead.
+        self.assertEqual("attachment1", data.attachments[0]["description"])
+        self.assertEqual(
+            "Attachment description.", data.attachments[1]["description"])
+        # The contents of the attachments are stored in files.
+        files = [attachment["content"] for attachment in data.attachments]
+        self.assertEqual(
+            b"This is an attachment.\n\nAnother line.\n\n", files[0].read())
+        files[0].close()
+        self.assertEqual(
+            b"This is another attachment, with a description.\n",
+            files[1].read())
+        files[1].close()
+
+    def test_parse_binary_attachments(self):
+        # Binary files are base64-encoded.  They are decoded automatically.
+        encoded_data = base64.b64encode(
+            b"\n".join([b"\x00" * 5, b"\x01" * 5])).decode("ASCII")
+        message = dedent("""\
+            MIME-Version: 1.0
+            Content-type: multipart/mixed; boundary=boundary
+
+            --boundary
+            Content-disposition: attachment; filename='attachment1'
+            Content-type: application/octet-stream
+            Content-transfer-encoding: base64
+
+            %s
+            --boundary--
+            """ % encoded_data).encode("ASCII")
+        parser = FileBugDataParser(io.BytesIO(message))
+        data = parser.parse()
+        self.assertEqual(1, len(data.attachments))
+        self.assertEqual(
+            b"\x00\x00\x00\x00\x00\n\x01\x01\x01\x01\x01",
+            data.attachments[0]["content"].read())
+        data.attachments[0]["content"].close()
+
+    def test_invalid_message(self):
+        # If someone gives an invalid message, for example one that doesn't
+        # have an end boundary, the parser raises an AssertionError.  We
+        # don't care about giving the user a good error message, since the
+        # format is well-known.
+        message = dedent("""\
+            MIME-Version: 1.0
+            Content-type: multipart/mixed; boundary=boundary
+
+            --boundary
+            Content-disposition: attachment; filename='attachment1'
+            Content-type: text/plain; charset=utf-8
+
+            This is an attachment.
+
+            Another line.""").encode("ASCII")
+        parser = FileBugDataParser(io.BytesIO(message))
+        self.assertRaisesWithContent(
+            AssertionError, "End of file reached.", parser.parse)