//! DB-layer contract tests for the sealed access gate in `db::subscriptions`. //! //! `subscriptions.rs` is the biggest money module in `db/` and it carried no //! test of its own: its `#[cfg(test)]` block holds a test-only gate constructor //! and nothing else. What the gate decides is who may reach paid content, so //! these pin its clauses one at a time: active AND unpaused AND inside the paid //! period, scoped to one subscriber and one target, with every non-active //! status denied on the other side of the boundary. //! //! The webhook-driven lifecycle writes of the same module are //! `db_subscriptions_lifecycle_layer`. //! //! Reach: every function in `db::subscriptions` is `pub(crate)`, so the //! integration test crate cannot call the module directly. Each test below //! drives the real production call site nearest to the function under test //! (db::items::check_item_access for the item gate, whose `subscription` field //! is filled by `SubscriptionGate::check` and nothing else; the subscribe route //! for the project-scope `has_access`) and asserts on what those calls answer. //! //! Delete this file and the gate's period and pause clauses become silently //! editable: nothing else asserts them at this layer. use crate::harness::db::TestDb; use crate::harness::{TestHarness, seed_project, seed_user}; use chrono::{DateTime, Duration, Utc}; use makenotwork::db::{self, ItemId, ProjectId, SubscriptionTierId, UserId}; use serde_json::Value; use sqlx::PgPool; // ── seeding ── /// An item in `project`. `slug` is unique per project, so callers pass one. async fn seed_item(pool: &PgPool, project: ProjectId, slug: &str) -> ItemId { sqlx::query_scalar::<_, ItemId>( "INSERT INTO items (project_id, title, item_type, price_cents, slug) VALUES ($1, 'Gated Item', 'audio', 1500, $2) RETURNING id", ) .bind(project) .bind(slug) .fetch_one(pool) .await .expect("seed item") } /// An item-scoped tier. `tier_exactly_one_target` forbids setting `project_id` /// as well, which is why the two seeders below are separate. async fn seed_item_tier(pool: &PgPool, item: ItemId) -> SubscriptionTierId { sqlx::query_scalar::<_, SubscriptionTierId>( "INSERT INTO subscription_tiers (item_id, name, price_cents) VALUES ($1, 'Item Tier', 1500) RETURNING id", ) .bind(item) .fetch_one(pool) .await .expect("seed item tier") } async fn seed_project_tier(pool: &PgPool, project: ProjectId) -> SubscriptionTierId { sqlx::query_scalar::<_, SubscriptionTierId>( "INSERT INTO subscription_tiers (project_id, name, price_cents) VALUES ($1, 'Project Tier', 1500) RETURNING id", ) .bind(project) .fetch_one(pool) .await .expect("seed project tier") } /// An item-scoped subscription row (`project_id` NULL, per `sub_exactly_one_target`). async fn seed_item_subscription( pool: &PgPool, subscriber: UserId, tier: SubscriptionTierId, item: ItemId, stripe_id: &str, status: &str, period_end: Option>, ) { sqlx::query( "INSERT INTO subscriptions (subscriber_id, tier_id, item_id, stripe_subscription_id, stripe_customer_id, status, current_period_start, current_period_end) VALUES ($1, $2, $3, $4, 'cus_gate_seed', $5, NOW() - interval '1 day', $6)", ) .bind(subscriber) .bind(tier) .bind(item) .bind(stripe_id) .bind(status) .bind(period_end) .execute(pool) .await .expect("seed item subscription"); } /// A project-scoped subscription row (`item_id` NULL). async fn seed_project_subscription( pool: &PgPool, subscriber: UserId, tier: SubscriptionTierId, project: ProjectId, stripe_id: &str, status: &str, period_end: Option>, ) { sqlx::query( "INSERT INTO subscriptions (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id, status, current_period_start, current_period_end) VALUES ($1, $2, $3, $4, 'cus_gate_seed', $5, NOW() - interval '1 day', $6)", ) .bind(subscriber) .bind(tier) .bind(project) .bind(stripe_id) .bind(status) .bind(period_end) .execute(pool) .await .expect("seed project subscription"); } /// Does the sealed gate currently grant `user` access to `item`? /// /// `check_item_access` asks `SubscriptionGate::check` and nothing else for the /// `subscription` field, so this reads the sealed predicate and not a copy of it. async fn item_gate_grants(pool: &PgPool, item: ItemId, user: Option) -> bool { db::items::check_item_access(pool, item, user) .await .expect("check_item_access ok") .expect("item exists") .subscription .is_some() } // ── the sealed access gate: what a lapsed subscriber may still reach ── #[tokio::test] async fn item_gate_grants_access_only_while_the_paid_period_is_unexpired() { let db = TestDb::new().await; let creator = seed_user(&db.pool, "gate_period_creator").await; let project = seed_project(&db.pool, creator, "gate-period").await; let item = seed_item(&db.pool, project, "gate-period-item").await; let tier = seed_item_tier(&db.pool, item).await; let fan = seed_user(&db.pool, "gate_period_fan").await; // Paid through: three days of period left. seed_item_subscription( &db.pool, fan, tier, item, "sub_gate_period", "active", Some(Utc::now() + Duration::days(3)), ) .await; assert!( item_gate_grants(&db.pool, item, Some(fan)).await, "an active subscription three days from renewal must grant access" ); // Three days PAST the period end, still `status = 'active'` because the // `customer.subscription.deleted` webhook was missed or delayed. The // `current_period_end > NOW()` half of the predicate is the whole reason // that case does not keep granting access, so both sides are asserted. sqlx::query( "UPDATE subscriptions SET current_period_end = $1 WHERE stripe_subscription_id = $2", ) .bind(Utc::now() - Duration::days(3)) .bind("sub_gate_period") .execute(&db.pool) .await .expect("expire the period"); assert!( !item_gate_grants(&db.pool, item, Some(fan)).await, "an active row whose paid period ended three days ago must NOT grant access" ); // A NULL period is the "Stripe has not told us a period yet" shape and is // explicitly permitted by the predicate; without this case the test could // not tell `> NOW()` from `IS NOT NULL AND > NOW()`. sqlx::query( "UPDATE subscriptions SET current_period_end = NULL WHERE stripe_subscription_id = $1", ) .bind("sub_gate_period") .execute(&db.pool) .await .expect("null the period"); assert!( item_gate_grants(&db.pool, item, Some(fan)).await, "a NULL current_period_end must grant access, not deny it" ); } #[tokio::test] async fn item_gate_denies_a_paused_subscription_and_grants_again_once_resumed() { let db = TestDb::new().await; let creator = seed_user(&db.pool, "gate_pause_creator").await; let project = seed_project(&db.pool, creator, "gate-pause").await; let item = seed_item(&db.pool, project, "gate-pause-item").await; let tier = seed_item_tier(&db.pool, item).await; let fan = seed_user(&db.pool, "gate_pause_fan").await; seed_item_subscription( &db.pool, fan, tier, item, "sub_gate_pause", "active", Some(Utc::now() + Duration::days(20)), ) .await; assert!( item_gate_grants(&db.pool, item, Some(fan)).await, "an unpaused in-period subscription grants access" ); // Pausing is what a creator suspension does to every fan subscription: the // fan stops being billed, so the fan must also stop having access, even // though status stays 'active' and the period is still open. sqlx::query("UPDATE subscriptions SET paused_at = NOW() WHERE stripe_subscription_id = $1") .bind("sub_gate_pause") .execute(&db.pool) .await .expect("pause the subscription"); assert!( !item_gate_grants(&db.pool, item, Some(fan)).await, "a paused subscription must not grant access while the creator is suspended" ); sqlx::query("UPDATE subscriptions SET paused_at = NULL WHERE stripe_subscription_id = $1") .bind("sub_gate_pause") .execute(&db.pool) .await .expect("resume the subscription"); assert!( item_gate_grants(&db.pool, item, Some(fan)).await, "resuming must restore access rather than leaving the fan locked out" ); } #[tokio::test] async fn item_gate_grants_on_active_and_denies_every_other_status() { let db = TestDb::new().await; let creator = seed_user(&db.pool, "gate_status_creator").await; let project = seed_project(&db.pool, creator, "gate-status").await; let item = seed_item(&db.pool, project, "gate-status-item").await; let tier = seed_item_tier(&db.pool, item).await; let fan = seed_user(&db.pool, "gate_status_fan").await; seed_item_subscription( &db.pool, fan, tier, item, "sub_gate_status", "active", Some(Utc::now() + Duration::days(9)), ) .await; // Every non-active status the column can hold. Walking all of them is what // separates "status = 'active'" from the weaker "status != 'canceled'": // trialing and past_due would pass the weaker predicate. for status in [ "trialing", "incomplete", "incomplete_expired", "past_due", "unpaid", "canceled", ] { sqlx::query("UPDATE subscriptions SET status = $1 WHERE stripe_subscription_id = $2") .bind(status) .bind("sub_gate_status") .execute(&db.pool) .await .expect("set status"); assert!( !item_gate_grants(&db.pool, item, Some(fan)).await, "status '{status}' must not grant access; only 'active' does" ); } sqlx::query("UPDATE subscriptions SET status = 'active' WHERE stripe_subscription_id = $1") .bind("sub_gate_status") .execute(&db.pool) .await .expect("restore active"); assert!( item_gate_grants(&db.pool, item, Some(fan)).await, "'active' grants access, so the loop above measured the status and not the fixture" ); } #[tokio::test] async fn item_gate_is_scoped_to_one_subscriber_one_item_and_never_to_anonymous() { let db = TestDb::new().await; let creator = seed_user(&db.pool, "gate_scope_creator").await; let project = seed_project(&db.pool, creator, "gate-scope").await; let subscribed_item = seed_item(&db.pool, project, "gate-scope-paid").await; let other_item = seed_item(&db.pool, project, "gate-scope-other").await; let item_tier = seed_item_tier(&db.pool, subscribed_item).await; let project_tier = seed_project_tier(&db.pool, project).await; let fan = seed_user(&db.pool, "gate_scope_fan").await; let stranger = seed_user(&db.pool, "gate_scope_stranger").await; let period_end = Some(Utc::now() + Duration::days(14)); seed_item_subscription( &db.pool, fan, item_tier, subscribed_item, "sub_gate_scope_item", "active", period_end, ) .await; // A live PROJECT subscription held by the same fan. The item arm of the gate // keys on item_id, so this row must not leak access to a sibling item; if it // did, an item-priced work would be readable by anyone subscribed to the // project at any tier. seed_project_subscription( &db.pool, fan, project_tier, project, "sub_gate_scope_project", "active", period_end, ) .await; assert!( item_gate_grants(&db.pool, subscribed_item, Some(fan)).await, "the fan's own item subscription grants access to that item" ); assert!( !item_gate_grants(&db.pool, other_item, Some(fan)).await, "a subscription to one item must not grant access to a sibling item" ); assert!( !item_gate_grants(&db.pool, subscribed_item, Some(stranger)).await, "another user must not inherit the fan's item subscription" ); assert!( !item_gate_grants(&db.pool, subscribed_item, None).await, "an anonymous viewer holds no subscription and must never be granted one" ); } // ── project-scope `has_access`: a lapsed subscriber is offered checkout again ── #[tokio::test] async fn a_lapsed_project_subscriber_is_sent_back_to_checkout_and_a_current_one_is_not() { let mut h = TestHarness::with_mocks().await; let creator = h .signup("gatecreator", "gatecreator@test.com", "password123") .await; h.grant_creator(creator).await; h.connect_stripe(creator, "acct_gate_route").await; h.client.post_form("/logout", "").await; h.login("gatecreator", "password123").await; let resp = h .client .post_form("/api/projects", "slug=gateroute&title=Gate+Route") .await; assert_eq!(resp.status, 200, "create project failed: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().expect("project id").to_string(); let project_uuid: ProjectId = project_id.parse().expect("project id parses"); let tier_id: SubscriptionTierId = sqlx::query_scalar( "INSERT INTO subscription_tiers (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id) VALUES ($1, 'Gold', 1500, true, 'prod_gate', 'price_gate') RETURNING id", ) .bind(project_uuid) .fetch_one(&h.db) .await .expect("seed tier"); h.client.post_form("/logout", "").await; let fan = h.signup("gatefan", "gatefan@test.com", "password123").await; // A subscription that is paid up for another 30 days. seed_project_subscription( &h.db, fan, tier_id, project_uuid, "sub_gate_route", "active", Some(Utc::now() + Duration::days(30)), ) .await; let resp = h .client .post_form(&format!("/stripe/subscribe/{tier_id}"), "") .await; assert_eq!( resp.status, 303, "subscribe should redirect, got {}: {}", resp.status, resp.text ); assert_eq!( resp.header("location"), Some("/p/gateroute"), "an already-subscribed fan is bounced to the project page, not to Stripe: {}", resp.text ); let checkouts = h.mock_stripe.as_ref().expect("mock stripe").checkouts(); assert!( checkouts.is_empty(), "no second checkout session may be created for a current subscriber, found {checkouts:?}" ); // Same row, period ended yesterday: the gate no longer grants access, so the // fan must be able to buy again. The two halves together are what stop both // a double charge and a permanent lockout. sqlx::query( "UPDATE subscriptions SET current_period_end = $1 WHERE stripe_subscription_id = $2", ) .bind(Utc::now() - Duration::days(1)) .bind("sub_gate_route") .execute(&h.db) .await .expect("expire the period"); let resp = h .client .post_form(&format!("/stripe/subscribe/{tier_id}"), "") .await; assert_eq!( resp.status, 303, "subscribe should redirect, got {}: {}", resp.status, resp.text ); let location = resp.header("location").unwrap_or_default().to_string(); assert!( location.starts_with("https://checkout.stripe.com/"), "a lapsed subscriber must be sent to a fresh checkout, went to {location} instead" ); let checkouts = h.mock_stripe.as_ref().expect("mock stripe").checkouts(); assert_eq!( checkouts.len(), 1, "exactly one checkout session belongs to the lapsed attempt, got {checkouts:?}" ); }