Skip to main content

max / makenotwork

11.8 KB · 375 lines History Blame Raw
1 //! Fan+ perks: + badge, signature, and image-embed gating.
2 //!
3 //! The plumbing for perks is exercised in `workflows::auth`; these tests focus
4 //! on the user-visible effects:
5 //! * Embed gate at submit time for non-plus users
6 //! * Plus users get image markdown rendered
7 //! * Signature endpoint refuses non-plus users
8 //! * + badge and signature show beneath posts of current Fan+ subscribers
9 //! * Signature hides when the author lapses
10
11 use crate::harness::TestHarness;
12 use axum::http::StatusCode;
13 use uuid::Uuid;
14
15 // --- helpers
16
17 /// Log in as `username` and set their denormalised perk flags + (optionally) a
18 /// pre-rendered signature. The `/_test/login` endpoint also stuffs perks into
19 /// the session, mirroring what the OAuth callback does.
20 async fn login_with_perks(
21 h: &mut TestHarness,
22 username: &str,
23 fan_plus: bool,
24 is_creator: bool,
25 signature: Option<(&str, &str)>,
26 ) -> Uuid {
27 let user_id = Uuid::new_v4();
28 sqlx::query(
29 "INSERT INTO users (mnw_account_id, username, display_name, is_fan_plus, is_creator) \
30 VALUES ($1, $2, $2, $3, $4) ON CONFLICT (mnw_account_id) DO UPDATE \
31 SET is_fan_plus = $3, is_creator = $4",
32 )
33 .bind(user_id)
34 .bind(username)
35 .bind(fan_plus)
36 .bind(is_creator)
37 .execute(&h.db)
38 .await
39 .expect("insert user");
40
41 if let Some((md, html)) = signature {
42 sqlx::query("UPDATE users SET signature_markdown = $2, signature_html = $3 WHERE mnw_account_id = $1")
43 .bind(user_id)
44 .bind(md)
45 .bind(html)
46 .execute(&h.db)
47 .await
48 .expect("seed signature");
49 }
50
51 h.client.get("/").await;
52 h.client
53 .post_json(
54 "/_test/login",
55 &serde_json::json!({
56 "user_id": user_id.to_string(),
57 "username": username,
58 "perks": { "fan_plus": fan_plus, "is_creator": is_creator },
59 })
60 .to_string(),
61 )
62 .await;
63 user_id
64 }
65
66 async fn setup_community(h: &TestHarness, user_id: Uuid) -> Uuid {
67 let comm_id = h.create_community("Test", "test").await;
68 h.create_category(comm_id, "General", "general").await;
69 h.add_membership(user_id, comm_id, "member").await;
70 comm_id
71 }
72
73 // --- embed gate
74
75 #[tokio::test]
76 async fn free_user_image_embed_rejected() {
77 let mut h = TestHarness::new().await;
78 let user_id = login_with_perks(&mut h, "freeposter", false, false, None).await;
79 let _ = setup_community(&h, user_id).await;
80
81 h.client.get("/p/test/general/new").await;
82 let resp = h
83 .client
84 .post_form(
85 "/p/test/general/new",
86 "title=Picture&body=Look%3A+%21%5Balt%5D%28https%3A%2F%2Fexample.com%2Fa.png%29",
87 )
88 .await;
89 assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
90 assert!(resp.text.contains("Fan+"));
91 }
92
93 #[tokio::test]
94 async fn plus_user_image_embed_renders() {
95 let mut h = TestHarness::new().await;
96 let user_id = login_with_perks(&mut h, "plusposter", true, false, None).await;
97 let _ = setup_community(&h, user_id).await;
98
99 h.client.get("/p/test/general/new").await;
100 let resp = h
101 .client
102 .post_form(
103 "/p/test/general/new",
104 "title=Picture&body=Look%3A+%21%5Balt%5D%28https%3A%2F%2Fexample.com%2Fa.png%29",
105 )
106 .await;
107 assert!(resp.status.is_redirection(), "status: {}", resp.status);
108
109 // Confirm the rendered HTML kept the image.
110 let html: String =
111 sqlx::query_scalar("SELECT body_html FROM posts ORDER BY created_at DESC LIMIT 1")
112 .fetch_one(&h.db)
113 .await
114 .unwrap();
115 assert!(
116 html.contains("<img"),
117 "expected img in rendered HTML, got: {html}"
118 );
119 }
120
121 #[tokio::test]
122 async fn creator_can_embed_via_auto_grant() {
123 // Creator auto-grant covers all Fan+ forum capabilities (image embeds
124 // included), but not the public + badge, that stays exclusive to direct
125 // Fan+ subscribers (tested elsewhere).
126 let mut h = TestHarness::new().await;
127 let user_id = login_with_perks(&mut h, "creator", false, true, None).await;
128 let _ = setup_community(&h, user_id).await;
129
130 h.client.get("/p/test/general/new").await;
131 let resp = h
132 .client
133 .post_form(
134 "/p/test/general/new",
135 "title=Pic&body=%21%5Balt%5D%28https%3A%2F%2Fexample.com%2Fb.png%29",
136 )
137 .await;
138 assert!(
139 resp.status.is_redirection(),
140 "creator post rejected: {}",
141 resp.status
142 );
143
144 let html: String =
145 sqlx::query_scalar("SELECT body_html FROM posts ORDER BY created_at DESC LIMIT 1")
146 .fetch_one(&h.db)
147 .await
148 .unwrap();
149 assert!(html.contains("<img"), "creator should get image rendered");
150 }
151
152 // --- signature edit gate
153
154 #[tokio::test]
155 async fn free_user_cannot_save_signature() {
156 let mut h = TestHarness::new().await;
157 let _user_id = login_with_perks(&mut h, "freesig", false, false, None).await;
158
159 h.client.get("/account").await;
160 let resp = h
161 .client
162 .post_form("/account/signature", "signature=hello")
163 .await;
164 assert_eq!(resp.status, StatusCode::FORBIDDEN);
165 }
166
167 #[tokio::test]
168 async fn plus_user_can_save_signature() {
169 let mut h = TestHarness::new().await;
170 let user_id = login_with_perks(&mut h, "plussig", true, false, None).await;
171
172 h.client.get("/account").await;
173 let resp = h
174 .client
175 .post_form("/account/signature", "signature=Hello+from+a+sig")
176 .await;
177 assert!(resp.status.is_redirection(), "status: {}", resp.status);
178
179 let (md, html): (Option<String>, Option<String>) = sqlx::query_as(
180 "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1",
181 )
182 .bind(user_id)
183 .fetch_one(&h.db)
184 .await
185 .unwrap();
186 assert_eq!(md.as_deref(), Some("Hello from a sig"));
187 assert!(html.as_deref().unwrap().contains("Hello from a sig"));
188 }
189
190 #[tokio::test]
191 async fn creator_can_save_signature_with_embed() {
192 // Creators get the auto-grant: same signature capabilities as Fan+ in the
193 // editor. Public visibility (rendering under posts) still requires the
194 // creator to also be a Fan+ subscriber, that gate lives in the thread
195 // template and is covered by `lapsed_plus_user_signature_hidden`.
196 let mut h = TestHarness::new().await;
197 let user_id = login_with_perks(&mut h, "creatorsig", false, true, None).await;
198
199 h.client.get("/account").await;
200 let resp = h
201 .client
202 .post_form(
203 "/account/signature",
204 "signature=%21%5Balt%5D%28https%3A%2F%2Fa.com%2Fb.png%29",
205 )
206 .await;
207 assert!(resp.status.is_redirection(), "status: {}", resp.status);
208
209 let html: Option<String> =
210 sqlx::query_scalar("SELECT signature_html FROM users WHERE mnw_account_id = $1")
211 .bind(user_id)
212 .fetch_one(&h.db)
213 .await
214 .unwrap();
215 assert!(html.as_deref().unwrap().contains("<img"));
216 }
217
218 #[tokio::test]
219 async fn clear_signature_button_wipes_row() {
220 let mut h = TestHarness::new().await;
221 let user_id = login_with_perks(
222 &mut h,
223 "plusclear",
224 true,
225 false,
226 Some(("old", "<p>old</p>")),
227 )
228 .await;
229
230 h.client.get("/account").await;
231 let resp = h
232 .client
233 .post_form("/account/signature", "signature=ignored&clear=1")
234 .await;
235 assert!(resp.status.is_redirection());
236
237 let md: Option<String> =
238 sqlx::query_scalar("SELECT signature_markdown FROM users WHERE mnw_account_id = $1")
239 .bind(user_id)
240 .fetch_one(&h.db)
241 .await
242 .unwrap();
243 assert!(md.is_none());
244 }
245
246 #[tokio::test]
247 async fn signature_length_capped() {
248 let mut h = TestHarness::new().await;
249 let _ = login_with_perks(&mut h, "longsig", true, false, None).await;
250
251 h.client.get("/account").await;
252 let body = format!("signature={}", "a".repeat(1025));
253 let resp = h.client.post_form("/account/signature", &body).await;
254 assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
255 }
256
257 // --- render-time visibility
258
259 #[tokio::test]
260 async fn plus_badge_and_signature_render_in_thread() {
261 let mut h = TestHarness::new().await;
262 let user_id = login_with_perks(
263 &mut h,
264 "plusauthor",
265 true,
266 false,
267 Some(("Cheers ~ plusauthor", "<p>Cheers ~ plusauthor</p>")),
268 )
269 .await;
270 let comm_id = setup_community(&h, user_id).await;
271 let cat_id: Uuid = sqlx::query_scalar("SELECT id FROM categories WHERE community_id = $1")
272 .bind(comm_id)
273 .fetch_one(&h.db)
274 .await
275 .unwrap();
276 let thread_id = h
277 .create_thread_with_post(cat_id, user_id, "Hello", "body")
278 .await;
279
280 let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await;
281 assert!(resp.text.contains("badge-plus"), "expected + badge");
282 assert!(
283 resp.text.contains("Cheers ~ plusauthor"),
284 "expected signature in rendered HTML"
285 );
286 }
287
288 #[tokio::test]
289 async fn lapsed_plus_user_signature_hidden() {
290 let mut h = TestHarness::new().await;
291 let user_id = login_with_perks(
292 &mut h,
293 "lapsed",
294 true,
295 false,
296 Some(("Old sig", "<p>Old sig</p>")),
297 )
298 .await;
299 let comm_id = setup_community(&h, user_id).await;
300 let cat_id: Uuid = sqlx::query_scalar("SELECT id FROM categories WHERE community_id = $1")
301 .bind(comm_id)
302 .fetch_one(&h.db)
303 .await
304 .unwrap();
305 let thread_id = h
306 .create_thread_with_post(cat_id, user_id, "Hi", "body")
307 .await;
308
309 // Simulate Fan+ lapse, denormalised flag flips, signature row preserved.
310 sqlx::query("UPDATE users SET is_fan_plus = FALSE WHERE mnw_account_id = $1")
311 .bind(user_id)
312 .execute(&h.db)
313 .await
314 .unwrap();
315
316 let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await;
317 assert!(
318 !resp.text.contains("Old sig"),
319 "lapsed signature should not render"
320 );
321 assert!(
322 !resp.text.contains("badge-plus"),
323 "lapsed user should not have + badge"
324 );
325
326 // Row still on disk, user gets it back on renewal.
327 let md: Option<String> =
328 sqlx::query_scalar("SELECT signature_markdown FROM users WHERE mnw_account_id = $1")
329 .bind(user_id)
330 .fetch_one(&h.db)
331 .await
332 .unwrap();
333 assert_eq!(md.as_deref(), Some("Old sig"));
334 }
335
336 #[tokio::test]
337 async fn free_author_no_badge_no_signature_section() {
338 let mut h = TestHarness::new().await;
339 let user_id = login_with_perks(&mut h, "freeauthor", false, false, None).await;
340 let comm_id = setup_community(&h, user_id).await;
341 let cat_id: Uuid = sqlx::query_scalar("SELECT id FROM categories WHERE community_id = $1")
342 .bind(comm_id)
343 .fetch_one(&h.db)
344 .await
345 .unwrap();
346 let thread_id = h
347 .create_thread_with_post(cat_id, user_id, "Hi", "body")
348 .await;
349
350 let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await;
351 assert!(!resp.text.contains("badge-plus"));
352 assert!(!resp.text.contains("post-signature"));
353 }
354
355 // --- account page gating
356
357 #[tokio::test]
358 async fn account_page_shows_upsell_for_free_user() {
359 let mut h = TestHarness::new().await;
360 let _ = login_with_perks(&mut h, "freeacct", false, false, None).await;
361 let resp = h.client.get("/account").await;
362 assert!(resp.status.is_success());
363 assert!(resp.text.contains("Fan+ feature"));
364 }
365
366 #[tokio::test]
367 async fn account_page_shows_editor_for_plus_user() {
368 let mut h = TestHarness::new().await;
369 let _ = login_with_perks(&mut h, "plusacct", true, false, None).await;
370 let resp = h.client.get("/account").await;
371 assert!(resp.status.is_success());
372 assert!(resp.text.contains("textarea"), "should show editor");
373 assert!(resp.text.contains("Save signature"));
374 }
375