Skip to main content

max / makenotwork

Pin user uniqueness and the account lifecycle at the db layer create_user now has its unique violations asserted the way the join wizard reads them: SQLSTATE 23505 with a constraint naming username or email, the email case normalized by Email::new before it collides. Three lifecycle tests cover deactivate/reactivate round-tripping the limbo timestamp, terminate stamping the export window and entering get_expired_terminated_ids only once 30 days have passed, and schedule_content_removal hiding the account and expiring after its 90-day grace period. Both expiry windows are read by backdating the stamp rather than by waiting.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 22:12 UTC
Signed with PGP, not checked
Commit: 32a08ab5f75aea7eca0e81c65c8c334a3a8bee19
Parent: 012c2db
1 file changed, +196 insertions, -1 deletion
@@ -6,10 +6,14 @@
6 6 //! the account surface leans on are pinned at the layer they live in: lookup
7 7 //! round-trip (found vs not-found), exact-vs-normalized key matching for
8 8 //! username/email, the creator-permission flag, the voluntary creator-pause
9 - //! toggle, and the Stripe-webhook status write keyed by connected account.
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.
10 13
11 14 use crate::harness::TestHarness;
12 15 use makenotwork::db::{self, Email, Username};
16 + use makenotwork::error::AppError;
13 17
14 18 // ── lookup round-trip: found vs not-found ──
15 19
@@ -247,3 +251,194 @@
247 251 "an unmatched stripe_account_id is a no-op returning None"
248 252 );
249 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 + }