max / makenotwork
6 files changed,
+922 insertions,
-0 deletions
| @@ -39,6 +39,14 @@ impl TestClient { | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | /// Fork a fresh client that talks to the same app but with its own empty | |
| 43 | + | /// cookie jar, CSRF token, and unique IP — simulates a second, independent | |
| 44 | + | /// browser session (e.g. the same user logged in on another device). | |
| 45 | + | #[allow(dead_code)] | |
| 46 | + | pub fn fork_fresh(&self) -> TestClient { | |
| 47 | + | TestClient::new(self.app.clone()) | |
| 48 | + | } | |
| 49 | + | ||
| 42 | 50 | /// Set the IP address used in the X-Forwarded-For header. | |
| 43 | 51 | #[allow(dead_code)] | |
| 44 | 52 | pub fn set_forwarded_ip(&mut self, ip: &str) { |
| @@ -588,3 +588,56 @@ async fn old_password_rejected_after_change() { | |||
| 588 | 588 | let resp = h.client.get("/dashboard").await; | |
| 589 | 589 | assert_eq!(resp.status, 200, "New password should work"); | |
| 590 | 590 | } | |
| 591 | + | ||
| 592 | + | /// Contract: changing the password revokes OTHER active web sessions (a | |
| 593 | + | /// stolen/leaked cookie must not outlive a password change) while keeping the | |
| 594 | + | /// session that made the change logged in. This pins the `delete_other_sessions` | |
| 595 | + | /// sweep in `update_password` — the subtle "revoke everyone else, keep me" half | |
| 596 | + | /// of the security contract that `old_password_rejected_after_change` (single | |
| 597 | + | /// session) does not exercise. | |
| 598 | + | #[tokio::test] | |
| 599 | + | async fn password_change_revokes_other_sessions() { | |
| 600 | + | let mut h = TestHarness::new().await; | |
| 601 | + | // Session A: signup leaves h.client authenticated as the user. | |
| 602 | + | h.signup("multisess", "multisess@test.com", "oldpassword1").await; | |
| 603 | + | let resp = h.client.get("/dashboard").await; | |
| 604 | + | assert_eq!(resp.status, 200, "session A should start logged in: {}", resp.text); | |
| 605 | + | ||
| 606 | + | // Session B: a second, independent client logs in as the same user. | |
| 607 | + | let mut other = h.client.fork_fresh(); | |
| 608 | + | other.fetch_csrf_token().await; | |
| 609 | + | let resp = other | |
| 610 | + | .post_form("/login", "login=multisess&password=oldpassword1") | |
| 611 | + | .await; | |
| 612 | + | assert!( | |
| 613 | + | resp.status.is_success() || resp.status.is_redirection(), | |
| 614 | + | "session B login failed: {} {}", | |
| 615 | + | resp.status, | |
| 616 | + | resp.text | |
| 617 | + | ); | |
| 618 | + | let resp = other.get("/dashboard").await; | |
| 619 | + | assert_eq!(resp.status, 200, "session B should be logged in before the change"); | |
| 620 | + | ||
| 621 | + | // Session A changes the password. | |
| 622 | + | let resp = h | |
| 623 | + | .client | |
| 624 | + | .put_form( | |
| 625 | + | "/api/users/me/password", | |
| 626 | + | "current_password=oldpassword1&new_password=newpassword1", | |
| 627 | + | ) | |
| 628 | + | .await; | |
| 629 | + | assert!(resp.status.is_success(), "password change failed: {}", resp.text); | |
| 630 | + | ||
| 631 | + | // Session B is now revoked (cookie no longer resolves to a live session). | |
| 632 | + | let resp = other.get("/dashboard").await; | |
| 633 | + | assert!( | |
| 634 | + | resp.status == 401 || resp.status.is_redirection(), | |
| 635 | + | "session B must be revoked after the password change: {} {}", | |
| 636 | + | resp.status, | |
| 637 | + | resp.text | |
| 638 | + | ); | |
| 639 | + | ||
| 640 | + | // Session A — the session that made the change — stays logged in. | |
| 641 | + | let resp = h.client.get("/dashboard").await; | |
| 642 | + | assert_eq!(resp.status, 200, "the changing session must remain logged in"); | |
| 643 | + | } |
| @@ -0,0 +1,245 @@ | |||
| 1 | + | //! DB-layer contract tests for core `db::items` CRUD — the single-item side. | |
| 2 | + | //! | |
| 3 | + | //! Audit Run 18 Testing flagged the item core as asserted only through the HTTP | |
| 4 | + | //! surface. The batch mutations already have their own layer file | |
| 5 | + | //! (`db_items_bulk_layer.rs`); these pin the single-item contracts it leaves | |
| 6 | + | //! out: `create_item` + `get_item_by_id` round-trip (and the not-found `None`), | |
| 7 | + | //! the denormalized `sales_count` increment/decrement (with the floor-at-zero | |
| 8 | + | //! clamp), `update_item`'s publish toggle + ownership seal, the | |
| 9 | + | //! post-grace hide/unhide round-trip, and the admin-removal republish block. | |
| 10 | + | ||
| 11 | + | use crate::harness::TestHarness; | |
| 12 | + | use makenotwork::db::{items, AiTier, ItemId, ItemType, PriceCents, ProjectId, UserId}; | |
| 13 | + | ||
| 14 | + | /// Create a logged-in creator with an empty-ish project and return | |
| 15 | + | /// `(user_id, project_id)`. (The helper also seeds one HTTP-created item, which | |
| 16 | + | /// these tests ignore — they add their own items via `items::create_item`.) | |
| 17 | + | async fn creator_project(h: &mut TestHarness, tag: &str) -> (UserId, ProjectId) { | |
| 18 | + | let setup = h.create_creator_with_item(&format!("itm_{tag}"), "digital", 1000).await; | |
| 19 | + | let project_id: ProjectId = setup.project_id.parse().expect("project id parses"); | |
| 20 | + | (setup.user_id, project_id) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Seed a bare user directly (no HTTP-created item), for tests that assert on a | |
| 24 | + | /// per-user, project-spanning count and so need a contamination-free item set. | |
| 25 | + | async fn seed_user(h: &TestHarness, username: &str) -> UserId { | |
| 26 | + | let hash = makenotwork::auth::hash_password("password123").expect("hash"); | |
| 27 | + | sqlx::query_scalar::<_, UserId>( | |
| 28 | + | "INSERT INTO users (username, email, password_hash, email_verified) | |
| 29 | + | VALUES ($1, $2, $3, true) RETURNING id", | |
| 30 | + | ) | |
| 31 | + | .bind(username) | |
| 32 | + | .bind(format!("{username}@test.com")) | |
| 33 | + | .bind(&hash) | |
| 34 | + | .fetch_one(&h.db) | |
| 35 | + | .await | |
| 36 | + | .expect("seed user") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// Seed a bare project directly for a user. | |
| 40 | + | async fn seed_project(h: &TestHarness, user: UserId, slug: &str) -> ProjectId { | |
| 41 | + | sqlx::query_scalar::<_, ProjectId>( | |
| 42 | + | "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, 'P') RETURNING id", | |
| 43 | + | ) | |
| 44 | + | .bind(user) | |
| 45 | + | .bind(slug) | |
| 46 | + | .fetch_one(&h.db) | |
| 47 | + | .await | |
| 48 | + | .expect("seed project") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Insert a fresh item into a project via the real `create_item` path. | |
| 52 | + | async fn make_item(h: &TestHarness, project: ProjectId, title: &str) -> ItemId { | |
| 53 | + | let item = items::create_item( | |
| 54 | + | &h.db, | |
| 55 | + | project, | |
| 56 | + | title, | |
| 57 | + | None, | |
| 58 | + | PriceCents::new(1000).expect("valid price"), | |
| 59 | + | ItemType::Digital, | |
| 60 | + | AiTier::Handmade, | |
| 61 | + | None, | |
| 62 | + | ) | |
| 63 | + | .await | |
| 64 | + | .expect("create_item"); | |
| 65 | + | item.id | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | async fn is_public(h: &TestHarness, item: ItemId) -> bool { | |
| 69 | + | sqlx::query_scalar::<_, bool>("SELECT is_public FROM items WHERE id = $1") | |
| 70 | + | .bind(item) | |
| 71 | + | .fetch_one(&h.db) | |
| 72 | + | .await | |
| 73 | + | .expect("read is_public") | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | async fn sales_count(h: &TestHarness, item: ItemId) -> i32 { | |
| 77 | + | sqlx::query_scalar::<_, i32>("SELECT sales_count FROM items WHERE id = $1") | |
| 78 | + | .bind(item) | |
| 79 | + | .fetch_one(&h.db) | |
| 80 | + | .await | |
| 81 | + | .expect("read sales_count") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // ── create_item + get_item_by_id: round-trip and not-found ──────────────────── | |
| 85 | + | ||
| 86 | + | #[tokio::test] | |
| 87 | + | async fn create_item_round_trips_through_get_by_id_and_missing_reads_none() { | |
| 88 | + | let mut h = TestHarness::new().await; | |
| 89 | + | let (_owner, project) = creator_project(&mut h, "get").await; | |
| 90 | + | ||
| 91 | + | let created = items::create_item( | |
| 92 | + | &h.db, | |
| 93 | + | project, | |
| 94 | + | "My First Track", | |
| 95 | + | Some("a description"), | |
| 96 | + | PriceCents::new(2500).expect("valid price"), | |
| 97 | + | ItemType::Audio, | |
| 98 | + | AiTier::Handmade, | |
| 99 | + | None, | |
| 100 | + | ) | |
| 101 | + | .await | |
| 102 | + | .expect("create_item"); | |
| 103 | + | ||
| 104 | + | // create_item auto-generates a URL-safe slug from the title. | |
| 105 | + | assert_eq!(created.slug, "my-first-track", "slug is derived from the title"); | |
| 106 | + | ||
| 107 | + | let fetched = items::get_item_by_id(&h.db, created.id) | |
| 108 | + | .await | |
| 109 | + | .expect("get_item_by_id ok") | |
| 110 | + | .expect("the created item is found by id"); | |
| 111 | + | assert_eq!(fetched.id, created.id); | |
| 112 | + | assert_eq!(fetched.title, "My First Track"); | |
| 113 | + | assert_eq!(fetched.price_cents, 2500, "stored price is the created 2500 cents"); | |
| 114 | + | ||
| 115 | + | // A random id that was never inserted reads as None, not an error. | |
| 116 | + | let missing = items::get_item_by_id(&h.db, ItemId::new()) | |
| 117 | + | .await | |
| 118 | + | .expect("get_item_by_id ok for a missing id"); | |
| 119 | + | assert!(missing.is_none(), "an unknown item id returns None"); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // ── increment/decrement_sales_count: the denormalized counter ───────────────── | |
| 123 | + | ||
| 124 | + | #[tokio::test] | |
| 125 | + | async fn sales_count_increments_decrements_and_floors_at_zero() { | |
| 126 | + | let mut h = TestHarness::new().await; | |
| 127 | + | let (_owner, project) = creator_project(&mut h, "sales").await; | |
| 128 | + | let item = make_item(&h, project, "Counter Item").await; | |
| 129 | + | ||
| 130 | + | assert_eq!(sales_count(&h, item).await, 0, "a new item starts at zero sales"); | |
| 131 | + | ||
| 132 | + | items::increment_sales_count(&h.db, item).await.expect("increment #1"); | |
| 133 | + | items::increment_sales_count(&h.db, item).await.expect("increment #2"); | |
| 134 | + | assert_eq!(sales_count(&h, item).await, 2, "two increments bump the counter to 2"); | |
| 135 | + | ||
| 136 | + | items::decrement_sales_count(&h.db, item).await.expect("decrement"); | |
| 137 | + | assert_eq!(sales_count(&h, item).await, 1, "a decrement (refund) walks it back"); | |
| 138 | + | ||
| 139 | + | // The clamp: decrementing past zero can't go negative (GREATEST(.. , 0)). | |
| 140 | + | items::decrement_sales_count(&h.db, item).await.expect("decrement to zero"); | |
| 141 | + | items::decrement_sales_count(&h.db, item).await.expect("decrement below zero is clamped"); | |
| 142 | + | assert_eq!(sales_count(&h, item).await, 0, "sales_count never goes negative"); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // ── update_item: publish toggle + ownership seal ────────────────────────────── | |
| 146 | + | ||
| 147 | + | #[tokio::test] | |
| 148 | + | async fn update_item_toggles_publish_and_is_owner_scoped() { | |
| 149 | + | let mut h = TestHarness::new().await; | |
| 150 | + | let (owner, project) = creator_project(&mut h, "upd").await; | |
| 151 | + | let attacker = h.signup("itm_upd_attacker", "itm_upd_attacker@test.com", "password123").await; | |
| 152 | + | let item = make_item(&h, project, "Toggle Me").await; | |
| 153 | + | ||
| 154 | + | // Start unpublished, then the owner publishes via update_item. | |
| 155 | + | items::update_item( | |
| 156 | + | &h.db, item, owner, | |
| 157 | + | None, None, None, None, Some(false), | |
| 158 | + | None, None, None, None, None, None, | |
| 159 | + | ) | |
| 160 | + | .await | |
| 161 | + | .expect("owner unpublish"); | |
| 162 | + | assert!(!is_public(&h, item).await); | |
| 163 | + | ||
| 164 | + | // A non-owner update matches zero rows: the ownership subquery excludes them, | |
| 165 | + | // so `fetch_one` finds nothing and the call errors — nothing changes. | |
| 166 | + | let hijack = items::update_item( | |
| 167 | + | &h.db, item, attacker, | |
| 168 | + | None, None, None, None, Some(true), | |
| 169 | + | None, None, None, None, None, None, | |
| 170 | + | ) | |
| 171 | + | .await; | |
| 172 | + | assert!(hijack.is_err(), "a non-owner update_item must not touch the row"); | |
| 173 | + | assert!(!is_public(&h, item).await, "the item stays unpublished after the hijack attempt"); | |
| 174 | + | ||
| 175 | + | // The owner flips it public. | |
| 176 | + | let published = items::update_item( | |
| 177 | + | &h.db, item, owner, | |
| 178 | + | None, None, None, None, Some(true), | |
| 179 | + | None, None, None, None, None, None, | |
| 180 | + | ) | |
| 181 | + | .await | |
| 182 | + | .expect("owner publish"); | |
| 183 | + | assert!(published.is_public, "the owner can publish their own item"); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // ── hide/unhide_all_items_for_user: the post-grace round-trip ───────────────── | |
| 187 | + | ||
| 188 | + | #[tokio::test] | |
| 189 | + | async fn hide_then_unhide_all_items_for_user_round_trips() { | |
| 190 | + | let h = TestHarness::new().await; | |
| 191 | + | // Seed a contamination-free creator: `hide_all_items_for_users` spans every | |
| 192 | + | // project the user owns, so the count must not include a stray HTTP-seeded item. | |
| 193 | + | let owner = seed_user(&h, "itm_hide_owner").await; | |
| 194 | + | let project = seed_project(&h, owner, "itm-hide-proj").await; | |
| 195 | + | // create_item leaves is_public at its column default (true), so both start public. | |
| 196 | + | let a = make_item(&h, project, "Public A").await; | |
| 197 | + | let b = make_item(&h, project, "Public B").await; | |
| 198 | + | assert!(is_public(&h, a).await && is_public(&h, b).await, "items start public"); | |
| 199 | + | ||
| 200 | + | // The scheduler sweep hides every public item for the creator in one shot. | |
| 201 | + | let hidden = items::hide_all_items_for_users(&h.db, &[owner]).await.expect("hide"); | |
| 202 | + | assert_eq!(hidden, 2, "both public items are hidden"); | |
| 203 | + | assert!(!is_public(&h, a).await && !is_public(&h, b).await); | |
| 204 | + | ||
| 205 | + | // An empty slice is a no-op, not a full-table update. | |
| 206 | + | assert_eq!(items::hide_all_items_for_users(&h.db, &[]).await.expect("empty hide"), 0); | |
| 207 | + | ||
| 208 | + | // Re-subscribing unhides them again and reports the count restored. | |
| 209 | + | let unhidden = items::unhide_all_items_for_user(&h.db, owner).await.expect("unhide"); | |
| 210 | + | assert_eq!(unhidden, 2, "both items are unhidden on re-subscribe"); | |
| 211 | + | assert!(is_public(&h, a).await && is_public(&h, b).await); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // ── admin_remove_item: hides + blocks creator republish ─────────────────────── | |
| 215 | + | ||
| 216 | + | #[tokio::test] | |
| 217 | + | async fn admin_removal_hides_the_item_and_blocks_creator_republish() { | |
| 218 | + | let mut h = TestHarness::new().await; | |
| 219 | + | let (owner, project) = creator_project(&mut h, "adm").await; | |
| 220 | + | let item = make_item(&h, project, "Flagged Item").await; | |
| 221 | + | assert!(is_public(&h, item).await, "item starts public"); | |
| 222 | + | ||
| 223 | + | let removed = items::admin_remove_item(&h.db, item, "violates policy") | |
| 224 | + | .await | |
| 225 | + | .expect("admin_remove_item"); | |
| 226 | + | assert!(removed.removed_by_admin, "the item is flagged removed_by_admin"); | |
| 227 | + | assert!(!removed.is_public, "admin removal unpublishes the item"); | |
| 228 | + | assert_eq!(removed.removal_reason.as_deref(), Some("violates policy")); | |
| 229 | + | ||
| 230 | + | // The creator cannot republish an admin-removed item: update_item's CASE | |
| 231 | + | // forces is_public back to false while removed_by_admin is set. | |
| 232 | + | let attempt = items::update_item( | |
| 233 | + | &h.db, item, owner, | |
| 234 | + | None, None, None, None, Some(true), | |
| 235 | + | None, None, None, None, None, None, | |
| 236 | + | ) | |
| 237 | + | .await | |
| 238 | + | .expect("owner update runs (row is theirs)"); | |
| 239 | + | assert!(!attempt.is_public, "a removed item cannot be republished by its creator"); | |
| 240 | + | ||
| 241 | + | // Admin restore clears the flags (but leaves it unpublished for manual republish). | |
| 242 | + | let restored = items::admin_restore_item(&h.db, item).await.expect("admin_restore_item"); | |
| 243 | + | assert!(!restored.removed_by_admin, "restore clears the admin-removed flag"); | |
| 244 | + | assert!(restored.removal_reason.is_none()); | |
| 245 | + | } |
| @@ -0,0 +1,365 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::synckit_billing` — the SyncKit v2 | |
| 2 | + | //! developer-billing writes. | |
| 3 | + | //! | |
| 4 | + | //! Audit Run 18 (Testing) flagged this module's guarded UPDATEs as asserted | |
| 5 | + | //! only indirectly through the HTTP/webhook billing flow. These call the | |
| 6 | + | //! `db::synckit_billing` functions directly against real Postgres so the | |
| 7 | + | //! invariants the billing safety leans on are pinned at the layer they live in: | |
| 8 | + | //! | |
| 9 | + | //! - `apply_billing_update` persists status + period in one guarded statement, | |
| 10 | + | //! - the terminal-`canceled` guard refuses to revive a canceled app (a stray | |
| 11 | + | //! `invoice.paid` after a `deleted` can't resurrect it or refresh its period), | |
| 12 | + | //! - the epoch-period guard drops a non-positive / inverted Stripe window | |
| 13 | + | //! rather than stamping a 1970 period onto a live app, | |
| 14 | + | //! - `activate_billing` is only valid from `draft` (replay / TOCTOU conflict), | |
| 15 | + | //! - `claim_key` enforces `key_cap` under the `FOR UPDATE` lock — a claim that | |
| 16 | + | //! would exceed the cap is refused atomically, never over-allocated, | |
| 17 | + | //! - `claim_key` / `release_key` are idempotent. | |
| 18 | + | ||
| 19 | + | use crate::harness::db::TestDb; | |
| 20 | + | use chrono::{DateTime, Utc}; | |
| 21 | + | use makenotwork::db::synckit_billing; | |
| 22 | + | use makenotwork::db::{SyncAppId, SyncBillingStatus, SyncEnforcementMode, UserId}; | |
| 23 | + | ||
| 24 | + | /// Seed a verified user. | |
| 25 | + | async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId { | |
| 26 | + | let hash = makenotwork::auth::hash_password("password123").expect("hash"); | |
| 27 | + | sqlx::query_scalar::<_, UserId>( | |
| 28 | + | "INSERT INTO users (username, email, password_hash, email_verified) | |
| 29 | + | VALUES ($1, $2, $3, true) RETURNING id", | |
| 30 | + | ) | |
| 31 | + | .bind(username) | |
| 32 | + | .bind(format!("{username}@test.com")) | |
| 33 | + | .bind(&hash) | |
| 34 | + | .fetch_one(pool) | |
| 35 | + | .await | |
| 36 | + | .expect("seed user") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// Seed a non-internal draft sync app plus its live-usage row (the row the | |
| 40 | + | /// storage layer normally inserts on app create, and that `claim_key` locks | |
| 41 | + | /// `FOR UPDATE`). | |
| 42 | + | async fn seed_billable_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 43 | + | let app: SyncAppId = sqlx::query_scalar::<_, SyncAppId>( | |
| 44 | + | "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status) | |
| 45 | + | VALUES ($1, $2, $3, $4, FALSE, 'draft') RETURNING id", | |
| 46 | + | ) | |
| 47 | + | .bind(user) | |
| 48 | + | .bind(name) | |
| 49 | + | .bind(format!("hash_{name}")) | |
| 50 | + | .bind(&name[..name.len().min(8)]) | |
| 51 | + | .fetch_one(pool) | |
| 52 | + | .await | |
| 53 | + | .expect("seed sync app"); | |
| 54 | + | ||
| 55 | + | sqlx::query("INSERT INTO sync_app_usage_current (app_id) VALUES ($1)") | |
| 56 | + | .bind(app) | |
| 57 | + | .execute(pool) | |
| 58 | + | .await | |
| 59 | + | .expect("seed usage row"); | |
| 60 | + | ||
| 61 | + | app | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Read the current `keys_claimed` counter off `sync_app_usage_current`. | |
| 65 | + | async fn keys_claimed(pool: &sqlx::PgPool, app: SyncAppId) -> i32 { | |
| 66 | + | sqlx::query_scalar::<_, i32>("SELECT keys_claimed FROM sync_app_usage_current WHERE app_id = $1") | |
| 67 | + | .bind(app) | |
| 68 | + | .fetch_one(pool) | |
| 69 | + | .await | |
| 70 | + | .expect("read keys_claimed") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Count of currently-active (un-released) key rows for the app. | |
| 74 | + | async fn active_key_rows(pool: &sqlx::PgPool, app: SyncAppId) -> i64 { | |
| 75 | + | sqlx::query_scalar::<_, i64>( | |
| 76 | + | "SELECT COUNT(*) FROM sync_app_keys WHERE app_id = $1 AND released_at IS NULL", | |
| 77 | + | ) | |
| 78 | + | .bind(app) | |
| 79 | + | .fetch_one(pool) | |
| 80 | + | .await | |
| 81 | + | .expect("count active keys") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn ts(secs: i64) -> DateTime<Utc> { | |
| 85 | + | DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Activate billing on a draft app in `bulk` mode with a 100 GB cap. Leaves the | |
| 89 | + | /// app `active` with the shape constraint satisfied (storage_gb_cap set, per-key | |
| 90 | + | /// knobs NULL). | |
| 91 | + | async fn activate_bulk(pool: &sqlx::PgPool, app: SyncAppId, sub_id: &str, start: i64, end: i64) { | |
| 92 | + | synckit_billing::activate_billing( | |
| 93 | + | pool, | |
| 94 | + | app, | |
| 95 | + | SyncEnforcementMode::Bulk, | |
| 96 | + | Some(100), | |
| 97 | + | None, | |
| 98 | + | None, | |
| 99 | + | sub_id, | |
| 100 | + | ts(start), | |
| 101 | + | ts(end), | |
| 102 | + | ) | |
| 103 | + | .await | |
| 104 | + | .expect("activate billing"); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // ── apply_billing_update: persistence ──────────────────────────────────────── | |
| 108 | + | ||
| 109 | + | #[tokio::test] | |
| 110 | + | async fn apply_billing_update_persists_status_and_period() { | |
| 111 | + | let db = TestDb::new().await; | |
| 112 | + | let user = seed_user(&db.pool, "sbb_persist").await; | |
| 113 | + | let app = seed_billable_app(&db.pool, user, "sbbpersist").await; | |
| 114 | + | activate_bulk(&db.pool, app, "sub_persist", 1_700_000_000, 1_700_100_000).await; | |
| 115 | + | ||
| 116 | + | // A Stripe-driven update flips status and refreshes the period atomically. | |
| 117 | + | let updated = synckit_billing::apply_billing_update( | |
| 118 | + | &db.pool, | |
| 119 | + | app, | |
| 120 | + | Some("suspended_unpaid"), | |
| 121 | + | Some((1_700_200_000, 1_700_300_000)), | |
| 122 | + | ) | |
| 123 | + | .await | |
| 124 | + | .expect("apply update"); | |
| 125 | + | assert!(updated, "a live app row is updated"); | |
| 126 | + | ||
| 127 | + | let billing = synckit_billing::get_app_with_billing(&db.pool, app) | |
| 128 | + | .await | |
| 129 | + | .expect("load billing") | |
| 130 | + | .expect("app exists"); | |
| 131 | + | assert_eq!(billing.billing_status, SyncBillingStatus::SuspendedUnpaid); | |
| 132 | + | assert_eq!(billing.current_period_start, Some(ts(1_700_200_000))); | |
| 133 | + | assert_eq!(billing.current_period_end, Some(ts(1_700_300_000))); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // ── apply_billing_update: terminal-canceled guard ──────────────────────────── | |
| 137 | + | ||
| 138 | + | #[tokio::test] | |
| 139 | + | async fn apply_billing_update_cannot_revive_a_canceled_app() { | |
| 140 | + | let db = TestDb::new().await; | |
| 141 | + | let user = seed_user(&db.pool, "sbb_revive").await; | |
| 142 | + | let app = seed_billable_app(&db.pool, user, "sbbrevive").await; | |
| 143 | + | activate_bulk(&db.pool, app, "sub_revive", 1_700_000_000, 1_700_100_000).await; | |
| 144 | + | ||
| 145 | + | // Cancel is terminal. (A `customer.subscription.deleted` webhook.) | |
| 146 | + | let canceled = synckit_billing::apply_billing_update(&db.pool, app, Some("canceled"), None) | |
| 147 | + | .await | |
| 148 | + | .expect("cancel"); | |
| 149 | + | assert!(canceled, "the cancel transition itself matches a live row"); | |
| 150 | + | ||
| 151 | + | // A stray `invoice.paid` afterward tries to move canceled -> active AND | |
| 152 | + | // refresh the period. The guard must refuse both in one statement. | |
| 153 | + | let revived = synckit_billing::apply_billing_update( | |
| 154 | + | &db.pool, | |
| 155 | + | app, | |
| 156 | + | Some("active"), | |
| 157 | + | Some((1_700_900_000, 1_701_000_000)), | |
| 158 | + | ) | |
| 159 | + | .await | |
| 160 | + | .expect("apply update"); | |
| 161 | + | assert!(!revived, "no row updated: canceled is terminal"); | |
| 162 | + | ||
| 163 | + | let billing = synckit_billing::get_app_with_billing(&db.pool, app) | |
| 164 | + | .await | |
| 165 | + | .expect("load billing") | |
| 166 | + | .expect("app exists"); | |
| 167 | + | assert_eq!(billing.billing_status, SyncBillingStatus::Canceled, "status stays canceled"); | |
| 168 | + | // The period must not have been refreshed by the refused update. | |
| 169 | + | assert_eq!( | |
| 170 | + | billing.current_period_end, | |
| 171 | + | Some(ts(1_700_100_000)), | |
| 172 | + | "period is not refreshed on a canceled app" | |
| 173 | + | ); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // ── apply_billing_update: epoch-period guard ───────────────────────────────── | |
| 177 | + | ||
| 178 | + | #[tokio::test] | |
| 179 | + | async fn apply_billing_update_ignores_a_nonpositive_period() { | |
| 180 | + | let db = TestDb::new().await; | |
| 181 | + | let user = seed_user(&db.pool, "sbb_epoch").await; | |
| 182 | + | let app = seed_billable_app(&db.pool, user, "sbbepoch").await; | |
| 183 | + | activate_bulk(&db.pool, app, "sub_epoch", 1_700_000_000, 1_700_100_000).await; | |
| 184 | + | ||
| 185 | + | // A thin/zero webhook: end <= 0. The row still matches (status unchanged), | |
| 186 | + | // but the COALESCE must keep the existing period rather than stamp 1970. | |
| 187 | + | let matched = synckit_billing::apply_billing_update(&db.pool, app, None, Some((0, 0))) | |
| 188 | + | .await | |
| 189 | + | .expect("apply update"); | |
| 190 | + | assert!(matched, "the live row still matches even with no writable fields"); | |
| 191 | + | ||
| 192 | + | let billing = synckit_billing::get_app_with_billing(&db.pool, app) | |
| 193 | + | .await | |
| 194 | + | .expect("load billing") | |
| 195 | + | .expect("app exists"); | |
| 196 | + | assert_eq!( | |
| 197 | + | billing.current_period_start, | |
| 198 | + | Some(ts(1_700_000_000)), | |
| 199 | + | "existing period start is preserved" | |
| 200 | + | ); | |
| 201 | + | assert_eq!( | |
| 202 | + | billing.current_period_end, | |
| 203 | + | Some(ts(1_700_100_000)), | |
| 204 | + | "a non-positive window never stamps a 1970 period" | |
| 205 | + | ); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[tokio::test] | |
| 209 | + | async fn apply_billing_update_ignores_an_inverted_period() { | |
| 210 | + | let db = TestDb::new().await; | |
| 211 | + | let user = seed_user(&db.pool, "sbb_inverted").await; | |
| 212 | + | let app = seed_billable_app(&db.pool, user, "sbbinvert").await; | |
| 213 | + | activate_bulk(&db.pool, app, "sub_invert", 1_700_000_000, 1_700_100_000).await; | |
| 214 | + | ||
| 215 | + | // end > 0 but start > end: an inverted range writes nothing. | |
| 216 | + | let matched = | |
| 217 | + | synckit_billing::apply_billing_update(&db.pool, app, None, Some((1_800_000_000, 1_700_000_000))) | |
| 218 | + | .await | |
| 219 | + | .expect("apply update"); | |
| 220 | + | assert!(matched); | |
| 221 | + | ||
| 222 | + | let billing = synckit_billing::get_app_with_billing(&db.pool, app) | |
| 223 | + | .await | |
| 224 | + | .expect("load billing") | |
| 225 | + | .expect("app exists"); | |
| 226 | + | assert_eq!(billing.current_period_start, Some(ts(1_700_000_000))); | |
| 227 | + | assert_eq!(billing.current_period_end, Some(ts(1_700_100_000))); | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // ── activate_billing: draft-only guard ─────────────────────────────────────── | |
| 231 | + | ||
| 232 | + | #[tokio::test] | |
| 233 | + | async fn activate_billing_is_only_valid_from_draft() { | |
| 234 | + | let db = TestDb::new().await; | |
| 235 | + | let user = seed_user(&db.pool, "sbb_activate").await; | |
| 236 | + | let app = seed_billable_app(&db.pool, user, "sbbactiv").await; | |
| 237 | + | ||
| 238 | + | activate_bulk(&db.pool, app, "sub_first", 1_700_000_000, 1_700_100_000).await; | |
| 239 | + | ||
| 240 | + | let billing = synckit_billing::get_app_with_billing(&db.pool, app) | |
| 241 | + | .await | |
| 242 | + | .expect("load billing") | |
| 243 | + | .expect("app exists"); | |
| 244 | + | assert_eq!(billing.billing_status, SyncBillingStatus::Active); | |
| 245 | + | assert_eq!(billing.stripe_subscription_id.as_deref(), Some("sub_first")); | |
| 246 | + | assert_eq!(billing.storage_gb_cap, Some(100)); | |
| 247 | + | ||
| 248 | + | // A replay / TOCTOU race that reaches activation again against a now-active | |
| 249 | + | // app must conflict, not silently orphan the live subscription. | |
| 250 | + | let err = synckit_billing::activate_billing( | |
| 251 | + | &db.pool, | |
| 252 | + | app, | |
| 253 | + | SyncEnforcementMode::Bulk, | |
| 254 | + | Some(250), | |
| 255 | + | None, | |
| 256 | + | None, | |
| 257 | + | "sub_second", | |
| 258 | + | ts(1_700_500_000), | |
| 259 | + | ts(1_700_600_000), | |
| 260 | + | ) | |
| 261 | + | .await | |
| 262 | + | .expect_err("re-activation of a non-draft app must fail"); | |
| 263 | + | assert!( | |
| 264 | + | matches!(err, makenotwork::error::AppError::Conflict(_)), | |
| 265 | + | "expected a Conflict, got {err:?}" | |
| 266 | + | ); | |
| 267 | + | ||
| 268 | + | // The first subscription id is untouched. | |
| 269 | + | let after = synckit_billing::get_app_with_billing(&db.pool, app) | |
| 270 | + | .await | |
| 271 | + | .expect("load billing") | |
| 272 | + | .expect("app exists"); | |
| 273 | + | assert_eq!(after.stripe_subscription_id.as_deref(), Some("sub_first")); | |
| 274 | + | assert_eq!(after.storage_gb_cap, Some(100), "knobs are not overwritten by the refused activation"); | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // ── claim_key: cap enforcement under the FOR UPDATE lock ────────────────────── | |
| 278 | + | ||
| 279 | + | #[tokio::test] | |
| 280 | + | async fn claim_key_enforces_cap_atomically() { | |
| 281 | + | let db = TestDb::new().await; | |
| 282 | + | let user = seed_user(&db.pool, "sbb_cap").await; | |
| 283 | + | let app = seed_billable_app(&db.pool, user, "sbbcap").await; | |
| 284 | + | ||
| 285 | + | // key_cap = 2. Two distinct claims fill it; the third is refused. | |
| 286 | + | let first = synckit_billing::claim_key(&db.pool, app, "k1", Some(2)) | |
| 287 | + | .await | |
| 288 | + | .expect("claim k1"); | |
| 289 | + | assert!(first.newly_claimed); | |
| 290 | + | assert!(!first.cap_reached); | |
| 291 | + | assert_eq!(first.total_claimed, 1); | |
| 292 | + | ||
| 293 | + | let second = synckit_billing::claim_key(&db.pool, app, "k2", Some(2)) | |
| 294 | + | .await | |
| 295 | + | .expect("claim k2"); | |
| 296 | + | assert!(second.newly_claimed); | |
| 297 | + | assert_eq!(second.total_claimed, 2); | |
| 298 | + | ||
| 299 | + | let third = synckit_billing::claim_key(&db.pool, app, "k3", Some(2)) | |
| 300 | + | .await | |
| 301 | + | .expect("claim k3"); | |
| 302 | + | assert!(!third.newly_claimed, "a new slot over the cap is refused"); | |
| 303 | + | assert!(third.cap_reached); | |
| 304 | + | assert_eq!(third.total_claimed, 2, "the cap is not overshot"); | |
| 305 | + | ||
| 306 | + | // The refused claim inserted no row and left the counter at the cap. | |
| 307 | + | assert_eq!(keys_claimed(&db.pool, app).await, 2); | |
| 308 | + | assert_eq!(active_key_rows(&db.pool, app).await, 2); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | #[tokio::test] | |
| 312 | + | async fn claim_key_is_idempotent_and_admits_reclaims_at_cap() { | |
| 313 | + | let db = TestDb::new().await; | |
| 314 | + | let user = seed_user(&db.pool, "sbb_reclaim").await; | |
| 315 | + | let app = seed_billable_app(&db.pool, user, "sbbreclaim").await; | |
| 316 | + | ||
| 317 | + | // key_cap = 1, filled by k1. | |
| 318 | + | let first = synckit_billing::claim_key(&db.pool, app, "k1", Some(1)) | |
| 319 | + | .await | |
| 320 | + | .expect("claim k1"); | |
| 321 | + | assert!(first.newly_claimed); | |
| 322 | + | assert_eq!(first.total_claimed, 1); | |
| 323 | + | ||
| 324 | + | // Re-claiming the already-active key consumes no new slot: not newly | |
| 325 | + | // claimed, and NOT reported as cap_reached (it's admitted). | |
| 326 | + | let reclaim = synckit_billing::claim_key(&db.pool, app, "k1", Some(1)) | |
| 327 | + | .await | |
| 328 | + | .expect("re-claim k1"); | |
| 329 | + | assert!(!reclaim.newly_claimed, "re-claim inserts nothing"); | |
| 330 | + | assert!(!reclaim.cap_reached, "an admitted re-claim is not a cap refusal"); | |
| 331 | + | assert_eq!(reclaim.total_claimed, 1); | |
| 332 | + | ||
| 333 | + | // Exactly one active row and counter of 1 — no double count. | |
| 334 | + | assert_eq!(keys_claimed(&db.pool, app).await, 1); | |
| 335 | + | assert_eq!(active_key_rows(&db.pool, app).await, 1); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // ── release_key: idempotency ───────────────────────────────────────────────── | |
| 339 | + | ||
| 340 | + | #[tokio::test] | |
| 341 | + | async fn release_key_is_idempotent() { | |
| 342 | + | let db = TestDb::new().await; | |
| 343 | + | let user = seed_user(&db.pool, "sbb_release").await; | |
| 344 | + | let app = seed_billable_app(&db.pool, user, "sbbrelease").await; | |
| 345 | + | ||
| 346 | + | synckit_billing::claim_key(&db.pool, app, "k1", Some(5)) | |
| 347 | + | .await | |
| 348 | + | .expect("claim k1"); | |
| 349 | + | ||
| 350 | + | let first = synckit_billing::release_key(&db.pool, app, "k1") | |
| 351 | + | .await | |
| 352 | + | .expect("release k1"); | |
| 353 | + | assert!(first.newly_released); | |
| 354 | + | assert_eq!(first.total_claimed, 0); | |
| 355 | + | ||
| 356 | + | // Releasing an already-released key is a no-op, and the counter never goes | |
| 357 | + | // negative. | |
| 358 | + | let second = synckit_billing::release_key(&db.pool, app, "k1") | |
| 359 | + | .await | |
| 360 | + | .expect("re-release k1"); | |
| 361 | + | assert!(!second.newly_released, "no active row to release"); | |
| 362 | + | assert_eq!(second.total_claimed, 0); | |
| 363 | + | assert_eq!(keys_claimed(&db.pool, app).await, 0); | |
| 364 | + | assert_eq!(active_key_rows(&db.pool, app).await, 0); | |
| 365 | + | } |
| @@ -0,0 +1,248 @@ | |||
| 1 | + | //! DB-layer contract tests for the account core (`db::users`). | |
| 2 | + | //! | |
| 3 | + | //! `users.rs` is the widest CRUD/lookup module and its contracts were asserted | |
| 4 | + | //! only indirectly through HTTP flows (auth, dashboard, admin) — audit Run 18 | |
| 5 | + | //! Testing. 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, and the Stripe-webhook status write keyed by connected account. | |
| 10 | + | ||
| 11 | + | use crate::harness::TestHarness; | |
| 12 | + | use makenotwork::db::{self, Email, Username}; | |
| 13 | + | ||
| 14 | + | // ── lookup round-trip: found vs not-found ── | |
| 15 | + | ||
| 16 | + | #[tokio::test] | |
| 17 | + | async fn lookups_round_trip_after_signup() { | |
| 18 | + | let mut h = TestHarness::new().await; | |
| 19 | + | let user_id = h | |
| 20 | + | .signup("lookup_rt", "lookup_rt@test.com", "password123") | |
| 21 | + | .await; | |
| 22 | + | ||
| 23 | + | // by id | |
| 24 | + | let by_id = db::users::get_user_by_id(&h.db, user_id) | |
| 25 | + | .await | |
| 26 | + | .expect("get_user_by_id ok") | |
| 27 | + | .expect("signed-up user is found by id"); | |
| 28 | + | assert_eq!(by_id.id, user_id); | |
| 29 | + | assert_eq!(by_id.username.as_str(), "lookup_rt"); | |
| 30 | + | assert_eq!(by_id.email.as_str(), "lookup_rt@test.com"); | |
| 31 | + | ||
| 32 | + | // by username (exact match) | |
| 33 | + | let username = Username::new("lookup_rt").expect("valid username"); | |
| 34 | + | let by_username = db::users::get_user_by_username(&h.db, &username) | |
| 35 | + | .await | |
| 36 | + | .expect("get_user_by_username ok") | |
| 37 | + | .expect("signed-up user is found by username"); | |
| 38 | + | assert_eq!(by_username.id, user_id); | |
| 39 | + | ||
| 40 | + | // by email (normalized match) | |
| 41 | + | let email = Email::new("lookup_rt@test.com").expect("valid email"); | |
| 42 | + | let by_email = db::users::get_user_by_email(&h.db, &email) | |
| 43 | + | .await | |
| 44 | + | .expect("get_user_by_email ok") | |
| 45 | + | .expect("signed-up user is found by email"); | |
| 46 | + | assert_eq!(by_email.id, user_id); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | #[tokio::test] | |
| 50 | + | async fn lookups_return_none_when_absent() { | |
| 51 | + | let h = TestHarness::new().await; | |
| 52 | + | ||
| 53 | + | // A random UUID that was never inserted. | |
| 54 | + | let missing_id = db::UserId::new(); | |
| 55 | + | assert!( | |
| 56 | + | db::users::get_user_by_id(&h.db, missing_id) | |
| 57 | + | .await | |
| 58 | + | .expect("get_user_by_id ok") | |
| 59 | + | .is_none(), | |
| 60 | + | "an unknown id resolves to None, not an error" | |
| 61 | + | ); | |
| 62 | + | ||
| 63 | + | let missing_username = Username::new("nobody_here").expect("valid username"); | |
| 64 | + | assert!( | |
| 65 | + | db::users::get_user_by_username(&h.db, &missing_username) | |
| 66 | + | .await | |
| 67 | + | .expect("get_user_by_username ok") | |
| 68 | + | .is_none(), | |
| 69 | + | "an unknown username resolves to None" | |
| 70 | + | ); | |
| 71 | + | ||
| 72 | + | let missing_email = Email::new("nobody@test.com").expect("valid email"); | |
| 73 | + | assert!( | |
| 74 | + | db::users::get_user_by_email(&h.db, &missing_email) | |
| 75 | + | .await | |
| 76 | + | .expect("get_user_by_email ok") | |
| 77 | + | .is_none(), | |
| 78 | + | "an unknown email resolves to None" | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // ── key matching: username is exact, email is normalized ── | |
| 83 | + | ||
| 84 | + | #[tokio::test] | |
| 85 | + | async fn username_lookup_is_case_exact() { | |
| 86 | + | let mut h = TestHarness::new().await; | |
| 87 | + | // Signup stores the username verbatim (no lowercasing); the lookup SQL is | |
| 88 | + | // `WHERE username = $1`, so a differently-cased handle must NOT collide. | |
| 89 | + | let user_id = h | |
| 90 | + | .signup("CaseUser", "caseuser@test.com", "password123") | |
| 91 | + | .await; | |
| 92 | + | ||
| 93 | + | let exact = Username::new("CaseUser").expect("valid username"); | |
| 94 | + | let found = db::users::get_user_by_username(&h.db, &exact) | |
| 95 | + | .await | |
| 96 | + | .expect("get_user_by_username ok") | |
| 97 | + | .expect("exact-case username is found"); | |
| 98 | + | assert_eq!(found.id, user_id); | |
| 99 | + | ||
| 100 | + | let wrong_case = Username::new("caseuser").expect("valid username"); | |
| 101 | + | assert!( | |
| 102 | + | db::users::get_user_by_username(&h.db, &wrong_case) | |
| 103 | + | .await | |
| 104 | + | .expect("get_user_by_username ok") | |
| 105 | + | .is_none(), | |
| 106 | + | "username match is case-exact, so a lowercased variant does not resolve" | |
| 107 | + | ); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[tokio::test] | |
| 111 | + | async fn email_lookup_is_case_insensitive_by_normalization() { | |
| 112 | + | let mut h = TestHarness::new().await; | |
| 113 | + | let user_id = h | |
| 114 | + | .signup("email_norm", "email_norm@test.com", "password123") | |
| 115 | + | .await; | |
| 116 | + | ||
| 117 | + | // `Email::new` trims + lowercases, so a mixed-case spelling normalizes to | |
| 118 | + | // the same stored value and still resolves to the same row. | |
| 119 | + | let mixed = Email::new("Email_Norm@Test.Com").expect("valid email"); | |
| 120 | + | let found = db::users::get_user_by_email(&h.db, &mixed) | |
| 121 | + | .await | |
| 122 | + | .expect("get_user_by_email ok") | |
| 123 | + | .expect("mixed-case email normalizes and is found"); | |
| 124 | + | assert_eq!(found.id, user_id); | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // ── creator permission flag ── | |
| 128 | + | ||
| 129 | + | #[tokio::test] | |
| 130 | + | async fn grant_creator_flips_the_creator_flag() { | |
| 131 | + | let mut h = TestHarness::new().await; | |
| 132 | + | let user_id = h | |
| 133 | + | .signup("creator_flag", "creator_flag@test.com", "password123") | |
| 134 | + | .await; | |
| 135 | + | ||
| 136 | + | let before = db::users::get_user_by_id(&h.db, user_id) | |
| 137 | + | .await | |
| 138 | + | .expect("get_user_by_id ok") | |
| 139 | + | .expect("user found"); | |
| 140 | + | assert!( | |
| 141 | + | !before.can_create_projects, | |
| 142 | + | "a fresh signup cannot create projects" | |
| 143 | + | ); | |
| 144 | + | ||
| 145 | + | h.grant_creator(user_id).await; | |
| 146 | + | ||
| 147 | + | let after = db::users::get_user_by_id(&h.db, user_id) | |
| 148 | + | .await | |
| 149 | + | .expect("get_user_by_id ok") | |
| 150 | + | .expect("user found"); | |
| 151 | + | assert!( | |
| 152 | + | after.can_create_projects, | |
| 153 | + | "granting creator access sets can_create_projects" | |
| 154 | + | ); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // ── voluntary creator pause toggle ── | |
| 158 | + | ||
| 159 | + | #[tokio::test] | |
| 160 | + | async fn pause_and_unpause_creator_toggle_the_flag() { | |
| 161 | + | let mut h = TestHarness::new().await; | |
| 162 | + | let user_id = h.create_creator("pause_toggle").await; | |
| 163 | + | ||
| 164 | + | let fresh = db::users::get_user_by_id(&h.db, user_id) | |
| 165 | + | .await | |
| 166 | + | .expect("get_user_by_id ok") | |
| 167 | + | .expect("user found"); | |
| 168 | + | assert!(!fresh.is_creator_paused(), "a new creator is not paused"); | |
| 169 | + | ||
| 170 | + | db::users::pause_creator(&h.db, user_id) | |
| 171 | + | .await | |
| 172 | + | .expect("pause_creator ok"); | |
| 173 | + | let paused = db::users::get_user_by_id(&h.db, user_id) | |
| 174 | + | .await | |
| 175 | + | .expect("get_user_by_id ok") | |
| 176 | + | .expect("user found"); | |
| 177 | + | assert!( | |
| 178 | + | paused.is_creator_paused(), | |
| 179 | + | "pause_creator stamps creator_paused_at" | |
| 180 | + | ); | |
| 181 | + | ||
| 182 | + | db::users::unpause_creator(&h.db, user_id) | |
| 183 | + | .await | |
| 184 | + | .expect("unpause_creator ok"); | |
| 185 | + | let resumed = db::users::get_user_by_id(&h.db, user_id) | |
| 186 | + | .await | |
| 187 | + | .expect("get_user_by_id ok") | |
| 188 | + | .expect("user found"); | |
| 189 | + | assert!( | |
| 190 | + | !resumed.is_creator_paused(), | |
| 191 | + | "unpause_creator clears creator_paused_at" | |
| 192 | + | ); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // ── Stripe webhook status write (keyed by connected account) ── | |
| 196 | + | ||
| 197 | + | #[tokio::test] | |
| 198 | + | async fn update_user_stripe_status_persists_flags_by_account() { | |
| 199 | + | let mut h = TestHarness::new().await; | |
| 200 | + | let user_id = h.create_creator("stripe_status").await; | |
| 201 | + | // connect_stripe sets stripe_account_id and flips all three Stripe flags on. | |
| 202 | + | h.connect_stripe(user_id, "acct_dbul_status").await; | |
| 203 | + | ||
| 204 | + | // The webhook write finds the row by stripe_account_id and returns it. | |
| 205 | + | let updated = db::users::update_user_stripe_status( | |
| 206 | + | &h.db, | |
| 207 | + | "acct_dbul_status", | |
| 208 | + | true, // onboarding_complete | |
| 209 | + | false, // payouts_enabled | |
| 210 | + | true, // charges_enabled | |
| 211 | + | ) | |
| 212 | + | .await | |
| 213 | + | .expect("update_user_stripe_status ok") | |
| 214 | + | .expect("a matching connected account returns the updated row"); | |
| 215 | + | assert_eq!(updated.id, user_id); | |
| 216 | + | assert!(updated.stripe_onboarding_complete); | |
| 217 | + | assert!(!updated.stripe_payouts_enabled); | |
| 218 | + | assert!(updated.stripe_charges_enabled); | |
| 219 | + | ||
| 220 | + | // The flags are durable, not just echoed by the RETURNING clause. | |
| 221 | + | let reread = db::users::get_user_by_id(&h.db, user_id) | |
| 222 | + | .await | |
| 223 | + | .expect("get_user_by_id ok") | |
| 224 | + | .expect("user found"); | |
| 225 | + | assert!(reread.stripe_onboarding_complete); | |
| 226 | + | assert!(!reread.stripe_payouts_enabled); | |
| 227 | + | assert!(reread.stripe_charges_enabled); | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | #[tokio::test] | |
| 231 | + | async fn update_user_stripe_status_is_noop_for_unknown_account() { | |
| 232 | + | let h = TestHarness::new().await; | |
| 233 | + | // No user carries this account id, so the UPDATE matches nothing and the | |
| 234 | + | // webhook handler gets None (rather than an error) to ignore the event. | |
| 235 | + | let result = db::users::update_user_stripe_status( | |
| 236 | + | &h.db, | |
| 237 | + | "acct_dbul_nonexistent", | |
| 238 | + | true, | |
| 239 | + | true, | |
| 240 | + | true, | |
| 241 | + | ) | |
| 242 | + | .await | |
| 243 | + | .expect("update_user_stripe_status ok"); | |
| 244 | + | assert!( | |
| 245 | + | result.is_none(), | |
| 246 | + | "an unmatched stripe_account_id is a no-op returning None" | |
| 247 | + | ); | |
| 248 | + | } |
| @@ -97,8 +97,11 @@ mod lifecycle; | |||
| 97 | 97 | mod cart; | |
| 98 | 98 | mod bundles; | |
| 99 | 99 | mod idempotency; | |
| 100 | + | mod db_items_layer; | |
| 100 | 101 | mod db_payments_layer; | |
| 102 | + | mod db_synckit_billing_layer; | |
| 101 | 103 | mod db_transactions_layer; | |
| 104 | + | mod db_users_layer; | |
| 102 | 105 | mod db_collections_layer; | |
| 103 | 106 | mod db_creator_tiers_layer; | |
| 104 | 107 | mod db_issues_layer; |