Skip to content

Commit 5d9e535

Browse files
authored
Fix email sending failure due to lazy-loaded bookFiles outside session (#2404)
Co-authored-by: acx10 <acx10@users.noreply.github.com>
1 parent 2cd2e7b commit 5d9e535

File tree

2 files changed

+339
-2
lines changed

2 files changed

+339
-2
lines changed

booklore-api/src/main/java/com/adityachandel/booklore/service/email/SendEmailV2Service.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public class SendEmailV2Service {
4343

4444
public void emailBookQuick(Long bookId) {
4545
BookLoreUser user = authenticationService.getAuthenticatedUser();
46-
BookEntity book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
46+
BookEntity book = bookRepository.findByIdWithBookFiles(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
4747
EmailProviderV2Entity defaultEmailProvider = getDefaultEmailProvider();
4848
EmailRecipientV2Entity defaultEmailRecipient = emailRecipientRepository.findDefaultEmailRecipientByUserId(user.getId()).orElseThrow(ApiError.DEFAULT_EMAIL_RECIPIENT_NOT_FOUND::createException);
4949
sendEmailInVirtualThread(defaultEmailProvider, defaultEmailRecipient.getEmail(), book);
@@ -56,7 +56,7 @@ public void emailBook(SendBookByEmailRequest request) {
5656
emailProviderRepository.findSharedProviderById(request.getProviderId())
5757
.orElseThrow(() -> ApiError.EMAIL_PROVIDER_NOT_FOUND.createException(request.getProviderId()))
5858
);
59-
BookEntity book = bookRepository.findById(request.getBookId()).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId()));
59+
BookEntity book = bookRepository.findByIdWithBookFiles(request.getBookId()).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId()));
6060
EmailRecipientV2Entity emailRecipient = emailRecipientRepository.findByIdAndUserId(request.getRecipientId(), user.getId()).orElseThrow(() -> ApiError.EMAIL_RECIPIENT_NOT_FOUND.createException(request.getRecipientId()));
6161
sendEmailInVirtualThread(emailProvider, emailRecipient.getEmail(), book);
6262
}
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
package com.adityachandel.booklore.service.email;
2+
3+
import com.adityachandel.booklore.config.security.service.AuthenticationService;
4+
import com.adityachandel.booklore.exception.APIException;
5+
import com.adityachandel.booklore.model.dto.BookLoreUser;
6+
import com.adityachandel.booklore.model.dto.request.SendBookByEmailRequest;
7+
import com.adityachandel.booklore.model.entity.*;
8+
import com.adityachandel.booklore.repository.BookRepository;
9+
import com.adityachandel.booklore.repository.EmailProviderV2Repository;
10+
import com.adityachandel.booklore.repository.EmailRecipientV2Repository;
11+
import com.adityachandel.booklore.repository.UserEmailProviderPreferenceRepository;
12+
import com.adityachandel.booklore.service.NotificationService;
13+
import com.adityachandel.booklore.util.FileUtils;
14+
import com.adityachandel.booklore.util.SecurityContextVirtualThread;
15+
import org.junit.jupiter.api.BeforeEach;
16+
import org.junit.jupiter.api.Test;
17+
import org.junit.jupiter.api.extension.ExtendWith;
18+
import org.mockito.InjectMocks;
19+
import org.mockito.Mock;
20+
import org.mockito.MockedStatic;
21+
import org.mockito.junit.jupiter.MockitoExtension;
22+
23+
import java.util.List;
24+
import java.util.Optional;
25+
26+
import static org.junit.jupiter.api.Assertions.assertThrows;
27+
import static org.mockito.Mockito.*;
28+
29+
@ExtendWith(MockitoExtension.class)
30+
class SendEmailV2ServiceTest {
31+
32+
@Mock
33+
private EmailProviderV2Repository emailProviderRepository;
34+
35+
@Mock
36+
private UserEmailProviderPreferenceRepository preferenceRepository;
37+
38+
@Mock
39+
private BookRepository bookRepository;
40+
41+
@Mock
42+
private EmailRecipientV2Repository emailRecipientRepository;
43+
44+
@Mock
45+
private NotificationService notificationService;
46+
47+
@Mock
48+
private AuthenticationService authenticationService;
49+
50+
@InjectMocks
51+
private SendEmailV2Service sendEmailV2Service;
52+
53+
private BookLoreUser user;
54+
private BookEntity book;
55+
private EmailProviderV2Entity emailProvider;
56+
private EmailRecipientV2Entity emailRecipient;
57+
private UserEmailProviderPreferenceEntity preference;
58+
59+
@BeforeEach
60+
void setUp() {
61+
user = BookLoreUser.builder().id(1L).username("testuser").build();
62+
63+
BookMetadataEntity metadata = BookMetadataEntity.builder()
64+
.title("Test Book")
65+
.build();
66+
67+
LibraryPathEntity libraryPath = new LibraryPathEntity();
68+
libraryPath.setPath("/library");
69+
70+
BookFileEntity bookFile = new BookFileEntity();
71+
bookFile.setFileName("test-book.epub");
72+
bookFile.setFileSubPath("books");
73+
bookFile.setBookFormat(true);
74+
75+
book = new BookEntity();
76+
book.setId(10L);
77+
book.setMetadata(metadata);
78+
book.setLibraryPath(libraryPath);
79+
book.setBookFiles(List.of(bookFile));
80+
81+
emailProvider = EmailProviderV2Entity.builder()
82+
.id(100L)
83+
.userId(1L)
84+
.name("Test Provider")
85+
.host("smtp.test.com")
86+
.port(587)
87+
.username("user@test.com")
88+
.password("password")
89+
.auth(true)
90+
.startTls(true)
91+
.build();
92+
93+
emailRecipient = EmailRecipientV2Entity.builder()
94+
.id(200L)
95+
.userId(1L)
96+
.email("recipient@test.com")
97+
.name("Test Recipient")
98+
.defaultRecipient(true)
99+
.build();
100+
101+
preference = UserEmailProviderPreferenceEntity.builder()
102+
.id(1L)
103+
.userId(1L)
104+
.defaultProviderId(100L)
105+
.build();
106+
}
107+
108+
@Test
109+
void emailBookQuick_success() {
110+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
111+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.of(book));
112+
when(preferenceRepository.findByUserId(1L)).thenReturn(Optional.of(preference));
113+
when(emailProviderRepository.findAccessibleProvider(100L, 1L)).thenReturn(Optional.of(emailProvider));
114+
when(emailRecipientRepository.findDefaultEmailRecipientByUserId(1L)).thenReturn(Optional.of(emailRecipient));
115+
116+
try (MockedStatic<SecurityContextVirtualThread> securityMock = mockStatic(SecurityContextVirtualThread.class)) {
117+
securityMock.when(() -> SecurityContextVirtualThread.runWithSecurityContext(any(Runnable.class)))
118+
.thenAnswer(invocation -> {
119+
Runnable task = invocation.getArgument(0);
120+
task.run();
121+
return null;
122+
});
123+
124+
try (MockedStatic<FileUtils> fileUtilsMock = mockStatic(FileUtils.class)) {
125+
fileUtilsMock.when(() -> FileUtils.getBookFullPath(book)).thenReturn("/library/books/test-book.epub");
126+
127+
sendEmailV2Service.emailBookQuick(10L);
128+
129+
securityMock.verify(() -> SecurityContextVirtualThread.runWithSecurityContext(any(Runnable.class)));
130+
verify(notificationService, atLeastOnce()).sendMessage(any(), any());
131+
}
132+
}
133+
}
134+
135+
@Test
136+
void emailBookQuick_bookNotFound() {
137+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
138+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.empty());
139+
140+
assertThrows(APIException.class, () -> sendEmailV2Service.emailBookQuick(10L));
141+
}
142+
143+
@Test
144+
void emailBookQuick_defaultProviderNotFound_noPreference() {
145+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
146+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.of(book));
147+
when(preferenceRepository.findByUserId(1L)).thenReturn(Optional.empty());
148+
149+
assertThrows(APIException.class, () -> sendEmailV2Service.emailBookQuick(10L));
150+
}
151+
152+
@Test
153+
void emailBookQuick_defaultProviderNotFound_providerMissing() {
154+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
155+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.of(book));
156+
when(preferenceRepository.findByUserId(1L)).thenReturn(Optional.of(preference));
157+
when(emailProviderRepository.findAccessibleProvider(100L, 1L)).thenReturn(Optional.empty());
158+
159+
assertThrows(APIException.class, () -> sendEmailV2Service.emailBookQuick(10L));
160+
}
161+
162+
@Test
163+
void emailBookQuick_defaultRecipientNotFound() {
164+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
165+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.of(book));
166+
when(preferenceRepository.findByUserId(1L)).thenReturn(Optional.of(preference));
167+
when(emailProviderRepository.findAccessibleProvider(100L, 1L)).thenReturn(Optional.of(emailProvider));
168+
when(emailRecipientRepository.findDefaultEmailRecipientByUserId(1L)).thenReturn(Optional.empty());
169+
170+
assertThrows(APIException.class, () -> sendEmailV2Service.emailBookQuick(10L));
171+
}
172+
173+
@Test
174+
void emailBook_success_userOwnedProvider() {
175+
SendBookByEmailRequest request = SendBookByEmailRequest.builder()
176+
.bookId(10L)
177+
.providerId(100L)
178+
.recipientId(200L)
179+
.build();
180+
181+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
182+
when(emailProviderRepository.findByIdAndUserId(100L, 1L)).thenReturn(Optional.of(emailProvider));
183+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.of(book));
184+
when(emailRecipientRepository.findByIdAndUserId(200L, 1L)).thenReturn(Optional.of(emailRecipient));
185+
186+
try (MockedStatic<SecurityContextVirtualThread> securityMock = mockStatic(SecurityContextVirtualThread.class)) {
187+
securityMock.when(() -> SecurityContextVirtualThread.runWithSecurityContext(any(Runnable.class)))
188+
.thenAnswer(invocation -> {
189+
Runnable task = invocation.getArgument(0);
190+
task.run();
191+
return null;
192+
});
193+
194+
try (MockedStatic<FileUtils> fileUtilsMock = mockStatic(FileUtils.class)) {
195+
fileUtilsMock.when(() -> FileUtils.getBookFullPath(book)).thenReturn("/library/books/test-book.epub");
196+
197+
sendEmailV2Service.emailBook(request);
198+
199+
securityMock.verify(() -> SecurityContextVirtualThread.runWithSecurityContext(any(Runnable.class)));
200+
verify(notificationService, atLeastOnce()).sendMessage(any(), any());
201+
}
202+
}
203+
}
204+
205+
@Test
206+
void emailBook_success_sharedProvider() {
207+
SendBookByEmailRequest request = SendBookByEmailRequest.builder()
208+
.bookId(10L)
209+
.providerId(100L)
210+
.recipientId(200L)
211+
.build();
212+
213+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
214+
when(emailProviderRepository.findByIdAndUserId(100L, 1L)).thenReturn(Optional.empty());
215+
when(emailProviderRepository.findSharedProviderById(100L)).thenReturn(Optional.of(emailProvider));
216+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.of(book));
217+
when(emailRecipientRepository.findByIdAndUserId(200L, 1L)).thenReturn(Optional.of(emailRecipient));
218+
219+
try (MockedStatic<SecurityContextVirtualThread> securityMock = mockStatic(SecurityContextVirtualThread.class)) {
220+
securityMock.when(() -> SecurityContextVirtualThread.runWithSecurityContext(any(Runnable.class)))
221+
.thenAnswer(invocation -> {
222+
Runnable task = invocation.getArgument(0);
223+
task.run();
224+
return null;
225+
});
226+
227+
try (MockedStatic<FileUtils> fileUtilsMock = mockStatic(FileUtils.class)) {
228+
fileUtilsMock.when(() -> FileUtils.getBookFullPath(book)).thenReturn("/library/books/test-book.epub");
229+
230+
sendEmailV2Service.emailBook(request);
231+
232+
securityMock.verify(() -> SecurityContextVirtualThread.runWithSecurityContext(any(Runnable.class)));
233+
verify(notificationService, atLeastOnce()).sendMessage(any(), any());
234+
}
235+
}
236+
}
237+
238+
@Test
239+
void emailBook_providerNotFound() {
240+
SendBookByEmailRequest request = SendBookByEmailRequest.builder()
241+
.bookId(10L)
242+
.providerId(100L)
243+
.recipientId(200L)
244+
.build();
245+
246+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
247+
when(emailProviderRepository.findByIdAndUserId(100L, 1L)).thenReturn(Optional.empty());
248+
when(emailProviderRepository.findSharedProviderById(100L)).thenReturn(Optional.empty());
249+
250+
assertThrows(APIException.class, () -> sendEmailV2Service.emailBook(request));
251+
}
252+
253+
@Test
254+
void emailBook_bookNotFound() {
255+
SendBookByEmailRequest request = SendBookByEmailRequest.builder()
256+
.bookId(10L)
257+
.providerId(100L)
258+
.recipientId(200L)
259+
.build();
260+
261+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
262+
when(emailProviderRepository.findByIdAndUserId(100L, 1L)).thenReturn(Optional.of(emailProvider));
263+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.empty());
264+
265+
assertThrows(APIException.class, () -> sendEmailV2Service.emailBook(request));
266+
}
267+
268+
@Test
269+
void emailBook_recipientNotFound() {
270+
SendBookByEmailRequest request = SendBookByEmailRequest.builder()
271+
.bookId(10L)
272+
.providerId(100L)
273+
.recipientId(200L)
274+
.build();
275+
276+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
277+
when(emailProviderRepository.findByIdAndUserId(100L, 1L)).thenReturn(Optional.of(emailProvider));
278+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.of(book));
279+
when(emailRecipientRepository.findByIdAndUserId(200L, 1L)).thenReturn(Optional.empty());
280+
281+
assertThrows(APIException.class, () -> sendEmailV2Service.emailBook(request));
282+
}
283+
284+
@Test
285+
void emailBookQuick_sendEmailFailure_logsError() {
286+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
287+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.of(book));
288+
when(preferenceRepository.findByUserId(1L)).thenReturn(Optional.of(preference));
289+
when(emailProviderRepository.findAccessibleProvider(100L, 1L)).thenReturn(Optional.of(emailProvider));
290+
when(emailRecipientRepository.findDefaultEmailRecipientByUserId(1L)).thenReturn(Optional.of(emailRecipient));
291+
292+
try (MockedStatic<SecurityContextVirtualThread> securityMock = mockStatic(SecurityContextVirtualThread.class)) {
293+
securityMock.when(() -> SecurityContextVirtualThread.runWithSecurityContext(any(Runnable.class)))
294+
.thenAnswer(invocation -> {
295+
Runnable task = invocation.getArgument(0);
296+
task.run();
297+
return null;
298+
});
299+
300+
try (MockedStatic<FileUtils> fileUtilsMock = mockStatic(FileUtils.class)) {
301+
fileUtilsMock.when(() -> FileUtils.getBookFullPath(book))
302+
.thenThrow(new IllegalStateException("Book file not found"));
303+
304+
sendEmailV2Service.emailBookQuick(10L);
305+
306+
// Error is caught and logged, not rethrown
307+
verify(notificationService, atLeastOnce()).sendMessage(any(), any());
308+
}
309+
}
310+
}
311+
312+
@Test
313+
void emailBook_notificationSentBeforeVirtualThread() {
314+
SendBookByEmailRequest request = SendBookByEmailRequest.builder()
315+
.bookId(10L)
316+
.providerId(100L)
317+
.recipientId(200L)
318+
.build();
319+
320+
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
321+
when(emailProviderRepository.findByIdAndUserId(100L, 1L)).thenReturn(Optional.of(emailProvider));
322+
when(bookRepository.findByIdWithBookFiles(10L)).thenReturn(Optional.of(book));
323+
when(emailRecipientRepository.findByIdAndUserId(200L, 1L)).thenReturn(Optional.of(emailRecipient));
324+
325+
try (MockedStatic<SecurityContextVirtualThread> securityMock = mockStatic(SecurityContextVirtualThread.class)) {
326+
// Don't execute the runnable - just capture it
327+
securityMock.when(() -> SecurityContextVirtualThread.runWithSecurityContext(any(Runnable.class)))
328+
.thenAnswer(invocation -> null);
329+
330+
sendEmailV2Service.emailBook(request);
331+
332+
// Log notification is sent before the virtual thread starts
333+
verify(notificationService).sendMessage(any(), any());
334+
securityMock.verify(() -> SecurityContextVirtualThread.runWithSecurityContext(any(Runnable.class)));
335+
}
336+
}
337+
}

0 commit comments

Comments
 (0)