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