Skip to main content

max / makenotwork

7.8 KB · 246 lines History Blame Raw
1 //! The recipient resolver, and the legacy writes that feed it (step 3 of
2 //! wiki `mnw-mailing-lists`).
3 //!
4 //! Sends now read the unified tables, so a write that reaches only the legacy
5 //! tables is a subscriber no send can see, or worse, an unsubscribe no send
6 //! honours. These pin the mirroring that closes that gap.
7
8 use crate::harness::TestHarness;
9 use makenotwork::db::{
10 ConsentEvent, ListKind, ListScope, SubscriptionSource, SubscriptionState, lists,
11 };
12
13 /// The resolver requires a verified, unsuspended account, matching the query it
14 /// replaced. `signup` does not verify, so tests that expect delivery say so.
15 async fn verify_email(h: &TestHarness, user: makenotwork::db::UserId) {
16 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
17 .bind(user)
18 .execute(&h.db)
19 .await
20 .expect("verify email");
21 }
22
23 /// Create a project through the API and return its unified content list.
24 async fn project_with_list(h: &mut TestHarness) -> (makenotwork::db::UserId, uuid::Uuid) {
25 let creator_id = h.signup("mirror", "mirror@test.com", "password123").await;
26 h.grant_creator(creator_id).await;
27 h.client.post_form("/logout", "").await;
28 h.login("mirror", "password123").await;
29 let resp = h
30 .client
31 .post_form("/api/projects", "slug=mirrorproj&title=Mirror+Project")
32 .await;
33 assert_eq!(resp.status, 200, "create project: {}", resp.text);
34 let project: serde_json::Value = resp.json();
35 let project_id: uuid::Uuid = project["id"].as_str().unwrap().parse().unwrap();
36 (creator_id, project_id)
37 }
38
39 /// Creating a project mirrors its default lists, so an announcement has
40 /// somewhere to resolve. Without this the send errors rather than silently
41 /// mailing nobody.
42 #[tokio::test]
43 async fn creating_a_project_mirrors_its_lists() {
44 let mut h = TestHarness::new().await;
45 let (_creator, project_id) = project_with_list(&mut h).await;
46
47 let content = lists::find_list(
48 &h.db,
49 ListScope::Project,
50 Some(project_id),
51 ListKind::Content,
52 )
53 .await
54 .expect("query");
55 assert!(content.is_some(), "content list was not mirrored");
56
57 let devlog = lists::find_list(
58 &h.db,
59 ListScope::Project,
60 Some(project_id),
61 ListKind::Devlog,
62 )
63 .await
64 .expect("query");
65 assert!(devlog.is_some(), "devlog list was not mirrored");
66 }
67
68 /// A subscribe through the legacy path reaches the audience the resolver
69 /// returns. This is the dual-write hazard: writes still go to the old tables,
70 /// and sends now read the new ones.
71 #[tokio::test]
72 async fn a_legacy_subscribe_reaches_the_resolved_audience() {
73 let mut h = TestHarness::new().await;
74 let (_creator, project_id) = project_with_list(&mut h).await;
75 let fan = h
76 .signup("mirrorfan", "mirrorfan@test.com", "password123")
77 .await;
78 verify_email(&h, fan).await;
79
80 let legacy = makenotwork::db::mailing_lists::get_list_by_project_and_type(
81 &h.db,
82 project_id.into(),
83 makenotwork::db::MailingListType::Content,
84 )
85 .await
86 .unwrap()
87 .expect("legacy list");
88 makenotwork::db::mailing_lists::subscribe(&h.db, legacy.id, fan)
89 .await
90 .expect("subscribe");
91
92 let unified = lists::find_list(
93 &h.db,
94 ListScope::Project,
95 Some(project_id),
96 ListKind::Content,
97 )
98 .await
99 .unwrap()
100 .unwrap();
101 let audience = lists::resolve_audience(&h.db, unified)
102 .await
103 .expect("resolve");
104 assert!(
105 audience.recipients.iter().any(|r| r.user_id == Some(fan)),
106 "a subscriber added through the legacy path is invisible to sends"
107 );
108 }
109
110 /// The one that matters most: an unsubscribe through the legacy path must
111 /// remove them from the audience. A missed mirror here means mailing somebody
112 /// who asked us not to.
113 #[tokio::test]
114 async fn a_legacy_unsubscribe_removes_them_from_the_audience() {
115 let mut h = TestHarness::new().await;
116 let (_creator, project_id) = project_with_list(&mut h).await;
117 let fan = h.signup("leaver", "leaver@test.com", "password123").await;
118 verify_email(&h, fan).await;
119
120 let legacy = makenotwork::db::mailing_lists::get_list_by_project_and_type(
121 &h.db,
122 project_id.into(),
123 makenotwork::db::MailingListType::Content,
124 )
125 .await
126 .unwrap()
127 .unwrap();
128 makenotwork::db::mailing_lists::subscribe(&h.db, legacy.id, fan)
129 .await
130 .unwrap();
131
132 let unified = lists::find_list(
133 &h.db,
134 ListScope::Project,
135 Some(project_id),
136 ListKind::Content,
137 )
138 .await
139 .unwrap()
140 .unwrap();
141
142 // Present first, so this cannot pass by never having been subscribed.
143 let before = lists::resolve_audience(&h.db, unified).await.unwrap();
144 assert!(
145 before.recipients.iter().any(|r| r.user_id == Some(fan)),
146 "test setup: the subscriber never reached the audience"
147 );
148
149 makenotwork::db::mailing_lists::unsubscribe(&h.db, legacy.id, fan)
150 .await
151 .unwrap();
152
153 let audience = lists::resolve_audience(&h.db, unified).await.unwrap();
154 assert!(
155 !audience.recipients.iter().any(|r| r.user_id == Some(fan)),
156 "an unsubscribed user is still in the send audience"
157 );
158
159 // And the opt-out is on the record, not just absent from the audience.
160 let events: Vec<String> = sqlx::query_scalar(
161 "SELECT ce.event FROM consent_events ce \
162 JOIN list_subscriptions ls ON ls.id = ce.subscription_id \
163 WHERE ls.user_id = $1 ORDER BY ce.at",
164 )
165 .bind(fan)
166 .fetch_all(&h.db)
167 .await
168 .unwrap();
169 assert!(events.contains(&"opt_out".to_string()));
170 }
171
172 /// A suppressed address never resolves, whatever its subscription says.
173 /// Bounces and complaints are the one rule that was already applied
174 /// consistently, and it stays that way.
175 #[tokio::test]
176 async fn suppressed_addresses_are_never_in_the_audience() {
177 let h = TestHarness::new().await;
178 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
179 .await
180 .unwrap()
181 .unwrap();
182
183 lists::subscribe(
184 &h.db,
185 list,
186 &lists::Subscriber::Email("bounced@example.com".to_string()),
187 SubscriptionState::Confirmed,
188 SubscriptionSource::LandingForm,
189 ConsentEvent::OptIn,
190 None,
191 )
192 .await
193 .unwrap();
194
195 let before = lists::resolve_audience(&h.db, list).await.unwrap();
196 assert_eq!(before.recipients.len(), 1);
197
198 sqlx::query("INSERT INTO email_suppressions (email, reason) VALUES ($1, 'bounce')")
199 .bind("bounced@example.com")
200 .execute(&h.db)
201 .await
202 .unwrap();
203
204 let after = lists::resolve_audience(&h.db, list).await.unwrap();
205 assert!(
206 after.recipients.is_empty(),
207 "a suppressed address resolved as deliverable"
208 );
209 }
210
211 /// An unsubscribed subscription is not sendable, and neither is a pending one:
212 /// nothing may be mailed on the strength of a double opt-in that never
213 /// completed.
214 #[tokio::test]
215 async fn unsendable_states_stay_out_of_the_audience() {
216 let h = TestHarness::new().await;
217 let list = lists::find_list(&h.db, ListScope::Platform, None, ListKind::Marketing)
218 .await
219 .unwrap()
220 .unwrap();
221
222 for (addr, state) in [
223 ("pending@example.com", SubscriptionState::Pending),
224 ("bounced2@example.com", SubscriptionState::Bounced),
225 ] {
226 lists::subscribe(
227 &h.db,
228 list,
229 &lists::Subscriber::Email(addr.to_string()),
230 state,
231 SubscriptionSource::LandingForm,
232 ConsentEvent::OptIn,
233 None,
234 )
235 .await
236 .unwrap();
237 }
238
239 let audience = lists::resolve_audience(&h.db, list).await.unwrap();
240 assert!(
241 audience.recipients.is_empty(),
242 "pending or bounced subscriptions resolved as deliverable: {:?}",
243 audience.recipients
244 );
245 }
246