Skip to content

Commit f70ff88

Browse files
mpgecursoragent
andcommitted
test(newsletter): add engine and public controller coverage
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e75c229 commit f70ff88

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package dev.escalated.controllers.newsletter;
2+
3+
import static org.mockito.ArgumentMatchers.anyString;
4+
import static org.mockito.Mockito.verify;
5+
import static org.mockito.Mockito.when;
6+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
7+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
8+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
9+
10+
import dev.escalated.repositories.ContactRepository;
11+
import dev.escalated.repositories.NewsletterDeliveryRepository;
12+
import dev.escalated.repositories.NewsletterRepository;
13+
import dev.escalated.repositories.NewsletterTemplateRepository;
14+
import dev.escalated.services.newsletter.NewsletterRenderer;
15+
import dev.escalated.services.newsletter.NewsletterTracker;
16+
import java.util.Optional;
17+
import org.junit.jupiter.api.Test;
18+
import org.springframework.beans.factory.annotation.Autowired;
19+
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
20+
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
21+
import org.springframework.test.context.TestPropertySource;
22+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
23+
import org.springframework.test.web.servlet.MockMvc;
24+
25+
@WebMvcTest(NewsletterPublicController.class)
26+
@AutoConfigureMockMvc(addFilters = false)
27+
@TestPropertySource(properties = "escalated.newsletters.enabled=true")
28+
class NewsletterPublicControllerTest {
29+
30+
@Autowired
31+
private MockMvc mockMvc;
32+
33+
@MockitoBean
34+
private NewsletterTracker tracker;
35+
@MockitoBean
36+
private NewsletterRenderer renderer;
37+
@MockitoBean
38+
private NewsletterDeliveryRepository deliveries;
39+
@MockitoBean
40+
private NewsletterRepository newsletters;
41+
@MockitoBean
42+
private NewsletterTemplateRepository templates;
43+
@MockitoBean
44+
private ContactRepository contacts;
45+
46+
@Test
47+
void open_returnsPixelAndRecordsOpen() throws Exception {
48+
mockMvc.perform(get("/escalated/n/o/abc.gif"))
49+
.andExpect(status().isOk())
50+
.andExpect(header().string("Content-Type", "image/png"));
51+
verify(tracker).recordOpen("abc");
52+
}
53+
54+
@Test
55+
void view_returnsUnavailableHtmlWhenMissing() throws Exception {
56+
when(deliveries.findByTrackingToken(anyString())).thenReturn(Optional.empty());
57+
mockMvc.perform(get("/escalated/n/v/missing"))
58+
.andExpect(status().isOk());
59+
}
60+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package dev.escalated.services.newsletter;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
import static org.mockito.ArgumentMatchers.any;
7+
import static org.mockito.Mockito.never;
8+
import static org.mockito.Mockito.verify;
9+
import static org.mockito.Mockito.when;
10+
11+
import com.fasterxml.jackson.databind.ObjectMapper;
12+
import dev.escalated.config.EscalatedProperties;
13+
import dev.escalated.models.Contact;
14+
import dev.escalated.models.EscalatedSettings;
15+
import dev.escalated.models.newsletter.Newsletter;
16+
import dev.escalated.models.newsletter.NewsletterDelivery;
17+
import dev.escalated.models.newsletter.NewsletterList;
18+
import dev.escalated.models.newsletter.NewsletterListMember;
19+
import dev.escalated.repositories.ContactRepository;
20+
import dev.escalated.repositories.EscalatedSettingsRepository;
21+
import dev.escalated.repositories.NewsletterDeliveryRepository;
22+
import dev.escalated.repositories.NewsletterListMemberRepository;
23+
import dev.escalated.repositories.NewsletterListRepository;
24+
import dev.escalated.repositories.NewsletterRepository;
25+
import dev.escalated.repositories.NewsletterTemplateRepository;
26+
import java.time.Instant;
27+
import java.util.List;
28+
import java.util.Optional;
29+
import org.junit.jupiter.api.BeforeEach;
30+
import org.junit.jupiter.api.Test;
31+
import org.junit.jupiter.api.extension.ExtendWith;
32+
import org.mockito.ArgumentCaptor;
33+
import org.mockito.Mock;
34+
import org.mockito.junit.jupiter.MockitoExtension;
35+
import org.springframework.data.domain.Pageable;
36+
import org.springframework.mail.javamail.JavaMailSender;
37+
38+
@ExtendWith(MockitoExtension.class)
39+
class NewsletterEngineServiceTest {
40+
41+
@Mock private EscalatedSettingsRepository settingsRepository;
42+
@Mock private ContactRepository contactRepository;
43+
@Mock private NewsletterListMemberRepository memberRepository;
44+
@Mock private NewsletterRepository newsletterRepository;
45+
@Mock private NewsletterListRepository listRepository;
46+
@Mock private NewsletterDeliveryRepository deliveryRepository;
47+
@Mock private NewsletterTemplateRepository templateRepository;
48+
@Mock private JavaMailSender mailSender;
49+
50+
private EscalatedProperties properties;
51+
private ObjectMapper objectMapper;
52+
private BounceSuppressionStore bounces;
53+
private ContactSegmentResolver segments;
54+
private NewsletterTracker tracker;
55+
private NewsletterPlanner planner;
56+
private NewsletterDispatcher dispatcher;
57+
private NewsletterRenderer renderer;
58+
59+
@BeforeEach
60+
void setUp() {
61+
properties = new EscalatedProperties();
62+
properties.getNewsletters().setEnabled(true);
63+
objectMapper = new ObjectMapper();
64+
bounces = new BounceSuppressionStore(settingsRepository, objectMapper);
65+
segments = new ContactSegmentResolver(contactRepository, memberRepository, objectMapper);
66+
tracker = new NewsletterTracker(deliveryRepository, newsletterRepository, bounces);
67+
planner = new NewsletterPlanner(
68+
segments, bounces, newsletterRepository, listRepository, deliveryRepository, contactRepository);
69+
NewsletterRenderer.Options opts = new NewsletterRenderer.Options();
70+
opts.baseUrl = "http://localhost";
71+
opts.themesDir = "src/main/resources/templates/escalated/newsletter_themes";
72+
renderer = new NewsletterRenderer(opts);
73+
dispatcher = new NewsletterDispatcher(
74+
properties,
75+
newsletterRepository,
76+
deliveryRepository,
77+
templateRepository,
78+
contactRepository,
79+
renderer,
80+
mailSender);
81+
}
82+
83+
@Test
84+
void bounceStore_filtersCaseInsensitively() throws Exception {
85+
when(settingsRepository.findByKey(BounceSuppressionStore.KEY)).thenReturn(Optional.empty());
86+
when(settingsRepository.save(any())).thenAnswer(inv -> {
87+
EscalatedSettings row = inv.getArgument(0);
88+
row.setId(1L);
89+
return row;
90+
});
91+
bounces.markBounced("USER@Example.com");
92+
assertTrue(bounces.isBounced("user@example.com"));
93+
assertEquals(List.of("ok@example.com"), bounces.filterSendable(List.of("user@example.com", "ok@example.com")));
94+
}
95+
96+
@Test
97+
void segmentResolver_resolvesStaticMembersAndSendable() {
98+
NewsletterList list = new NewsletterList();
99+
list.setId(9L);
100+
list.setKind("static");
101+
NewsletterListMember m1 = new NewsletterListMember();
102+
m1.setContactId(1L);
103+
NewsletterListMember m2 = new NewsletterListMember();
104+
m2.setContactId(2L);
105+
when(memberRepository.findByListId(9L)).thenReturn(List.of(m1, m2));
106+
107+
Contact c1 = new Contact();
108+
c1.setId(1L);
109+
Contact c2 = new Contact();
110+
c2.setId(2L);
111+
c2.setMarketingOptOutAt(Instant.now());
112+
when(contactRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(c1, c2));
113+
114+
assertEquals(List.of(1L, 2L), segments.resolve(list));
115+
assertEquals(List.of(1L), segments.resolveSendable(list));
116+
}
117+
118+
@Test
119+
void planner_createsDeliveriesWithUniqueTokens() {
120+
Newsletter newsletter = new Newsletter();
121+
newsletter.setId(10L);
122+
newsletter.setTargetListId(1L);
123+
NewsletterList list = new NewsletterList();
124+
list.setId(1L);
125+
list.setKind("static");
126+
127+
Contact ok = new Contact();
128+
ok.setId(1L);
129+
ok.setEmail("ok@example.com");
130+
Contact bounced = new Contact();
131+
bounced.setId(2L);
132+
bounced.setEmail("bounced@example.com");
133+
134+
when(listRepository.findById(1L)).thenReturn(Optional.of(list));
135+
when(memberRepository.findByListId(1L)).thenReturn(List.of(member(1L), member(2L)));
136+
when(contactRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(ok, bounced));
137+
when(settingsRepository.findByKey(BounceSuppressionStore.KEY)).thenReturn(Optional.empty());
138+
when(settingsRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
139+
when(newsletterRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
140+
141+
planner.plan(newsletter);
142+
143+
ArgumentCaptor<List<NewsletterDelivery>> captor = ArgumentCaptor.forClass(List.class);
144+
verify(deliveryRepository).saveAll(captor.capture());
145+
List<NewsletterDelivery> rows = captor.getValue();
146+
assertEquals(2, rows.size());
147+
assertEquals(2, newsletter.getSummaryTotal());
148+
}
149+
150+
@Test
151+
void tracker_recordsFirstOpenOnce() {
152+
NewsletterDelivery delivery = delivery("token1", "sent");
153+
Newsletter newsletter = new Newsletter();
154+
newsletter.setId(1L);
155+
when(deliveryRepository.findByTrackingToken("token1")).thenReturn(Optional.of(delivery));
156+
when(newsletterRepository.findById(1L)).thenReturn(Optional.of(newsletter));
157+
158+
tracker.recordOpen("token1");
159+
tracker.recordOpen("token1");
160+
161+
assertEquals(1, newsletter.getSummaryOpened());
162+
verify(newsletterRepository, org.mockito.Mockito.times(1)).save(newsletter);
163+
}
164+
165+
@Test
166+
void dispatcher_skipsClaimWhenRateLimitExceeded() {
167+
properties.getNewsletters().setRateLimitPerMinute(0);
168+
when(deliveryRepository.reclaimStuck(any())).thenReturn(0);
169+
when(newsletterRepository.findByStatus("sending")).thenReturn(List.of());
170+
171+
dispatcher.dispatchBatch();
172+
173+
verify(deliveryRepository, never()).findPendingForDispatch(any(), any(Pageable.class));
174+
}
175+
176+
@Test
177+
void dispatcher_noopsWhenDisabled() {
178+
properties.getNewsletters().setEnabled(false);
179+
dispatcher.dispatchBatch();
180+
verify(deliveryRepository, never()).findPendingForDispatch(any(), any());
181+
}
182+
183+
private static NewsletterListMember member(long contactId) {
184+
NewsletterListMember member = new NewsletterListMember();
185+
member.setContactId(contactId);
186+
return member;
187+
}
188+
189+
private static NewsletterDelivery delivery(String token, String status) {
190+
NewsletterDelivery delivery = new NewsletterDelivery();
191+
delivery.setId(1L);
192+
delivery.setNewsletterId(1L);
193+
delivery.setContactId(1L);
194+
delivery.setEmailAtSend("user@example.com");
195+
delivery.setTrackingToken(token);
196+
delivery.setStatus(status);
197+
return delivery;
198+
}
199+
200+
private static Contact contact(NewsletterDelivery delivery) {
201+
Contact contact = new Contact();
202+
contact.setId(delivery.getContactId());
203+
contact.setEmail(delivery.getEmailAtSend());
204+
return contact;
205+
}
206+
}

0 commit comments

Comments
 (0)