Skip to content

Commit 23f15e4

Browse files
committed
fix: 문서 생성 유니크 제약 충돌을 도메인 오류로 처리
1 parent 1956c36 commit 23f15e4

4 files changed

Lines changed: 120 additions & 4 deletions

File tree

src/main/java/io/ejangs/docsa/domain/doc/app/DocReader.java

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@
88
import io.ejangs.docsa.global.exception.errorcode.DocErrorCode;
99
import io.ejangs.docsa.global.exception.errorcode.UserErrorCode;
1010
import lombok.RequiredArgsConstructor;
11+
import org.hibernate.exception.ConstraintViolationException;
12+
import org.springframework.dao.DataIntegrityViolationException;
1113
import org.springframework.stereotype.Service;
1214
import org.springframework.transaction.annotation.Transactional;
1315

1416
@Service
1517
@RequiredArgsConstructor
1618
public class DocReader {
1719

20+
private static final String DOC_TITLE_UNIQUE_CONSTRAINT = "uk_user_title";
21+
1822
private final UserRepository userRepository;
1923
private final DocRepository docRepository;
2024

@@ -26,10 +30,17 @@ public User getUserOrThrow(Long userId) {
2630
}
2731

2832
public Doc create(User user, String title) {
29-
Doc doc = docRepository.save(Doc.builder().title(title).user(user).build());
30-
docRepository.flush();
31-
user.addDocument(doc);
32-
return doc;
33+
try {
34+
Doc doc = docRepository.save(Doc.builder().title(title).user(user).build());
35+
docRepository.flush();
36+
user.addDocument(doc);
37+
return doc;
38+
} catch (DataIntegrityViolationException e) {
39+
if (isTitleDuplicate(e)) {
40+
throw new CustomException(DocErrorCode.TITLE_DUPLICATION);
41+
}
42+
throw e;
43+
}
3344
}
3445

3546
public void checkTitleDuplicate(Long userId, String title) {
@@ -57,4 +68,13 @@ public void checkByIdAndUserId(Long docId, Long userId) {
5768
}
5869
}
5970

71+
private boolean isTitleDuplicate(DataIntegrityViolationException e) {
72+
if (!(e.getCause() instanceof ConstraintViolationException constraintViolationException)) {
73+
return false;
74+
}
75+
76+
String constraintName = constraintViolationException.getConstraintName();
77+
return constraintName != null && constraintName.endsWith(DOC_TITLE_UNIQUE_CONSTRAINT);
78+
}
79+
6080
}

src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ public DocCreateResponse create(String title, User user) {
3030

3131
try {
3232
return docCreateMySqlTxService.createMySqlPart(title, user, saveContentId);
33+
} catch (CustomException e) {
34+
log.warn("[SAGA] 문서 생성 실패 -> Mongo 삭제 Outbox 기록.", e);
35+
compensateMongo(saveContentId);
36+
throw e;
3337
} catch (Exception e) {
3438
log.warn("[SAGA] 문서 생성 실패 -> Mongo 삭제 Outbox 기록.", e);
3539
compensateMongo(saveContentId);

src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCreateOrchestratorTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,28 @@ void create_fail_compensateMongo() {
8282
&& ids.blockIds().isEmpty())
8383
);
8484
}
85+
86+
@Test
87+
@DisplayName("문서 생성 Saga 실패 - MySQL 파트에서 CustomException 발생 시 보상 삭제 후 그대로 전파한다")
88+
void create_fail_rethrowCustomException() {
89+
User user = org.mockito.Mockito.mock(User.class);
90+
SaveContent saved = SaveContent.builder().build();
91+
ReflectionTestUtils.setField(saved, "id", "save-1");
92+
93+
when(saveWriter.createSaveContent()).thenReturn(saved);
94+
when(docCreateMySqlTxService.createMySqlPart("doc", user, "save-1"))
95+
.thenThrow(new CustomException(DocErrorCode.TITLE_DUPLICATION));
96+
97+
assertThatThrownBy(() -> orchestrator.create("doc", user))
98+
.isInstanceOf(CustomException.class)
99+
.hasMessage(DocErrorCode.TITLE_DUPLICATION.getMessage());
100+
101+
verify(mongoDeleteJobEnqueuer).enqueueDocCreateCompensation(
102+
eq("save-1"),
103+
argThat(ids ->
104+
ids.saveContentsIds().contains("save-1")
105+
&& ids.commitBlockSequenceIds().isEmpty()
106+
&& ids.blockIds().isEmpty())
107+
);
108+
}
85109
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package io.ejangs.docsa.domain.doc.app.unit;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
import static org.mockito.Mockito.verify;
6+
import static org.mockito.Mockito.when;
7+
8+
import io.ejangs.docsa.domain.doc.app.DocReader;
9+
import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository;
10+
import io.ejangs.docsa.domain.doc.entity.Doc;
11+
import io.ejangs.docsa.domain.doc.util.DocTestUtils;
12+
import io.ejangs.docsa.domain.user.dao.mysql.UserRepository;
13+
import io.ejangs.docsa.domain.user.entity.User;
14+
import io.ejangs.docsa.global.exception.CustomException;
15+
import io.ejangs.docsa.global.exception.errorcode.DocErrorCode;
16+
import org.hibernate.exception.ConstraintViolationException;
17+
import org.junit.jupiter.api.DisplayName;
18+
import org.junit.jupiter.api.Test;
19+
import org.junit.jupiter.api.extension.ExtendWith;
20+
import org.mockito.InjectMocks;
21+
import org.mockito.Mock;
22+
import org.mockito.junit.jupiter.MockitoExtension;
23+
import org.springframework.dao.DataIntegrityViolationException;
24+
25+
@ExtendWith(MockitoExtension.class)
26+
class DocReaderUnitTest {
27+
28+
@Mock
29+
private UserRepository userRepository;
30+
31+
@Mock
32+
private DocRepository docRepository;
33+
34+
@InjectMocks
35+
private DocReader docReader;
36+
37+
@Test
38+
@DisplayName("문서 생성 성공 - 저장 후 flush하고 유저에 문서를 연결한다")
39+
void create_success() {
40+
User user = DocTestUtils.createUser();
41+
Doc doc = Doc.builder().title("doc").user(user).build();
42+
43+
when(docRepository.save(org.mockito.ArgumentMatchers.any(Doc.class))).thenReturn(doc);
44+
45+
Doc result = docReader.create(user, "doc");
46+
47+
assertThat(result).isEqualTo(doc);
48+
assertThat(user.getDocs()).contains(doc);
49+
verify(docRepository).flush();
50+
}
51+
52+
@Test
53+
@DisplayName("문서 생성 실패 - 제목 유니크 제약 충돌이면 TITLE_DUPLICATION을 반환한다")
54+
void create_fail_duplicateTitleConstraint() {
55+
User user = DocTestUtils.createUser();
56+
ConstraintViolationException cause = new ConstraintViolationException(
57+
"Duplicate entry '1-doc' for key 'docs.uk_user_title'",
58+
null,
59+
"docs.uk_user_title");
60+
61+
when(docRepository.save(org.mockito.ArgumentMatchers.any(Doc.class)))
62+
.thenThrow(new DataIntegrityViolationException("could not execute statement", cause));
63+
64+
assertThatThrownBy(() -> docReader.create(user, "doc"))
65+
.isInstanceOf(CustomException.class)
66+
.hasMessage(DocErrorCode.TITLE_DUPLICATION.getMessage());
67+
}
68+
}

0 commit comments

Comments
 (0)