From bcb3f9bda8f4fc716659829dbea751d85d99a748 Mon Sep 17 00:00:00 2001 From: Kirill Sizov Date: Wed, 5 Oct 2022 13:01:53 +0300 Subject: [PATCH] Tests for Webhook sender (#5017) * tests/rest_api/assets: update webhooks in testdb * tests/rest_api/test_webhook_sender: add tests for project and ping events * tests/rest_api/test_webhook_sender: add tests for webhook intersection case * tests/rest_api/test_webhook_sender: add tests for task update events * tests/rest_api/test_webhook_sender: add tests for task create and job update events * tests/rest_api/test_webhook_sender: add tests for issue events * tests/rest_api/test_webhook_sender: add tests for membership events * tests/rest_api/test_webhook_sender: add tests for organization event * Fix Pylint warnings * apps/engine: get rid of sending `create` signals in views * apps/organizations: get rid of sending `create` signals in views * tests/rest_api/test_webhooks: fix typo * apps/engine: get rid of sending signals in task view * tests/rest_api: remove debug prints * apps/engine: fix pylint errors * tests/rest_api/test_webhooks_sender: add tests for `redelivery` method * tests: define tag for minio image * apps/webhooks: remove owner_id from write serializer * tests/rest_api/test_webhooks: fix code style * tests/rest_api: added tests for webhook comment events * tests/rest_api: fix typo Co-authored-by: Anastasia Yasakova * tests: fix warnings from black Co-authored-by: Anastasia Yasakova --- cvat/apps/engine/mixins.py | 8 +- cvat/apps/engine/views.py | 52 +- cvat/apps/organizations/views.py | 8 +- cvat/apps/webhooks/models.py | 1 - cvat/apps/webhooks/serializers.py | 8 +- tests/docker-compose.minio.yml | 4 +- tests/python/rest_api/test_webhooks.py | 6 +- tests/python/rest_api/test_webhooks_sender.py | 699 +++++++++++++++++- tests/python/shared/assets/comments.json | 91 +++ tests/python/shared/assets/cvat_db/data.json | 65 +- tests/python/shared/assets/users.json | 2 +- tests/python/shared/assets/webhooks.json | 12 +- tests/python/shared/fixtures/data.py | 11 +- tests/python/shared/utils/dump_objects.py | 1 + 14 files changed, 835 insertions(+), 133 deletions(-) create mode 100644 tests/python/shared/assets/comments.json diff --git a/cvat/apps/engine/mixins.py b/cvat/apps/engine/mixins.py index 8b080c08..5a4d03d0 100644 --- a/cvat/apps/engine/mixins.py +++ b/cvat/apps/engine/mixins.py @@ -325,8 +325,8 @@ class SerializeMixin: class CreateModelMixin(mixins.CreateModelMixin): - def perform_create(self, serializer): - super().perform_create(serializer) + def perform_create(self, serializer, **kwargs): + serializer.save(**kwargs) signal_create.send(self, instance=serializer.instance) class PartialUpdateModelMixin: @@ -337,8 +337,10 @@ class PartialUpdateModelMixin: """ def perform_update(self, serializer): + instance = serializer.instance + data = serializer.to_representation(instance) old_values = { - attr: serializer.to_representation(serializer.instance).get(attr, None) + attr: data[attr] if attr in data else getattr(instance, attr, None) for attr in self.request.data.keys() } diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index c00e4f13..f0300120 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -41,7 +41,6 @@ from rest_framework.response import Response from rest_framework.exceptions import PermissionDenied from django_sendfile import sendfile -from cvat.apps.webhooks.signals import signal_create, signal_update import cvat.apps.dataset_manager as dm import cvat.apps.dataset_manager.views # pylint: disable=unused-import from cvat.apps.engine.cloud_provider import ( @@ -73,7 +72,7 @@ from cvat.apps.engine.serializers import ( from utils.dataset_manifest import ImageManifestManager from cvat.apps.engine.utils import av_scan_paths, process_failed_job, configure_dependent_job from cvat.apps.engine import backup -from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, DestroyModelMixin +from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, DestroyModelMixin, CreateModelMixin from cvat.apps.engine.location import get_location_configuration, StorageType from . import models, task @@ -273,7 +272,7 @@ class ServerViewSet(viewsets.ViewSet): }) ) class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin ): queryset = models.Project.objects.prefetch_related(Prefetch('label_set', @@ -308,9 +307,11 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, return queryset def perform_create(self, serializer): - serializer.save(owner=self.request.user, - organization=self.request.iam_context['organization']) - signal_create.send(self, instance=serializer.instance) + super().perform_create( + serializer, + owner=self.request.user, + organization=self.request.iam_context['organization'] + ) @extend_schema( summary='Method returns information of the tasks of the project with the selected id', @@ -714,7 +715,7 @@ class DataChunkGetter: }) ) class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin ): queryset = Task.objects.prefetch_related( @@ -803,32 +804,25 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, def perform_update(self, serializer): instance = serializer.instance - old_values = {} - old_repr = serializer.to_representation(instance) - for attr in self.request.data.keys(): - old_values[attr] = old_repr[attr] if attr in old_repr \ - else getattr(instance, attr, None) + super().perform_update(serializer) - updated_instance = serializer.save() + updated_instance = serializer.instance if instance.project: instance.project.save() if updated_instance.project: updated_instance.project.save() - if getattr(instance, '_prefetched_objects_cache', None): - instance._prefetched_objects_cache = {} - - signal_update.send(self, instance=serializer.instance, old_values=old_values) - def perform_create(self, serializer): - instance = serializer.save(owner=self.request.user, - organization=self.request.iam_context['organization']) - if instance.project: - db_project = instance.project + super().perform_create( + serializer, + owner=self.request.user, + organization=self.request.iam_context['organization'] + ) + if serializer.instance.project: + db_project = serializer.instance.project db_project.save() - assert instance.organization == db_project.organization - signal_create.send(self, instance=serializer.instance) + assert serializer.instance.organization == db_project.organization def perform_destroy(self, instance): task_dirname = instance.get_dirname() @@ -1708,7 +1702,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, }) ) class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin ): queryset = Issue.objects.all().order_by('-id') @@ -1739,8 +1733,7 @@ class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, return IssueWriteSerializer def perform_create(self, serializer): - serializer.save(owner=self.request.user) - signal_create.send(self, instance=serializer.instance) + super().perform_create(serializer, owner=self.request.user) @extend_schema(summary='The action returns all comments of a specific issue', responses=CommentReadSerializer(many=True)) # Duplicate to still get 'list' op. name @@ -1789,7 +1782,7 @@ class IssueViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, }) ) class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, - mixins.RetrieveModelMixin, mixins.CreateModelMixin, DestroyModelMixin, + mixins.RetrieveModelMixin, CreateModelMixin, DestroyModelMixin, PartialUpdateModelMixin ): queryset = Comment.objects.all().order_by('-id') @@ -1815,8 +1808,7 @@ class CommentViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, return CommentWriteSerializer def perform_create(self, serializer): - serializer.save(owner=self.request.user) - signal_create.send(self, instance=serializer.instance) + super().perform_create(serializer, owner=self.request.user) @extend_schema(tags=['users']) @extend_schema_view( diff --git a/cvat/apps/organizations/views.py b/cvat/apps/organizations/views.py index 4c94c21a..3e4da2f3 100644 --- a/cvat/apps/organizations/views.py +++ b/cvat/apps/organizations/views.py @@ -8,8 +8,7 @@ from rest_framework.permissions import SAFE_METHODS from django.utils.crypto import get_random_string from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view -from cvat.apps.engine.mixins import PartialUpdateModelMixin, DestroyModelMixin -from cvat.apps.webhooks.signals import signal_create +from cvat.apps.engine.mixins import PartialUpdateModelMixin, DestroyModelMixin, CreateModelMixin from cvat.apps.iam.permissions import ( InvitationPermission, MembershipPermission, OrganizationPermission) @@ -177,8 +176,8 @@ class MembershipViewSet(mixins.RetrieveModelMixin, DestroyModelMixin, class InvitationViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.ListModelMixin, - mixins.CreateModelMixin, mixins.UpdateModelMixin, + CreateModelMixin, DestroyModelMixin, ): queryset = Invitation.objects.all() @@ -208,8 +207,7 @@ class InvitationViewSet(viewsets.GenericViewSet, 'key': get_random_string(length=64), 'organization': self.request.iam_context['organization'] } - serializer.save(**extra_kwargs) - signal_create.send(self, instance=serializer.instance) + super().perform_create(serializer, **extra_kwargs) def perform_update(self, serializer): if 'accepted' in self.request.query_params: diff --git a/cvat/apps/webhooks/models.py b/cvat/apps/webhooks/models.py index c5636575..e8cf6705 100644 --- a/cvat/apps/webhooks/models.py +++ b/cvat/apps/webhooks/models.py @@ -54,7 +54,6 @@ class Webhook(models.Model): created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) - # questionable: should we keep webhook if owner has been deleted? owner = models.ForeignKey( User, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" ) diff --git a/cvat/apps/webhooks/serializers.py b/cvat/apps/webhooks/serializers.py index 653659e6..d06a5edf 100644 --- a/cvat/apps/webhooks/serializers.py +++ b/cvat/apps/webhooks/serializers.py @@ -97,11 +97,6 @@ class WebhookReadSerializer(serializers.ModelSerializer): class WebhookWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): events = EventTypesSerializer(write_only=True) - # Q: should be owner_id required or not? - owner_id = serializers.IntegerField( - write_only=True, allow_null=True, required=False - ) - project_id = serializers.IntegerField( write_only=True, allow_null=True, required=False ) @@ -120,11 +115,10 @@ class WebhookWriteSerializer(WriteOnceMixin, serializers.ModelSerializer): "secret", "is_active", "enable_ssl", - "owner_id", "project_id", "events", ) - write_once_fields = ("type", "owner_id", "project_id") + write_once_fields = ("type", "project_id") validators = [EventTypeValidator()] def create(self, validated_data): diff --git a/tests/docker-compose.minio.yml b/tests/docker-compose.minio.yml index 572c9482..be8093fc 100644 --- a/tests/docker-compose.minio.yml +++ b/tests/docker-compose.minio.yml @@ -2,7 +2,7 @@ version: '3.3' services: minio: - image: quay.io/minio/minio + image: quay.io/minio/minio:RELEASE.2022-09-17T00-09-45Z hostname: minio restart: always command: server /data --console-address ":9001" @@ -25,7 +25,7 @@ services: aliases: - minio mc: - image: minio/mc + image: minio/mc:RELEASE.2022-09-16T09-16-47Z depends_on: - minio environment: diff --git a/tests/python/rest_api/test_webhooks.py b/tests/python/rest_api/test_webhooks.py index 010e0ab7..17d29df0 100644 --- a/tests/python/rest_api/test_webhooks.py +++ b/tests/python/rest_api/test_webhooks.py @@ -305,7 +305,7 @@ class TestPostWebhooks: assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR def test_cannot_create_non_unique_webhook(self): - pytest.skip("Not implemeted yet") + pytest.skip("Not implemented yet") response = post_method("admin2", "webhooks", self.proj_webhook) response = post_method("admin2", "webhooks", self.proj_webhook) @@ -769,9 +769,7 @@ class TestPatchWebhooks: ) def test_cannot_update_with_nonexistent_contenttype(self): - patch_data = { - "content_type": "application/x-www-form-urlencoded", - } + patch_data = {"content_type": "application/x-www-form-urlencoded"} response = patch_method("admin2", f"webhooks/{self.WID}", patch_data) assert response.status_code == HTTPStatus.BAD_REQUEST diff --git a/tests/python/rest_api/test_webhooks_sender.py b/tests/python/rest_api/test_webhooks_sender.py index 5236d2ac..562af214 100644 --- a/tests/python/rest_api/test_webhooks_sender.py +++ b/tests/python/rest_api/test_webhooks_sender.py @@ -10,11 +10,11 @@ import pytest from deepdiff import DeepDiff from shared.fixtures.init import CVAT_ROOT_DIR, _run -from shared.utils.config import get_method, patch_method, post_method +from shared.utils.config import delete_method, get_method, patch_method, post_method # Testing webhook functionality: # - webhook_receiver container receive post request and return responses with the same body -# - cvat save response body for each delivery +# - CVAT save response body for each delivery # # So idea of this testing system is quite simple: # 1) trigger some webhook @@ -49,39 +49,50 @@ def webhook_spec(events, project_id=None, webhook_type="organization"): } +def create_webhook(events, webhook_type, project_id=None, org_id=""): + assert (webhook_type == "project" and project_id is not None) or ( + webhook_type == "organization" and org_id + ) + + response = post_method( + "admin1", "webhooks", webhook_spec(events, project_id, webhook_type), org_id=org_id + ) + assert response.status_code == HTTPStatus.CREATED + + return response.json() + + +def get_deliveries(webhook_id): + response = get_method("admin1", f"webhooks/{webhook_id}/deliveries") + assert response.status_code == HTTPStatus.OK + + deliveries = response.json() + last_payload = json.loads(deliveries["results"][0]["response"]) + + return deliveries, last_payload + + @pytest.mark.usefixtures("changedb") class TestWebhookProjectEvents: - def test_webhook_project_update(self): - events = ["update:project"] - patch_data = {"name": "new_project_name"} - - # create project + def test_webhook_update_project_name(self): response = post_method("admin1", "projects", {"name": "project"}) assert response.status_code == HTTPStatus.CREATED project = response.json() - # create webhook - response = post_method( - "admin1", "webhooks", webhook_spec(events, project["id"], webhook_type="project") - ) - assert response.status_code == HTTPStatus.CREATED - webhook = response.json() + events = ["update:project"] + webhook = create_webhook(events, "project", project_id=project["id"]) - # update project + patch_data = {"name": "new_project_name"} response = patch_method("admin1", f"projects/{project['id']}", patch_data) assert response.status_code == HTTPStatus.OK - # get list of deliveries of webhook response = get_method("admin1", f"webhooks/{webhook['id']}/deliveries") assert response.status_code == HTTPStatus.OK - response_data = response.json() + deliveries, payload = get_deliveries(webhook["id"]) - # check that we sent only one webhook - assert response_data["count"] == 1 + assert deliveries["count"] == 1 - # check value of payload that CVAT sent - payload = json.loads(response_data["results"][0]["response"]) assert payload["event"] == events[0] assert payload["sender"]["username"] == "admin1" assert payload["before_update"]["name"] == project["name"] @@ -96,3 +107,651 @@ class TestWebhookProjectEvents: ) == {} ) + + def test_webhook_update_project_labels(self): + response = post_method("admin1", "projects", {"name": "project"}) + assert response.status_code == HTTPStatus.CREATED + project = response.json() + + events = ["update:project"] + webhook = create_webhook(events, "project", project["id"]) + + patch_data = {"labels": [{"name": "label_0", "color": "#aabbcc"}]} + response = patch_method("admin1", f"projects/{project['id']}", patch_data) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + assert payload["event"] == events[0] + assert len(payload["before_update"]["labels"]) == 0 + assert len(payload["project"]["labels"]) == 1 + assert payload["project"]["labels"][0]["name"] == patch_data["labels"][0]["name"] + assert payload["project"]["labels"][0]["color"] == patch_data["labels"][0]["color"] + + def test_webhook_create_and_delete_project(self, organizations): + org_id = list(organizations)[0]["id"] + events = ["create:project", "delete:project"] + + webhook = create_webhook(events, "organization", org_id=org_id) + + response = post_method("admin1", "projects", {"name": "project_name"}, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + project = response.json() + + deliveries, create_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + response = delete_method("admin1", f"projects/{project['id']}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + deliveries, delete_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 2 + + assert create_payload["event"] == "create:project" + assert delete_payload["event"] == "delete:project" + assert ( + DeepDiff( + create_payload["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + delete_payload["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookIntersection: + # Test case description: + # few webhooks are triggered by the same event + # In this case we need to check that CVAT will sent + # the right number of payloads to the target url + + def test_project_and_organization_webhooks_intersection(self, organizations): + org_id = list(organizations)[0]["id"] + post_data = {"name": "project_name"} + + response = post_method("admin1", "projects", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + events = ["update:project"] + project_id = response.json()["id"] + webhook_id_1 = create_webhook(events, "organization", org_id=org_id)["id"] + webhook_id_2 = create_webhook(events, "project", project_id=project_id, org_id=org_id)["id"] + + patch_data = {"name": "new_project_name"} + response = patch_method("admin1", f"projects/{project_id}", patch_data) + assert response.status_code == HTTPStatus.OK + + deliveries_1, payload_1 = get_deliveries(webhook_id_1) + deliveries_2, payload_2 = get_deliveries(webhook_id_2) + + assert deliveries_1["count"] == deliveries_2["count"] == 1 + + assert payload_1["project"]["name"] == payload_2["project"]["name"] == patch_data["name"] + + assert ( + payload_1["before_update"]["name"] + == payload_2["before_update"]["name"] + == post_data["name"] + ) + + assert payload_1["webhook_id"] == webhook_id_1 + assert payload_2["webhook_id"] == webhook_id_2 + + assert deliveries_1["results"][0]["webhook_id"] == webhook_id_1 + assert deliveries_2["results"][0]["webhook_id"] == webhook_id_2 + + def test_two_project_webhooks_intersection(self): + post_data = {"name": "project_name"} + response = post_method("admin1", "projects", post_data) + assert response.status_code == HTTPStatus.CREATED + + project_id = response.json()["id"] + events_1 = ["create:task", "update:project"] + events_2 = ["create:task", "create:issue"] + webhook_id_1 = create_webhook(events_1, "project", project_id=project_id)["id"] + webhook_id_2 = create_webhook(events_2, "project", project_id=project_id)["id"] + + post_data = {"name": "project_name", "project_id": project_id} + response = post_method("admin1", "tasks", post_data) + assert response.status_code == HTTPStatus.CREATED + + deliveries_1, payload_1 = get_deliveries(webhook_id_1) + deliveries_2, payload_2 = get_deliveries(webhook_id_2) + + assert deliveries_1["count"] == deliveries_2["count"] == 1 + + assert payload_1["event"] == payload_2["event"] == "create:task" + assert payload_1["task"]["name"] == payload_2["task"]["name"] == post_data["name"] + + assert payload_1["webhook_id"] == webhook_id_1 + assert payload_2["webhook_id"] == webhook_id_2 + + def test_two_organization_webhook_intersection(self, organizations): + org_id = list(organizations)[0]["id"] + + events_1 = ["create:project", "update:membership"] + events_2 = ["create:project", "update:job"] + + webhook_id_1 = create_webhook(events_1, "organization", org_id=org_id)["id"] + webhook_id_2 = create_webhook(events_2, "organization", org_id=org_id)["id"] + + post_data = {"name": "project_name"} + response = post_method("admin1", "projects", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + project = response.json() + + deliveries_1, payload_1 = get_deliveries(webhook_id_1) + deliveries_2, payload_2 = get_deliveries(webhook_id_2) + + assert deliveries_1["count"] == deliveries_2["count"] == 1 + + assert payload_1["webhook_id"] == webhook_id_1 + assert payload_2["webhook_id"] == webhook_id_2 + + assert ( + DeepDiff( + payload_1["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + payload_2["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookTaskEvents: + def test_webhook_update_task_assignee(self, users, tasks): + task_id, project_id = next( + ( + (task["id"], task["project_id"]) + for task in tasks + if task["project_id"] is not None + and task["organization"] is None + and task["assignee"] is not None + ) + ) + + assignee_id = next( + (user["id"] for user in users if user["id"] != tasks[task_id]["assignee"]["id"]) + ) + + webhook_id = create_webhook(["update:task"], "project", project_id=project_id)["id"] + + patch_data = {"assignee_id": assignee_id} + response = patch_method("admin1", f"tasks/{task_id}", patch_data) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id=webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["assignee_id"] == tasks[task_id]["assignee"]["id"] + assert payload["task"]["assignee"]["id"] == assignee_id + + def test_webhook_update_task_label(self, tasks): + task_id, org_id = next( + ( + (task["id"], task["organization"]) + for task in tasks + if task["project_id"] is None and task["organization"] is not None + ) + ) + + webhook_id = create_webhook(["update:task"], "organization", org_id=org_id)["id"] + + patch_data = {"labels": [{"name": "new_label"}]} + response = patch_method("admin1", f"tasks/{task_id}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id=webhook_id) + + assert deliveries["count"] == 1 + assert ( + len(payload["before_update"]["labels"]) + == len(tasks[task_id]["labels"]) + == len(payload["task"]["labels"]) - 1 + ) + + def test_webhook_create_and_delete_task(self, organizations): + org_id = list(organizations)[0]["id"] + events = ["create:task", "delete:task"] + + webhook = create_webhook(events, "organization", org_id=org_id) + + post_data = {"name": "task_name", "labels": [{"name": "label_0"}]} + response = post_method("admin1", "tasks", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + task = response.json() + + deliveries, create_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + response = delete_method("admin1", f"tasks/{task['id']}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + deliveries, delete_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 2 + + assert create_payload["event"] == "create:task" + assert delete_payload["event"] == "delete:task" + assert ( + DeepDiff( + create_payload["task"], + task, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + delete_payload["task"], + task, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookJobEvents: + def test_webhook_update_job_assignee(self, jobs, tasks, users): + job = next( + ( + job + for job in jobs + if job["assignee"] is None and tasks[job["task_id"]]["organization"] is not None + ) + ) + + org_id = tasks[job["task_id"]]["organization"] + + webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"] + + patch_data = {"assignee": list(users)[0]["id"]} + response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["assignee"] is None + assert payload["job"]["assignee"]["id"] == patch_data["assignee"] + + def test_webhook_update_job_stage(self, jobs, tasks): + stages = {"annotation", "validation", "acceptance"} + job = next((job for job in jobs if tasks[job["task_id"]]["organization"] is not None)) + + org_id = tasks[job["task_id"]]["organization"] + + webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"] + + patch_data = {"stage": (stages - {job["stage"]}).pop()} + response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + assert deliveries["count"] == 1 + assert payload["before_update"]["stage"] == job["stage"] + assert payload["job"]["stage"] == patch_data["stage"] + + def test_webhook_update_job_state(self, jobs, tasks): + states = {"new", "in progress", "rejected", "completed"} + job = next( + ( + job + for job in jobs + if tasks[job["task_id"]]["organization"] is not None + and job["state"] == "in progress" + ) + ) + + org_id = tasks[job["task_id"]]["organization"] + + webhook_id = create_webhook(["update:job"], "organization", org_id=org_id)["id"] + + patch_data = {"state": (states - {job["state"]}).pop()} + response = patch_method("admin1", f"jobs/{job['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + assert deliveries["count"] == 1 + assert payload["before_update"]["state"] == job["state"] + assert payload["job"]["state"] == patch_data["state"] + + +@pytest.mark.usefixtures("changedb") +class TestWebhookIssueEvents: + def test_webhook_update_issue_resolved(self, issues, jobs, tasks): + issue = next( + ( + issue + for issue in issues + if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None + ) + ) + + org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"] + + webhook_id = create_webhook(["update:issue"], "organization", org_id=org_id)["id"] + + patch_data = {"resolved": not issue["resolved"]} + response = patch_method("admin1", f"issues/{issue['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["resolved"] == issue["resolved"] + assert payload["issue"]["resolved"] == patch_data["resolved"] + + def test_webhook_update_issue_position(self, issues, jobs, tasks): + issue = next( + ( + issue + for issue in issues + if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None + ) + ) + + org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"] + + webhook_id = create_webhook(["update:issue"], "organization", org_id=org_id)["id"] + + patch_data = {"position": [0, 1, 2, 3]} + response = patch_method("admin1", f"issues/{issue['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["position"] == issue["position"] + assert payload["issue"]["position"] == patch_data["position"] + + def test_webhook_create_and_delete_issue(self, organizations, jobs, tasks): + org_id = list(organizations)[0]["id"] + job_id = next( + (job["id"] for job in jobs if tasks[job["task_id"]]["organization"] == org_id) + ) + events = ["create:issue", "delete:issue"] + + webhook = create_webhook(events, "organization", org_id=org_id) + + post_data = {"frame": 0, "position": [0, 1, 2, 3], "job": job_id, "message": "issue_msg"} + response = post_method("admin1", "issues", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + issue = response.json() + + deliveries, create_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + response = delete_method("admin1", f"issues/{issue['id']}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + deliveries, delete_payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 2 + + assert create_payload["event"] == "create:issue" + assert delete_payload["event"] == "delete:issue" + assert ( + DeepDiff( + create_payload["issue"], + issue, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + delete_payload["issue"], + issue, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookMembershipEvents: + def test_webhook_update_membership_role(self, memberships): + roles = {"worker", "supervisor", "maintainer"} + + membership = next( + (membership for membership in memberships if membership["role"] != "owner") + ) + org_id = membership["organization"] + + webhook_id = create_webhook(["update:membership"], "organization", org_id=org_id)["id"] + + patch_data = {"role": (roles - {membership["role"]}).pop()} + response = patch_method( + "admin1", f"memberships/{membership['id']}", patch_data, org_id=org_id + ) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["role"] == membership["role"] + assert payload["membership"]["role"] == patch_data["role"] + + def test_webhook_delete_membership(self, memberships): + membership = next( + (membership for membership in memberships if membership["role"] != "owner") + ) + org_id = membership["organization"] + + webhook_id = create_webhook(["delete:membership"], "organization", org_id=org_id)["id"] + + response = delete_method("admin1", f"memberships/{membership['id']}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert ( + DeepDiff( + payload["membership"], + membership, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookOrganizationEvents: + def test_webhook_update_organization_name(self, organizations): + org_id = list(organizations)[0]["id"] + + webhook_id = create_webhook(["update:organization"], "organization", org_id=org_id)["id"] + + patch_data = {"name": "new_org_name"} + patch_method("admin1", f"organizations/{org_id}", patch_data, org_id=org_id) + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["name"] == organizations[org_id]["name"] + assert payload["organization"]["name"] == patch_data["name"] + + +@pytest.mark.usefixtures("changedb") +class TestWebhookCommentEvents: + def test_webhook_update_comment_message(self, comments, issues, jobs, tasks): + org_comments = list( + (comment, tasks[jobs[issues[comment["issue"]]["job"]]["task_id"]]["organization"]) + for comment in comments + ) + + comment, org_id = next( + ((comment, org_id) for comment, org_id in org_comments if org_id is not None) + ) + + webhook_id = create_webhook(["update:comment"], "organization", org_id=org_id)["id"] + + patch_data = {"message": "new comment message"} + response = patch_method("admin1", f"comments/{comment['id']}", patch_data, org_id=org_id) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook_id) + + assert deliveries["count"] == 1 + assert payload["before_update"]["message"] == comment["message"] + + comment.update(patch_data) + assert ( + DeepDiff( + payload["comment"], + comment, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + def test_webhook_create_and_delete_comment(self, issues, jobs, tasks): + issue = next( + ( + issue + for issue in issues + if tasks[jobs[issue["job"]]["task_id"]]["organization"] is not None + ) + ) + + org_id = tasks[jobs[issue["job"]]["task_id"]]["organization"] + + events = ["create:comment", "delete:comment"] + webhook_id = create_webhook(events, "organization", org_id=org_id)["id"] + + post_data = {"issue": issue["id"], "message": "new comment message"} + response = post_method("admin1", f"comments", post_data, org_id=org_id) + assert response.status_code == HTTPStatus.CREATED + + create_deliveries, create_payload = get_deliveries(webhook_id) + + comment_id = response.json()["id"] + response = delete_method("admin1", f"comments/{comment_id}", org_id=org_id) + assert response.status_code == HTTPStatus.NO_CONTENT + + delete_deliveries, delete_payload = get_deliveries(webhook_id) + + assert create_deliveries["count"] == 1 + assert delete_deliveries["count"] == 2 + + assert create_payload["event"] == "create:comment" + assert delete_payload["event"] == "delete:comment" + + assert ( + create_payload["comment"]["message"] + == delete_payload["comment"]["message"] + == post_data["message"] + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookPing: + def test_ping_webhook(self, projects): + project_id = list(projects)[0]["id"] + + webhook = create_webhook(["create:task"], "project", project_id=project_id) + + response = post_method("admin1", f"webhooks/{webhook['id']}/ping", {}) + assert response.status_code == HTTPStatus.OK + + deliveries, payload = get_deliveries(webhook["id"]) + + assert deliveries["count"] == 1 + + assert ( + DeepDiff( + payload["webhook"], + webhook, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + + +@pytest.mark.usefixtures("changedb") +class TestWebhookRedelivery: + def test_webhook_redelivery(self, projects): + project = list(projects)[0] + + webhook_id = create_webhook(["update:project"], "project", project_id=project["id"])["id"] + + patch_data = {"name": "new_project_name"} + response = patch_method("admin1", f"projects/{project['id']}", patch_data) + assert response.status_code == HTTPStatus.OK + + deliveries_1, payload_1 = get_deliveries(webhook_id) + delivery_id = deliveries_1["results"][0]["id"] + + response = post_method( + "admin1", f"webhooks/{webhook_id}/deliveries/{delivery_id}/redelivery", {} + ) + assert response.status_code == HTTPStatus.OK + + deliveries_2, payload_2 = get_deliveries(webhook_id) + + assert deliveries_1["count"] == 1 + assert deliveries_2["count"] == 2 + + assert deliveries_1["results"][0]["redelivery"] is False + assert deliveries_2["results"][0]["redelivery"] is True + + project.update(patch_data) + assert ( + DeepDiff( + payload_1["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) + assert ( + DeepDiff( + payload_2["project"], + project, + ignore_order=True, + exclude_paths=["root['updated_date']"], + ) + == {} + ) diff --git a/tests/python/shared/assets/comments.json b/tests/python/shared/assets/comments.json new file mode 100644 index 00000000..f1f7457e --- /dev/null +++ b/tests/python/shared/assets/comments.json @@ -0,0 +1,91 @@ +{ + "count": 6, + "next": null, + "previous": null, + "results": [ + { + "created_date": "2022-03-16T12:49:29.372000Z", + "id": 6, + "issue": 5, + "message": "Wrong position", + "owner": { + "first_name": "User", + "id": 20, + "last_name": "Sixth", + "url": "http://localhost:8080/api/users/20", + "username": "user6" + }, + "updated_date": "2022-03-16T12:49:29.372000Z" + }, + { + "created_date": "2022-03-16T12:40:00.767000Z", + "id": 5, + "issue": 4, + "message": "Issue with empty frame", + "owner": { + "first_name": "User", + "id": 2, + "last_name": "First", + "url": "http://localhost:8080/api/users/2", + "username": "user1" + }, + "updated_date": "2022-03-16T12:40:00.767000Z" + }, + { + "created_date": "2022-03-16T11:08:18.370000Z", + "id": 4, + "issue": 3, + "message": "Another one issue", + "owner": { + "first_name": "Business", + "id": 11, + "last_name": "Second", + "url": "http://localhost:8080/api/users/11", + "username": "business2" + }, + "updated_date": "2022-03-16T11:08:18.370000Z" + }, + { + "created_date": "2022-03-16T11:07:22.173000Z", + "id": 3, + "issue": 2, + "message": "Something should be here", + "owner": { + "first_name": "Business", + "id": 11, + "last_name": "Second", + "url": "http://localhost:8080/api/users/11", + "username": "business2" + }, + "updated_date": "2022-03-16T11:07:22.173000Z" + }, + { + "created_date": "2022-03-16T11:04:49.821000Z", + "id": 2, + "issue": 1, + "message": "Just to suffer?", + "owner": { + "first_name": "User", + "id": 2, + "last_name": "First", + "url": "http://localhost:8080/api/users/2", + "username": "user1" + }, + "updated_date": "2022-03-16T11:04:49.821000Z" + }, + { + "created_date": "2022-03-16T11:04:39.447000Z", + "id": 1, + "issue": 1, + "message": "Why are we still here?", + "owner": { + "first_name": "User", + "id": 2, + "last_name": "First", + "url": "http://localhost:8080/api/users/2", + "username": "user1" + }, + "updated_date": "2022-03-16T11:04:39.447000Z" + } + ] +} \ No newline at end of file diff --git a/tests/python/shared/assets/cvat_db/data.json b/tests/python/shared/assets/cvat_db/data.json index eba496a4..f9bd1a6e 100644 --- a/tests/python/shared/assets/cvat_db/data.json +++ b/tests/python/shared/assets/cvat_db/data.json @@ -36,7 +36,7 @@ "pk": 1, "fields": { "password": "pbkdf2_sha256$260000$DevmxlmLwciP1P6sZs2Qag$U9DFtjTWx96Sk95qY6UXVcvpdQEP2LcoFBftk5D2RKY=", - "last_login": "2022-09-28T12:20:48.633Z", + "last_login": "2022-09-29T08:00:09.733Z", "is_superuser": true, "username": "admin1", "first_name": "Admin", @@ -463,6 +463,14 @@ "expire_date": "2022-03-07T10:37:08.963Z" } }, +{ + "model": "sessions.session", + "pk": "8r8hzr05yskg13ugvyysz5mq67yu0q09", + "fields": { + "session_data": ".eJxVjEEOwiAQRe_C2pCh1AIu3fcMZIYZbNVAUtqV8e7apAvd_vfef6mI2zrFrckSZ1YXZdTpdyNMDyk74DuWW9WplnWZSe-KPmjTY2V5Xg_372DCNn3rkIZEASSjiAUx3RkkEHXOsA_kDLAAos0eemvYosvBu5xC1_NAnEC9P_-NOIo:1odoSn:hXr9ZRCpPPwIFiVyFia14QHX4dwZigtH7yGUNgU2Gfg", + "expire_date": "2022-10-13T08:00:09.738Z" + } +}, { "model": "sessions.session", "pk": "9432vwcpkukpdrme8vipuk9rmt4jv6c8", @@ -5846,18 +5854,18 @@ }, { "model": "webhooks.webhook", - "pk": 5, + "pk": 6, "fields": { - "target_url": "http://example.com", + "target_url": "http://example.com/", "description": "", - "events": "delete:invitation,delete:project,create:project,delete:comment,update:organization,update:issue,update:task,update:comment,update:job,update:project,delete:task,create:comment,delete:issue,delete:membership,create:invitation,create:task,create:issue,update:membership", + "events": "delete:task,create:task,update:issue,update:membership,create:comment,delete:comment,update:job,update:comment,create:project,delete:invitation,create:invitation,update:project,update:task,delete:membership,delete:issue,delete:project,update:organization,create:issue", "type": "organization", "content_type": "application/json", "secret": "", "is_active": true, "enable_ssl": true, - "created_date": "2022-09-28T12:51:06.703Z", - "updated_date": "2022-09-28T12:51:06.703Z", + "created_date": "2022-09-29T08:00:48.440Z", + "updated_date": "2022-09-29T08:00:48.441Z", "owner": [ "admin1" ], @@ -5865,51 +5873,6 @@ "organization": 1 } }, -{ - "model": "webhooks.webhookdelivery", - "pk": 8, - "fields": { - "webhook": 5, - "event": "create:invitation", - "status_code": 200, - "redelivery": false, - "created_date": "2022-09-28T13:11:37.850Z", - "updated_date": "2022-09-28T13:11:38.311Z", - "changed_fields": "", - "request": { - "event": "create:invitation", - "sender": { - "id": 1, - "url": "http://localhost:8080/api/users/1", - "username": "admin1", - "last_name": "First", - "first_name": "Admin" - }, - "invitation": { - "key": "hIH9RB3QqZLFwdUDmufaSPc2H8uS5cNjcG6pk8gfAIQ4jg6nJZZWDIQHMN1gFMk9", - "role": "supervisor", - "user": { - "id": 19, - "url": "http://localhost:8080/api/users/19", - "username": "user5", - "last_name": "Fifth", - "first_name": "User" - }, - "owner": { - "id": 1, - "url": "http://localhost:8080/api/users/1", - "username": "admin1", - "last_name": "First", - "first_name": "Admin" - }, - "created_date": "2022-09-28T13:11:37.839853Z", - "organization": 1 - }, - "webhook_id": 5 - }, - "response": "\n\n\n Example Domain\n\n \n \n \n \n\n\n\n
\n

Example Domain

\n

This domain is for use in illustrative examples in documents. You may use this\n domain in literature without prior coordination or asking for permission.

\n

More information...

\n
\n\n\n" - } -}, { "model": "admin.logentry", "pk": 1, diff --git a/tests/python/shared/assets/users.json b/tests/python/shared/assets/users.json index 8560b7a3..31846b64 100644 --- a/tests/python/shared/assets/users.json +++ b/tests/python/shared/assets/users.json @@ -310,7 +310,7 @@ "is_active": true, "is_staff": true, "is_superuser": true, - "last_login": "2022-09-28T12:20:48.633000Z", + "last_login": "2022-09-29T08:00:09.733000Z", "last_name": "First", "url": "http://localhost:8080/api/users/1", "username": "admin1" diff --git a/tests/python/shared/assets/webhooks.json b/tests/python/shared/assets/webhooks.json index ba8f754a..ec2a71bd 100644 --- a/tests/python/shared/assets/webhooks.json +++ b/tests/python/shared/assets/webhooks.json @@ -5,7 +5,7 @@ "results": [ { "content_type": "application/json", - "created_date": "2022-09-28T12:51:06.703000Z", + "created_date": "2022-09-29T08:00:48.440000Z", "description": "", "enable_ssl": true, "events": [ @@ -28,10 +28,8 @@ "update:project", "update:task" ], - "id": 5, + "id": 6, "is_active": true, - "last_delivery_date": "2022-09-28T13:11:38.311000Z", - "last_status": 200, "organization": 1, "owner": { "first_name": "Admin", @@ -41,10 +39,10 @@ "username": "admin1" }, "project": null, - "target_url": "http://example.com", + "target_url": "http://example.com/", "type": "organization", - "updated_date": "2022-09-28T12:51:06.703000Z", - "url": "http://localhost:8080/api/webhooks/5" + "updated_date": "2022-09-29T08:00:48.441000Z", + "url": "http://localhost:8080/api/webhooks/6" }, { "content_type": "application/json", diff --git a/tests/python/shared/fixtures/data.py b/tests/python/shared/fixtures/data.py index 81c5091e..18b9c0f3 100644 --- a/tests/python/shared/fixtures/data.py +++ b/tests/python/shared/fixtures/data.py @@ -97,6 +97,12 @@ def issues(): return Container(json.load(f)["results"]) +@pytest.fixture(scope="session") +def comments(): + with open(osp.join(ASSETS_DIR, "comments.json")) as f: + return Container(json.load(f)["results"]) + + @pytest.fixture(scope="session") def webhooks(): with open(osp.join(ASSETS_DIR, "webhooks.json")) as f: @@ -328,8 +334,9 @@ def find_issue_staff_user(is_issue_staff, is_issue_admin): def find(issues, users, is_staff, is_admin): for issue in issues: for user in users: - i_admin, i_staff = is_issue_admin(user["id"], issue["id"]), is_issue_staff( - user["id"], issue["id"] + i_admin, i_staff = ( + is_issue_admin(user["id"], issue["id"]), + is_issue_staff(user["id"], issue["id"]), ) if (is_admin is None and (i_staff or i_admin) == is_staff) or ( is_admin == i_admin and is_staff == i_staff diff --git a/tests/python/shared/utils/dump_objects.py b/tests/python/shared/utils/dump_objects.py index a3202954..7a1bbc6a 100644 --- a/tests/python/shared/utils/dump_objects.py +++ b/tests/python/shared/utils/dump_objects.py @@ -18,6 +18,7 @@ if __name__ == "__main__": "membership", "invitation", "cloudstorage", + "comment", "issue", "webhook", ]: