blob: 7cfee41af482221d330764631c7488e5a977367b [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2020 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6"""Tests for the issues servicer."""
7from __future__ import print_function
8from __future__ import division
9from __future__ import absolute_import
10
11import copy
12import unittest
13import mock
14
15from api.v3 import converters
16from api.v3 import issues_servicer
17from api.v3.api_proto import issues_pb2
18from api.v3.api_proto import issue_objects_pb2
19from framework import exceptions
20from framework import framework_helpers
21from framework import monorailcontext
22from framework import permissions
23from proto import tracker_pb2
24from testing import fake
25from services import service_manager
26
27from google.appengine.ext import testbed
28from google.protobuf import timestamp_pb2
29from google.protobuf import field_mask_pb2
30
31
32def _Issue(project_id, local_id):
33 issue = tracker_pb2.Issue(owner_id=0)
34 issue.project_name = 'proj-%d' % project_id
35 issue.project_id = project_id
36 issue.local_id = local_id
37 issue.issue_id = project_id * 100 + local_id
38 return issue
39
40
41CURRENT_TIME = 12346.78
42
43
44class IssuesServicerTest(unittest.TestCase):
45
46 def setUp(self):
47 # memcache and datastore needed for generating page tokens.
48 self.testbed = testbed.Testbed()
49 self.testbed.activate()
50 self.testbed.init_memcache_stub()
51 self.testbed.init_datastore_v3_stub()
52
53 self.cnxn = fake.MonorailConnection()
54 self.services = service_manager.Services(
55 config=fake.ConfigService(),
56 issue=fake.IssueService(),
57 issue_star=fake.IssueStarService(),
58 project=fake.ProjectService(),
59 features=fake.FeaturesService(),
60 spam=fake.SpamService(),
61 user=fake.UserService(),
62 usergroup=fake.UserGroupService())
63 self.issues_svcr = issues_servicer.IssuesServicer(
64 self.services, make_rate_limiter=False)
65 self.PAST_TIME = int(CURRENT_TIME - 1)
66
67 self.owner = self.services.user.TestAddUser('owner@example.com', 111)
68 self.user_2 = self.services.user.TestAddUser('user_2@example.com', 222)
69
70 self.project_1 = self.services.project.TestAddProject(
71 'chicken', project_id=789)
72 self.issue_1_resource_name = 'projects/chicken/issues/1234'
73 self.issue_1 = fake.MakeTestIssue(
74 self.project_1.project_id,
75 1234,
76 'sum',
77 'New',
78 self.owner.user_id,
79 labels=['find-me', 'pri-3'],
80 project_name=self.project_1.project_name)
81 self.services.issue.TestAddIssue(self.issue_1)
82
83 self.project_2 = self.services.project.TestAddProject('cow', project_id=788)
84 self.issue_2_resource_name = 'projects/cow/issues/1235'
85 self.issue_2 = fake.MakeTestIssue(
86 self.project_2.project_id,
87 1235,
88 'sum',
89 'New',
90 self.user_2.user_id,
91 project_name=self.project_2.project_name)
92 self.services.issue.TestAddIssue(self.issue_2)
93 self.issue_3 = fake.MakeTestIssue(
94 self.project_2.project_id,
95 1236,
96 'sum',
97 'New',
98 self.user_2.user_id,
99 labels=['find-me', 'pri-1'],
100 project_name=self.project_2.project_name)
101 self.services.issue.TestAddIssue(self.issue_3)
102
103 def CallWrapped(self, wrapped_handler, mc, *args, **kwargs):
104 self.issues_svcr.converter = converters.Converter(mc, self.services)
105 return wrapped_handler.wrapped(self.issues_svcr, mc, *args, **kwargs)
106
107 def testGetIssue(self):
108 """We can get an issue."""
109 request = issues_pb2.GetIssueRequest(name=self.issue_1_resource_name)
110 mc = monorailcontext.MonorailContext(
111 self.services, cnxn=self.cnxn, requester=self.owner.email)
112 actual_response = self.CallWrapped(self.issues_svcr.GetIssue, mc, request)
113 self.assertEqual(
114 actual_response, self.issues_svcr.converter.ConvertIssue(self.issue_1))
115
116 def testBatchGetIssues(self):
117 """We can batch get issues."""
118 mc = monorailcontext.MonorailContext(
119 self.services, cnxn=self.cnxn, requester=self.owner.email)
120 request = issues_pb2.BatchGetIssuesRequest(
121 names=['projects/cow/issues/1235', 'projects/cow/issues/1236'])
122 actual_response = self.CallWrapped(
123 self.issues_svcr.BatchGetIssues, mc, request)
124 self.assertEqual(
125 [issue.name for issue in actual_response.issues],
126 ['projects/cow/issues/1235', 'projects/cow/issues/1236'])
127
128 def testBatchGetIssues_Empty(self):
129 """We can return a response if the request has no names."""
130 mc = monorailcontext.MonorailContext(
131 self.services, cnxn=self.cnxn, requester=self.owner.email)
132 request = issues_pb2.BatchGetIssuesRequest(names=[])
133 actual_response = self.CallWrapped(
134 self.issues_svcr.BatchGetIssues, mc, request)
135 self.assertEqual(
136 actual_response, issues_pb2.BatchGetIssuesResponse(issues=[]))
137
138 def testBatchGetIssues_WithParent(self):
139 """We can batch get issues with a given parent."""
140 mc = monorailcontext.MonorailContext(
141 self.services, cnxn=self.cnxn, requester=self.owner.email)
142 request = issues_pb2.BatchGetIssuesRequest(
143 parent='projects/cow',
144 names=['projects/cow/issues/1235', 'projects/cow/issues/1236'])
145 actual_response = self.CallWrapped(
146 self.issues_svcr.BatchGetIssues, mc, request)
147 self.assertEqual(
148 [issue.name for issue in actual_response.issues],
149 ['projects/cow/issues/1235', 'projects/cow/issues/1236'])
150
151 def testBatchGetIssues_FromMultipleProjects(self):
152 """We can batch get issues from multiple projects."""
153 mc = monorailcontext.MonorailContext(
154 self.services, cnxn=self.cnxn, requester=self.owner.email)
155 request = issues_pb2.BatchGetIssuesRequest(
156 names=[
157 'projects/chicken/issues/1234', 'projects/cow/issues/1235',
158 'projects/cow/issues/1236'
159 ])
160 actual_response = self.CallWrapped(
161 self.issues_svcr.BatchGetIssues, mc, request)
162 self.assertEqual(
163 [issue.name for issue in actual_response.issues], [
164 'projects/chicken/issues/1234', 'projects/cow/issues/1235',
165 'projects/cow/issues/1236'
166 ])
167
168 def testBatchGetIssues_WithBadInput(self):
169 """We raise an exception with bad input to batch get issues."""
170 mc = monorailcontext.MonorailContext(
171 self.services, cnxn=self.cnxn, requester=self.owner.email)
172 request = issues_pb2.BatchGetIssuesRequest(
173 parent='projects/cow',
174 names=['projects/cow/issues/1235', 'projects/chicken/issues/1234'])
175 with self.assertRaisesRegexp(
176 exceptions.InputException,
177 'projects/chicken/issues/1234 is not a child issue of projects/cow.'):
178 self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
179
180 request = issues_pb2.BatchGetIssuesRequest(
181 parent='projects/sheep',
182 names=['projects/cow/issues/1235', 'projects/chicken/issues/1234'])
183 with self.assertRaisesRegexp(
184 exceptions.InputException,
185 'projects/cow/issues/1235 is not a child issue of projects/sheep.\n' +
186 'projects/chicken/issues/1234 is not a child issue of projects/sheep.'):
187 self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
188
189 request = issues_pb2.BatchGetIssuesRequest(
190 parent='projects/cow',
191 names=['projects/cow/badformat/1235', 'projects/chicken/issues/1234'])
192 with self.assertRaisesRegexp(
193 exceptions.InputException,
194 'Invalid resource name: projects/cow/badformat/1235.'):
195 self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
196
197 def testBatchGetIssues_NonExistentIssues(self):
198 """We raise an exception with bad input to batch get issues."""
199 mc = monorailcontext.MonorailContext(
200 self.services, cnxn=self.cnxn, requester=self.owner.email)
201 request = issues_pb2.BatchGetIssuesRequest(
202 parent='projects/chicken',
203 names=['projects/chicken/issues/1', 'projects/chicken/issues/2'])
204 with self.assertRaisesRegexp(
205 exceptions.NoSuchIssueException,
206 "\['projects/chicken/issues/1', 'projects/chicken/issues/2'\] not found"
207 ):
208 self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
209
210 @mock.patch('api.v3.api_constants.MAX_BATCH_ISSUES', 2)
211 def testBatchGetIssues(self):
212 mc = monorailcontext.MonorailContext(
213 self.services, cnxn=self.cnxn, requester=self.owner.email)
214 request = issues_pb2.BatchGetIssuesRequest(
215 parent='projects/cow',
216 names=[
217 'projects/cow/issues/1235', 'projects/chicken/issues/1234',
218 'projects/cow/issues/1233'
219 ])
220 with self.assertRaises(exceptions.InputException):
221 self.CallWrapped(self.issues_svcr.BatchGetIssues, mc, request)
222
223 @mock.patch('search.frontendsearchpipeline.FrontendSearchPipeline')
224 @mock.patch('api.v3.api_constants.MAX_ISSUES_PER_PAGE', 2)
225 def testSearchIssues(self, mock_pipeline):
226 """We can search for issues in some projects."""
227 request = issues_pb2.SearchIssuesRequest(
228 projects=['projects/chicken', 'projects/cow'],
229 query='label:find-me',
230 order_by='-pri',
231 page_size=3)
232 mc = monorailcontext.MonorailContext(
233 self.services, cnxn=self.cnxn, requester=self.user_2.email)
234
235 instance = mock.Mock(
236 spec=True, total_count=3, visible_results=[self.issue_1, self.issue_3])
237 mock_pipeline.return_value = instance
238 instance.SearchForIIDs = mock.Mock()
239 instance.MergeAndSortIssues = mock.Mock()
240 instance.Paginate = mock.Mock()
241
242 actual_response = self.CallWrapped(
243 self.issues_svcr.SearchIssues, mc, request)
244 # start index is 0.
245 # number of items is coerced from 3 -> 2
246 mock_pipeline.assert_called_once_with(
247 self.cnxn,
248 self.services,
249 mc.auth, [222],
250 'label:find-me', ['chicken', 'cow'],
251 2,
252 0,
253 1,
254 '',
255 '-pri',
256 mc.warnings,
257 mc.errors,
258 True,
259 mc.profiler,
260 project=None)
261 self.assertEqual(
262 [issue.name for issue in actual_response.issues],
263 ['projects/chicken/issues/1234', 'projects/cow/issues/1236'])
264
265 # Check the `next_page_token` can be used to get the next page of results.
266 request.page_token = actual_response.next_page_token
267 self.CallWrapped(self.issues_svcr.SearchIssues, mc, request)
268 # start index is now 2.
269 mock_pipeline.assert_called_with(
270 self.cnxn,
271 self.services,
272 mc.auth, [222],
273 'label:find-me', ['chicken', 'cow'],
274 2,
275 2,
276 1,
277 '',
278 '-pri',
279 mc.warnings,
280 mc.errors,
281 True,
282 mc.profiler,
283 project=None)
284
285 @mock.patch('search.frontendsearchpipeline.FrontendSearchPipeline')
286 @mock.patch('api.v3.api_constants.MAX_ISSUES_PER_PAGE', 2)
287 def testSearchIssues_PaginationErrorOrderByChanged(self, mock_pipeline):
288 """Error when changing the order_by and using the same page_otoken."""
289 request = issues_pb2.SearchIssuesRequest(
290 projects=['projects/chicken', 'projects/cow'],
291 query='label:find-me',
292 order_by='-pri',
293 page_size=3)
294 mc = monorailcontext.MonorailContext(
295 self.services, cnxn=self.cnxn, requester=self.user_2.email)
296
297 instance = mock.Mock(
298 spec=True, total_count=3, visible_results=[self.issue_1, self.issue_3])
299 mock_pipeline.return_value = instance
300 instance.SearchForIIDs = mock.Mock()
301 instance.MergeAndSortIssues = mock.Mock()
302 instance.Paginate = mock.Mock()
303
304 actual_response = self.CallWrapped(
305 self.issues_svcr.SearchIssues, mc, request)
306
307 # The request should fail if we use `next_page_token` and change parameters.
308 request.page_token = actual_response.next_page_token
309 request.order_by = 'owner'
310 with self.assertRaises(exceptions.PageTokenException):
311 self.CallWrapped(self.issues_svcr.SearchIssues, mc, request)
312
313 # Note the 'empty' case doesn't make sense for ListComments, as one is created
314 # for every issue.
315 def testListComments(self):
316 comment_2 = tracker_pb2.IssueComment(
317 id=123,
318 issue_id=self.issue_1.issue_id,
319 project_id=self.issue_1.project_id,
320 user_id=self.owner.user_id,
321 content='comment 2')
322 self.services.issue.TestAddComment(comment_2, self.issue_1.local_id)
323 request = issues_pb2.ListCommentsRequest(
324 parent=self.issue_1_resource_name, page_size=1)
325 mc = monorailcontext.MonorailContext(
326 self.services, cnxn=self.cnxn, requester=self.owner.email)
327 actual_response = self.CallWrapped(
328 self.issues_svcr.ListComments, mc, request)
329 self.assertEqual(len(actual_response.comments), 1)
330
331 # Check the `next_page_token` can be used to get the next page of results
332 request.page_token = actual_response.next_page_token
333 next_actual_response = self.CallWrapped(
334 self.issues_svcr.ListComments, mc, request)
335 self.assertEqual(len(next_actual_response.comments), 1)
336 self.assertEqual(next_actual_response.comments[0].content, 'comment 2')
337
338 def testListComments_UnsupportedFilter(self):
339 """If anything other than approval is provided, it's an error."""
340 filter_str = 'content = "x"'
341 request = issues_pb2.ListCommentsRequest(
342 parent=self.issue_1_resource_name, page_size=1, filter=filter_str)
343 mc = monorailcontext.MonorailContext(
344 self.services, cnxn=self.cnxn, requester=self.owner.email)
345 with self.assertRaises(exceptions.InputException):
346 self.CallWrapped(self.issues_svcr.ListComments, mc, request)
347
348 def testListComments_TwoApprovalsErrors(self):
349 """If anything other than a single approval is provided, it's an error."""
350 filter_str = (
351 'approval = "projects/chicken/approvalDefs/404" OR '
352 'approval = "projects/chicken/approvalDefs/405')
353 request = issues_pb2.ListCommentsRequest(
354 parent=self.issue_1_resource_name, page_size=1, filter=filter_str)
355 mc = monorailcontext.MonorailContext(
356 self.services, cnxn=self.cnxn, requester=self.owner.email)
357 with self.assertRaises(exceptions.InputException):
358 self.CallWrapped(self.issues_svcr.ListComments, mc, request)
359
360 def testListComments_FilterTypoError(self):
361 """Even an extra space is an error."""
362 filter_str = 'approval = "projects/chicken/approvalDefs/404" '
363 request = issues_pb2.ListCommentsRequest(
364 parent=self.issue_1_resource_name, page_size=1, filter=filter_str)
365 mc = monorailcontext.MonorailContext(
366 self.services, cnxn=self.cnxn, requester=self.owner.email)
367 with self.assertRaises(exceptions.InputException):
368 self.CallWrapped(self.issues_svcr.ListComments, mc, request)
369
370 def testListComments_UnknownApprovalInFilter(self):
371 """Filter with unknown approval returns no error and no comments."""
372 approval_comment = tracker_pb2.IssueComment(
373 id=123,
374 issue_id=self.issue_1.issue_id,
375 project_id=self.issue_1.project_id,
376 user_id=self.owner.user_id,
377 content='comment 2 - approval 1',
378 approval_id=1)
379 self.services.issue.TestAddComment(approval_comment, self.issue_1.local_id)
380 request = issues_pb2.ListCommentsRequest(
381 parent=self.issue_1_resource_name, page_size=1,
382 filter='approval = "projects/chicken/approvalDefs/404"')
383 mc = monorailcontext.MonorailContext(
384 self.services, cnxn=self.cnxn, requester=self.owner.email)
385 response = self.CallWrapped(self.issues_svcr.ListComments, mc, request)
386 self.assertEqual(len(response.comments), 0)
387
388 def testListComments_ApprovalInFilter(self):
389 approval_comment = tracker_pb2.IssueComment(
390 id=123,
391 issue_id=self.issue_1.issue_id,
392 project_id=self.issue_1.project_id,
393 user_id=self.owner.user_id,
394 content='comment 2 - approval 1',
395 approval_id=1)
396 self.services.issue.TestAddComment(approval_comment, self.issue_1.local_id)
397 request = issues_pb2.ListCommentsRequest(
398 parent=self.issue_1_resource_name, page_size=1,
399 filter='approval = "projects/chicken/approvalDefs/1"')
400 mc = monorailcontext.MonorailContext(
401 self.services, cnxn=self.cnxn, requester=self.owner.email)
402 response = self.CallWrapped(self.issues_svcr.ListComments, mc, request)
403 self.assertEqual(len(response.comments), 1)
404 self.assertEqual(response.comments[0].content, approval_comment.content)
405
406 def testListApprovalValues(self):
407 config = fake.MakeTestConfig(self.project_2.project_id, [], [])
408 self.services.config.StoreConfig(self.cnxn, config)
409
410 # Make regular field def and value
411 fd_1 = fake.MakeTestFieldDef(
412 1, self.project_2.project_id, tracker_pb2.FieldTypes.STR_TYPE,
413 field_name='field1')
414 self.services.config.TestAddFieldDef(fd_1)
415 fv_1 = fake.MakeFieldValue(
416 field_id=fd_1.field_id, str_value='value1', derived=False)
417
418 # Make testing approval def and its associated field def
419 approval_gate = fake.MakeTestFieldDef(
420 2, self.project_2.project_id, tracker_pb2.FieldTypes.APPROVAL_TYPE,
421 field_name='approval-gate-1')
422 self.services.config.TestAddFieldDef(approval_gate)
423 ad = fake.MakeTestApprovalDef(2, approver_ids=[self.user_2.user_id])
424 self.services.config.TestAddApprovalDef(ad, self.project_2.project_id)
425
426 # Make approval value
427 av = fake.MakeApprovalValue(2, set_on=self.PAST_TIME,
428 approver_ids=[self.user_2.user_id], setter_id=self.user_2.user_id)
429
430 # Make field def that belongs to above approval_def
431 fd_2 = fake.MakeTestFieldDef(
432 3, self.project_2.project_id, tracker_pb2.FieldTypes.STR_TYPE,
433 field_name='field2', approval_id=2)
434 self.services.config.TestAddFieldDef(fd_2)
435 fv_2 = fake.MakeFieldValue(
436 field_id=fd_2.field_id, str_value='value2', derived=False)
437
438 issue_resource_name = 'projects/cow/issues/1237'
439 issue = fake.MakeTestIssue(
440 self.project_2.project_id,
441 1237,
442 'sum',
443 'New',
444 self.user_2.user_id,
445 project_name=self.project_2.project_name,
446 field_values=[fv_1, fv_2],
447 approval_values=[av])
448 self.services.issue.TestAddIssue(issue)
449
450 request = issues_pb2.ListApprovalValuesRequest(parent=issue_resource_name)
451 mc = monorailcontext.MonorailContext(
452 self.services, cnxn=self.cnxn, requester=self.owner.email)
453 actual_response = self.CallWrapped(
454 self.issues_svcr.ListApprovalValues, mc, request)
455
456 self.assertEqual(len(actual_response.approval_values), 1)
457 expected_fv = issue_objects_pb2.FieldValue(
458 field='projects/cow/fieldDefs/3',
459 value='value2',
460 derivation=issue_objects_pb2.Derivation.Value('EXPLICIT'))
461 expected = issue_objects_pb2.ApprovalValue(
462 name='projects/cow/issues/1237/approvalValues/2',
463 status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NOT_SET'),
464 approvers=['users/222'],
465 approval_def='projects/cow/approvalDefs/2',
466 set_time=timestamp_pb2.Timestamp(seconds=self.PAST_TIME),
467 setter='users/222',
468 field_values=[expected_fv])
469 self.assertEqual(actual_response.approval_values[0], expected)
470
471 def testListApprovalValues_Empty(self):
472 request = issues_pb2.ListApprovalValuesRequest(
473 parent=self.issue_1_resource_name)
474 mc = monorailcontext.MonorailContext(
475 self.services, cnxn=self.cnxn, requester=self.owner.email)
476 actual_response = self.CallWrapped(
477 self.issues_svcr.ListApprovalValues, mc, request)
478 self.assertEqual(len(actual_response.approval_values), 0)
479
480 @mock.patch(
481 'features.send_notifications.PrepareAndSendIssueChangeNotification')
482 def testMakeIssue(self, _fake_pasicn):
483 request_issue = issue_objects_pb2.Issue(
484 summary='sum',
485 status=issue_objects_pb2.Issue.StatusValue(status='New'),
486 cc_users=[issue_objects_pb2.Issue.UserValue(user='users/222')],
487 labels=[issue_objects_pb2.Issue.LabelValue(label='foo-bar')]
488 )
489 request = issues_pb2.MakeIssueRequest(
490 parent='projects/chicken',
491 issue=request_issue,
492 description='description'
493 )
494 mc = monorailcontext.MonorailContext(
495 self.services, cnxn=self.cnxn, requester=self.owner.email)
496 response = self.CallWrapped(
497 self.issues_svcr.MakeIssue, mc, request)
498 self.assertEqual(response.summary, 'sum')
499 self.assertEqual(response.status.status, 'New')
500 self.assertEqual(response.cc_users[0].user, 'users/222')
501 self.assertEqual(response.labels[0].label, 'foo-bar')
502 self.assertEqual(response.star_count, 1)
503
504 @mock.patch(
505 'features.send_notifications.PrepareAndSendIssueChangeNotification')
506 @mock.patch('time.time')
507 def testModifyIssues(self, fake_time, fake_notify):
508 fake_time.return_value = 12345
509
510 issue = _Issue(780, 1)
511 self.services.project.TestAddProject(
512 issue.project_name, project_id=issue.project_id,
513 owner_ids=[self.owner.user_id])
514
515 issue.labels = ['keep-me', 'remove-me']
516 self.services.issue.TestAddIssue(issue)
517 exp_issue = copy.deepcopy(issue)
518
519 self.services.issue.CreateIssueComment = mock.Mock()
520 mc = monorailcontext.MonorailContext(
521 self.services, cnxn=self.cnxn, requester=self.owner.email)
522
523 request = issues_pb2.ModifyIssuesRequest(
524 deltas=[
525 issues_pb2.IssueDelta(
526 issue=issue_objects_pb2.Issue(
527 name='projects/proj-780/issues/1',
528 labels=[issue_objects_pb2.Issue.LabelValue(
529 label='add-me')]),
530 update_mask=field_mask_pb2.FieldMask(paths=['labels']),
531 labels_remove=['remove-me'])],
532 uploads=[issues_pb2.AttachmentUpload(
533 filename='mowgli.gif', content='cute dog')],
534 comment_content='Release the chicken.',
535 notify_type=issues_pb2.NotifyType.Value('NO_NOTIFICATION'))
536
537 response = self.CallWrapped(
538 self.issues_svcr.ModifyIssues, mc, request)
539 exp_issue.labels = ['keep-me', 'add-me']
540 exp_issue.modified_timestamp = 12345
541 exp_api_issue = self.issues_svcr.converter.ConvertIssue(exp_issue)
542 self.assertEqual([iss for iss in response.issues], [exp_api_issue])
543
544 # All updated issues should have been fetched from DB, skipping cache.
545 # So we expect assume_stale=False was applied to all issues during the
546 # the fetch.
547 exp_issue.assume_stale = False
548 # These derived values get set to the following when an issue goes through
549 # the ApplyFilterRules path. (see filter_helpers._ComputeDerivedFields)
550 exp_issue.derived_owner_id = 0
551 exp_issue.derived_status = ''
552 exp_attachments = [framework_helpers.AttachmentUpload(
553 'mowgli.gif', 'cute dog', 'image/gif')]
554 exp_amendments = [tracker_pb2.Amendment(
555 field=tracker_pb2.FieldID.LABELS, newvalue='-remove-me add-me')]
556 self.services.issue.CreateIssueComment.assert_called_once_with(
557 self.cnxn, exp_issue, mc.auth.user_id, 'Release the chicken.',
558 attachments=exp_attachments, amendments=exp_amendments, commit=False)
559 fake_notify.assert_called_once_with(
560 issue.issue_id, 'testing-app.appspot.com', self.owner.user_id,
561 comment_id=mock.ANY, old_owner_id=None, send_email=False)
562
563 def testModifyIssues_Empty(self):
564 mc = monorailcontext.MonorailContext(
565 self.services, cnxn=self.cnxn, requester=self.owner.email)
566 request = issues_pb2.ModifyIssuesRequest()
567 response = self.CallWrapped(self.issues_svcr.ModifyIssues, mc, request)
568 self.assertEqual(response, issues_pb2.ModifyIssuesResponse())
569
570 @mock.patch('api.v3.api_constants.MAX_MODIFY_ISSUES', 2)
571 @mock.patch('api.v3.api_constants.MAX_MODIFY_IMPACTED_ISSUES', 4)
572 def testModifyIssues_TooMany(self):
573 mc = monorailcontext.MonorailContext(
574 self.services, cnxn=self.cnxn, requester=self.owner.email)
575 request = issues_pb2.ModifyIssuesRequest(
576 deltas=[
577 issues_pb2.IssueDelta(),
578 issues_pb2.IssueDelta(),
579 issues_pb2.IssueDelta()
580 ])
581 with self.assertRaisesRegexp(
582 exceptions.InputException,
583 'Requesting 3 updates when the allowed maximum is 2 updates.'):
584 self.CallWrapped(self.issues_svcr.ModifyIssues, mc, request)
585
586 issue_ref_list = [issue_objects_pb2.IssueRef()]
587 request = issues_pb2.ModifyIssuesRequest(
588 deltas=[
589 issues_pb2.IssueDelta(
590 issue=issue_objects_pb2.Issue(
591 blocked_on_issue_refs=issue_ref_list),
592 blocked_on_issues_remove=issue_ref_list,
593 update_mask=field_mask_pb2.FieldMask(
594 paths=['merged_into_issue_ref'])),
595 issues_pb2.IssueDelta(
596 issue=issue_objects_pb2.Issue(
597 blocking_issue_refs=issue_ref_list),
598 blocking_issues_remove=issue_ref_list)
599 ])
600 with self.assertRaisesRegexp(
601 exceptions.InputException,
602 'Updates include 5 impacted issues when the allowed maximum is 4.'):
603 self.CallWrapped(self.issues_svcr.ModifyIssues, mc, request)
604
605 @mock.patch('time.time', mock.MagicMock(return_value=CURRENT_TIME))
606 @mock.patch(
607 'features.send_notifications.PrepareAndSendApprovalChangeNotification')
608 def testModifyIssueApprovalValues(self, fake_notify):
609 self.services.issue.DeltaUpdateIssueApproval = mock.Mock()
610 config = fake.MakeTestConfig(self.project_1.project_id, [], [])
611 self.services.config.StoreConfig(self.cnxn, config)
612
613 # Make testing approval def and its associated field def
614 field_id = 2
615 approval_field_def = fake.MakeTestFieldDef(
616 field_id,
617 self.project_1.project_id,
618 tracker_pb2.FieldTypes.APPROVAL_TYPE,
619 field_name='approval-gate-1')
620 self.services.config.TestAddFieldDef(approval_field_def)
621 ad = fake.MakeTestApprovalDef(field_id, approver_ids=[self.owner.user_id])
622 self.services.config.TestAddApprovalDef(ad, self.project_1.project_id)
623
624 # Make approval value
625 av = fake.MakeApprovalValue(
626 field_id,
627 status=tracker_pb2.ApprovalStatus.NEEDS_REVIEW,
628 set_on=self.PAST_TIME,
629 approver_ids=[self.owner.user_id],
630 setter_id=self.user_2.user_id)
631
632 issue = fake.MakeTestIssue(
633 self.project_1.project_id,
634 1237,
635 'sum',
636 'New',
637 self.owner.user_id,
638 project_name=self.project_1.project_name,
639 approval_values=[av])
640 self.services.issue.TestAddIssue(issue)
641
642 av_name = 'projects/%s/issues/%d/approvalValues/%d' % (
643 self.project_1.project_name, issue.local_id, ad.approval_id)
644 delta = issues_pb2.ApprovalDelta(
645 approval_value=issue_objects_pb2.ApprovalValue(
646 name=av_name,
647 status=issue_objects_pb2.ApprovalValue.ApprovalStatus.Value('NA')),
648 update_mask=field_mask_pb2.FieldMask(paths=['status']))
649
650 request = issues_pb2.ModifyIssueApprovalValuesRequest(deltas=[delta],)
651 mc = monorailcontext.MonorailContext(
652 self.services, cnxn=self.cnxn, requester=self.owner.email)
653 response = self.CallWrapped(
654 self.issues_svcr.ModifyIssueApprovalValues, mc, request)
655 expected_ingested_delta = tracker_pb2.ApprovalDelta(
656 status=tracker_pb2.ApprovalStatus.NA,
657 set_on=int(CURRENT_TIME),
658 setter_id=self.owner.user_id,
659 )
660 # NOTE: Because we mock out DeltaUpdateIssueApproval, the ApprovalValues
661 # returned haven't been changed in this test. We can't test that it was
662 # changed correctly, but we can make sure it's for the right ApprovalValue.
663 self.assertEqual(len(response.approval_values), 1)
664 self.assertEqual(response.approval_values[0].name, av_name)
665 self.services.issue.DeltaUpdateIssueApproval.assert_called_once_with(
666 mc.cnxn,
667 self.owner.user_id,
668 config,
669 issue,
670 av,
671 expected_ingested_delta,
672 comment_content=u'',
673 is_description=False,
674 attachments=None,
675 kept_attachments=None)
676 fake_notify.assert_called_once_with(
677 issue.issue_id,
678 ad.approval_id,
679 'testing-app.appspot.com',
680 mock.ANY,
681 send_email=True)
682
683 @mock.patch('api.v3.api_constants.MAX_MODIFY_APPROVAL_VALUES', 2)
684 def testModifyIssueApprovalValues_TooMany(self):
685 mc = monorailcontext.MonorailContext(
686 self.services, cnxn=self.cnxn, requester=self.owner.email)
687 request = issues_pb2.ModifyIssueApprovalValuesRequest(
688 deltas=[
689 issues_pb2.ApprovalDelta(),
690 issues_pb2.ApprovalDelta(),
691 issues_pb2.ApprovalDelta()
692 ])
693 with self.assertRaises(exceptions.InputException):
694 self.CallWrapped(self.issues_svcr.ModifyIssueApprovalValues, mc, request)
695
696 def testModifyIssueApprovalValues_Empty(self):
697 request = issues_pb2.ModifyIssueApprovalValuesRequest()
698 mc = monorailcontext.MonorailContext(
699 self.services, cnxn=self.cnxn, requester=self.owner.email)
700 response = self.CallWrapped(
701 self.issues_svcr.ModifyIssueApprovalValues, mc, request)
702 self.assertEqual(len(response.approval_values), 0)
703
704 @mock.patch(
705 'businesslogic.work_env.WorkEnv.GetIssue',
706 return_value=tracker_pb2.Issue(
707 owner_id=0,
708 project_name='chicken',
709 project_id=789,
710 local_id=1234,
711 issue_id=80134))
712 def testModifyCommentState(self, mocked_get_issue):
713 name = self.issue_1_resource_name + '/comments/1'
714 state = issue_objects_pb2.IssueContentState.Value('DELETED')
715 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
716 mc = monorailcontext.MonorailContext(
717 self.services, cnxn=self.cnxn, requester=self.owner.email)
718 with self.assertRaises(exceptions.NoSuchCommentException):
719 self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
720 mocked_get_issue.assert_any_call(self.issue_1.issue_id, use_cache=False)
721
722 def testModifyCommentState_Delete(self):
723 comment_1 = tracker_pb2.IssueComment(
724 id=124,
725 issue_id=self.issue_1.issue_id,
726 project_id=self.issue_1.project_id,
727 user_id=self.owner.user_id,
728 content='first actual comment')
729 self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
730
731 name = self.issue_1_resource_name + '/comments/1'
732 state = issue_objects_pb2.IssueContentState.Value('DELETED')
733 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
734 mc = monorailcontext.MonorailContext(
735 self.services, cnxn=self.cnxn, requester=self.owner.email)
736 response = self.CallWrapped(
737 self.issues_svcr.ModifyCommentState, mc, request)
738 self.assertEqual(response.comment.state, state)
739 self.assertEqual(response.comment.content, 'first actual comment')
740
741 # Test noop
742 response = self.CallWrapped(
743 self.issues_svcr.ModifyCommentState, mc, request)
744 self.assertEqual(response.comment.state, state)
745
746 # Test undelete
747 state = issue_objects_pb2.IssueContentState.Value('ACTIVE')
748 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
749 mc = monorailcontext.MonorailContext(
750 self.services, cnxn=self.cnxn, requester=self.owner.email)
751 response = self.CallWrapped(
752 self.issues_svcr.ModifyCommentState, mc, request)
753 self.assertEqual(response.comment.state, state)
754
755 @mock.patch(
756 'framework.permissions.UpdateIssuePermissions',
757 return_value=permissions.ADMIN_PERMISSIONSET)
758 def testModifyCommentState_Spam(self, _mocked):
759 comment_1 = tracker_pb2.IssueComment(
760 id=124,
761 issue_id=self.issue_1.issue_id,
762 project_id=self.issue_1.project_id,
763 user_id=self.owner.user_id,
764 content='first actual comment')
765 self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
766
767 name = self.issue_1_resource_name + '/comments/1'
768 state = issue_objects_pb2.IssueContentState.Value('SPAM')
769 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
770 mc = monorailcontext.MonorailContext(
771 self.services, cnxn=self.cnxn, requester=self.owner.email)
772 response = self.CallWrapped(
773 self.issues_svcr.ModifyCommentState, mc, request)
774 self.assertEqual(response.comment.state, state)
775
776 # Test noop
777 response = self.CallWrapped(
778 self.issues_svcr.ModifyCommentState, mc, request)
779 self.assertEqual(response.comment.state, state)
780
781 # Test unflag as spam
782 state = issue_objects_pb2.IssueContentState.Value('ACTIVE')
783 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
784 mc = monorailcontext.MonorailContext(
785 self.services, cnxn=self.cnxn, requester=self.owner.email)
786 response = self.CallWrapped(
787 self.issues_svcr.ModifyCommentState, mc, request)
788 self.assertEqual(response.comment.state, state)
789
790 def testModifyCommentState_Active(self):
791 comment_1 = tracker_pb2.IssueComment(
792 id=124,
793 issue_id=self.issue_1.issue_id,
794 project_id=self.issue_1.project_id,
795 user_id=self.owner.user_id,
796 content='first actual comment')
797 self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
798
799 name = self.issue_1_resource_name + '/comments/1'
800 state = issue_objects_pb2.IssueContentState.Value('ACTIVE')
801 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
802 mc = monorailcontext.MonorailContext(
803 self.services, cnxn=self.cnxn, requester=self.owner.email)
804 response = self.CallWrapped(
805 self.issues_svcr.ModifyCommentState, mc, request)
806 self.assertEqual(response.comment.state, state)
807
808 def testModifyCommentState_Spam_ActionNotSupported(self):
809 # Cannot transition from deleted to spam
810 comment_1 = tracker_pb2.IssueComment(
811 id=124,
812 issue_id=self.issue_1.issue_id,
813 project_id=self.issue_1.project_id,
814 user_id=self.owner.user_id,
815 content='first actual comment',
816 deleted_by=self.owner.user_id)
817 self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
818
819 name = self.issue_1_resource_name + '/comments/1'
820 state = issue_objects_pb2.IssueContentState.Value('SPAM')
821 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
822 mc = monorailcontext.MonorailContext(
823 self.services, cnxn=self.cnxn, requester=self.owner.email)
824 with self.assertRaises(exceptions.ActionNotSupported):
825 self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
826
827 def testModifyCommentState_Delete_ActionNotSupported(self):
828 # Cannot transition from spam to deleted
829 comment_1 = tracker_pb2.IssueComment(
830 id=124,
831 issue_id=self.issue_1.issue_id,
832 project_id=self.issue_1.project_id,
833 user_id=self.owner.user_id,
834 content='first actual comment',
835 is_spam=True)
836 self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
837
838 name = self.issue_1_resource_name + '/comments/1'
839 state = issue_objects_pb2.IssueContentState.Value('DELETED')
840 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
841 mc = monorailcontext.MonorailContext(
842 self.services, cnxn=self.cnxn, requester=self.owner.email)
843 with self.assertRaises(exceptions.ActionNotSupported):
844 self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
845
846 def testModifyCommentState_NoSuchComment(self):
847 name = self.issue_1_resource_name + '/comments/1'
848 state = issue_objects_pb2.IssueContentState.Value('DELETED')
849 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
850 mc = monorailcontext.MonorailContext(
851 self.services, cnxn=self.cnxn, requester=self.owner.email)
852 with self.assertRaises(exceptions.NoSuchCommentException):
853 self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
854
855 def testModifyCommentState_Delete_PermissionException(self):
856 comment_1 = tracker_pb2.IssueComment(
857 id=124,
858 issue_id=self.issue_1.issue_id,
859 project_id=self.issue_1.project_id,
860 user_id=self.owner.user_id,
861 content='first actual comment')
862 self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
863
864 name = self.issue_1_resource_name + '/comments/1'
865 state = issue_objects_pb2.IssueContentState.Value('DELETED')
866 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
867 mc = monorailcontext.MonorailContext(
868 self.services, cnxn=self.cnxn, requester=self.user_2.email)
869 with self.assertRaises(permissions.PermissionException):
870 self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)
871
872 @mock.patch(
873 'framework.permissions.UpdateIssuePermissions',
874 return_value=permissions.READ_ONLY_PERMISSIONSET)
875 def testModifyCommentState_Spam_PermissionException(self, _mocked):
876 comment_1 = tracker_pb2.IssueComment(
877 id=124,
878 issue_id=self.issue_1.issue_id,
879 project_id=self.issue_1.project_id,
880 user_id=self.owner.user_id,
881 content='first actual comment')
882 self.services.issue.TestAddComment(comment_1, self.issue_1.local_id)
883
884 name = self.issue_1_resource_name + '/comments/1'
885 state = issue_objects_pb2.IssueContentState.Value('SPAM')
886 request = issues_pb2.ModifyCommentStateRequest(name=name, state=state)
887 mc = monorailcontext.MonorailContext(
888 self.services, cnxn=self.cnxn, requester=self.user_2.email)
889 with self.assertRaises(permissions.PermissionException):
890 self.CallWrapped(self.issues_svcr.ModifyCommentState, mc, request)