Repository: Nitrate/Nitrate

Issue:
Log link URL is encoded incorrectly
The log link URL is encoded incorrectly during exporting case runs in CSV format. Test should be updated to cover, which was missed before.


Discussion:

Resolving patch:
diff --git a/src/tcms/testruns/helpers/serializer.py b/src/tcms/testruns/helpers/serializer.py
--- a/src/tcms/testruns/helpers/serializer.py
+++ b/src/tcms/testruns/helpers/serializer.py
@@ -5,18 +5,8 @@
 
 import csv
 
-from xml.sax.saxutils import escape
-
-
-def escape_entities(text):
-    """Convert all XML entities
-
-    :param text: a string containing entities possibly
-    :type text: str
-    :return: converted version of text
-    :rtype: str
-    """
-    return escape(text, {'"': '&quot;'}) if text else text
+from django.template import Template
+from django.template.loader import get_template
 
 
 # TODO: rewrite export module to export TestCaseRuns, TestPlans and other
@@ -27,48 +17,36 @@ class TCR2File:
     """
     Write TestCaseRun queryset into CSV or XML.
     """
-    ROOT = 'testcaseruns'
     HEADERS = ("Case Run ID", "Case ID",
                "Category", "Status", "Summary",
                "script", "Automated", "Log Link",
                "Issue Keys")
 
     def __init__(self, tcrs):
-        self.root = self.ROOT
         self.headers = self.HEADERS
-
-        qs = tcrs.select_related('case',
-                                 'case__category',
-                                 'case_run_status')
-        self.tcrs = qs.only('case__summary',
-                            'case__script',
-                            'case__is_automated',
-                            'case__category__name',
-                            'case_run_status__name')
+        self.tcrs = (tcrs.select_related('case', 'case__category', 'case_run_status')
+                     .only('case__summary', 'case__script', 'case__is_automated',
+                           'case__category__name', 'case_run_status__name'))
         self.rows = []
 
     def tcr_attrs_in_a_list(self, tcr):
         if tcr.case.script is None:
             case_script = ''
         else:
-            case_script = tcr.case.script.encode('utf-8')
+            case_script = tcr.case.script.encode()
 
