diff --git a/app/core/const/tag.py b/app/core/const/tag.py index 5bb8452..912cb6a 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -3,6 +3,7 @@ class OpenAPITag: EVENT = "Event" EVENT_PRESENTATION = "Event > Presentation" EVENT_SPONSOR = "Event > Sponsor" + EVENT_PRESENTATION_BOOKMARK = "Event > Presentation Bookmark" SHOP_USER = "Shop > 고객" SHOP_PRODUCT = "Shop > 상품" diff --git a/app/core/urls.py b/app/core/urls.py index 2cb76d7..e3dcc3f 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -23,6 +23,7 @@ from django.urls import include, path, re_path, resolvers from django.views.decorators.cache import cache_page from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from event.presentation.urls import bookmark_urlpatterns # type: ignore[assignment] v1_apis: list[resolvers.URLPattern | resolvers.URLResolver] = [ @@ -30,6 +31,7 @@ path("admin-api/", include("admin_api.urls")), path("participant-portal/", include("participant_portal_api.urls")), path("event/presentation/", include("event.presentation.urls")), + path("events//presentation-bookmarks/", include(bookmark_urlpatterns)), path("event/sponsor/", include("event.sponsor.urls")), path("event/", include("event.urls")), path("external-api/", include("external_api.urls")), diff --git a/app/event/presentation/filters.py b/app/event/presentation/filters.py index ff4c3ce..9fb43f9 100644 --- a/app/event/presentation/filters.py +++ b/app/event/presentation/filters.py @@ -1,8 +1,16 @@ +from enum import StrEnum + from core.models import BaseAbstractModelQuerySet from django.db.models import Q from django_filters import rest_framework as filters from django_filters.constants import EMPTY_VALUES from event.filters import EventFilterMixin +from event.presentation.models import PresentationBookmark +from rest_framework import exceptions + + +class PresentationBookmarkErrorCode(StrEnum): + EVENT_NOT_FOUND = "event_not_found" class PresentationFilterSet(EventFilterMixin): @@ -14,3 +22,22 @@ def filter_by_type_names(self, queryset: BaseAbstractModelQuerySet, name: str, v return queryset return queryset.filter(Q(type__name_ko__in=values) | Q(type__name_en__in=values)) + + +class PresentationBookmarkFilterSet(filters.FilterSet): + event = filters.UUIDFilter(method="filter_by_event") + + class Meta: + model = PresentationBookmark + fields = ["event"] + + def filter_by_event( + self, queryset: BaseAbstractModelQuerySet, name: str, values: list[str] + ) -> BaseAbstractModelQuerySet: + filtered_queryset = queryset.filter(presentation__type__event__id=values) + if not filtered_queryset.exists(): + raise exceptions.NotFound( + detail="해당 행사 정보가 없습니다.", + code=PresentationBookmarkErrorCode.EVENT_NOT_FOUND, + ) + return filtered_queryset diff --git a/app/event/presentation/migrations/0017_presentationbookmark.py b/app/event/presentation/migrations/0017_presentationbookmark.py new file mode 100644 index 0000000..30576d4 --- /dev/null +++ b/app/event/presentation/migrations/0017_presentationbookmark.py @@ -0,0 +1,56 @@ +# Generated by Django 6.0.6 on 2026-07-11 08:37 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("presentation", "0016_room_order"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PresentationBookmark", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "presentation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bookmarks", + to="presentation.presentation", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="presentation_bookmarks", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("user", "presentation"), + name="uq__prst_bkmk__user__presentation", + ) + ], + }, + ), + ] diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py index b3f4feb..b8f52bf 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -210,3 +210,21 @@ class RoomSchedule(BaseAbstractModel): def __str__(self) -> str: return f"[{self.room}] {self.start_at} - {self.end_at} ({self.presentation})" + + +class PresentationBookmark(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="presentation_bookmarks") + presentation = models.ForeignKey(Presentation, on_delete=models.CASCADE, related_name="bookmarks") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "presentation"], + name="uq__prst_bkmk__user__presentation", + ) + ] + + def __str__(self) -> str: + return f"[Bookmark] {self.user} - {self.presentation}" diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py index 0e9a3b5..97f1c0c 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -1,13 +1,39 @@ +from typing import Any + from event.presentation.models import ( CallForPresentationSchedule, Presentation, + PresentationBookmark, PresentationCategory, PresentationSpeaker, PresentationType, RoomSchedule, ) from event.serializers import EventSerializer -from rest_framework import serializers +from rest_framework import exceptions, serializers + + +class NotFoundPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + def fail(self, key, **kwargs): + if key == "does_not_exist": + raise exceptions.NotFound("해당 세션 정보가 없습니다.") + super().fail(key, **kwargs) + + +class PresentationBookmarkRequestSerializer(serializers.Serializer): + presentation = NotFoundPrimaryKeyRelatedField(queryset=Presentation.objects.filter(deleted_at__isnull=True)) + + def create(self, validated_data: Any) -> tuple[PresentationBookmark, bool]: + validated_data["user"] = self.context["request"].user + return PresentationBookmark.objects.get_or_create(**validated_data) + + +class PresentationBookmarkListResponseSerializer(serializers.Serializer): + presentation_ids = serializers.ListField(child=serializers.UUIDField()) + + +class PresentationBookmarkResponseSerializer(serializers.Serializer): + presentation_id = serializers.UUIDField() class PresentationTypeSerializer(serializers.ModelSerializer): diff --git a/app/event/presentation/test/bookmark_api_test.py b/app/event/presentation/test/bookmark_api_test.py new file mode 100644 index 0000000..fda8a48 --- /dev/null +++ b/app/event/presentation/test/bookmark_api_test.py @@ -0,0 +1,611 @@ +import http +import uuid +from datetime import datetime + +import pytest +from django.urls import reverse +from event.models import Event +from event.presentation.models import Presentation, PresentationBookmark, PresentationType +from model_bakery import baker +from rest_framework.test import APIClient +from user.models.organization import Organization +from user.models.user import UserExt + +# ────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────── + + +@pytest.fixture +def user(db) -> UserExt: + return baker.make(UserExt) + + +@pytest.fixture +def other_user(db) -> UserExt: + return baker.make(UserExt) + + +@pytest.fixture +def authed_client(user: UserExt) -> APIClient: + client = APIClient() + client.force_authenticate(user=user) + return client + + +@pytest.fixture +def anon_client() -> APIClient: + return APIClient() + + +@pytest.fixture +def organization(db) -> Organization: + return baker.make(Organization) + + +@pytest.fixture +def event(organization: Organization) -> Event: + return Event.objects.create( + organization=organization, + name="파이콘 한국 2026", + event_start_at=datetime(2026, 8, 15), + ) + + +@pytest.fixture +def other_event(organization: Organization) -> Event: + return Event.objects.create( + organization=organization, + name="파이콘 한국 2025", + event_start_at=datetime(2025, 8, 15), + ) + + +@pytest.fixture +def presentation_type(event: Event) -> PresentationType: + return PresentationType.objects.create(event=event, name="Talk") + + +@pytest.fixture +def presentation(presentation_type: PresentationType) -> Presentation: + return Presentation.objects.create(type=presentation_type, title="Django 심화") + + +@pytest.fixture +def presentation_2(presentation_type: PresentationType) -> Presentation: + return Presentation.objects.create(type=presentation_type, title="FastAPI 입문") + + +@pytest.fixture +def other_event_presentation(other_event: Event) -> Presentation: + pt = PresentationType.objects.create(event=other_event, name="Talk") + return Presentation.objects.create(type=pt, title="작년 발표") + + +# ────────────────────────────────────────────── +# Helper +# ────────────────────────────────────────────── + + +def list_url(event_id: uuid.UUID | str) -> str: + return reverse("v1:presentation-bookmark-list", kwargs={"event_id": str(event_id)}) + + +def detail_url(event_id: uuid.UUID | str, presentation_id: uuid.UUID | str) -> str: + return reverse( + "v1:presentation-bookmark-detail", + kwargs={"event_id": str(event_id), "presentation_id": str(presentation_id)}, + ) + + +# ══════════════════════════════════════════════ +# GET /v1/events/{event_id}/presentation-bookmarks/ +# ══════════════════════════════════════════════ + + +class TestBookmarkList: + """GET 북마크 목록 조회 API 테스트""" + + @pytest.mark.django_db + def test_returns_empty_list_when_no_bookmarks(self, authed_client: APIClient, event: Event): + """ + 북마크가 하나도 없을 때 빈 배열을 반환하는지 검증합니다. + 프론트에서 빈 상태 UI를 렌더링하기 위해 빈 배열이 정상 응답이어야 합니다. + """ + response = authed_client.get(list_url(event.id)) + + assert response.status_code == http.HTTPStatus.OK + assert response.json()["presentation_ids"] == [] + + @pytest.mark.django_db + def test_returns_bookmarked_presentation_ids( + self, + authed_client: APIClient, + user: UserExt, + event: Event, + presentation: Presentation, + presentation_2: Presentation, + ): + """ + 유저가 북마크한 세션의 presentation_id 목록이 정확히 반환되는지 검증합니다. + 프론트는 이 ID 목록을 전체 세션 목록과 매칭해서 내 시간표를 구성합니다. + """ + PresentationBookmark.objects.create(user=user, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation_2) + + response = authed_client.get(list_url(event.id)) + + assert response.status_code == http.HTTPStatus.OK + returned_ids = set(response.json()["presentation_ids"]) + assert returned_ids == {str(presentation.id), str(presentation_2.id)} + + @pytest.mark.django_db + def test_filters_by_event_id( + self, + authed_client: APIClient, + user: UserExt, + event: Event, + other_event: Event, + presentation: Presentation, + other_event_presentation: Presentation, + ): + """ + event_id path로 필터링했을 때, 해당 행사의 북마크만 반환되는지 검증합니다. + 다른 행사의 북마크가 섞여 나오면 안 됩니다. + """ + PresentationBookmark.objects.create(user=user, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=other_event_presentation) + + response = authed_client.get(list_url(event.id)) + + assert response.status_code == http.HTTPStatus.OK + returned_ids = response.json()["presentation_ids"] + assert len(returned_ids) == 1 + assert returned_ids[0] == str(presentation.id) + + @pytest.mark.django_db + def test_does_not_return_other_users_bookmarks( + self, authed_client: APIClient, other_user: UserExt, event: Event, presentation: Presentation + ): + """ + 다른 유저가 북마크한 세션이 현재 유저의 목록에 포함되지 않는지 검증합니다. + 북마크는 유저별로 완전히 격리되어야 합니다. + """ + PresentationBookmark.objects.create(user=other_user, presentation=presentation) + + response = authed_client.get(list_url(event.id)) + + assert response.status_code == http.HTTPStatus.OK + assert response.json()["presentation_ids"] == [] + + @pytest.mark.django_db + def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event): + """ + 로그인하지 않은 유저가 요청하면 403 Forbidden을 반환하는지 검증합니다. + DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, + IsAuthenticated 권한 체크에서 403을 반환합니다. + 프론트는 403을 받으면 로그인 모달을 띄웁니다. + """ + response = anon_client.get(list_url(event.id)) + + assert response.status_code == http.HTTPStatus.FORBIDDEN + + @pytest.mark.django_db + def test_nonexistent_event_returns_404(self, authed_client: APIClient): + """ + 존재하지 않는 event_id로 요청하면 404를 반환하는지 검증합니다. + """ + response = authed_client.get(list_url(uuid.uuid4())) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + +# ══════════════════════════════════════════════ +# POST /v1/events/{event_id}/presentation-bookmarks/ +# ══════════════════════════════════════════════ + + +class TestBookmarkCreate: + """POST 북마크 추가 API 테스트""" + + @pytest.mark.django_db + def test_creates_bookmark_and_returns_201( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 새로운 세션을 북마크하면 201 Created와 함께 + 해당 presentation_id가 응답으로 반환되는지 검증합니다. + DB에 북마크 레코드가 실제로 생성되었는지도 확인합니다. + """ + response = authed_client.post( + list_url(event.id), + data={"presentation": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.CREATED + assert response.json()["presentation_id"] == str(presentation.id) + assert PresentationBookmark.objects.filter(user=user, presentation=presentation).exists() + + @pytest.mark.django_db + def test_sets_event_from_presentation( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 북마크 생성 시 presentation의 type.event로부터 event를 자동 설정하는지 검증합니다. + 프론트는 POST 시 event를 명시적으로 보내지 않으므로, 서버가 presentation에서 파생해야 합니다. + """ + authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + + bookmark = PresentationBookmark.objects.get(user=user, presentation=presentation) + assert bookmark.presentation.type.event.id == event.id + + @pytest.mark.django_db + def test_duplicate_bookmark_returns_200_idempotent( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 이미 북마크한 세션을 다시 추가하면 에러 대신 200 OK를 반환하는지 검증합니다. + (멱등성) 프론트의 되돌리기/연타로 중복 요청이 발생할 수 있으므로, + 두 번째 요청도 정상 응답이어야 합니다. + """ + PresentationBookmark.objects.create(user=user, presentation=presentation) + + response = authed_client.post( + list_url(event.id), + data={"presentation": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.OK + assert response.json()["presentation_id"] == str(presentation.id) + # 중복 레코드가 생성되지 않았는지 확인 + assert PresentationBookmark.objects.filter(user=user, presentation=presentation).count() == 1 + + @pytest.mark.django_db + def test_nonexistent_presentation_returns_404(self, authed_client: APIClient, event: Event): + """ + 존재하지 않는 presentation_id로 북마크를 추가하면 404를 반환하는지 검증합니다. + """ + response = authed_client.post( + list_url(event.id), + data={"presentation": str(uuid.uuid4())}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db + def test_soft_deleted_presentation_returns_404( + self, authed_client: APIClient, event: Event, presentation: Presentation + ): + """ + 소프트 삭제된(deleted_at이 설정된) presentation을 북마크하려고 하면 + 404를 반환하는지 검증합니다. + filter_active()가 삭제된 레코드를 제외해야 합니다. + """ + presentation.delete() + + response = authed_client.post( + list_url(event.id), + data={"presentation": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db + def test_invalid_presentation_id_format_returns_400(self, authed_client: APIClient, event: Event): + """ + presentation_id에 UUID가 아닌 값을 보내면 400 Bad Request를 반환하는지 검증합니다. + serializer의 UUIDField 유효성 검사가 동작해야 합니다. + """ + response = authed_client.post( + list_url(event.id), + data={"presentation": "not-a-uuid"}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + @pytest.mark.django_db + def test_missing_presentation_id_returns_400(self, authed_client: APIClient, event: Event): + """ + 요청 바디에 presentation 필드가 없으면 400 Bad Request를 반환하는지 검증합니다. + """ + response = authed_client.post(list_url(event.id), data={}, format="json") + + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + @pytest.mark.django_db + def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event, presentation: Presentation): + """ + 로그인하지 않은 유저가 북마크 추가를 시도하면 403 Forbidden을 반환하는지 검증합니다. + DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, + IsAuthenticated 권한 체크에서 403을 반환합니다. + """ + response = anon_client.post( + list_url(event.id), + data={"presentation": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.FORBIDDEN + + @pytest.mark.django_db + def test_allows_overlapping_time_sessions( + self, + authed_client: APIClient, + user: UserExt, + event: Event, + presentation: Presentation, + presentation_2: Presentation, + ): + """ + 시간이 겹치는 세션들도 모두 북마크할 수 있는지 검증합니다. + UX 확정 사항: 겹침 경고는 프론트가 처리하고, 서버는 시간대 충돌을 검사하지 않습니다. + """ + resp1 = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(event.id), data={"presentation": str(presentation_2.id)}, format="json") + + assert resp1.status_code == http.HTTPStatus.CREATED + assert resp2.status_code == http.HTTPStatus.CREATED + assert PresentationBookmark.objects.filter(user=user).count() == 2 + + @pytest.mark.django_db + def test_nonexistent_event_returns_404(self, authed_client: APIClient, presentation: Presentation): + """ + 존재하지 않는 event_id로 북마크를 추가하면 404를 반환하는지 검증합니다. + """ + response = authed_client.post( + list_url(uuid.uuid4()), + data={"presentation": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + +# ══════════════════════════════════════════════ +# DELETE /v1/events/{event_id}/presentation-bookmarks/{presentation_id}/ +# ══════════════════════════════════════════════ + + +class TestBookmarkDestroy: + """DELETE 북마크 삭제 API 테스트""" + + @pytest.mark.django_db + def test_deletes_bookmark_and_returns_204( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 북마크된 세션을 삭제하면 204 No Content를 반환하고, + DB에서 실제로 레코드가 삭제(hard delete)되는지 검증합니다. + """ + PresentationBookmark.objects.create(user=user, presentation=presentation) + + response = authed_client.delete(detail_url(event.id, presentation.id)) + + assert response.status_code == http.HTTPStatus.NO_CONTENT + assert not PresentationBookmark.objects.filter(user=user, presentation=presentation).exists() + + @pytest.mark.django_db + def test_not_bookmarked_returns_204_idempotent( + self, authed_client: APIClient, event: Event, presentation: Presentation + ): + """ + 유저가 북마크하지 않은 세션에 대해 삭제를 요청해도 + 에러 대신 204를 반환하는지 검증합니다. (멱등성) + 프론트의 되돌리기 UX에서 DELETE 직후 같은 세션에 POST가 오고, + 다시 DELETE가 올 수 있으므로 멱등이어야 합니다. + """ + response = authed_client.delete(detail_url(event.id, presentation.id)) + + assert response.status_code == http.HTTPStatus.NO_CONTENT + + @pytest.mark.django_db + def test_nonexistent_presentation_returns_404(self, authed_client: APIClient, event: Event): + """ + 존재하지 않는 presentation_id로 삭제를 요청하면 404를 반환하는지 검증합니다. + presentation 자체가 DB에 없는 경우만 404이고, + presentation은 있지만 북마크가 없는 경우는 204입니다. + """ + response = authed_client.delete(detail_url(event.id, uuid.uuid4())) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db + def test_does_not_delete_other_users_bookmark( + self, authed_client: APIClient, other_user: UserExt, event: Event, presentation: Presentation + ): + """ + 삭제 요청이 다른 유저의 북마크에 영향을 주지 않는지 검증합니다. + user.presentation 필터로 현재 유저의 북마크만 삭제해야 합니다. + """ + PresentationBookmark.objects.create(user=other_user, presentation=presentation) + + response = authed_client.delete(detail_url(event.id, presentation.id)) + + assert response.status_code == http.HTTPStatus.NO_CONTENT + # 다른 유저의 북마크는 그대로 남아 있어야 함 + assert PresentationBookmark.objects.filter(user=other_user, presentation=presentation).exists() + + @pytest.mark.django_db + def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event, presentation: Presentation): + """ + 로그인하지 않은 유저가 북마크 삭제를 시도하면 403 Forbidden을 반환하는지 검증합니다. + DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, + IsAuthenticated 권한 체크에서 403을 반환합니다. + """ + response = anon_client.delete(detail_url(event.id, presentation.id)) + + assert response.status_code == http.HTTPStatus.FORBIDDEN + + @pytest.mark.django_db + def test_only_deletes_specified_bookmark( + self, + authed_client: APIClient, + user: UserExt, + event: Event, + presentation: Presentation, + presentation_2: Presentation, + ): + """ + 특정 세션 하나를 삭제할 때, 유저의 다른 북마크는 유지되는지 검증합니다. + 삭제 범위가 정확히 요청된 presentation에만 한정되어야 합니다. + """ + PresentationBookmark.objects.create(user=user, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation_2) + + authed_client.delete(detail_url(event.id, presentation.id)) + + assert not PresentationBookmark.objects.filter(user=user, presentation=presentation).exists() + assert PresentationBookmark.objects.filter(user=user, presentation=presentation_2).exists() + + @pytest.mark.django_db + def test_nonexistent_event_returns_404(self, authed_client: APIClient, presentation: Presentation): + """ + 존재하지 않는 event_id로 삭제를 요청하면 404를 반환하는지 검증합니다. + """ + response = authed_client.delete(detail_url(uuid.uuid4(), presentation.id)) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + +# ══════════════════════════════════════════════ +# 통합 시나리오: 담기 → 빼기 → 되돌리기 (POST → DELETE → POST) +# ══════════════════════════════════════════════ + + +class TestBookmarkUndoFlow: + """프론트의 '담기 → 빼기 → 되돌리기' 시나리오 통합 테스트""" + + @pytest.mark.django_db + def test_add_remove_re_add_flow( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 프론트의 실제 사용 시나리오를 재현합니다: + 1. 세션 담기 (POST) → 201 + 2. 세션 빼기 (DELETE) → 204 + 3. 되돌리기 (POST 재전송) → 201 + 각 단계에서 GET으로 목록을 조회해 상태가 올바른지 확인합니다. + """ + # 1단계: 담기 + resp = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + assert resp.status_code == http.HTTPStatus.CREATED + + # GET으로 확인: 1개 + resp = authed_client.get(list_url(event.id)) + assert len(resp.json()["presentation_ids"]) == 1 + + # 2단계: 빼기 + resp = authed_client.delete(detail_url(event.id, presentation.id)) + assert resp.status_code == http.HTTPStatus.NO_CONTENT + + # GET으로 확인: 0개 + resp = authed_client.get(list_url(event.id)) + assert len(resp.json()["presentation_ids"]) == 0 + + # 3단계: 되돌리기 (다시 POST) + resp = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + assert resp.status_code == http.HTTPStatus.CREATED + + # GET으로 확인: 다시 1개 + resp = authed_client.get(list_url(event.id)) + assert len(resp.json()["presentation_ids"]) == 1 + + @pytest.mark.django_db + def test_rapid_double_delete_is_safe( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 같은 세션에 대해 DELETE가 빠르게 2번 연속 호출되어도 + 두 번째 요청이 에러 없이 204를 반환하는지 검증합니다. + 네트워크 재시도나 프론트 연타로 발생할 수 있는 시나리오입니다. + """ + PresentationBookmark.objects.create(user=user, presentation=presentation) + + resp1 = authed_client.delete(detail_url(event.id, presentation.id)) + resp2 = authed_client.delete(detail_url(event.id, presentation.id)) + + assert resp1.status_code == http.HTTPStatus.NO_CONTENT + assert resp2.status_code == http.HTTPStatus.NO_CONTENT + + @pytest.mark.django_db + def test_rapid_double_post_is_safe( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 같은 세션에 대해 POST가 빠르게 2번 연속 호출되어도 + 두 번째 요청이 에러 없이 200을 반환하고 중복 레코드가 생기지 않는지 검증합니다. + """ + resp1 = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + + assert resp1.status_code == http.HTTPStatus.CREATED + assert resp2.status_code == http.HTTPStatus.OK + assert PresentationBookmark.objects.filter(user=user, presentation=presentation).count() == 1 + + +# ══════════════════════════════════════════════ +# 에러 응답 포맷 검증 +# ══════════════════════════════════════════════ + + +class TestErrorResponseFormat: + """에러 응답이 drf-standardized-errors의 공통 envelope 포맷을 따르는지 검증""" + + @pytest.mark.django_db + def test_403_follows_error_envelope(self, anon_client: APIClient, event: Event): + """ + 403 응답이 프론트의 ErrorResponseSchema와 호환되는 + { "type": "...", "errors": [{ "code": "...", "detail": "...", "attr": ... }] } + 포맷인지 검증합니다. + 프론트는 errors[0].code로 에러 종류를 분기합니다. + DRF SessionAuthentication + IsAuthenticated 조합에서 + 미인증 요청은 403 + code: "not_authenticated"로 응답합니다. + """ + response = anon_client.get(list_url(event.id)) + + assert response.status_code == http.HTTPStatus.FORBIDDEN + body = response.json() + assert "type" in body + assert "errors" in body + assert len(body["errors"]) > 0 + assert "code" in body["errors"][0] + assert "detail" in body["errors"][0] + assert body["errors"][0]["code"] == "not_authenticated" + + @pytest.mark.django_db + def test_404_follows_error_envelope(self, authed_client: APIClient, event: Event): + """ + 404 응답이 공통 에러 envelope 포맷을 따르는지 검증합니다. + """ + response = authed_client.post( + list_url(event.id), + data={"presentation": str(uuid.uuid4())}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + body = response.json() + assert body["type"] == "client_error" + assert "errors" in body + assert body["errors"][0]["code"] == "not_found" + + @pytest.mark.django_db + def test_400_validation_error_follows_envelope(self, authed_client: APIClient, event: Event): + """ + 400 유효성 검사 에러가 공통 에러 envelope 포맷을 따르는지 검증합니다. + """ + response = authed_client.post( + list_url(event.id), + data={"presentation": "invalid"}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.BAD_REQUEST + body = response.json() + assert body["type"] == "validation_error" + assert "errors" in body diff --git a/app/event/presentation/urls.py b/app/event/presentation/urls.py index d47dec0..7118cb6 100644 --- a/app/event/presentation/urls.py +++ b/app/event/presentation/urls.py @@ -1,20 +1,3 @@ -""" -URL configuration for core project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.2/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" - from django.urls import include, path from event.presentation import views from rest_framework import routers @@ -23,4 +6,13 @@ cms_router.register("category", views.PresentationCategoryViewSet, basename="presentation-category") cms_router.register("", views.PresentationViewSet, basename="presentation") -urlpatterns = [path("", include(cms_router.urls))] +bookmark_router = routers.SimpleRouter() +bookmark_router.register("", views.PresentationBookmarkViewSet, basename="presentation-bookmark") + +urlpatterns = [ + path("", include(cms_router.urls)), +] + +bookmark_urlpatterns = [ + path("", include(bookmark_router.urls)), +] diff --git a/app/event/presentation/views.py b/app/event/presentation/views.py index 130e94d..babea15 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,10 +1,23 @@ from core.const.tag import OpenAPITag +from django.db.models.query import QuerySet +from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema +from drf_standardized_errors.openapi_serializers import ( + ErrorResponse401Serializer, + ErrorResponse404Serializer, +) +from event.models import Event from event.presentation.filters import PresentationFilterSet -from event.presentation.models import Presentation, PresentationCategory -from event.presentation.serializers import PresentationCategorySerializer, PresentationSerializer -from rest_framework import mixins, viewsets +from event.presentation.models import Presentation, PresentationBookmark, PresentationCategory +from event.presentation.serializers import ( + PresentationBookmarkListResponseSerializer, + PresentationBookmarkRequestSerializer, + PresentationBookmarkResponseSerializer, + PresentationCategorySerializer, + PresentationSerializer, +) +from rest_framework import exceptions, mixins, permissions, request, response, status, viewsets @method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) @@ -19,3 +32,62 @@ class PresentationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view queryset = Presentation.objects.get_all_nested_data() serializer_class = PresentationSerializer filterset_class = PresentationFilterSet + + +@extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION_BOOKMARK]) +class PresentationBookmarkViewSet(viewsets.GenericViewSet): + queryset = PresentationBookmark.objects.all() + permission_classes = [permissions.IsAuthenticated] + serializer_class = PresentationBookmarkRequestSerializer + lookup_field = "presentation_id" + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + get_object_or_404(Event, id=self.kwargs["event_id"]) + + def get_queryset(self) -> QuerySet: + return ( + super().get_queryset().filter(user=self.request.user, presentation__type__event_id=self.kwargs["event_id"]) + ) + + def destroy(self, request: request.Request, **kwargs) -> response.Response: + presentation_id = self.kwargs["presentation_id"] + if not Presentation.objects.filter(id=presentation_id, deleted_at__isnull=True).exists(): + raise exceptions.NotFound("해당 세션 정보가 없습니다.") + self.get_queryset().filter(presentation_id=presentation_id).delete() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="북마크 목록 조회", + parameters=[], + responses={ + status.HTTP_200_OK: PresentationBookmarkListResponseSerializer, + status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer, + status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, + }, + ) + def list(self, request: request.Request, **kwargs) -> response.Response: + queryset = self.get_queryset() + presentation_ids = list(queryset.values_list("presentation_id", flat=True)) + return response.Response({"presentation_ids": presentation_ids}) + + @extend_schema( + summary="북마크 추가", + request=PresentationBookmarkRequestSerializer, + responses={ + status.HTTP_201_CREATED: PresentationBookmarkResponseSerializer, + status.HTTP_200_OK: PresentationBookmarkResponseSerializer, + status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer, + status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, + }, + ) + def create(self, request: request.Request, **kwargs) -> response.Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + instance, created = serializer.create(serializer.validated_data) + serializer.save() + + return response.Response( + {"presentation_id": str(instance.presentation_id)}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + )