Skip to main content

max / makenotwork

11.7 KB · 365 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 = sqlx::query_scalar("SELECT body_html FROM posts ORDER BY created_at DESC LIMIT 1")
111 .fetch_one(&h.db)
112 .await
113 .unwrap();
114 assert!(html.contains("<img"), "expected img in rendered HTML, got: {}", html);
115 }
116
117 #[tokio::test]
118 async fn creator_can_embed_via_auto_grant() {
119 // Creator auto-grant covers all Fan+ forum capabilities (image embeds
120 // included), but not the public + badge — that stays exclusive to direct
121 // Fan+ subscribers (tested elsewhere).
122 let mut h = TestHarness::new().await;
123 let user_id = login_with_perks(&mut h, "creator", false, true, None).await;
124 let _ = setup_community(&h, user_id).await;
125
126 h.client.get("/p/test/general/new").await;
127 let resp = h
128 .client
129 .post_form(
130 "/p/test/general/new",
131 "title=Pic&body=%21%5Balt%5D%28https%3A%2F%2Fexample.com%2Fb.png%29",
132 )
133 .await;
134 assert!(resp.status.is_redirection(), "creator post rejected: {}", resp.status);
135
136 let html: String =
137 sqlx::query_scalar("SELECT body_html FROM posts ORDER BY created_at DESC LIMIT 1")
138 .fetch_one(&h.db)
139 .await
140 .unwrap();
141 assert!(html.contains("<img"), "creator should get image rendered");
142 }
143
144 // ── Signature edit gate ──
145
146 #[tokio::test]
147 async fn free_user_cannot_save_signature() {
148 let mut h = TestHarness::new().await;
149 let _user_id = login_with_perks(&mut h, "freesig", false, false, None).await;
150
151 h.client.get("/account").await;
152 let resp = h
153 .client
154 .post_form("/account/signature", "signature=hello")
155 .await;
156 assert_eq!(resp.status, StatusCode::FORBIDDEN);
157 }
158
159 #[tokio::test]
160 async fn plus_user_can_save_signature() {
161 let mut h = TestHarness::new().await;
162 let user_id = login_with_perks(&mut h, "plussig", true, false, None).await;
163
164 h.client.get("/account").await;
165 let resp = h
166 .client
167 .post_form("/account/signature", "signature=Hello+from+a+sig")
168 .await;
169 assert!(resp.status.is_redirection(), "status: {}", resp.status);
170
171 let (md, html): (Option<String>, Option<String>) = sqlx::query_as(
172 "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1",
173 )
174 .bind(user_id)
175 .fetch_one(&h.db)
176 .await
177 .unwrap();
178 assert_eq!(md.as_deref(), Some("Hello from a sig"));
179 assert!(html.as_deref().unwrap().contains("Hello from a sig"));
180 }
181
182 #[tokio::test]
183 async fn creator_can_save_signature_with_embed() {
184 // Creators get the auto-grant: same signature capabilities as Fan+ in the
185 // editor. Public visibility (rendering under posts) still requires the
186 // creator to also be a Fan+ subscriber — that gate lives in the thread
187 // template and is covered by `lapsed_plus_user_signature_hidden`.
188 let mut h = TestHarness::new().await;
189 let user_id = login_with_perks(&mut h, "creatorsig", false, true, None).await;
190
191 h.client.get("/account").await;
192 let resp = h
193 .client
194 .post_form(
195 "/account/signature",
196 "signature=%21%5Balt%5D%28https%3A%2F%2Fa.com%2Fb.png%29",
197 )
198 .await;
199 assert!(resp.status.is_redirection(), "status: {}", resp.status);
200
201 let html: Option<String> =
202 sqlx::query_scalar("SELECT signature_html FROM users WHERE mnw_account_id = $1")
203 .bind(user_id)
204 .fetch_one(&h.db)
205 .await
206 .unwrap();
207 assert!(html.as_deref().unwrap().contains("<img"));
208 }
209
210 #[tokio::test]
211 async fn clear_signature_button_wipes_row() {
212 let mut h = TestHarness::new().await;
213 let user_id = login_with_perks(
214 &mut h,
215 "plusclear",
216 true,
217 false,
218 Some(("old", "<p>old</p>")),
219 )
220 .await;
221
222 h.client.get("/account").await;
223 let resp = h
224 .client
225 .post_form("/account/signature", "signature=ignored&clear=1")
226 .await;
227 assert!(resp.status.is_redirection());
228
229 let md: Option<String> = sqlx::query_scalar("SELECT signature_markdown FROM users WHERE mnw_account_id = $1")
230 .bind(user_id)
231 .fetch_one(&h.db)
232 .await
233 .unwrap();
234 assert!(md.is_none());
235 }
236
237 #[tokio::test]
238 async fn signature_length_capped() {
239 let mut h = TestHarness::new().await;
240 let _ = login_with_perks(&mut h, "longsig", true, false, None).await;
241
242 h.client.get("/account").await;
243 let body = format!("signature={}", "a".repeat(1025));
244 let resp = h.client.post_form("/account/signature", &body).await;
245 assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
246 }
247
248 // ── Render-time visibility ──
249
250 #[tokio::test]
251 async fn plus_badge_and_signature_render_in_thread() {
252 let mut h = TestHarness::new().await;
253 let user_id = login_with_perks(
254 &mut h,
255 "plusauthor",
256 true,
257 false,
258 Some(("Cheers ~ plusauthor", "<p>Cheers ~ plusauthor</p>")),
259 )
260 .await;
261 let comm_id = setup_community(&h, user_id).await;
262 let cat_id: Uuid = sqlx::query_scalar("SELECT id FROM categories WHERE community_id = $1")
263 .bind(comm_id)
264 .fetch_one(&h.db)
265 .await
266 .unwrap();
267 let thread_id = h
268 .create_thread_with_post(cat_id, user_id, "Hello", "body")
269 .await;
270
271 let resp = h.client.get(&format!("/p/test/general/{}", thread_id)).await;
272 assert!(resp.text.contains("badge-plus"), "expected + badge");
273 assert!(
274 resp.text.contains("Cheers ~ plusauthor"),
275 "expected signature in rendered HTML"
276 );
277 }
278
279 #[tokio::test]
280 async fn lapsed_plus_user_signature_hidden() {
281 let mut h = TestHarness::new().await;
282 let user_id = login_with_perks(
283 &mut h,
284 "lapsed",
285 true,
286 false,
287 Some(("Old sig", "<p>Old sig</p>")),
288 )
289 .await;
290 let comm_id = setup_community(&h, user_id).await;
291 let cat_id: Uuid = sqlx::query_scalar("SELECT id FROM categories WHERE community_id = $1")
292 .bind(comm_id)
293 .fetch_one(&h.db)
294 .await
295 .unwrap();
296 let thread_id = h
297 .create_thread_with_post(cat_id, user_id, "Hi", "body")
298 .await;
299
300 // Simulate Fan+ lapse — denormalised flag flips, signature row preserved.
301 sqlx::query("UPDATE users SET is_fan_plus = FALSE WHERE mnw_account_id = $1")
302 .bind(user_id)
303 .execute(&h.db)
304 .await
305 .unwrap();
306
307 let resp = h.client.get(&format!("/p/test/general/{}", thread_id)).await;
308 assert!(
309 !resp.text.contains("Old sig"),
310 "lapsed signature should not render"
311 );
312 assert!(
313 !resp.text.contains("badge-plus"),
314 "lapsed user should not have + badge"
315 );
316
317 // Row still on disk — user gets it back on renewal.
318 let md: Option<String> = sqlx::query_scalar("SELECT signature_markdown FROM users WHERE mnw_account_id = $1")
319 .bind(user_id)
320 .fetch_one(&h.db)
321 .await
322 .unwrap();
323 assert_eq!(md.as_deref(), Some("Old sig"));
324 }
325
326 #[tokio::test]
327 async fn free_author_no_badge_no_signature_section() {
328 let mut h = TestHarness::new().await;
329 let user_id = login_with_perks(&mut h, "freeauthor", false, false, None).await;
330 let comm_id = setup_community(&h, user_id).await;
331 let cat_id: Uuid = sqlx::query_scalar("SELECT id FROM categories WHERE community_id = $1")
332 .bind(comm_id)
333 .fetch_one(&h.db)
334 .await
335 .unwrap();
336 let thread_id = h
337 .create_thread_with_post(cat_id, user_id, "Hi", "body")
338 .await;
339
340 let resp = h.client.get(&format!("/p/test/general/{}", thread_id)).await;
341 assert!(!resp.text.contains("badge-plus"));
342 assert!(!resp.text.contains("post-signature"));
343 }
344
345 // ── Account page gating ──
346
347 #[tokio::test]
348 async fn account_page_shows_upsell_for_free_user() {
349 let mut h = TestHarness::new().await;
350 let _ = login_with_perks(&mut h, "freeacct", false, false, None).await;
351 let resp = h.client.get("/account").await;
352 assert!(resp.status.is_success());
353 assert!(resp.text.contains("Fan+ feature"));
354 }
355
356 #[tokio::test]
357 async fn account_page_shows_editor_for_plus_user() {
358 let mut h = TestHarness::new().await;
359 let _ = login_with_perks(&mut h, "plusacct", true, false, None).await;
360 let resp = h.client.get("/account").await;
361 assert!(resp.status.is_success());
362 assert!(resp.text.contains("textarea"), "should show editor");
363 assert!(resp.text.contains("Save signature"));
364 }
365