pre-upload: add unittests for commit message checks

The Change-Id checker was a little too forgiving, so tighten that up.

BUG=None
TEST=`./pre-upload_unittest.py` passes

Change-Id: I338ac029d51584dea87816aeb8f12647f7720589
Reviewed-on: https://chromium-review.googlesource.com/226681
Commit-Queue: Mike Frysinger <vapier@chromium.org>
Tested-by: Mike Frysinger <vapier@chromium.org>
Reviewed-by: Steve Fung <stevefung@chromium.org>
diff --git a/pre-upload.py b/pre-upload.py
index 064bb8f..b44cbc9 100755
--- a/pre-upload.py
+++ b/pre-upload.py
@@ -707,9 +707,10 @@
 
 def _check_change_has_proper_changeid(_project, commit):
   """Verify that Change-ID is present in last paragraph of commit message."""
+  CHANGE_ID_RE = r'\nChange-Id: I[a-f0-9]+\n'
   desc = _get_commit_desc(commit)
-  loc = desc.rfind('\nChange-Id:')
-  if loc == -1 or re.search('\n\s*\n\s*\S+', desc[loc:]):
+  m = re.search(CHANGE_ID_RE, desc)
+  if not m or desc[m.end():].strip():
     return HookFailure('Change-Id must be in last paragraph of description.')
 
 
diff --git a/pre-upload_unittest.py b/pre-upload_unittest.py
index 0a9959e..eddbb7e 100755
--- a/pre-upload_unittest.py
+++ b/pre-upload_unittest.py
@@ -471,5 +471,176 @@
       self.assertNotEqual(None, pre_upload._check_license('proj', 'sha1'))
 
 
+class CommitMessageTestCase(cros_test_lib.MockTestCase):
+  """Test case for funcs that check commit messages."""
+
+  def setUp(self):
+    self.msg_mock = self.PatchObject(pre_upload, '_get_commit_desc')
+
+  @staticmethod
+  def CheckMessage(_project, _commit):
+    raise AssertionError('Test class must declare CheckMessage')
+    # This dummy return is to silence pylint warning W1111 so we don't have to
+    # enable it for all the call sites below.
+    return 1 # pylint: disable=W0101
+
+  def assertMessageAccepted(self, msg, project='project', commit='1234'):
+    """Assert _check_change_has_bug_field accepts |msg|."""
+    self.msg_mock.return_value = msg
+    ret = self.CheckMessage(project, commit)
+    self.assertEqual(ret, None)
+
+  def assertMessageRejected(self, msg, project='project', commit='1234'):
+    """Assert _check_change_has_bug_field rejects |msg|."""
+    self.msg_mock.return_value = msg
+    ret = self.CheckMessage(project, commit)
+    self.assertTrue(isinstance(ret, errors.HookFailure))
+
+
+class CheckCommitMessageBug(CommitMessageTestCase):
+  """Tests for _check_change_has_bug_field."""
+
+  @staticmethod
+  def CheckMessage(project, commit):
+    return pre_upload._check_change_has_bug_field(project, commit)
+
+  def testNormal(self):
+    """Accept a commit message w/a valid BUG."""
+    self.assertMessageAccepted('\nBUG=chromium:1234\n')
+    self.assertMessageAccepted('\nBUG=chrome-os-partner:1234\n')
+
+  def testNone(self):
+    """Accept BUG=None."""
+    self.assertMessageAccepted('\nBUG=None\n')
+    self.assertMessageAccepted('\nBUG=none\n')
+    self.assertMessageRejected('\nBUG=NONE\n')
+
+  def testBlank(self):
+    """Reject blank values."""
+    self.assertMessageRejected('\nBUG=\n')
+    self.assertMessageRejected('\nBUG=    \n')
+
+  def testNotFirstLine(self):
+    """Reject the first line."""
+    self.assertMessageRejected('BUG=None\n\n\n')
+
+  def testNotInline(self):
+    """Reject not at the start of line."""
+    self.assertMessageRejected('\n BUG=None\n')
+    self.assertMessageRejected('\n\tBUG=None\n')
+
+  def testOldTrackers(self):
+    """Reject commit messages using old trackers."""
+    self.assertMessageRejected('\nBUG=chromium-os:1234\n')
+
+  def testNoTrackers(self):
+    """Reject commit messages w/invalid trackers."""
+    self.assertMessageRejected('\nBUG=booga:1234\n')
+
+  def testMissing(self):
+    """Reject commit messages w/no BUG line."""
+    self.assertMessageRejected('foo\n')
+
+  def testCase(self):
+    """Reject bug lines that are not BUG."""
+    self.assertMessageRejected('\nbug=none\n')
+
+
+class CheckCommitMessageCqDepend(CommitMessageTestCase):
+  """Tests for _check_change_has_valid_cq_depend."""
+
+  @staticmethod
+  def CheckMessage(project, commit):
+    return pre_upload._check_change_has_valid_cq_depend(project, commit)
+
+  def testNormal(self):
+    """Accept valid CQ-DEPENDs line."""
+    self.assertMessageAccepted('\nCQ-DEPEND=CL:1234\n')
+
+  def testInvalid(self):
+    """Reject invalid CQ-DEPENDs line."""
+    self.assertMessageRejected('\nCQ-DEPEND=CL=1234\n')
+    self.assertMessageRejected('\nCQ-DEPEND=None\n')
+
+
+class CheckCommitMessageTest(CommitMessageTestCase):
+  """Tests for _check_change_has_test_field."""
+
+  @staticmethod
+  def CheckMessage(project, commit):
+    return pre_upload._check_change_has_test_field(project, commit)
+
+  def testNormal(self):
+    """Accept a commit message w/a valid TEST."""
+    self.assertMessageAccepted('\nTEST=i did it\n')
+
+  def testNone(self):
+    """Accept TEST=None."""
+    self.assertMessageAccepted('\nTEST=None\n')
+    self.assertMessageAccepted('\nTEST=none\n')
+
+  def testBlank(self):
+    """Reject blank values."""
+    self.assertMessageRejected('\nTEST=\n')
+    self.assertMessageRejected('\nTEST=     \n')
+
+  def testNotFirstLine(self):
+    """Reject the first line."""
+    self.assertMessageRejected('TEST=None\n\n\n')
+
+  def testNotInline(self):
+    """Reject not at the start of line."""
+    self.assertMessageRejected('\n TEST=None\n')
+    self.assertMessageRejected('\n\tTEST=None\n')
+
+  def testMissing(self):
+    """Reject commit messages w/no TEST line."""
+    self.assertMessageRejected('foo\n')
+
+  def testCase(self):
+    """Reject bug lines that are not TEST."""
+    self.assertMessageRejected('\ntest=none\n')
+
+
+class CheckCommitMessageChangeId(CommitMessageTestCase):
+  """Tests for _check_change_has_proper_changeid."""
+
+  @staticmethod
+  def CheckMessage(project, commit):
+    return pre_upload._check_change_has_proper_changeid(project, commit)
+
+  def testNormal(self):
+    """Accept a commit message w/a valid Change-Id."""
+    self.assertMessageAccepted('foo\n\nChange-Id: I1234\n')
+
+  def testBlank(self):
+    """Reject blank values."""
+    self.assertMessageRejected('\nChange-Id:\n')
+    self.assertMessageRejected('\nChange-Id:       \n')
+
+  def testNotFirstLine(self):
+    """Reject the first line."""
+    self.assertMessageRejected('TEST=None\n\n\n')
+
+  def testNotInline(self):
+    """Reject not at the start of line."""
+    self.assertMessageRejected('\n Change-Id: I1234\n')
+    self.assertMessageRejected('\n\tChange-Id: I1234\n')
+
+  def testMissing(self):
+    """Reject commit messages missing the line."""
+    self.assertMessageRejected('foo\n')
+
+  def testCase(self):
+    """Reject bug lines that are not Change-Id."""
+    self.assertMessageRejected('\nchange-id: I1234\n')
+    self.assertMessageRejected('\nChange-id: I1234\n')
+    self.assertMessageRejected('\nChange-ID: I1234\n')
+
+  def testEnd(self):
+    """Reject Change-Id's that are not last."""
+    self.assertMessageRejected('\nChange-Id: I1234\nbar\n')
+
+
 if __name__ == '__main__':
   cros_test_lib.main()