Skip to content

Commit

Permalink
좋아요와 좋아요 취소 요청을 보낼 수 있다. (#29)
Browse files Browse the repository at this point in the history
* refactor: mutation 시 isPending으로 중복 제출 방지

* feat: reaction entity 구현

* feat: like service 구현

* refactor: 좋아요하지 않은 상태에서 좋아요 시 예외 처리 변경

* refactor: Like, LikeTarget dto 생성

* feat: exma like, unlike api 구현

* test: exam like document test 작성

* feat: reaction ddl
  • Loading branch information
alstn113 authored Jan 15, 2025
1 parent a094746 commit af6c417
Show file tree
Hide file tree
Showing 20 changed files with 343 additions and 8 deletions.
9 changes: 9 additions & 0 deletions server/src/docs/asciidoc/exam-like.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
== Exam Like API

=== 시험 좋아요 등록

operation::exam-like-document-test/like[]

=== 시험 좋아요 취소

operation::exam-like-document-test/unlike[]
3 changes: 2 additions & 1 deletion server/src/docs/asciidoc/exam.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ operation::exam-document-test/update-title[]

=== 시험 설명 수정

operation::exam-document-test/update-description[]
operation::exam-document-test/update-description[]

4 changes: 4 additions & 0 deletions server/src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
= Fluffy API 명세서

include::auth.adoc[]

include::oauth2.adoc[]

include::exam.adoc[]
include::exam-like.adoc[]

include::submission.adoc[]
38 changes: 38 additions & 0 deletions server/src/main/java/com/fluffy/exam/api/ExamLikeController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.fluffy.exam.api;

import com.fluffy.exam.application.ExamLikeService;
import com.fluffy.global.web.Accessor;
import com.fluffy.global.web.Auth;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class ExamLikeController {

private final ExamLikeService examLikeService;

@PostMapping("/api/v1/exams/{examId}/like")
public ResponseEntity<Void> like(
@PathVariable Long examId,
@Auth Accessor accessor
) {
examLikeService.like(examId, accessor);

return ResponseEntity.ok().build();
}

@DeleteMapping("/api/v1/exams/{examId}/like")
public ResponseEntity<Void> unlike(
@PathVariable Long examId,
@Auth Accessor accessor
) {
examLikeService.unlike(examId, accessor);

return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.fluffy.exam.application;

import com.fluffy.global.web.Accessor;
import com.fluffy.reaction.application.Like;
import com.fluffy.reaction.application.LikeService;
import com.fluffy.reaction.application.LikeTarget;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class ExamLikeService {

private final LikeService likeService;

@Transactional
public Long like(Long examId, Accessor accessor) {
return likeService.like(new Like(LikeTarget.EXAM, examId), accessor.id());
}

@Transactional
public Long unlike(Long examId, Accessor accessor) {
return likeService.removeLike(new Like(LikeTarget.EXAM, examId), accessor.id());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fluffy.reaction.application;

public record Like(LikeTarget target, Long targetId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.fluffy.reaction.application;

import com.fluffy.global.exception.BadRequestException;
import com.fluffy.reaction.domain.Reaction;
import com.fluffy.reaction.domain.ReactionRepository;
import com.fluffy.reaction.domain.ReactionType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class LikeService {

private final ReactionRepository reactionRepository;

@Transactional
public Long like(Like like, Long memberId) {
String targetType = like.target().name();
Long targetId = like.targetId();

Reaction reaction = reactionRepository.findByTargetTypeAndTargetIdAndMemberId(targetType, targetId, memberId)
.orElse(reactionRepository.save(new Reaction(targetType, targetId, memberId, ReactionType.LIKE)));

reaction.active();

return reaction.getId();
}

@Transactional
public Long removeLike(Like like, Long memberId) {
String targetType = like.target().name();
Long targetId = like.targetId();

Reaction reaction = reactionRepository.findByTargetTypeAndTargetIdAndMemberId(targetType, targetId, memberId)
.orElseThrow(() -> new BadRequestException("좋아요를 한 상태가 아닙니다."));

reaction.delete();

return reaction.getId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.fluffy.reaction.application;

public enum LikeTarget {

EXAM
}
72 changes: 72 additions & 0 deletions server/src/main/java/com/fluffy/reaction/domain/Reaction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.fluffy.reaction.domain;

import com.fluffy.global.persistence.AuditableEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(uniqueConstraints = {
@jakarta.persistence.UniqueConstraint(columnNames = {"targetType", "targetId", "memberId", "type"})
})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Reaction extends AuditableEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String targetType;

@Column(nullable = false)
private Long targetId;

@Column(nullable = false)
private Long memberId;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ReactionType type;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ReactionStatus status;

public Reaction(String targetType, Long targetId, Long memberId, ReactionType type) {
this(null, targetType, targetId, memberId, type, ReactionStatus.ACTIVE);
}

public Reaction(
Long id,
String targetType,
Long targetId,
Long memberId,
ReactionType type,
ReactionStatus status
) {
this.id = id;
this.targetType = targetType;
this.targetId = targetId;
this.memberId = memberId;
this.type = type;
this.status = status;
}

public void active() {
this.status = ReactionStatus.ACTIVE;
}

public void delete() {
this.status = ReactionStatus.DELETED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.fluffy.reaction.domain;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ReactionRepository extends JpaRepository<Reaction, Long> {

Optional<Reaction> findByTargetTypeAndTargetIdAndMemberId(String targetType, Long targetId, Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.fluffy.reaction.domain;

public enum ReactionStatus {

ACTIVE, DELETED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.fluffy.reaction.domain;

public enum ReactionType {

LIKE
}
18 changes: 18 additions & 0 deletions server/src/main/resources/db/migration/V5__add_reaction_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE reaction
(
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
targetType VARCHAR(20) NOT NULL,
targetId BIGINT NOT NULL,
memberId BIGINT NOT NULL,
type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (id)
);

ALTER TABLE reaction
ADD CONSTRAINT fk_member FOREIGN KEY (memberId) REFERENCES member (id);

ALTER TABLE reaction
ADD CONSTRAINT unique_reaction UNIQUE (targetType, targetId, memberId, type);
42 changes: 42 additions & 0 deletions server/src/test/java/com/fluffy/exam/api/ExamLikeDocumentTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.fluffy.exam.api;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.fluffy.support.AbstractDocumentTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class ExamLikeDocumentTest extends AbstractDocumentTest {

@Test
@DisplayName("시험에 좋아요를 할 수 있다.")
void like() throws Exception {
mockMvc.perform(post("/api/v1/exams/{examId}/like", 1)
.cookie(new Cookie("accessToken", "{ACCESS_TOKEN}")))
.andExpect(status().isOk())
.andDo(restDocs.document(
pathParameters(
parameterWithName("examId").description("시험 ID")
)
));
}

@Test
@DisplayName("시험에 좋아요를 취소할 수 있다.")
void unlike() throws Exception {
mockMvc.perform(post("/api/v1/exams/{examId}/like", 1)
.cookie(new Cookie("accessToken", "{ACCESS_TOKEN}")))
.andExpect(status().isOk())
.andDo(restDocs.document(
pathParameters(
parameterWithName("examId").description("시험 ID")
)
));
}
}
45 changes: 45 additions & 0 deletions server/src/test/java/com/fluffy/reaction/domain/ReactionTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.fluffy.reaction.domain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class ReactionTest {

@Test
@DisplayName("반응을 정상적으로 생성할 수 있다.")
void create() {
// when & then
assertThatCode(() -> new Reaction("EXAM", 1L, 1L, ReactionType.LIKE))
.doesNotThrowAnyException();
}

@Test
@DisplayName("반응을 삭제 상태로 변경할 수 있다.")
void delete() {
// given
Reaction reaction = new Reaction("EXAM", 1L, 1L, ReactionType.LIKE);

// when
reaction.delete();

// then
assertThat(reaction.getStatus()).isEqualTo(ReactionStatus.DELETED);
}

@Test
@DisplayName("삭제 상태의 반응을 활성 상태로 변경할 수 있다.")
void active() {
// given
Reaction reaction = new Reaction("EXAM", 1L, 1L, ReactionType.LIKE);
reaction.delete();

// when
reaction.active();

// then
assertThat(reaction.getStatus()).isEqualTo(ReactionStatus.ACTIVE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import com.fluffy.auth.api.AuthController;
import com.fluffy.auth.application.AuthService;
import com.fluffy.exam.api.ExamController;
import com.fluffy.exam.api.ExamLikeController;
import com.fluffy.exam.application.ExamLikeService;
import com.fluffy.exam.application.ExamQueryService;
import com.fluffy.exam.application.ExamService;
import com.fluffy.global.web.Accessor;
Expand All @@ -27,6 +29,7 @@
@WebMvcTest({
AuthController.class,
ExamController.class,
ExamLikeController.class,
OAuth2Controller.class,
SubmissionController.class,
})
Expand All @@ -50,6 +53,9 @@ public abstract class AbstractControllerTest {
@MockBean
protected ExamService examService;

@MockBean
protected ExamLikeService examLikeService;

@MockBean
protected ExamQueryService examQueryService;

Expand Down
4 changes: 2 additions & 2 deletions web/src/components/exams/MoveQuestionButtonGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const MoveQuestionButtonGroup = () => {
const params = useParams() as { examId: string };
const examId = Number(params.examId);
const navigate = useNavigate();
const { mutate } = useCreateSubmission();
const { mutate, isPending } = useCreateSubmission();
const { questionResponses } = useSubmissionStore();

const handleSubmit = () => {
Expand Down Expand Up @@ -77,7 +77,7 @@ const MoveQuestionButtonGroup = () => {
<Button color="danger" variant="light" onPress={onClose}>
닫기
</Button>
<Button color="primary" onPress={handleSubmit}>
<Button color="primary" onPress={handleSubmit} isLoading={isPending}>
확인
</Button>
</ModalFooter>
Expand Down
Loading

0 comments on commit af6c417

Please sign in to comment.