//! DB-layer contract tests for the account core (`db::users`). //! //! `users.rs` is the widest CRUD/lookup module and its contracts were asserted //! only indirectly through HTTP flows (auth, dashboard, admin), audit Run 18 //! Testing. These call the `db::users::` functions directly so the invariants //! the account surface leans on are pinned at the layer they live in: lookup //! round-trip (found vs not-found), exact-vs-normalized key matching for //! username/email, the creator-permission flag, the voluntary creator-pause //! toggle, the Stripe-webhook status write keyed by connected account, the //! uniqueness the signup handler leans on, and the account lifecycle //! (deactivate/reactivate, terminate, content removal) with the expiry sets the //! scheduler reads. use crate::harness::TestHarness; use makenotwork::db::{self, Email, Username}; use makenotwork::error::AppError; // ── lookup round-trip: found vs not-found ── #[tokio::test] async fn lookups_round_trip_after_signup() { let mut h = TestHarness::new().await; let user_id = h .signup("lookup_rt", "lookup_rt@test.com", "password123") .await; let by_id = db::users::get_user_by_id(&h.db, user_id) .await .expect("get_user_by_id ok") .expect("signed-up user is found by id"); assert_eq!(by_id.id, user_id); assert_eq!(by_id.username.as_str(), "lookup_rt"); assert_eq!(by_id.email.as_str(), "lookup_rt@test.com"); // by username (exact match) let username = Username::new("lookup_rt").expect("valid username"); let by_username = db::users::get_user_by_username(&h.db, &username) .await .expect("get_user_by_username ok") .expect("signed-up user is found by username"); assert_eq!(by_username.id, user_id); // by email (normalized match) let email = Email::new("lookup_rt@test.com").expect("valid email"); let by_email = db::users::get_user_by_email(&h.db, &email) .await .expect("get_user_by_email ok") .expect("signed-up user is found by email"); assert_eq!(by_email.id, user_id); } #[tokio::test] async fn lookups_return_none_when_absent() { let h = TestHarness::new().await; // A random UUID that was never inserted. let missing_id = db::UserId::new(); assert!( db::users::get_user_by_id(&h.db, missing_id) .await .expect("get_user_by_id ok") .is_none(), "an unknown id resolves to None, not an error" ); let missing_username = Username::new("nobody_here").expect("valid username"); assert!( db::users::get_user_by_username(&h.db, &missing_username) .await .expect("get_user_by_username ok") .is_none(), "an unknown username resolves to None" ); let missing_email = Email::new("nobody@test.com").expect("valid email"); assert!( db::users::get_user_by_email(&h.db, &missing_email) .await .expect("get_user_by_email ok") .is_none(), "an unknown email resolves to None" ); } // ── key matching: username is exact, email is normalized ── #[tokio::test] async fn username_lookup_is_case_exact() { let mut h = TestHarness::new().await; // Signup stores the username verbatim (no lowercasing); the lookup SQL is // `WHERE username = $1`, so a differently-cased handle must NOT collide. let user_id = h .signup("CaseUser", "caseuser@test.com", "password123") .await; let exact = Username::new("CaseUser").expect("valid username"); let found = db::users::get_user_by_username(&h.db, &exact) .await .expect("get_user_by_username ok") .expect("exact-case username is found"); assert_eq!(found.id, user_id); let wrong_case = Username::new("caseuser").expect("valid username"); assert!( db::users::get_user_by_username(&h.db, &wrong_case) .await .expect("get_user_by_username ok") .is_none(), "username match is case-exact, so a lowercased variant does not resolve" ); } #[tokio::test] async fn email_lookup_is_case_insensitive_by_normalization() { let mut h = TestHarness::new().await; let user_id = h .signup("email_norm", "email_norm@test.com", "password123") .await; // `Email::new` trims + lowercases, so a mixed-case spelling normalizes to // the same stored value and still resolves to the same row. let mixed = Email::new("Email_Norm@Test.Com").expect("valid email"); let found = db::users::get_user_by_email(&h.db, &mixed) .await .expect("get_user_by_email ok") .expect("mixed-case email normalizes and is found"); assert_eq!(found.id, user_id); } // ── creator permission flag ── #[tokio::test] async fn grant_creator_flips_the_creator_flag() { let mut h = TestHarness::new().await; let user_id = h .signup("creator_flag", "creator_flag@test.com", "password123") .await; let before = db::users::get_user_by_id(&h.db, user_id) .await .expect("get_user_by_id ok") .expect("user found"); assert!( !before.can_create_projects, "a fresh signup cannot create projects" ); h.grant_creator(user_id).await; let after = db::users::get_user_by_id(&h.db, user_id) .await .expect("get_user_by_id ok") .expect("user found"); assert!( after.can_create_projects, "granting creator access sets can_create_projects" ); } // ── voluntary creator pause toggle ── #[tokio::test] async fn pause_and_unpause_creator_toggle_the_flag() { let mut h = TestHarness::new().await; let user_id = h.create_creator("pause_toggle").await; let fresh = db::users::get_user_by_id(&h.db, user_id) .await .expect("get_user_by_id ok") .expect("user found"); assert!(!fresh.is_creator_paused(), "a new creator is not paused"); db::users::pause_creator(&h.db, user_id) .await .expect("pause_creator ok"); let paused = db::users::get_user_by_id(&h.db, user_id) .await .expect("get_user_by_id ok") .expect("user found"); assert!( paused.is_creator_paused(), "pause_creator stamps creator_paused_at" ); db::users::unpause_creator(&h.db, user_id) .await .expect("unpause_creator ok"); let resumed = db::users::get_user_by_id(&h.db, user_id) .await .expect("get_user_by_id ok") .expect("user found"); assert!( !resumed.is_creator_paused(), "unpause_creator clears creator_paused_at" ); } // ── Stripe webhook status write (keyed by connected account) ── #[tokio::test] async fn update_user_stripe_status_persists_flags_by_account() { let mut h = TestHarness::new().await; let user_id = h.create_creator("stripe_status").await; // connect_stripe sets stripe_account_id and flips all three Stripe flags on. h.connect_stripe(user_id, "acct_dbul_status").await; // The webhook write finds the row by stripe_account_id and returns it. let updated = db::users::update_user_stripe_status( &h.db, "acct_dbul_status", true, // onboarding_complete false, // payouts_enabled true, // charges_enabled None, // settlement_currency: Stripe reported nothing usable ) .await .expect("update_user_stripe_status ok") .expect("a matching connected account returns the updated row"); assert_eq!(updated.id, user_id); assert!(updated.stripe_onboarding_complete); assert!(!updated.stripe_payouts_enabled); assert!(updated.stripe_charges_enabled); // The flags are durable, not just echoed by the RETURNING clause. let reread = db::users::get_user_by_id(&h.db, user_id) .await .expect("get_user_by_id ok") .expect("user found"); assert!(reread.stripe_onboarding_complete); assert!(!reread.stripe_payouts_enabled); assert!(reread.stripe_charges_enabled); } #[tokio::test] async fn update_user_stripe_status_is_noop_for_unknown_account() { let h = TestHarness::new().await; // No user carries this account id, so the UPDATE matches nothing and the // webhook handler gets None (rather than an error) to ignore the event. let result = db::users::update_user_stripe_status( &h.db, "acct_dbul_nonexistent", true, true, true, None, ) .await .expect("update_user_stripe_status ok"); assert!( result.is_none(), "an unmatched stripe_account_id is a no-op returning None" ); } // ── create_user: the uniqueness the signup handler catches as 23505 ── /// The join wizard's uniqueness pre-check is best-effort; the real guard is the /// unique index, and the handler reads `db_err.constraint()` to say which field /// collided. Both halves of that are pinned here. #[tokio::test] async fn create_user_rejects_duplicate_username_and_duplicate_normalized_email() { let mut h = TestHarness::new().await; h.signup("dupe_user", "dupe_user@test.com", "password123") .await; let taken_username = db::users::create_user( &h.db, &Username::new("dupe_user").expect("valid username"), &Email::new("other_address@test.com").expect("valid email"), "hash", ) .await; match taken_username { Err(AppError::Database(sqlx::Error::Database(db_err))) => { assert_eq!(db_err.code().as_deref(), Some("23505")); assert!( db_err.constraint().unwrap_or("").contains("username"), "the constraint must name username so signup can point at the field: {:?}", db_err.constraint() ); } other => panic!("a duplicate username must be a unique violation, got {other:?}"), } // `Email::new` lowercases, so a mixed-case spelling is the same stored key // and collides on the same index. let taken_email = db::users::create_user( &h.db, &Username::new("other_name").expect("valid username"), &Email::new("Dupe_User@Test.Com").expect("valid email"), "hash", ) .await; match taken_email { Err(AppError::Database(sqlx::Error::Database(db_err))) => { assert_eq!(db_err.code().as_deref(), Some("23505")); assert!( db_err.constraint().unwrap_or("").contains("email"), "the constraint must name email: {:?}", db_err.constraint() ); } other => panic!("a duplicate normalized email must be a unique violation, got {other:?}"), } } // ── account lifecycle: deactivate / terminate / content removal ── /// Backdate a lifecycle timestamp so the scheduler's expiry windows can be read /// without waiting out 30 or 90 days. async fn backdate(h: &TestHarness, user_id: db::UserId, column: &str, days: i64) { let sql = format!("UPDATE users SET {column} = NOW() - make_interval(days => $2::int) WHERE id = $1"); sqlx::query(&sql) .bind(user_id) .bind(i32::try_from(days).expect("days fits")) .execute(&h.db) .await .expect("backdate ok"); } #[tokio::test] async fn deactivate_and_reactivate_round_trip_the_limbo_flag() { let mut h = TestHarness::new().await; let user_id = h .signup("limbo_user", "limbo_user@test.com", "password123") .await; let before = db::users::get_user_by_id(&h.db, user_id) .await .unwrap() .expect("user found"); assert!(before.deactivated_at.is_none(), "a new account is active"); db::users::deactivate_user(&h.db, user_id) .await .expect("deactivate ok"); let deactivated = db::users::get_user_by_id(&h.db, user_id) .await .unwrap() .expect("user found"); assert!( deactivated.deactivated_at.is_some(), "deactivation stamps the limbo timestamp" ); assert!( deactivated.jwt_invalidated_at.is_some(), "deactivation invalidates outstanding JWTs" ); db::users::reactivate_user(&h.db, user_id) .await .expect("reactivate ok"); let back = db::users::get_user_by_id(&h.db, user_id) .await .unwrap() .expect("user found"); assert!( back.deactivated_at.is_none(), "reactivation clears the limbo timestamp" ); } #[tokio::test] async fn terminated_account_enters_the_expired_set_only_after_its_window() { let mut h = TestHarness::new().await; let user_id = h .signup("term_user", "term_user@test.com", "password123") .await; db::users::terminate_user(&h.db, user_id) .await .expect("terminate ok"); let terminated = db::users::get_user_by_id(&h.db, user_id) .await .unwrap() .expect("user found"); assert!( terminated.terminated_at.is_some(), "termination stamps the export-window start" ); assert!( terminated.jwt_invalidated_at.is_some(), "termination invalidates outstanding JWTs" ); let fresh = db::users::get_expired_terminated_ids(&h.db) .await .expect("expired ids ok"); assert!( !fresh.contains(&user_id), "the 30-day export window has not elapsed" ); backdate(&h, user_id, "terminated_at", 31).await; let expired = db::users::get_expired_terminated_ids(&h.db) .await .expect("expired ids ok"); assert!( expired.contains(&user_id), "past the window the scheduler picks the account up" ); } #[tokio::test] async fn scheduled_content_removal_expires_after_its_grace_period() { let mut h = TestHarness::new().await; let user_id = h .signup("removal_user", "removal_user@test.com", "password123") .await; db::users::schedule_content_removal(&h.db, user_id) .await .expect("schedule removal ok"); let scheduled = db::users::get_user_by_id(&h.db, user_id) .await .unwrap() .expect("user found"); assert!( scheduled.content_removal_at.is_some(), "scheduling stamps the removal date" ); assert!( scheduled.deactivated_at.is_some(), "scheduling removal also hides the account" ); let fresh = db::users::get_expired_content_removal_ids(&h.db) .await .expect("expired ids ok"); assert!( !fresh.contains(&user_id), "the 90-day grace period has not elapsed" ); backdate(&h, user_id, "content_removal_at", 1).await; let expired = db::users::get_expired_content_removal_ids(&h.db) .await .expect("expired ids ok"); assert!( expired.contains(&user_id), "past the grace period the scheduler picks the account up" ); }