From 943ba43480fd7bf9cd381a230efde788e06e53a6 Mon Sep 17 00:00:00 2001 From: earthyoung Date: Sat, 11 Jul 2026 14:34:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=91=9C=20=EB=8B=B4=EA=B8=B0=20API=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/const/tag.py | 1 + .../0017_add_presentation_bookmark.py | 65 ++ app/event/presentation/models.py | 19 + app/event/presentation/serializers.py | 12 + .../presentation/test/bookmark_api_test.py | 596 ++++++++++++++++++ app/event/presentation/urls.py | 25 +- app/event/presentation/views.py | 105 ++- 7 files changed, 802 insertions(+), 21 deletions(-) create mode 100644 app/event/presentation/migrations/0017_add_presentation_bookmark.py create mode 100644 app/event/presentation/test/bookmark_api_test.py 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/event/presentation/migrations/0017_add_presentation_bookmark.py b/app/event/presentation/migrations/0017_add_presentation_bookmark.py new file mode 100644 index 0000000..993e7c8 --- /dev/null +++ b/app/event/presentation/migrations/0017_add_presentation_bookmark.py @@ -0,0 +1,65 @@ +# Generated by Django 6.0.6 on 2026-07-11 05:12 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("event", "0006_event_logo_historicalevent_logo"), + ("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)), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="presentation_bookmarks", + to="event.event", + ), + ), + ( + "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..994adc8 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -210,3 +210,22 @@ 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") + event = models.ForeignKey(Event, 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..471b51f 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -10,6 +10,18 @@ from rest_framework import serializers +class PresentationBookmarkRequestSerializer(serializers.Serializer): + presentation_id = serializers.UUIDField() + + +class PresentationBookmarkListResponseSerializer(serializers.Serializer): + presentation_ids = serializers.ListField(child=serializers.UUIDField()) + + +class PresentationBookmarkResponseSerializer(serializers.Serializer): + presentation_id = serializers.UUIDField() + + class PresentationTypeSerializer(serializers.ModelSerializer): event = EventSerializer(read_only=True) 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..21a132a --- /dev/null +++ b/app/event/presentation/test/bookmark_api_test.py @@ -0,0 +1,596 @@ +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_name: str | None = None) -> str: + url = reverse("v1:presentation-bookmark-list") + if event_name: + url += f"?event={event_name}" + return url + + +def detail_url(presentation_id: uuid.UUID | str) -> str: + return reverse("v1:presentation-bookmark-detail", kwargs={"pk": str(presentation_id)}) + + +# ══════════════════════════════════════════════ +# GET /v1/event/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.name)) + + 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, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation_2) + + response = authed_client.get(list_url(event.name)) + + 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_name( + self, + authed_client: APIClient, + user: UserExt, + event: Event, + other_event: Event, + presentation: Presentation, + other_event_presentation: Presentation, + ): + """ + event 쿼리 파라미터로 필터링했을 때, 해당 행사의 북마크만 반환되는지 검증합니다. + 다른 행사의 북마크가 섞여 나오면 안 됩니다. + """ + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, event=other_event, presentation=other_event_presentation) + + response = authed_client.get(list_url(event.name)) + + 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_defaults_to_latest_event_when_no_event_param( + self, authed_client: APIClient, user: UserExt, event: Event, other_event: Event, presentation: Presentation + ): + """ + event 쿼리 파라미터를 생략하면, 기존 presentation API와 동일하게 + 가장 최신 행사(event_start_at 기준)의 북마크를 반환하는지 검증합니다. + """ + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + + response = authed_client.get(list_url()) + + assert response.status_code == http.HTTPStatus.OK + # event(2026)이 other_event(2025)보다 최신이므로 event의 북마크가 반환되어야 함 + assert str(presentation.id) in response.json()["presentation_ids"] + + @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, event=event, presentation=presentation) + + response = authed_client.get(list_url(event.name)) + + 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.name)) + + assert response.status_code == http.HTTPStatus.FORBIDDEN + + @pytest.mark.django_db + def test_nonexistent_event_returns_404(self, authed_client: APIClient): + """ + 존재하지 않는 행사 이름으로 요청하면 404를 반환하는지 검증합니다. + """ + response = authed_client.get(list_url("존재하지 않는 행사")) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + +# ══════════════════════════════════════════════ +# POST /v1/event/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(), + data={"presentation_id": 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(), data={"presentation_id": str(presentation.id)}, format="json") + + bookmark = PresentationBookmark.objects.get(user=user, presentation=presentation) + assert bookmark.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, event=event, presentation=presentation) + + response = authed_client.post( + list_url(), + data={"presentation_id": 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): + """ + 존재하지 않는 presentation_id로 북마크를 추가하면 404를 반환하는지 검증합니다. + """ + response = authed_client.post( + list_url(), + data={"presentation_id": 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, presentation: Presentation): + """ + 소프트 삭제된(deleted_at이 설정된) presentation을 북마크하려고 하면 + 404를 반환하는지 검증합니다. + filter_active()가 삭제된 레코드를 제외해야 합니다. + """ + presentation.delete() + + response = authed_client.post( + list_url(), + data={"presentation_id": 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): + """ + presentation_id에 UUID가 아닌 값을 보내면 400 Bad Request를 반환하는지 검증합니다. + serializer의 UUIDField 유효성 검사가 동작해야 합니다. + """ + response = authed_client.post( + list_url(), + data={"presentation_id": "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): + """ + 요청 바디에 presentation_id 필드가 없으면 400 Bad Request를 반환하는지 검증합니다. + """ + response = authed_client.post(list_url(), data={}, format="json") + + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + @pytest.mark.django_db + def test_unauthenticated_returns_403(self, anon_client: APIClient, presentation: Presentation): + """ + 로그인하지 않은 유저가 북마크 추가를 시도하면 403 Forbidden을 반환하는지 검증합니다. + DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, + IsAuthenticated 권한 체크에서 403을 반환합니다. + """ + response = anon_client.post( + list_url(), + data={"presentation_id": 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, presentation: Presentation, presentation_2: Presentation + ): + """ + 시간이 겹치는 세션들도 모두 북마크할 수 있는지 검증합니다. + UX 확정 사항: 겹침 경고는 프론트가 처리하고, 서버는 시간대 충돌을 검사하지 않습니다. + """ + resp1 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(), data={"presentation_id": 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 + + +# ══════════════════════════════════════════════ +# DELETE /v1/event/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, event=event, presentation=presentation) + + response = authed_client.delete(detail_url(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(presentation.id)) + + assert response.status_code == http.HTTPStatus.NO_CONTENT + + @pytest.mark.django_db + def test_nonexistent_presentation_returns_404(self, authed_client: APIClient): + """ + 존재하지 않는 presentation_id로 삭제를 요청하면 404를 반환하는지 검증합니다. + presentation 자체가 DB에 없는 경우만 404이고, + presentation은 있지만 북마크가 없는 경우는 204입니다. + """ + response = authed_client.delete(detail_url(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, event=event, presentation=presentation) + + response = authed_client.delete(detail_url(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, presentation: Presentation): + """ + 로그인하지 않은 유저가 북마크 삭제를 시도하면 403 Forbidden을 반환하는지 검증합니다. + DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, + IsAuthenticated 권한 체크에서 403을 반환합니다. + """ + response = anon_client.delete(detail_url(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, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation_2) + + authed_client.delete(detail_url(presentation.id)) + + assert not PresentationBookmark.objects.filter(user=user, presentation=presentation).exists() + assert PresentationBookmark.objects.filter(user=user, presentation=presentation_2).exists() + + +# ══════════════════════════════════════════════ +# 통합 시나리오: 담기 → 빼기 → 되돌리기 (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(), data={"presentation_id": str(presentation.id)}, format="json") + assert resp.status_code == http.HTTPStatus.CREATED + + # GET으로 확인: 1개 + resp = authed_client.get(list_url(event.name)) + assert len(resp.json()["presentation_ids"]) == 1 + + # 2단계: 빼기 + resp = authed_client.delete(detail_url(presentation.id)) + assert resp.status_code == http.HTTPStatus.NO_CONTENT + + # GET으로 확인: 0개 + resp = authed_client.get(list_url(event.name)) + assert len(resp.json()["presentation_ids"]) == 0 + + # 3단계: 되돌리기 (다시 POST) + resp = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + assert resp.status_code == http.HTTPStatus.CREATED + + # GET으로 확인: 다시 1개 + resp = authed_client.get(list_url(event.name)) + 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, event=event, presentation=presentation) + + resp1 = authed_client.delete(detail_url(presentation.id)) + resp2 = authed_client.delete(detail_url(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, presentation: Presentation): + """ + 같은 세션에 대해 POST가 빠르게 2번 연속 호출되어도 + 두 번째 요청이 에러 없이 200을 반환하고 중복 레코드가 생기지 않는지 검증합니다. + """ + resp1 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(), data={"presentation_id": 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.name)) + + 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): + """ + 404 응답이 공통 에러 envelope 포맷을 따르는지 검증합니다. + """ + response = authed_client.post( + list_url(), + data={"presentation_id": 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): + """ + 400 유효성 검사 에러가 공통 에러 envelope 포맷을 따르는지 검증합니다. + """ + response = authed_client.post( + list_url(), + data={"presentation_id": "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..df73b04 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,10 @@ 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("bookmarks", views.PresentationBookmarkViewSet, basename="presentation-bookmark") + +urlpatterns = [ + path("", include(bookmark_router.urls)), + path("", include(cms_router.urls)), +] diff --git a/app/event/presentation/views.py b/app/event/presentation/views.py index 130e94d..a4f4c8d 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,10 +1,26 @@ from core.const.tag import OpenAPITag +from django.db.models import Q 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 mixins, status, viewsets +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response @method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) @@ -19,3 +35,86 @@ 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): + permission_classes = [IsAuthenticated] + serializer_class = PresentationBookmarkRequestSerializer + + def _resolve_event(self, request: Request) -> Event: + event_name = request.query_params.get("event") + if not event_name: + event = Event.objects.filter_active().first() + if not event: + raise NotFound("해당 행사 정보가 없습니다.") + return event + + event = Event.objects.filter_active().filter(Q(name_ko=event_name) | Q(name_en=event_name)).first() + if not event: + raise NotFound("해당 행사 정보가 없습니다.") + return event + + @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) -> Response: + event = self._resolve_event(request) + presentation_ids = list( + PresentationBookmark.objects.filter(user=request.user, event=event).values_list( + "presentation_id", flat=True + ) + ) + return 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) -> Response: + serializer = PresentationBookmarkRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + presentation_id = serializer.validated_data["presentation_id"] + + presentation = Presentation.objects.filter_active().filter(id=presentation_id).first() + if not presentation: + raise NotFound("해당 세션 정보가 없습니다.") + + event = presentation.type.event + _, created = PresentationBookmark.objects.get_or_create( + user=request.user, + presentation=presentation, + defaults={"event": event}, + ) + return Response( + {"presentation_id": str(presentation.id)}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @extend_schema( + summary="북마크 삭제", + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer, + status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, + }, + ) + def destroy(self, request: Request, pk: str = None) -> Response: + presentation = Presentation.objects.filter_active().filter(id=pk).first() + if not presentation: + raise NotFound("해당 세션 정보가 없습니다.") + + PresentationBookmark.objects.filter(user=request.user, presentation=presentation).delete() + return Response(status=status.HTTP_204_NO_CONTENT) From 0a53920780c061707859023776be3707245d5b79 Mon Sep 17 00:00:00 2001 From: earthyoung Date: Sat, 11 Jul 2026 22:06:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20PR=20=EB=A6=AC=EB=B7=B0=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/urls.py | 2 + app/event/presentation/filters.py | 27 +++ ...okmark.py => 0017_presentationbookmark.py} | 11 +- app/event/presentation/models.py | 1 - app/event/presentation/serializers.py | 18 +- .../presentation/test/bookmark_api_test.py | 209 ++++++++++-------- app/event/presentation/urls.py | 7 +- app/event/presentation/views.py | 87 +++----- 8 files changed, 193 insertions(+), 169 deletions(-) rename app/event/presentation/migrations/{0017_add_presentation_bookmark.py => 0017_presentationbookmark.py} (80%) 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_add_presentation_bookmark.py b/app/event/presentation/migrations/0017_presentationbookmark.py similarity index 80% rename from app/event/presentation/migrations/0017_add_presentation_bookmark.py rename to app/event/presentation/migrations/0017_presentationbookmark.py index 993e7c8..30576d4 100644 --- a/app/event/presentation/migrations/0017_add_presentation_bookmark.py +++ b/app/event/presentation/migrations/0017_presentationbookmark.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.6 on 2026-07-11 05:12 +# Generated by Django 6.0.6 on 2026-07-11 08:37 import uuid @@ -9,7 +9,6 @@ class Migration(migrations.Migration): dependencies = [ - ("event", "0006_event_logo_historicalevent_logo"), ("presentation", "0016_room_order"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -28,14 +27,6 @@ class Migration(migrations.Migration): ), ), ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "event", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="presentation_bookmarks", - to="event.event", - ), - ), ( "presentation", models.ForeignKey( diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py index 994adc8..b8f52bf 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -215,7 +215,6 @@ def __str__(self) -> str: 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") - event = models.ForeignKey(Event, 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) diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py index 471b51f..97f1c0c 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -1,17 +1,31 @@ +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_id = serializers.UUIDField() + 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): diff --git a/app/event/presentation/test/bookmark_api_test.py b/app/event/presentation/test/bookmark_api_test.py index 21a132a..fda8a48 100644 --- a/app/event/presentation/test/bookmark_api_test.py +++ b/app/event/presentation/test/bookmark_api_test.py @@ -87,19 +87,19 @@ def other_event_presentation(other_event: Event) -> Presentation: # ────────────────────────────────────────────── -def list_url(event_name: str | None = None) -> str: - url = reverse("v1:presentation-bookmark-list") - if event_name: - url += f"?event={event_name}" - return url +def list_url(event_id: uuid.UUID | str) -> str: + return reverse("v1:presentation-bookmark-list", kwargs={"event_id": str(event_id)}) -def detail_url(presentation_id: uuid.UUID | str) -> str: - return reverse("v1:presentation-bookmark-detail", kwargs={"pk": str(presentation_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/event/presentation/bookmarks/ +# GET /v1/events/{event_id}/presentation-bookmarks/ # ══════════════════════════════════════════════ @@ -112,10 +112,10 @@ def test_returns_empty_list_when_no_bookmarks(self, authed_client: APIClient, ev 북마크가 하나도 없을 때 빈 배열을 반환하는지 검증합니다. 프론트에서 빈 상태 UI를 렌더링하기 위해 빈 배열이 정상 응답이어야 합니다. """ - response = authed_client.get(list_url(event.name)) + response = authed_client.get(list_url(event.id)) assert response.status_code == http.HTTPStatus.OK - assert response.json() == {"presentation_ids": []} + assert response.json()["presentation_ids"] == [] @pytest.mark.django_db def test_returns_bookmarked_presentation_ids( @@ -130,17 +130,17 @@ def test_returns_bookmarked_presentation_ids( 유저가 북마크한 세션의 presentation_id 목록이 정확히 반환되는지 검증합니다. 프론트는 이 ID 목록을 전체 세션 목록과 매칭해서 내 시간표를 구성합니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation_2) + PresentationBookmark.objects.create(user=user, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation_2) - response = authed_client.get(list_url(event.name)) + 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_name( + def test_filters_by_event_id( self, authed_client: APIClient, user: UserExt, @@ -150,35 +150,19 @@ def test_filters_by_event_name( other_event_presentation: Presentation, ): """ - event 쿼리 파라미터로 필터링했을 때, 해당 행사의 북마크만 반환되는지 검증합니다. + event_id path로 필터링했을 때, 해당 행사의 북마크만 반환되는지 검증합니다. 다른 행사의 북마크가 섞여 나오면 안 됩니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) - PresentationBookmark.objects.create(user=user, event=other_event, presentation=other_event_presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=other_event_presentation) - response = authed_client.get(list_url(event.name)) + 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_defaults_to_latest_event_when_no_event_param( - self, authed_client: APIClient, user: UserExt, event: Event, other_event: Event, presentation: Presentation - ): - """ - event 쿼리 파라미터를 생략하면, 기존 presentation API와 동일하게 - 가장 최신 행사(event_start_at 기준)의 북마크를 반환하는지 검증합니다. - """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) - - response = authed_client.get(list_url()) - - assert response.status_code == http.HTTPStatus.OK - # event(2026)이 other_event(2025)보다 최신이므로 event의 북마크가 반환되어야 함 - assert str(presentation.id) in response.json()["presentation_ids"] - @pytest.mark.django_db def test_does_not_return_other_users_bookmarks( self, authed_client: APIClient, other_user: UserExt, event: Event, presentation: Presentation @@ -187,9 +171,9 @@ def test_does_not_return_other_users_bookmarks( 다른 유저가 북마크한 세션이 현재 유저의 목록에 포함되지 않는지 검증합니다. 북마크는 유저별로 완전히 격리되어야 합니다. """ - PresentationBookmark.objects.create(user=other_user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=other_user, presentation=presentation) - response = authed_client.get(list_url(event.name)) + response = authed_client.get(list_url(event.id)) assert response.status_code == http.HTTPStatus.OK assert response.json()["presentation_ids"] == [] @@ -202,22 +186,22 @@ def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event) IsAuthenticated 권한 체크에서 403을 반환합니다. 프론트는 403을 받으면 로그인 모달을 띄웁니다. """ - response = anon_client.get(list_url(event.name)) + 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): """ - 존재하지 않는 행사 이름으로 요청하면 404를 반환하는지 검증합니다. + 존재하지 않는 event_id로 요청하면 404를 반환하는지 검증합니다. """ - response = authed_client.get(list_url("존재하지 않는 행사")) + response = authed_client.get(list_url(uuid.uuid4())) assert response.status_code == http.HTTPStatus.NOT_FOUND # ══════════════════════════════════════════════ -# POST /v1/event/presentation/bookmarks/ +# POST /v1/events/{event_id}/presentation-bookmarks/ # ══════════════════════════════════════════════ @@ -234,8 +218,8 @@ def test_creates_bookmark_and_returns_201( DB에 북마크 레코드가 실제로 생성되었는지도 확인합니다. """ response = authed_client.post( - list_url(), - data={"presentation_id": str(presentation.id)}, + list_url(event.id), + data={"presentation": str(presentation.id)}, format="json", ) @@ -251,10 +235,10 @@ def test_sets_event_from_presentation( 북마크 생성 시 presentation의 type.event로부터 event를 자동 설정하는지 검증합니다. 프론트는 POST 시 event를 명시적으로 보내지 않으므로, 서버가 presentation에서 파생해야 합니다. """ - authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") bookmark = PresentationBookmark.objects.get(user=user, presentation=presentation) - assert bookmark.event_id == event.id + assert bookmark.presentation.type.event.id == event.id @pytest.mark.django_db def test_duplicate_bookmark_returns_200_idempotent( @@ -265,11 +249,11 @@ def test_duplicate_bookmark_returns_200_idempotent( (멱등성) 프론트의 되돌리기/연타로 중복 요청이 발생할 수 있으므로, 두 번째 요청도 정상 응답이어야 합니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation) response = authed_client.post( - list_url(), - data={"presentation_id": str(presentation.id)}, + list_url(event.id), + data={"presentation": str(presentation.id)}, format="json", ) @@ -279,20 +263,22 @@ def test_duplicate_bookmark_returns_200_idempotent( assert PresentationBookmark.objects.filter(user=user, presentation=presentation).count() == 1 @pytest.mark.django_db - def test_nonexistent_presentation_returns_404(self, authed_client: APIClient): + def test_nonexistent_presentation_returns_404(self, authed_client: APIClient, event: Event): """ 존재하지 않는 presentation_id로 북마크를 추가하면 404를 반환하는지 검증합니다. """ response = authed_client.post( - list_url(), - data={"presentation_id": str(uuid.uuid4())}, + 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, presentation: Presentation): + def test_soft_deleted_presentation_returns_404( + self, authed_client: APIClient, event: Event, presentation: Presentation + ): """ 소프트 삭제된(deleted_at이 설정된) presentation을 북마크하려고 하면 404를 반환하는지 검증합니다. @@ -301,46 +287,46 @@ def test_soft_deleted_presentation_returns_404(self, authed_client: APIClient, p presentation.delete() response = authed_client.post( - list_url(), - data={"presentation_id": str(presentation.id)}, + 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): + 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(), - data={"presentation_id": "not-a-uuid"}, + 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): + def test_missing_presentation_id_returns_400(self, authed_client: APIClient, event: Event): """ - 요청 바디에 presentation_id 필드가 없으면 400 Bad Request를 반환하는지 검증합니다. + 요청 바디에 presentation 필드가 없으면 400 Bad Request를 반환하는지 검증합니다. """ - response = authed_client.post(list_url(), data={}, format="json") + 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, presentation: Presentation): + 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(), - data={"presentation_id": str(presentation.id)}, + list_url(event.id), + data={"presentation": str(presentation.id)}, format="json", ) @@ -348,22 +334,40 @@ def test_unauthenticated_returns_403(self, anon_client: APIClient, presentation: @pytest.mark.django_db def test_allows_overlapping_time_sessions( - self, authed_client: APIClient, user: UserExt, presentation: Presentation, presentation_2: Presentation + self, + authed_client: APIClient, + user: UserExt, + event: Event, + presentation: Presentation, + presentation_2: Presentation, ): """ 시간이 겹치는 세션들도 모두 북마크할 수 있는지 검증합니다. UX 확정 사항: 겹침 경고는 프론트가 처리하고, 서버는 시간대 충돌을 검사하지 않습니다. """ - resp1 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") - resp2 = authed_client.post(list_url(), data={"presentation_id": str(presentation_2.id)}, format="json") + 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/event/presentation/bookmarks/{presentation_id}/ +# DELETE /v1/events/{event_id}/presentation-bookmarks/{presentation_id}/ # ══════════════════════════════════════════════ @@ -378,9 +382,9 @@ def test_deletes_bookmark_and_returns_204( 북마크된 세션을 삭제하면 204 No Content를 반환하고, DB에서 실제로 레코드가 삭제(hard delete)되는지 검증합니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation) - response = authed_client.delete(detail_url(presentation.id)) + 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() @@ -395,18 +399,18 @@ def test_not_bookmarked_returns_204_idempotent( 프론트의 되돌리기 UX에서 DELETE 직후 같은 세션에 POST가 오고, 다시 DELETE가 올 수 있으므로 멱등이어야 합니다. """ - response = authed_client.delete(detail_url(presentation.id)) + 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): + 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(uuid.uuid4())) + response = authed_client.delete(detail_url(event.id, uuid.uuid4())) assert response.status_code == http.HTTPStatus.NOT_FOUND @@ -418,22 +422,22 @@ def test_does_not_delete_other_users_bookmark( 삭제 요청이 다른 유저의 북마크에 영향을 주지 않는지 검증합니다. user.presentation 필터로 현재 유저의 북마크만 삭제해야 합니다. """ - PresentationBookmark.objects.create(user=other_user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=other_user, presentation=presentation) - response = authed_client.delete(detail_url(presentation.id)) + 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, presentation: Presentation): + 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(presentation.id)) + response = anon_client.delete(detail_url(event.id, presentation.id)) assert response.status_code == http.HTTPStatus.FORBIDDEN @@ -450,14 +454,23 @@ def test_only_deletes_specified_bookmark( 특정 세션 하나를 삭제할 때, 유저의 다른 북마크는 유지되는지 검증합니다. 삭제 범위가 정확히 요청된 presentation에만 한정되어야 합니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation_2) + PresentationBookmark.objects.create(user=user, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation_2) - authed_client.delete(detail_url(presentation.id)) + 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) @@ -479,27 +492,27 @@ def test_add_remove_re_add_flow( 각 단계에서 GET으로 목록을 조회해 상태가 올바른지 확인합니다. """ # 1단계: 담기 - resp = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + 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.name)) + resp = authed_client.get(list_url(event.id)) assert len(resp.json()["presentation_ids"]) == 1 # 2단계: 빼기 - resp = authed_client.delete(detail_url(presentation.id)) + 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.name)) + resp = authed_client.get(list_url(event.id)) assert len(resp.json()["presentation_ids"]) == 0 # 3단계: 되돌리기 (다시 POST) - resp = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + 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.name)) + resp = authed_client.get(list_url(event.id)) assert len(resp.json()["presentation_ids"]) == 1 @pytest.mark.django_db @@ -511,22 +524,24 @@ def test_rapid_double_delete_is_safe( 두 번째 요청이 에러 없이 204를 반환하는지 검증합니다. 네트워크 재시도나 프론트 연타로 발생할 수 있는 시나리오입니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation) - resp1 = authed_client.delete(detail_url(presentation.id)) - resp2 = authed_client.delete(detail_url(presentation.id)) + 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, presentation: Presentation): + 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(), data={"presentation_id": str(presentation.id)}, format="json") - resp2 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + 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 @@ -551,7 +566,7 @@ def test_403_follows_error_envelope(self, anon_client: APIClient, event: Event): DRF SessionAuthentication + IsAuthenticated 조합에서 미인증 요청은 403 + code: "not_authenticated"로 응답합니다. """ - response = anon_client.get(list_url(event.name)) + response = anon_client.get(list_url(event.id)) assert response.status_code == http.HTTPStatus.FORBIDDEN body = response.json() @@ -563,13 +578,13 @@ def test_403_follows_error_envelope(self, anon_client: APIClient, event: Event): assert body["errors"][0]["code"] == "not_authenticated" @pytest.mark.django_db - def test_404_follows_error_envelope(self, authed_client: APIClient): + def test_404_follows_error_envelope(self, authed_client: APIClient, event: Event): """ 404 응답이 공통 에러 envelope 포맷을 따르는지 검증합니다. """ response = authed_client.post( - list_url(), - data={"presentation_id": str(uuid.uuid4())}, + list_url(event.id), + data={"presentation": str(uuid.uuid4())}, format="json", ) @@ -580,13 +595,13 @@ def test_404_follows_error_envelope(self, authed_client: APIClient): assert body["errors"][0]["code"] == "not_found" @pytest.mark.django_db - def test_400_validation_error_follows_envelope(self, authed_client: APIClient): + def test_400_validation_error_follows_envelope(self, authed_client: APIClient, event: Event): """ 400 유효성 검사 에러가 공통 에러 envelope 포맷을 따르는지 검증합니다. """ response = authed_client.post( - list_url(), - data={"presentation_id": "invalid"}, + list_url(event.id), + data={"presentation": "invalid"}, format="json", ) diff --git a/app/event/presentation/urls.py b/app/event/presentation/urls.py index df73b04..7118cb6 100644 --- a/app/event/presentation/urls.py +++ b/app/event/presentation/urls.py @@ -7,9 +7,12 @@ cms_router.register("", views.PresentationViewSet, basename="presentation") bookmark_router = routers.SimpleRouter() -bookmark_router.register("bookmarks", views.PresentationBookmarkViewSet, basename="presentation-bookmark") +bookmark_router.register("", views.PresentationBookmarkViewSet, basename="presentation-bookmark") urlpatterns = [ - path("", include(bookmark_router.urls)), 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 a4f4c8d..babea15 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,5 +1,6 @@ from core.const.tag import OpenAPITag -from django.db.models import Q +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 ( @@ -16,11 +17,7 @@ PresentationCategorySerializer, PresentationSerializer, ) -from rest_framework import mixins, status, viewsets -from rest_framework.exceptions import NotFound -from rest_framework.permissions import IsAuthenticated -from rest_framework.request import Request -from rest_framework.response import Response +from rest_framework import exceptions, mixins, permissions, request, response, status, viewsets @method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) @@ -39,21 +36,26 @@ class PresentationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view @extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION_BOOKMARK]) class PresentationBookmarkViewSet(viewsets.GenericViewSet): - permission_classes = [IsAuthenticated] + queryset = PresentationBookmark.objects.all() + permission_classes = [permissions.IsAuthenticated] serializer_class = PresentationBookmarkRequestSerializer + lookup_field = "presentation_id" - def _resolve_event(self, request: Request) -> Event: - event_name = request.query_params.get("event") - if not event_name: - event = Event.objects.filter_active().first() - if not event: - raise NotFound("해당 행사 정보가 없습니다.") - return event + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + get_object_or_404(Event, id=self.kwargs["event_id"]) - event = Event.objects.filter_active().filter(Q(name_ko=event_name) | Q(name_en=event_name)).first() - if not event: - raise NotFound("해당 행사 정보가 없습니다.") - return event + 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="북마크 목록 조회", @@ -64,14 +66,10 @@ def _resolve_event(self, request: Request) -> Event: status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, }, ) - def list(self, request: Request) -> Response: - event = self._resolve_event(request) - presentation_ids = list( - PresentationBookmark.objects.filter(user=request.user, event=event).values_list( - "presentation_id", flat=True - ) - ) - return Response({"presentation_ids": presentation_ids}) + 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="북마크 추가", @@ -83,38 +81,13 @@ def list(self, request: Request) -> Response: status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, }, ) - def create(self, request: Request) -> Response: - serializer = PresentationBookmarkRequestSerializer(data=request.data) + def create(self, request: request.Request, **kwargs) -> response.Response: + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - presentation_id = serializer.validated_data["presentation_id"] - - presentation = Presentation.objects.filter_active().filter(id=presentation_id).first() - if not presentation: - raise NotFound("해당 세션 정보가 없습니다.") + instance, created = serializer.create(serializer.validated_data) + serializer.save() - event = presentation.type.event - _, created = PresentationBookmark.objects.get_or_create( - user=request.user, - presentation=presentation, - defaults={"event": event}, - ) - return Response( - {"presentation_id": str(presentation.id)}, + return response.Response( + {"presentation_id": str(instance.presentation_id)}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, ) - - @extend_schema( - summary="북마크 삭제", - responses={ - status.HTTP_204_NO_CONTENT: None, - status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer, - status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, - }, - ) - def destroy(self, request: Request, pk: str = None) -> Response: - presentation = Presentation.objects.filter_active().filter(id=pk).first() - if not presentation: - raise NotFound("해당 세션 정보가 없습니다.") - - PresentationBookmark.objects.filter(user=request.user, presentation=presentation).delete() - return Response(status=status.HTTP_204_NO_CONTENT)