Skip to main content

max / makenotwork

14.7 KB · 445 lines History Blame Raw
1 //! DB-layer contract tests for the account core (`db::users`).
2 //!
3 //! `users.rs` is the widest CRUD/lookup module, and its contracts are otherwise
4 //! asserted only indirectly through HTTP flows (auth, dashboard, admin).
5 //! These call the `db::users::` functions directly so the invariants
6 //! the account surface leans on are pinned at the layer they live in: lookup
7 //! round-trip (found vs not-found), exact-vs-normalized key matching for
8 //! username/email, the creator-permission flag, the voluntary creator-pause
9 //! toggle, the Stripe-webhook status write keyed by connected account, the
10 //! uniqueness the signup handler leans on, and the account lifecycle
11 //! (deactivate/reactivate, terminate, content removal) with the expiry sets the
12 //! scheduler reads.
13
14 use crate::harness::TestHarness;
15 use makenotwork::db::{self, Email, Username};
16 use makenotwork::error::AppError;
17
18 // ── lookup round-trip: found vs not-found ──
19
20 #[tokio::test]
21 async fn lookups_round_trip_after_signup() {
22 let mut h = TestHarness::new().await;
23 let user_id = h
24 .signup("lookup_rt", "lookup_rt@test.com", "password123")
25 .await;
26
27 let by_id = db::users::get_user_by_id(&h.db, user_id)
28 .await
29 .expect("get_user_by_id ok")
30 .expect("signed-up user is found by id");
31 assert_eq!(by_id.id, user_id);
32 assert_eq!(by_id.username.as_str(), "lookup_rt");
33 assert_eq!(by_id.email.as_str(), "lookup_rt@test.com");
34
35 // by username (exact match)
36 let username = Username::new("lookup_rt").expect("valid username");
37 let by_username = db::users::get_user_by_username(&h.db, &username)
38 .await
39 .expect("get_user_by_username ok")
40 .expect("signed-up user is found by username");
41 assert_eq!(by_username.id, user_id);
42
43 // by email (normalized match)
44 let email = Email::new("lookup_rt@test.com").expect("valid email");
45 let by_email = db::users::get_user_by_email(&h.db, &email)
46 .await
47 .expect("get_user_by_email ok")
48 .expect("signed-up user is found by email");
49 assert_eq!(by_email.id, user_id);
50 }
51
52 #[tokio::test]
53 async fn lookups_return_none_when_absent() {
54 let h = TestHarness::new().await;
55
56 // A random UUID that was never inserted.
57 let missing_id = db::UserId::new();
58 assert!(
59 db::users::get_user_by_id(&h.db, missing_id)
60 .await
61 .expect("get_user_by_id ok")
62 .is_none(),
63 "an unknown id resolves to None, not an error"
64 );
65
66 let missing_username = Username::new("nobody_here").expect("valid username");
67 assert!(
68 db::users::get_user_by_username(&h.db, &missing_username)
69 .await
70 .expect("get_user_by_username ok")
71 .is_none(),
72 "an unknown username resolves to None"
73 );
74
75 let missing_email = Email::new("nobody@test.com").expect("valid email");
76 assert!(
77 db::users::get_user_by_email(&h.db, &missing_email)
78 .await
79 .expect("get_user_by_email ok")
80 .is_none(),
81 "an unknown email resolves to None"
82 );
83 }
84
85 // ── key matching: username is exact, email is normalized ──
86
87 #[tokio::test]
88 async fn username_lookup_is_case_exact() {
89 let mut h = TestHarness::new().await;
90 // Signup stores the username verbatim (no lowercasing); the lookup SQL is
91 // `WHERE username = $1`, so a differently-cased handle must NOT collide.
92 let user_id = h
93 .signup("CaseUser", "caseuser@test.com", "password123")
94 .await;
95
96 let exact = Username::new("CaseUser").expect("valid username");
97 let found = db::users::get_user_by_username(&h.db, &exact)
98 .await
99 .expect("get_user_by_username ok")
100 .expect("exact-case username is found");
101 assert_eq!(found.id, user_id);
102
103 let wrong_case = Username::new("caseuser").expect("valid username");
104 assert!(
105 db::users::get_user_by_username(&h.db, &wrong_case)
106 .await
107 .expect("get_user_by_username ok")
108 .is_none(),
109 "username match is case-exact, so a lowercased variant does not resolve"
110 );
111 }
112
113 #[tokio::test]
114 async fn email_lookup_is_case_insensitive_by_normalization() {
115 let mut h = TestHarness::new().await;
116 let user_id = h
117 .signup("email_norm", "email_norm@test.com", "password123")
118 .await;
119
120 // `Email::new` trims + lowercases, so a mixed-case spelling normalizes to
121 // the same stored value and still resolves to the same row.
122 let mixed = Email::new("Email_Norm@Test.Com").expect("valid email");
123 let found = db::users::get_user_by_email(&h.db, &mixed)
124 .await
125 .expect("get_user_by_email ok")
126 .expect("mixed-case email normalizes and is found");
127 assert_eq!(found.id, user_id);
128 }
129
130 // ── creator permission flag ──
131
132 #[tokio::test]
133 async fn grant_creator_flips_the_creator_flag() {
134 let mut h = TestHarness::new().await;
135 let user_id = h
136 .signup("creator_flag", "creator_flag@test.com", "password123")
137 .await;
138
139 let before = db::users::get_user_by_id(&h.db, user_id)
140 .await
141 .expect("get_user_by_id ok")
142 .expect("user found");
143 assert!(
144 !before.can_create_projects,
145 "a fresh signup cannot create projects"
146 );
147
148 h.grant_creator(user_id).await;
149
150 let after = db::users::get_user_by_id(&h.db, user_id)
151 .await
152 .expect("get_user_by_id ok")
153 .expect("user found");
154 assert!(
155 after.can_create_projects,
156 "granting creator access sets can_create_projects"
157 );
158 }
159
160 // ── voluntary creator pause toggle ──
161
162 #[tokio::test]
163 async fn pause_and_unpause_creator_toggle_the_flag() {
164 let mut h = TestHarness::new().await;
165 let user_id = h.create_creator("pause_toggle").await;
166
167 let fresh = db::users::get_user_by_id(&h.db, user_id)
168 .await
169 .expect("get_user_by_id ok")
170 .expect("user found");
171 assert!(!fresh.is_creator_paused(), "a new creator is not paused");
172
173 db::users::pause_creator(&h.db, user_id)
174 .await
175 .expect("pause_creator ok");
176 let paused = db::users::get_user_by_id(&h.db, user_id)
177 .await
178 .expect("get_user_by_id ok")
179 .expect("user found");
180 assert!(
181 paused.is_creator_paused(),
182 "pause_creator stamps creator_paused_at"
183 );
184
185 db::users::unpause_creator(&h.db, user_id)
186 .await
187 .expect("unpause_creator ok");
188 let resumed = db::users::get_user_by_id(&h.db, user_id)
189 .await
190 .expect("get_user_by_id ok")
191 .expect("user found");
192 assert!(
193 !resumed.is_creator_paused(),
194 "unpause_creator clears creator_paused_at"
195 );
196 }
197
198 // ── Stripe webhook status write (keyed by connected account) ──
199
200 #[tokio::test]
201 async fn update_user_stripe_status_persists_flags_by_account() {
202 let mut h = TestHarness::new().await;
203 let user_id = h.create_creator("stripe_status").await;
204 // connect_stripe sets stripe_account_id and flips all three Stripe flags on.
205 h.connect_stripe(user_id, "acct_dbul_status").await;
206
207 // The webhook write finds the row by stripe_account_id and returns it.
208 let updated = db::users::update_user_stripe_status(
209 &h.db,
210 "acct_dbul_status",
211 true, // onboarding_complete
212 false, // payouts_enabled
213 true, // charges_enabled
214 None, // settlement_currency: Stripe reported nothing usable
215 )
216 .await
217 .expect("update_user_stripe_status ok")
218 .expect("a matching connected account returns the updated row");
219 assert_eq!(updated.id, user_id);
220 assert!(updated.stripe_onboarding_complete);
221 assert!(!updated.stripe_payouts_enabled);
222 assert!(updated.stripe_charges_enabled);
223
224 // The flags are durable, not just echoed by the RETURNING clause.
225 let reread = db::users::get_user_by_id(&h.db, user_id)
226 .await
227 .expect("get_user_by_id ok")
228 .expect("user found");
229 assert!(reread.stripe_onboarding_complete);
230 assert!(!reread.stripe_payouts_enabled);
231 assert!(reread.stripe_charges_enabled);
232 }
233
234 #[tokio::test]
235 async fn update_user_stripe_status_is_noop_for_unknown_account() {
236 let h = TestHarness::new().await;
237 // No user carries this account id, so the UPDATE matches nothing and the
238 // webhook handler gets None (rather than an error) to ignore the event.
239 let result = db::users::update_user_stripe_status(
240 &h.db,
241 "acct_dbul_nonexistent",
242 true,
243 true,
244 true,
245 None,
246 )
247 .await
248 .expect("update_user_stripe_status ok");
249 assert!(
250 result.is_none(),
251 "an unmatched stripe_account_id is a no-op returning None"
252 );
253 }
254
255 // ── create_user: the uniqueness the signup handler catches as 23505 ──
256
257 /// The join wizard's uniqueness pre-check is best-effort; the real guard is the
258 /// unique index, and the handler reads `db_err.constraint()` to say which field
259 /// collided. Both halves of that are pinned here.
260 #[tokio::test]
261 async fn create_user_rejects_duplicate_username_and_duplicate_normalized_email() {
262 let mut h = TestHarness::new().await;
263 h.signup("dupe_user", "dupe_user@test.com", "password123")
264 .await;
265
266 let taken_username = db::users::create_user(
267 &h.db,
268 &Username::new("dupe_user").expect("valid username"),
269 &Email::new("other_address@test.com").expect("valid email"),
270 "hash",
271 )
272 .await;
273 match taken_username {
274 Err(AppError::Database(sqlx::Error::Database(db_err))) => {
275 assert_eq!(db_err.code().as_deref(), Some("23505"));
276 assert!(
277 db_err.constraint().unwrap_or("").contains("username"),
278 "the constraint must name username so signup can point at the field: {:?}",
279 db_err.constraint()
280 );
281 }
282 other => panic!("a duplicate username must be a unique violation, got {other:?}"),
283 }
284
285 // `Email::new` lowercases, so a mixed-case spelling is the same stored key
286 // and collides on the same index.
287 let taken_email = db::users::create_user(
288 &h.db,
289 &Username::new("other_name").expect("valid username"),
290 &Email::new("Dupe_User@Test.Com").expect("valid email"),
291 "hash",
292 )
293 .await;
294 match taken_email {
295 Err(AppError::Database(sqlx::Error::Database(db_err))) => {
296 assert_eq!(db_err.code().as_deref(), Some("23505"));
297 assert!(
298 db_err.constraint().unwrap_or("").contains("email"),
299 "the constraint must name email: {:?}",
300 db_err.constraint()
301 );
302 }
303 other => panic!("a duplicate normalized email must be a unique violation, got {other:?}"),
304 }
305 }
306
307 // ── account lifecycle: deactivate / terminate / content removal ──
308
309 /// Backdate a lifecycle timestamp so the scheduler's expiry windows can be read
310 /// without waiting out 30 or 90 days.
311 async fn backdate(h: &TestHarness, user_id: db::UserId, column: &str, days: i64) {
312 let sql =
313 format!("UPDATE users SET {column} = NOW() - make_interval(days => $2::int) WHERE id = $1");
314 sqlx::query(&sql)
315 .bind(user_id)
316 .bind(i32::try_from(days).expect("days fits"))
317 .execute(&h.db)
318 .await
319 .expect("backdate ok");
320 }
321
322 #[tokio::test]
323 async fn deactivate_and_reactivate_round_trip_the_limbo_flag() {
324 let mut h = TestHarness::new().await;
325 let user_id = h
326 .signup("limbo_user", "limbo_user@test.com", "password123")
327 .await;
328
329 let before = db::users::get_user_by_id(&h.db, user_id)
330 .await
331 .unwrap()
332 .expect("user found");
333 assert!(before.deactivated_at.is_none(), "a new account is active");
334
335 db::users::deactivate_user(&h.db, user_id)
336 .await
337 .expect("deactivate ok");
338 let deactivated = db::users::get_user_by_id(&h.db, user_id)
339 .await
340 .unwrap()
341 .expect("user found");
342 assert!(
343 deactivated.deactivated_at.is_some(),
344 "deactivation stamps the limbo timestamp"
345 );
346 assert!(
347 deactivated.jwt_invalidated_at.is_some(),
348 "deactivation invalidates outstanding JWTs"
349 );
350
351 db::users::reactivate_user(&h.db, user_id)
352 .await
353 .expect("reactivate ok");
354 let back = db::users::get_user_by_id(&h.db, user_id)
355 .await
356 .unwrap()
357 .expect("user found");
358 assert!(
359 back.deactivated_at.is_none(),
360 "reactivation clears the limbo timestamp"
361 );
362 }
363
364 #[tokio::test]
365 async fn terminated_account_enters_the_expired_set_only_after_its_window() {
366 let mut h = TestHarness::new().await;
367 let user_id = h
368 .signup("term_user", "term_user@test.com", "password123")
369 .await;
370
371 db::users::terminate_user(&h.db, user_id)
372 .await
373 .expect("terminate ok");
374 let terminated = db::users::get_user_by_id(&h.db, user_id)
375 .await
376 .unwrap()
377 .expect("user found");
378 assert!(
379 terminated.terminated_at.is_some(),
380 "termination stamps the export-window start"
381 );
382 assert!(
383 terminated.jwt_invalidated_at.is_some(),
384 "termination invalidates outstanding JWTs"
385 );
386
387 let fresh = db::users::get_expired_terminated_ids(&h.db)
388 .await
389 .expect("expired ids ok");
390 assert!(
391 !fresh.contains(&user_id),
392 "the 30-day export window has not elapsed"
393 );
394
395 backdate(&h, user_id, "terminated_at", 31).await;
396 let expired = db::users::get_expired_terminated_ids(&h.db)
397 .await
398 .expect("expired ids ok");
399 assert!(
400 expired.contains(&user_id),
401 "past the window the scheduler picks the account up"
402 );
403 }
404
405 #[tokio::test]
406 async fn scheduled_content_removal_expires_after_its_grace_period() {
407 let mut h = TestHarness::new().await;
408 let user_id = h
409 .signup("removal_user", "removal_user@test.com", "password123")
410 .await;
411
412 db::users::schedule_content_removal(&h.db, user_id)
413 .await
414 .expect("schedule removal ok");
415 let scheduled = db::users::get_user_by_id(&h.db, user_id)
416 .await
417 .unwrap()
418 .expect("user found");
419 assert!(
420 scheduled.content_removal_at.is_some(),
421 "scheduling stamps the removal date"
422 );
423 assert!(
424 scheduled.deactivated_at.is_some(),
425 "scheduling removal also hides the account"
426 );
427
428 let fresh = db::users::get_expired_content_removal_ids(&h.db)
429 .await
430 .expect("expired ids ok");
431 assert!(
432 !fresh.contains(&user_id),
433 "the 90-day grace period has not elapsed"
434 );
435
436 backdate(&h, user_id, "content_removal_at", 1).await;
437 let expired = db::users::get_expired_content_removal_ids(&h.db)
438 .await
439 .expect("expired ids ok");
440 assert!(
441 expired.contains(&user_id),
442 "past the grace period the scheduler picks the account up"
443 );
444 }
445