-        line = (tcr.pk, tcr.case.pk,
-                tcr.case.category.name.encode('utf-8'),
-                tcr.case_run_status.name.encode('utf-8'),
-                tcr.case.summary.encode('utf-8'),
+        return (tcr.pk, tcr.case.pk,
+                tcr.case.category.name.encode(),
+                tcr.case_run_status.name.encode(),
+                tcr.case.summary.encode(),
                 case_script,
                 tcr.case.is_automated,
                 self.log_links(tcr),
                 self.issue_keys(tcr))
-        return line
 
     def log_links(self, tcr):
         """Wrap log links into a single cell by joining log links"""
-        return '\n'.join(
-            (url.encode('utf-8')
-             for url in tcr.links.values_list('url', flat=True))
-        )
+        return '\n'.join(tcr.links.values_list('url', flat=True))
 
     def issue_keys(self, tcr):
         """Wrap issues into a single cell by joining issue keys"""
@@ -92,30 +70,5 @@ def write_to_xml(self, output):
         .. versionchanged:: 4.2
            Element ``bugs`` is renamed to ``issues``.
         """
-        write_to_output = output.write
-        tcr_start_elem = (
-            '<testcaserun case_run_id="%d" case_id="%d" category="%s" '
-            'status="%s" summary="%s" scripts="%s" automated="%s">'
-        )
-
-        write_to_output('<%s>' % self.root)
-        for tcr in self.tcrs.iterator():
-            summary = escape_entities(tcr.case.summary)
-            script = escape_entities(tcr.case.script)
-            write_to_output(tcr_start_elem % (tcr.pk, tcr.case.pk,
-                                              tcr.case.category.name or '',
-                                              tcr.case_run_status.name,
-                                              summary or '',
-                                              script or '',
-                                              str(tcr.case.is_automated)))
-            write_to_output('<loglinks>')
-            for link in tcr.links.iterator():
-                write_to_output(
-                    f'<loglink name="{link.name}" url="{link.url}" />')
-            write_to_output('</loglinks>')
-            write_to_output('<issues>')
-            for issue in tcr.issues.iterator():
-                write_to_output('<issue key="%s" />' % issue.issue_key)
-            write_to_output('</issues>')
-            write_to_output('</testcaserun>')
-        write_to_output('</%s>' % self.root)
+        xml_template: Template = get_template('run/export-to.xml')
+        output.write(xml_template.render({'case_runs': self.tcrs}))


Test patch:
diff --git a/src/tests/testruns/test_serializer.py b/src/tests/testruns/test_serializer.py
--- a/src/tests/testruns/test_serializer.py
+++ b/src/tests/testruns/test_serializer.py
@@ -3,14 +3,25 @@
 import io
 
 from xml.etree import ElementTree
+from xml.sax.saxutils import escape
 
 from tcms.linkreference.models import create_link
-from tcms.testruns.helpers.serializer import escape_entities
 from tcms.testruns.helpers.serializer import TCR2File
 from tcms.testruns.models import TestCaseRun
 from tests import factories as f, BaseCaseRun
 
 
+def escape_entities(text):
+    """Convert all XML entities
+
+    :param text: a string containing entities possibly
+    :type text: str
+    :return: converted version of text
+    :rtype: str
+    """
+    return escape(text, {'"': '&quot;'}) if text else text
+
+
 class TestTCR2FileToExportToXML(BaseCaseRun):
     """Test TCR2File to export XML"""
 
@@ -66,12 +77,9 @@ def test_export_to_xml(self):
             self.assertEqual(case_run.case.pk, int(elem.get('case_id')))
             self.assertEqual(case_run.case.category.name, elem.get('category'))
             self.assertEqual(case_run.case_run_status.name, elem.get('status'))
-            self.assertEqual(escape_entities(case_run.case.summary),
-                             elem.get('summary'))
-            self.assertEqual(escape_entities(case_run.case.script),
-                             elem.get('scripts'))
-            self.assertEqual(str(case_run.case.is_automated),
-                             elem.get('automated'))
+            self.assertEqual(escape_entities(case_run.case.summary), elem.get('summary'))
+            self.assertEqual(escape_entities(case_run.case.script), elem.get('scripts'))
+            self.assertEqual(str(case_run.case.is_automated), elem.get('automated'))
 
     def test_export_contains_issues(self):
         xml_doc = self._export_to_xml()
diff --git a/src/tests/testruns/test_views.py b/src/tests/testruns/test_views.py
--- a/src/tests/testruns/test_views.py
+++ b/src/tests/testruns/test_views.py
@@ -1,6 +1,9 @@
 # -*- coding: utf-8 -*-
+
+import csv
 import json
 import os
+import tempfile
 
 from datetime import datetime, timedelta
 from http import HTTPStatus
@@ -17,6 +20,7 @@
 from django.contrib.auth.models import User
 
 from tcms.issuetracker.models import Issue
+from tcms.linkreference.models import create_link
 from tcms.testruns.models import TCMSEnvRunValueMap
 from tcms.testruns.models import TestCaseRun
 from tcms.testruns.models import TestCaseRunStatus
@@ -991,6 +995,10 @@ class TestExportTestRunCases(BaseCaseRun):
     def setUpTestData(cls):
         super().setUpTestData()
         cls.export_url = reverse('run-export', args=[cls.test_run.pk])
+        cls.log_link1 = 'https://somewhere/log1.txt'
+        cls.log_link2 = 'https://somewhere/log2.txt'
+        create_link('Log1', cls.log_link1, link_to=cls.case_run_1)
+        create_link('一份很长的日志', cls.log_link2, link_to=cls.case_run_2)
 
     @patch('tcms.testruns.views.time.strftime', return_value='2017-06-17')
     def test_export_to_xml_file(self, strftime):
@@ -1008,10 +1016,26 @@ def test_export_to_csv_file(self, strftime):
 
     def test_export_all_case_runs_to_csv_by_default(self):
         response = self.client.get(self.export_url, {'format': 'csv'})
-        self.assertEqual(
-            self.test_run.case_run.count(),
-            # Do not count header line
-            len(response.content.decode('utf-8').strip().split(os.linesep)) - 1)
+
+        actual_rows_count = 0
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            csv_file = os.path.join(tmpdir, 'file.csv')
+            with open(csv_file, 'w') as f:
+                f.write(response.content.decode())
+
+            with open(csv_file, 'r', newline='') as f:
+                reader = csv.reader(f)
+                next(reader)  # Skip the header line
+                for row in reader:
+                    actual_rows_count += 1
+                    if row[0] == self.case_run_1.pk:
+                        self.assertEqual(self.log_link1, row[-2])
+                    elif row[0] == self.case_run_2.pk:
+                        self.assertEqual(self.log_link2, row[-2])
+
+        self.assertEqual(self.test_run.case_run.count(), actual_rows_count,
+                         msg='Not all case runs are exported.')
 
     def test_export_all_case_runs_to_xml_by_default(self):
         response = self.client.get(self.export_url, {'format': 'xml'})