max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
20 files changed,
+8469 insertions,
-2 deletions
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | //! tests. It counts files in those areas that contain no test of any kind and | |
| 6 | 6 | //! fails when the number goes up. It cannot make anyone write a good test; it | |
| 7 | 7 | //! can stop a new payment handler or sync table from landing with none at all, | |
| 8 | - | //! which is how the current 34 accumulated, one reasonable-looking file at a | |
| 8 | + | //! which is how the original 34 accumulated, one reasonable-looking file at a | |
| 9 | 9 | //! time. | |
| 10 | 10 | //! | |
| 11 | 11 | //! Deliberately dumb. A file counts as covered if it holds a test attribute, if | |
| @@ -33,7 +33,7 @@ | |||
| 33 | 33 | /// Lower it when you cover one. Never raise it: a new untested file in these | |
| 34 | 34 | /// areas is the thing this seal exists to refuse. If you genuinely need to add | |
| 35 | 35 | /// one, the honest move is to write the test, not to bump the constant. | |
| 36 | - | const UNTESTED_HIGH_WATER: usize = 32; | |
| 36 | + | const UNTESTED_HIGH_WATER: usize = 10; | |
| 37 | 37 | ||
| 38 | 38 | /// Anything that moves money or decides what someone is entitled to. | |
| 39 | 39 | const MONEY: &[&str] = &[ |
| @@ -31,21 +31,33 @@ | |||
| 31 | 31 | mod custom_pages; | |
| 32 | 32 | mod db_collections_layer; | |
| 33 | 33 | mod db_creator_tiers_layer; | |
| 34 | + | mod db_fan_plus_layer; | |
| 34 | 35 | mod db_imports_layer; | |
| 35 | 36 | mod db_issues_layer; | |
| 36 | 37 | mod db_items_bulk_layer; | |
| 37 | 38 | mod db_items_layer; | |
| 39 | + | mod db_items_media_layer; | |
| 38 | 40 | mod db_passkeys_layer; | |
| 39 | 41 | mod db_payments_layer; | |
| 42 | + | mod db_pending_uploads_layer; | |
| 43 | + | mod db_revenue_stats_layer; | |
| 40 | 44 | mod db_scan_jobs_layer; | |
| 41 | 45 | mod db_scanning_layer; | |
| 42 | 46 | mod db_ssh_keys_layer; | |
| 47 | + | mod db_subscriptions_layer; | |
| 48 | + | mod db_subscriptions_lifecycle_layer; | |
| 43 | 49 | mod db_synckit_accounts_layer; | |
| 44 | 50 | mod db_synckit_billing_layer; | |
| 51 | + | mod db_synckit_blobs_layer; | |
| 52 | + | mod db_synckit_devices_keys_layer; | |
| 45 | 53 | mod db_synckit_groups; | |
| 46 | 54 | mod db_synckit_invitations; | |
| 47 | 55 | mod db_synckit_layer; | |
| 56 | + | mod db_synckit_log_layer; | |
| 57 | + | mod db_synckit_log_push_layer; | |
| 48 | 58 | mod db_synckit_rotation; | |
| 59 | + | mod db_synckit_security_layer; | |
| 60 | + | mod db_synckit_subscriptions_layer; | |
| 49 | 61 | mod db_transactions_layer; | |
| 50 | 62 | mod db_users_layer; | |
| 51 | 63 | mod db_webhook_events; | |
| @@ -116,8 +128,13 @@ | |||
| 116 | 128 | mod ssh_management; | |
| 117 | 129 | mod sso; | |
| 118 | 130 | mod storage; | |
| 131 | + | mod storage_routes_workflows; | |
| 119 | 132 | mod streaming; | |
| 133 | + | mod stripe_checkout_routes; | |
| 120 | 134 | mod stripe_disconnect; | |
| 135 | + | mod stripe_tip_checkout_routes; | |
| 136 | + | mod stripe_webhook_billing_replay; | |
| 137 | + | mod stripe_webhook_exactly_once; | |
| 121 | 138 | mod stripe_webhooks; | |
| 122 | 139 | mod subscriptions; | |
| 123 | 140 | mod suspension; | |
| @@ -129,6 +146,7 @@ | |||
| 129 | 146 | mod synckit_groups_billing; | |
| 130 | 147 | mod synckit_paid_sync; | |
| 131 | 148 | mod synckit_per_key_storage; | |
| 149 | + | mod synckit_routes_workflows; | |
| 132 | 150 | mod synckit_security; | |
| 133 | 151 | mod synckit_selective; | |
| 134 | 152 | mod synckit_sse; |
| @@ -1,0 +1,391 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::fan_plus`, the Fan+ membership store. | |
| 2 | + | //! | |
| 3 | + | //! Every function in the module is `pub(crate)`, so it is driven here through | |
| 4 | + | //! the Stripe webhook endpoint that is its only production caller, and the | |
| 5 | + | //! assertions read the `fan_plus_subscriptions` rows the module writes. What is | |
| 6 | + | //! pinned is the part Stripe redelivery can corrupt: a Fan+ checkout creates | |
| 7 | + | //! exactly one subscription however many times it is delivered, a fresh | |
| 8 | + | //! checkout revives a canceled membership rather than opening a second row, a | |
| 9 | + | //! status update writes status, period and the cancel-at-period-end flag | |
| 10 | + | //! together, and a repeated cancellation keeps the first `canceled_at` instead | |
| 11 | + | //! of sliding it forward. | |
| 12 | + | //! | |
| 13 | + | //! Existing coverage of the canceled-is-terminal guard lives in | |
| 14 | + | //! `stripe_webhooks.rs` and is not repeated here. | |
| 15 | + | //! | |
| 16 | + | //! Delete this file and nothing checks that a redelivered Fan+ webhook writes | |
| 17 | + | //! one row, which is the failure Stripe's at-least-once delivery produces. | |
| 18 | + | ||
| 19 | + | use crate::harness::TestHarness; | |
| 20 | + | use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload}; | |
| 21 | + | use makenotwork::db::UserId; | |
| 22 | + | // ── db::fan_plus, driven through its only production caller ── | |
| 23 | + | ||
| 24 | + | /// POST a signed Stripe event to the webhook endpoint. | |
| 25 | + | async fn post_event( | |
| 26 | + | h: &mut TestHarness, | |
| 27 | + | event_id: &str, | |
| 28 | + | event_type: &str, | |
| 29 | + | object: serde_json::Value, | |
| 30 | + | ) -> crate::harness::client::TestResponse { | |
| 31 | + | let payload = serde_json::json!({ | |
| 32 | + | "id": event_id, | |
| 33 | + | "type": event_type, | |
| 34 | + | "data": {"object": object}, | |
| 35 | + | }) | |
| 36 | + | .to_string(); | |
| 37 | + | let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET); | |
| 38 | + | h.client | |
| 39 | + | .request_with_headers( | |
| 40 | + | "POST", | |
| 41 | + | "/stripe/webhook", | |
| 42 | + | Some(&payload), | |
| 43 | + | &[ | |
| 44 | + | ("stripe-signature", &signature), | |
| 45 | + | ("content-type", "application/json"), | |
| 46 | + | ], | |
| 47 | + | ) | |
| 48 | + | .await | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// A completed Fan+ checkout session. `checkout_type=fan_plus` is what routes | |
| 52 | + | /// it to the Fan+ handler; `user_id` is who gets the membership. | |
| 53 | + | fn fan_plus_session(session_id: &str, user_id: UserId, sub_id: &str) -> serde_json::Value { | |
| 54 | + | serde_json::json!({ | |
| 55 | + | "id": session_id, | |
| 56 | + | "object": "checkout.session", | |
| 57 | + | "mode": "subscription", | |
| 58 | + | "metadata": {"checkout_type": "fan_plus", "user_id": user_id.to_string()}, | |
| 59 | + | "subscription": sub_id, | |
| 60 | + | "customer": format!("cus_{sub_id}"), | |
| 61 | + | "payment_status": "no_payment_required", | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// A `customer.subscription.*` object carrying a period on its first item. | |
| 66 | + | fn subscription_object( | |
| 67 | + | sub_id: &str, | |
| 68 | + | status: &str, | |
| 69 | + | cancel_at_period_end: bool, | |
| 70 | + | period: (i64, i64), | |
| 71 | + | ) -> serde_json::Value { | |
| 72 | + | serde_json::json!({ | |
| 73 | + | "id": sub_id, | |
| 74 | + | "object": "subscription", | |
| 75 | + | "status": status, | |
| 76 | + | "cancel_at_period_end": cancel_at_period_end, | |
| 77 | + | "items": {"object": "list", "data": [{ | |
| 78 | + | "id": "si_fp_layer", | |
| 79 | + | "current_period_start": period.0, | |
| 80 | + | "current_period_end": period.1, | |
| 81 | + | }]}, | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | async fn fan_plus_row_count(pool: &sqlx::PgPool, user_id: UserId) -> i64 { | |
| 86 | + | sqlx::query_scalar("SELECT COUNT(*) FROM fan_plus_subscriptions WHERE user_id = $1") | |
| 87 | + | .bind(user_id) | |
| 88 | + | .fetch_one(pool) | |
| 89 | + | .await | |
| 90 | + | .expect("count fan plus rows") | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[tokio::test] | |
| 94 | + | async fn fan_plus_checkout_creates_exactly_one_subscription_under_redelivery() { | |
| 95 | + | let mut h = TestHarness::with_stripe().await; | |
| 96 | + | let user_id = h | |
| 97 | + | .signup("fplayer_new", "fplayer_new@test.com", "password123") | |
| 98 | + | .await; | |
| 99 | + | ||
| 100 | + | let session = fan_plus_session("cs_fp_layer_1", user_id, "sub_fp_layer_1"); | |
| 101 | + | let resp = post_event( | |
| 102 | + | &mut h, | |
| 103 | + | "evt_fp_layer_create", | |
| 104 | + | "checkout.session.completed", | |
| 105 | + | session.clone(), | |
| 106 | + | ) | |
| 107 | + | .await; | |
| 108 | + | assert_eq!( | |
| 109 | + | resp.status.as_u16(), | |
| 110 | + | 200, | |
| 111 | + | "Fan+ checkout webhook failed: {}", | |
| 112 | + | resp.text | |
| 113 | + | ); | |
| 114 | + | assert_eq!( | |
| 115 | + | fan_plus_row_count(&h.db, user_id).await, | |
| 116 | + | 1, | |
| 117 | + | "one checkout creates one membership" | |
| 118 | + | ); | |
| 119 | + | ||
| 120 | + | // Stripe's own retry: identical event id. The dedup layer must swallow it. | |
| 121 | + | let resp = post_event( | |
| 122 | + | &mut h, | |
| 123 | + | "evt_fp_layer_create", | |
| 124 | + | "checkout.session.completed", | |
| 125 | + | session.clone(), | |
| 126 | + | ) | |
| 127 | + | .await; | |
| 128 | + | assert_eq!( | |
| 129 | + | resp.status.as_u16(), | |
| 130 | + | 200, | |
| 131 | + | "retried delivery must be accepted, not errored: {}", | |
| 132 | + | resp.text | |
| 133 | + | ); | |
| 134 | + | ||
| 135 | + | // A redelivery that escapes the event-id dedup (distinct id, same session) | |
| 136 | + | // reaches `create_fan_plus_subscription`, whose ON CONFLICT (user_id) is the | |
| 137 | + | // backstop: still one row, still the same Stripe ids. | |
| 138 | + | let resp = post_event( | |
| 139 | + | &mut h, | |
| 140 | + | "evt_fp_layer_create_dup", | |
| 141 | + | "checkout.session.completed", | |
| 142 | + | session, | |
| 143 | + | ) | |
| 144 | + | .await; | |
| 145 | + | assert_eq!( | |
| 146 | + | resp.status.as_u16(), | |
| 147 | + | 200, | |
| 148 | + | "duplicate Fan+ checkout must not error: {}", | |
| 149 | + | resp.text | |
| 150 | + | ); | |
| 151 | + | assert_eq!( | |
| 152 | + | fan_plus_row_count(&h.db, user_id).await, | |
| 153 | + | 1, | |
| 154 | + | "a redelivered Fan+ checkout must never mint a second membership" | |
| 155 | + | ); | |
| 156 | + | ||
| 157 | + | let (sub_id, customer_id, status): (String, String, String) = sqlx::query_as( | |
| 158 | + | "SELECT stripe_subscription_id, stripe_customer_id, status \ | |
| 159 | + | FROM fan_plus_subscriptions WHERE user_id = $1", | |
| 160 | + | ) | |
| 161 | + | .bind(user_id) | |
| 162 | + | .fetch_one(&h.db) | |
| 163 | + | .await | |
| 164 | + | .unwrap(); | |
| 165 | + | assert_eq!(sub_id, "sub_fp_layer_1"); | |
| 166 | + | assert_eq!(customer_id, "cus_sub_fp_layer_1"); | |
| 167 | + | assert_eq!(status, "active", "a new membership starts active"); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | #[tokio::test] | |
| 171 | + | async fn a_new_fan_plus_checkout_reactivates_the_canceled_membership_row() { | |
| 172 | + | let mut h = TestHarness::with_stripe().await; | |
| 173 | + | let user_id = h | |
| 174 | + | .signup("fplayer_re", "fplayer_re@test.com", "password123") | |
| 175 | + | .await; | |
| 176 | + | ||
| 177 | + | // A previously canceled membership. The unique constraint is on user_id, so | |
| 178 | + | // re-subscribing has to reuse this row rather than insert beside it. | |
| 179 | + | sqlx::query( | |
| 180 | + | "INSERT INTO fan_plus_subscriptions \ | |
| 181 | + | (user_id, stripe_subscription_id, stripe_customer_id, status, canceled_at) \ | |
| 182 | + | VALUES ($1, 'sub_fp_layer_old', 'cus_fp_layer_old', 'canceled', NOW())", | |
| 183 | + | ) | |
| 184 | + | .bind(user_id) | |
| 185 | + | .execute(&h.db) | |
| 186 | + | .await | |
| 187 | + | .unwrap(); | |
| 188 | + | ||
| 189 | + | let resp = post_event( | |
| 190 | + | &mut h, | |
| 191 | + | "evt_fp_layer_resub", | |
| 192 | + | "checkout.session.completed", | |
| 193 | + | fan_plus_session("cs_fp_layer_re", user_id, "sub_fp_layer_new"), | |
| 194 | + | ) | |
| 195 | + | .await; | |
| 196 | + | assert_eq!( | |
| 197 | + | resp.status.as_u16(), | |
| 198 | + | 200, | |
| 199 | + | "re-subscribe webhook failed: {}", | |
| 200 | + | resp.text | |
| 201 | + | ); | |
| 202 | + | ||
| 203 | + | assert_eq!( | |
| 204 | + | fan_plus_row_count(&h.db, user_id).await, | |
| 205 | + | 1, | |
| 206 | + | "re-subscribing updates the existing row rather than adding one" | |
| 207 | + | ); | |
| 208 | + | let (status, sub_id, canceled_at): (String, String, Option<chrono::DateTime<chrono::Utc>>) = | |
| 209 | + | sqlx::query_as( | |
| 210 | + | "SELECT status, stripe_subscription_id, canceled_at \ | |
| 211 | + | FROM fan_plus_subscriptions WHERE user_id = $1", | |
| 212 | + | ) | |
| 213 | + | .bind(user_id) | |
| 214 | + | .fetch_one(&h.db) | |
| 215 | + | .await | |
| 216 | + | .unwrap(); | |
| 217 | + | assert_eq!(status, "active", "checkout is the reactivation path"); | |
| 218 | + | assert_eq!( | |
| 219 | + | sub_id, "sub_fp_layer_new", | |
| 220 | + | "the row now points at the new Stripe subscription" | |
| 221 | + | ); | |
| 222 | + | assert_eq!( | |
| 223 | + | canceled_at, None, | |
| 224 | + | "reactivation clears the old cancellation stamp" | |
| 225 | + | ); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[tokio::test] | |
| 229 | + | async fn a_stripe_update_writes_fan_plus_status_period_and_cancel_flag_together() { | |
| 230 | + | let mut h = TestHarness::with_stripe().await; | |
| 231 | + | let user_id = h | |
| 232 | + | .signup("fplayer_upd", "fplayer_upd@test.com", "password123") | |
| 233 | + | .await; | |
| 234 | + | let sub_id = "sub_fp_layer_upd"; | |
| 235 | + | sqlx::query( | |
| 236 | + | "INSERT INTO fan_plus_subscriptions \ | |
| 237 | + | (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \ | |
| 238 | + | VALUES ($1, $2, 'cus_fp_layer_upd', 'active', to_timestamp(1600000000))", | |
| 239 | + | ) | |
| 240 | + | .bind(user_id) | |
| 241 | + | .bind(sub_id) | |
| 242 | + | .execute(&h.db) | |
| 243 | + | .await | |
| 244 | + | .unwrap(); | |
| 245 | + | ||
| 246 | + | // Payment fell behind and the fan scheduled a cancellation: one event | |
| 247 | + | // carries status, period and the flag, and all three must land. | |
| 248 | + | let resp = post_event( | |
| 249 | + | &mut h, | |
| 250 | + | "evt_fp_layer_upd", | |
| 251 | + | "customer.subscription.updated", | |
| 252 | + | subscription_object(sub_id, "past_due", true, (1_700_000_000, 1_702_592_000)), | |
| 253 | + | ) | |
| 254 | + | .await; | |
| 255 | + | assert_eq!( | |
| 256 | + | resp.status.as_u16(), | |
| 257 | + | 200, | |
| 258 | + | "update webhook failed: {}", | |
| 259 | + | resp.text | |
| 260 | + | ); | |
| 261 | + | ||
| 262 | + | let (status, period_end, cancel_pending): (String, i64, bool) = sqlx::query_as( | |
| 263 | + | "SELECT status, EXTRACT(EPOCH FROM current_period_end)::BIGINT, cancel_at_period_end \ | |
| 264 | + | FROM fan_plus_subscriptions WHERE stripe_subscription_id = $1", | |
| 265 | + | ) | |
| 266 | + | .bind(sub_id) | |
| 267 | + | .fetch_one(&h.db) | |
| 268 | + | .await | |
| 269 | + | .unwrap(); | |
| 270 | + | assert_eq!(status, "past_due", "Stripe's status is written through"); | |
| 271 | + | assert_eq!( | |
| 272 | + | period_end, 1_702_592_000, | |
| 273 | + | "the period advances to the one Stripe sent, not the seeded 1600000000" | |
| 274 | + | ); | |
| 275 | + | assert!( | |
| 276 | + | cancel_pending, | |
| 277 | + | "cancel_at_period_end tracks Stripe, which owns that flag" | |
| 278 | + | ); | |
| 279 | + | ||
| 280 | + | // And back: resuming in Stripe's portal clears the flag here. | |
| 281 | + | let resp = post_event( | |
| 282 | + | &mut h, | |
| 283 | + | "evt_fp_layer_upd_resume", | |
| 284 | + | "customer.subscription.updated", | |
| 285 | + | subscription_object(sub_id, "active", false, (1_700_000_000, 1_702_592_000)), | |
| 286 | + | ) | |
| 287 | + | .await; | |
| 288 | + | assert_eq!( | |
| 289 | + | resp.status.as_u16(), | |
| 290 | + | 200, | |
| 291 | + | "resume update failed: {}", | |
| 292 | + | resp.text | |
| 293 | + | ); | |
| 294 | + | let (status, cancel_pending): (String, bool) = sqlx::query_as( | |
| 295 | + | "SELECT status, cancel_at_period_end FROM fan_plus_subscriptions \ | |
| 296 | + | WHERE stripe_subscription_id = $1", | |
| 297 | + | ) | |
| 298 | + | .bind(sub_id) | |
| 299 | + | .fetch_one(&h.db) | |
| 300 | + | .await | |
| 301 | + | .unwrap(); | |
| 302 | + | assert_eq!( | |
| 303 | + | status, "active", | |
| 304 | + | "a recovered payment restores the membership" | |
| 305 | + | ); | |
| 306 | + | assert!(!cancel_pending, "the scheduled cancellation is cleared"); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | #[tokio::test] | |
| 310 | + | async fn a_repeated_cancellation_keeps_the_first_canceled_at() { | |
| 311 | + | let mut h = TestHarness::with_stripe().await; | |
| 312 | + | let user_id = h | |
| 313 | + | .signup("fplayer_del", "fplayer_del@test.com", "password123") | |
| 314 | + | .await; | |
| 315 | + | let sub_id = "sub_fp_layer_del"; | |
| 316 | + | sqlx::query( | |
| 317 | + | "INSERT INTO fan_plus_subscriptions \ | |
| 318 | + | (user_id, stripe_subscription_id, stripe_customer_id, status, current_period_end) \ | |
| 319 | + | VALUES ($1, $2, 'cus_fp_layer_del', 'active', to_timestamp(1702592000))", | |
| 320 | + | ) | |
| 321 | + | .bind(user_id) | |
| 322 | + | .bind(sub_id) | |
| 323 | + | .execute(&h.db) | |
| 324 | + | .await | |
| 325 | + | .unwrap(); | |
| 326 | + | ||
| 327 | + | let resp = post_event( | |
| 328 | + | &mut h, | |
| 329 | + | "evt_fp_layer_del", | |
| 330 | + | "customer.subscription.deleted", | |
| 331 | + | subscription_object(sub_id, "canceled", false, (1_700_000_000, 1_702_592_000)), | |
| 332 | + | ) | |
| 333 | + | .await; | |
| 334 | + | assert_eq!( | |
| 335 | + | resp.status.as_u16(), | |
| 336 | + | 200, | |
| 337 | + | "cancellation webhook failed: {}", | |
| 338 | + | resp.text | |
| 339 | + | ); | |
| 340 | + | ||
| 341 | + | let (status, first_canceled_at): (String, Option<chrono::DateTime<chrono::Utc>>) = | |
| 342 | + | sqlx::query_as( | |
| 343 | + | "SELECT status, canceled_at FROM fan_plus_subscriptions \ | |
| 344 | + | WHERE stripe_subscription_id = $1", | |
| 345 | + | ) | |
| 346 | + | .bind(sub_id) | |
| 347 | + | .fetch_one(&h.db) | |
| 348 | + | .await | |
| 349 | + | .unwrap(); | |
| 350 | + | assert_eq!(status, "canceled"); | |
| 351 | + | let first_canceled_at = first_canceled_at.expect("cancellation stamps canceled_at"); | |
| 352 | + | ||
| 353 | + | // A second delivery of the cancellation (distinct event id, so it reaches | |
| 354 | + | // the handler) must be a no-op on the stamp: `COALESCE(canceled_at, NOW())` | |
| 355 | + | // is what stops the cancellation date sliding forward on every redelivery, | |
| 356 | + | // and that date is what the fan's remaining access window is judged from. | |
| 357 | + | let resp = post_event( | |
| 358 | + | &mut h, | |
| 359 | + | "evt_fp_layer_del_again", | |
| 360 | + | "customer.subscription.deleted", | |
| 361 | + | subscription_object(sub_id, "canceled", false, (1_700_000_000, 1_702_592_000)), | |
| 362 | + | ) | |
| 363 | + | .await; | |
| 364 | + | assert_eq!( | |
| 365 | + | resp.status.as_u16(), | |
| 366 | + | 200, | |
| 367 | + | "repeat cancellation must not error: {}", | |
| 368 | + | resp.text | |
| 369 | + | ); | |
| 370 | + | ||
| 371 | + | let (status, second_canceled_at): (String, Option<chrono::DateTime<chrono::Utc>>) = | |
| 372 | + | sqlx::query_as( | |
| 373 | + | "SELECT status, canceled_at FROM fan_plus_subscriptions \ | |
| 374 | + | WHERE stripe_subscription_id = $1", | |
| 375 | + | ) | |
| 376 | + | .bind(sub_id) | |
| 377 | + | .fetch_one(&h.db) | |
| 378 | + | .await | |
| 379 | + | .unwrap(); | |
| 380 | + | assert_eq!(status, "canceled", "still canceled"); | |
| 381 | + | assert_eq!( | |
| 382 | + | second_canceled_at, | |
| 383 | + | Some(first_canceled_at), | |
| 384 | + | "a redelivered cancellation keeps the original canceled_at" | |
| 385 | + | ); | |
| 386 | + | assert_eq!( | |
| 387 | + | fan_plus_row_count(&h.db, user_id).await, | |
| 388 | + | 1, | |
| 389 | + | "cancellation never adds rows" | |
| 390 | + | ); | |
| 391 | + | } |
| @@ -1,0 +1,652 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::items::media`, the writeback layer every | |
| 2 | + | //! upload confirm lands on. | |
| 3 | + | //! | |
| 4 | + | //! This is the compare-and-swap that decides whether storage may be credited, | |
| 5 | + | //! the cover triple-write (url + key + size), and the ownership-filtered | |
| 6 | + | //! size/metadata writebacks. These pin what each one actually promises: which | |
| 7 | + | //! outcome a stale expectation produces, that a losing write leaves every | |
| 8 | + | //! column untouched, that each file type writes its own column pair and no | |
| 9 | + | //! other, and that a non-owner never lands a byte. | |
| 10 | + | //! | |
| 11 | + | //! The pending-uploads table that used to share this file has its own | |
| 12 | + | //! module, db_pending_uploads_layer. | |
| 13 | + | //! | |
| 14 | + | //! Delete this file and two classes of loss stop being observable: a dropped | |
| 15 | + | //! CAS predicate (double-credited storage and clobbered live objects), and a | |
| 16 | + | //! size or metadata writeback that ignores its ownership filter. | |
| 17 | + | ||
| 18 | + | use crate::harness::db::TestDb; | |
| 19 | + | use crate::harness::{seed_project, seed_user}; | |
| 20 | + | use makenotwork::db::items::{FileConfirmOutcome, update_item_file_cas}; | |
| 21 | + | use makenotwork::db::{ItemId, ProjectId, UserId, items}; | |
| 22 | + | use makenotwork::error::AppError; | |
| 23 | + | use makenotwork::storage::FileType; | |
| 24 | + | ||
| 25 | + | // ── helpers ────────────────────────────────────────────────────────────────── | |
| 26 | + | ||
| 27 | + | async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId { | |
| 28 | + | sqlx::query_scalar::<_, ItemId>( | |
| 29 | + | "INSERT INTO items (project_id, title, item_type, price_cents, slug) | |
| 30 | + | VALUES ($1, $2, 'digital', 1000, $3) RETURNING id", | |
| 31 | + | ) | |
| 32 | + | .bind(project) | |
| 33 | + | .bind(format!("Item {slug}")) | |
| 34 | + | .bind(slug) | |
| 35 | + | .fetch_one(pool) | |
| 36 | + | .await | |
| 37 | + | .expect("seed item") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// A user, a project and one item, the minimum an item-media writeback needs. | |
| 41 | + | async fn owner_and_item(db: &TestDb, tag: &str) -> (UserId, ItemId) { | |
| 42 | + | let user = seed_user(&db.pool, &format!("media_{tag}")).await; | |
| 43 | + | let project = seed_project(&db.pool, user, &format!("media-{tag}")).await; | |
| 44 | + | let item = seed_item(&db.pool, project, &format!("i-{tag}")).await; | |
| 45 | + | (user, item) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// The audio pair as stored. | |
| 49 | + | async fn audio_cols(pool: &sqlx::PgPool, item: ItemId) -> (Option<String>, Option<i64>) { | |
| 50 | + | sqlx::query_as("SELECT audio_s3_key, audio_file_size_bytes FROM items WHERE id = $1") | |
| 51 | + | .bind(item) | |
| 52 | + | .fetch_one(pool) | |
| 53 | + | .await | |
| 54 | + | .expect("read audio columns") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The video pair as stored. | |
| 58 | + | async fn video_cols(pool: &sqlx::PgPool, item: ItemId) -> (Option<String>, Option<i64>) { | |
| 59 | + | sqlx::query_as("SELECT video_s3_key, video_file_size_bytes FROM items WHERE id = $1") | |
| 60 | + | .bind(item) | |
| 61 | + | .fetch_one(pool) | |
| 62 | + | .await | |
| 63 | + | .expect("read video columns") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// The cover triple as stored. | |
| 67 | + | #[allow(clippy::type_complexity)] | |
| 68 | + | async fn cover_cols( | |
| 69 | + | pool: &sqlx::PgPool, | |
| 70 | + | item: ItemId, | |
| 71 | + | ) -> (Option<String>, Option<String>, Option<i64>) { | |
| 72 | + | sqlx::query_as( | |
| 73 | + | "SELECT cover_image_url, cover_s3_key, cover_file_size_bytes FROM items WHERE id = $1", | |
| 74 | + | ) | |
| 75 | + | .bind(item) | |
| 76 | + | .fetch_one(pool) | |
| 77 | + | .await | |
| 78 | + | .expect("read cover columns") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // ── update_item_file_cas: the guarded write behind every storage credit ─────── | |
| 82 | + | ||
| 83 | + | /// The redelivery case. A confirm that is delivered twice observes the same | |
| 84 | + | /// pre-state twice, so the second call carries the same `expected_old_key`. It | |
| 85 | + | /// must report `LostRace` and leave the row exactly as the winner wrote it, | |
| 86 | + | /// because the caller credits storage on `Committed` and would otherwise be | |
| 87 | + | /// charged twice for one object. | |
| 88 | + | #[tokio::test] | |
| 89 | + | async fn a_replayed_file_confirm_reports_a_lost_race_and_credits_nothing_twice() { | |
| 90 | + | let db = TestDb::new().await; | |
| 91 | + | let (owner, item) = owner_and_item(&db, "replay").await; | |
| 92 | + | ||
| 93 | + | let first = update_item_file_cas( | |
| 94 | + | &db.pool, | |
| 95 | + | item, | |
| 96 | + | owner, | |
| 97 | + | FileType::Audio, | |
| 98 | + | None, | |
| 99 | + | "staging/first.mp3", | |
| 100 | + | 7_340_032, | |
| 101 | + | ) | |
| 102 | + | .await | |
| 103 | + | .expect("first confirm runs"); | |
| 104 | + | assert_eq!( | |
| 105 | + | first, | |
| 106 | + | FileConfirmOutcome::Committed, | |
| 107 | + | "the first confirm against a NULL column must commit" | |
| 108 | + | ); | |
| 109 | + | ||
| 110 | + | // The exact byte count matters: the caller credits this number against the | |
| 111 | + | // creator's quota, so an off-by-anything is a billing error. | |
| 112 | + | let after_first = audio_cols(&db.pool, item).await; | |
| 113 | + | assert_eq!( | |
| 114 | + | after_first, | |
| 115 | + | (Some("staging/first.mp3".to_string()), Some(7_340_032)), | |
| 116 | + | "committed confirm must store the exact key and size, got {after_first:?}" | |
| 117 | + | ); | |
| 118 | + | ||
| 119 | + | // Same event delivered again: the column no longer holds NULL. | |
| 120 | + | let replay = update_item_file_cas( | |
| 121 | + | &db.pool, | |
| 122 | + | item, | |
| 123 | + | owner, | |
| 124 | + | FileType::Audio, | |
| 125 | + | None, | |
| 126 | + | "staging/first.mp3", | |
| 127 | + | 7_340_032, | |
| 128 | + | ) | |
| 129 | + | .await | |
| 130 | + | .expect("replayed confirm runs"); | |
| 131 | + | assert_eq!( | |
| 132 | + | replay, | |
| 133 | + | FileConfirmOutcome::LostRace, | |
| 134 | + | "a redelivered confirm must lose the CAS, not commit a second time" | |
| 135 | + | ); | |
| 136 | + | ||
| 137 | + | let after_replay = audio_cols(&db.pool, item).await; | |
| 138 | + | assert_eq!( | |
| 139 | + | after_replay, after_first, | |
| 140 | + | "the replay must leave the row byte-identical, got {after_replay:?}" | |
| 141 | + | ); | |
| 142 | + | ||
| 143 | + | // A genuine replace observes the current key and swaps it. The new size | |
| 144 | + | // REPLACES the old one: 2_097_152, not 9_437_184 (a summing bug) and not | |
| 145 | + | // 7_340_032 (a write that never landed). | |
| 146 | + | let replace = update_item_file_cas( | |
| 147 | + | &db.pool, | |
| 148 | + | item, | |
| 149 | + | owner, | |
| 150 | + | FileType::Audio, | |
| 151 | + | Some("staging/first.mp3"), | |
| 152 | + | "staging/second.mp3", | |
| 153 | + | 2_097_152, | |
| 154 | + | ) | |
| 155 | + | .await | |
| 156 | + | .expect("replace confirm runs"); | |
| 157 | + | assert_eq!( | |
| 158 | + | replace, | |
| 159 | + | FileConfirmOutcome::Committed, | |
| 160 | + | "a confirm carrying the current key must commit the swap" | |
| 161 | + | ); | |
| 162 | + | let after_replace = audio_cols(&db.pool, item).await; | |
| 163 | + | assert_eq!( | |
| 164 | + | after_replace, | |
| 165 | + | (Some("staging/second.mp3".to_string()), Some(2_097_152)), | |
| 166 | + | "the replace must overwrite both columns, got {after_replace:?}" | |
| 167 | + | ); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// The ownership filter is part of the same predicate as the CAS, so a stranger | |
| 171 | + | /// holding the correct expected key still writes nothing. Asserted separately | |
| 172 | + | /// from the CAS because a regression could drop either half alone. | |
| 173 | + | #[tokio::test] | |
| 174 | + | async fn a_non_owner_file_confirm_loses_the_race_and_writes_nothing() { | |
| 175 | + | let db = TestDb::new().await; | |
| 176 | + | let (owner, item) = owner_and_item(&db, "owner").await; | |
| 177 | + | let stranger = seed_user(&db.pool, "media_stranger").await; | |
| 178 | + | ||
| 179 | + | update_item_file_cas( | |
| 180 | + | &db.pool, | |
| 181 | + | item, | |
| 182 | + | owner, | |
| 183 | + | FileType::Video, | |
| 184 | + | None, | |
| 185 | + | "staging/owned.mp4", | |
| 186 | + | 4_500_000, | |
| 187 | + | ) | |
| 188 | + | .await | |
| 189 | + | .expect("owner confirm runs"); | |
| 190 | + | ||
| 191 | + | let outcome = update_item_file_cas( | |
| 192 | + | &db.pool, | |
| 193 | + | item, | |
| 194 | + | stranger, | |
| 195 | + | FileType::Video, | |
| 196 | + | Some("staging/owned.mp4"), | |
| 197 | + | "staging/stolen.mp4", | |
| 198 | + | 99, | |
| 199 | + | ) | |
| 200 | + | .await | |
| 201 | + | .expect("stranger confirm runs"); | |
| 202 | + | assert_eq!( | |
| 203 | + | outcome, | |
| 204 | + | FileConfirmOutcome::LostRace, | |
| 205 | + | "a non-owner must not be able to swap another creator's file" | |
| 206 | + | ); | |
| 207 | + | ||
| 208 | + | let cols = video_cols(&db.pool, item).await; | |
| 209 | + | assert_eq!( | |
| 210 | + | cols, | |
| 211 | + | (Some("staging/owned.mp4".to_string()), Some(4_500_000)), | |
| 212 | + | "the non-owner write must not land, got {cols:?}" | |
| 213 | + | ); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Each file type owns exactly one column pair. A mapping that crossed audio and | |
| 217 | + | /// video would still "work" for a single-file item, so both are written on one | |
| 218 | + | /// item with distinct keys and distinct sizes and each pair is read back. | |
| 219 | + | #[tokio::test] | |
| 220 | + | async fn each_file_type_writes_only_its_own_column_pair() { | |
| 221 | + | let db = TestDb::new().await; | |
| 222 | + | let (owner, item) = owner_and_item(&db, "cols").await; | |
| 223 | + | ||
| 224 | + | update_item_file_cas( | |
| 225 | + | &db.pool, | |
| 226 | + | item, | |
| 227 | + | owner, | |
| 228 | + | FileType::Audio, | |
| 229 | + | None, | |
| 230 | + | "staging/a.mp3", | |
| 231 | + | 5_000_000, | |
| 232 | + | ) | |
| 233 | + | .await | |
| 234 | + | .expect("audio confirm runs"); | |
| 235 | + | update_item_file_cas( | |
| 236 | + | &db.pool, | |
| 237 | + | item, | |
| 238 | + | owner, | |
| 239 | + | FileType::Video, | |
| 240 | + | None, | |
| 241 | + | "staging/v.mp4", | |
| 242 | + | 3_000_000, | |
| 243 | + | ) | |
| 244 | + | .await | |
| 245 | + | .expect("video confirm runs"); | |
| 246 | + | ||
| 247 | + | let audio = audio_cols(&db.pool, item).await; | |
| 248 | + | let video = video_cols(&db.pool, item).await; | |
| 249 | + | assert_eq!( | |
| 250 | + | audio, | |
| 251 | + | (Some("staging/a.mp3".to_string()), Some(5_000_000)), | |
| 252 | + | "audio columns hold the audio confirm, got {audio:?}" | |
| 253 | + | ); | |
| 254 | + | assert_eq!( | |
| 255 | + | video, | |
| 256 | + | (Some("staging/v.mp4".to_string()), Some(3_000_000)), | |
| 257 | + | "video columns hold the video confirm, got {video:?}" | |
| 258 | + | ); | |
| 259 | + | // Neither generic confirm touches the cover triple. | |
| 260 | + | let cover = cover_cols(&db.pool, item).await; | |
| 261 | + | assert_eq!( | |
| 262 | + | cover, | |
| 263 | + | (None, None, None), | |
| 264 | + | "audio/video confirms must leave the cover columns alone, got {cover:?}" | |
| 265 | + | ); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// Types that need a third column (or another table) are refused rather than | |
| 269 | + | /// half-written. The error names the route the caller should have used, which is | |
| 270 | + | /// what makes the rejection actionable. | |
| 271 | + | #[tokio::test] | |
| 272 | + | async fn a_file_type_with_a_dedicated_route_is_refused_before_any_write() { | |
| 273 | + | let db = TestDb::new().await; | |
| 274 | + | let (owner, item) = owner_and_item(&db, "route").await; | |
| 275 | + | ||
| 276 | + | let err = update_item_file_cas( | |
| 277 | + | &db.pool, | |
| 278 | + | item, | |
| 279 | + | owner, | |
| 280 | + | FileType::Cover, | |
| 281 | + | None, | |
| 282 | + | "staging/cover.png", | |
| 283 | + | 640_000, | |
| 284 | + | ) | |
| 285 | + | .await | |
| 286 | + | .expect_err("a cover must not be confirmable through the generic writer"); | |
| 287 | + | let message = err.to_string(); | |
| 288 | + | match err { | |
| 289 | + | AppError::Internal(inner) => { | |
| 290 | + | let detail = inner.to_string(); | |
| 291 | + | assert!( | |
| 292 | + | detail.contains("/api/items/image/confirm"), | |
| 293 | + | "the refusal must name the dedicated route, got {detail}" | |
| 294 | + | ); | |
| 295 | + | assert!( | |
| 296 | + | detail.contains("cover"), | |
| 297 | + | "the refusal must name the offending file type, got {detail}" | |
| 298 | + | ); | |
| 299 | + | } | |
| 300 | + | other => panic!("expected AppError::Internal, got {other:?} ({message})"), | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | let cover = cover_cols(&db.pool, item).await; | |
| 304 | + | assert_eq!( | |
| 305 | + | cover, | |
| 306 | + | (None, None, None), | |
| 307 | + | "a refused confirm must not half-write the row, got {cover:?}" | |
| 308 | + | ); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | /// The function takes any executor so the confirm and the storage credit share | |
| 312 | + | /// one transaction. That is only worth anything if a rollback takes the file | |
| 313 | + | /// writeback with it. | |
| 314 | + | #[tokio::test] | |
| 315 | + | async fn a_file_confirm_rolled_back_with_its_transaction_leaves_no_write() { | |
| 316 | + | let db = TestDb::new().await; | |
| 317 | + | let (owner, item) = owner_and_item(&db, "tx").await; | |
| 318 | + | ||
| 319 | + | let mut tx = db.pool.begin().await.expect("begin"); | |
| 320 | + | let outcome = update_item_file_cas( | |
| 321 | + | &mut *tx, | |
| 322 | + | item, | |
| 323 | + | owner, | |
| 324 | + | FileType::Audio, | |
| 325 | + | None, | |
| 326 | + | "staging/rolled-back.mp3", | |
| 327 | + | 8_800_000, | |
| 328 | + | ) | |
| 329 | + | .await | |
| 330 | + | .expect("in-transaction confirm runs"); | |
| 331 | + | assert_eq!( | |
| 332 | + | outcome, | |
| 333 | + | FileConfirmOutcome::Committed, | |
| 334 | + | "inside the transaction the CAS matches" | |
| 335 | + | ); | |
| 336 | + | tx.rollback().await.expect("rollback"); | |
| 337 | + | ||
| 338 | + | let cols = audio_cols(&db.pool, item).await; | |
| 339 | + | assert_eq!( | |
| 340 | + | cols, | |
| 341 | + | (None, None), | |
| 342 | + | "rolling back the storage credit must undo the file writeback too, got {cols:?}" | |
| 343 | + | ); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // ── update_item_cover: the three-column write ──────────────────────────────── | |
| 347 | + | ||
| 348 | + | /// The cover write is the one that must move three columns together, and it | |
| 349 | + | /// carries the same CAS as the audio/video path. A stale expectation must leave | |
| 350 | + | /// all three as the winner left them: a partial write here shows a cover whose | |
| 351 | + | /// url, key and size disagree. | |
| 352 | + | #[tokio::test] | |
| 353 | + | async fn a_cover_write_moves_url_key_and_size_together_and_guards_a_stale_key() { | |
| 354 | + | let db = TestDb::new().await; | |
| 355 | + | let (owner, item) = owner_and_item(&db, "cover").await; | |
| 356 | + | ||
| 357 | + | let first = items::update_item_cover( | |
| 358 | + | &db.pool, | |
| 359 | + | item, | |
| 360 | + | owner, | |
| 361 | + | None, | |
| 362 | + | "https://cdn.test/cover-one.png", | |
| 363 | + | "covers/one.png", | |
| 364 | + | 640_000, | |
| 365 | + | ) | |
| 366 | + | .await | |
| 367 | + | .expect("first cover write runs"); | |
| 368 | + | assert!(first, "the first cover write against NULL must land"); | |
| 369 | + | let after_first = cover_cols(&db.pool, item).await; | |
| 370 | + | assert_eq!( | |
| 371 | + | after_first, | |
| 372 | + | ( | |
| 373 | + | Some("https://cdn.test/cover-one.png".to_string()), | |
| 374 | + | Some("covers/one.png".to_string()), | |
| 375 | + | Some(640_000) | |
| 376 | + | ), | |
| 377 | + | "all three cover columns must be written, got {after_first:?}" | |
| 378 | + | ); | |
| 379 | + | ||
| 380 | + | // A second confirm that still believes the cover is unset loses. | |
| 381 | + | let stale = items::update_item_cover( | |
| 382 | + | &db.pool, | |
| 383 | + | item, | |
| 384 | + | owner, | |
| 385 | + | None, | |
| 386 | + | "https://cdn.test/cover-two.png", | |
| 387 | + | "covers/two.png", | |
| 388 | + | 250_000, | |
| 389 | + | ) | |
| 390 | + | .await | |
| 391 | + | .expect("stale cover write runs"); | |
| 392 | + | assert!( | |
| 393 | + | !stale, | |
| 394 | + | "a cover confirm carrying a stale expected key must report no rows updated" | |
| 395 | + | ); | |
| 396 | + | let after_stale = cover_cols(&db.pool, item).await; | |
| 397 | + | assert_eq!( | |
| 398 | + | after_stale, after_first, | |
| 399 | + | "the loser must not overwrite any of the three columns, got {after_stale:?}" | |
| 400 | + | ); | |
| 401 | + | ||
| 402 | + | // The correct expectation replaces all three. 250_000 replaces 640_000; a | |
| 403 | + | // summing bug would read 890_000 and a dropped write 640_000. | |
| 404 | + | let replace = items::update_item_cover( | |
| 405 | + | &db.pool, | |
| 406 | + | item, | |
| 407 | + | owner, | |
| 408 | + | Some("covers/one.png"), | |
| 409 | + | "https://cdn.test/cover-two.png", | |
| 410 | + | "covers/two.png", | |
| 411 | + | 250_000, | |
| 412 | + | ) | |
| 413 | + | .await | |
| 414 | + | .expect("cover replace runs"); | |
| 415 | + | assert!( | |
| 416 | + | replace, | |
| 417 | + | "a cover confirm carrying the current key must land" | |
| 418 | + | ); | |
| 419 | + | let after_replace = cover_cols(&db.pool, item).await; | |
| 420 | + | assert_eq!( | |
| 421 | + | after_replace, | |
| 422 | + | ( | |
| 423 | + | Some("https://cdn.test/cover-two.png".to_string()), | |
| 424 | + | Some("covers/two.png".to_string()), | |
| 425 | + | Some(250_000) | |
| 426 | + | ), | |
| 427 | + | "the replace must swap all three columns, got {after_replace:?}" | |
| 428 | + | ); | |
| 429 | + | ||
| 430 | + | // Ownership is the other half of the same predicate. | |
| 431 | + | let stranger = seed_user(&db.pool, "media_cover_stranger").await; | |
| 432 | + | let by_stranger = items::update_item_cover( | |
| 433 | + | &db.pool, | |
| 434 | + | item, | |
| 435 | + | stranger, | |
| 436 | + | Some("covers/two.png"), | |
| 437 | + | "https://cdn.test/stolen.png", | |
| 438 | + | "covers/stolen.png", | |
| 439 | + | 11, | |
| 440 | + | ) | |
| 441 | + | .await | |
| 442 | + | .expect("stranger cover write runs"); | |
| 443 | + | assert!( | |
| 444 | + | !by_stranger, | |
| 445 | + | "a non-owner cover write must report no rows updated" | |
| 446 | + | ); | |
| 447 | + | let after_stranger = cover_cols(&db.pool, item).await; | |
| 448 | + | assert_eq!( | |
| 449 | + | after_stranger, after_replace, | |
| 450 | + | "the non-owner write must not land, got {after_stranger:?}" | |
| 451 | + | ); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // ── size and metadata writebacks ───────────────────────────────────────────── | |
| 455 | + | ||
| 456 | + | /// `get_item_file_sizes` feeds the storage decrement on delete, so it must read | |
| 457 | + | /// the three columns into the three fields without crossing them, and a missing | |
| 458 | + | /// item must read as three `None`s rather than an error (the delete path calls | |
| 459 | + | /// it after the row may already be gone). | |
| 460 | + | #[tokio::test] | |
| 461 | + | async fn file_sizes_read_back_per_column_and_a_missing_item_reads_as_none() { | |
| 462 | + | let db = TestDb::new().await; | |
| 463 | + | let (_owner, item) = owner_and_item(&db, "sizes").await; | |
| 464 | + | ||
| 465 | + | // Three distinct values, so a column swap changes the answer. | |
| 466 | + | sqlx::query( | |
| 467 | + | "UPDATE items SET audio_file_size_bytes = 5000000, | |
| 468 | + | cover_file_size_bytes = 250000, | |
| 469 | + | video_file_size_bytes = 3000000 | |
| 470 | + | WHERE id = $1", | |
| 471 | + | ) | |
| 472 | + | .bind(item) | |
| 473 | + | .execute(&db.pool) | |
| 474 | + | .await | |
| 475 | + | .expect("seed the three sizes"); | |
| 476 | + | ||
| 477 | + | let sizes = items::get_item_file_sizes(&db.pool, item) | |
| 478 | + | .await | |
| 479 | + | .expect("read sizes"); | |
| 480 | + | assert_eq!( | |
| 481 | + | sizes.audio_file_size_bytes, | |
| 482 | + | Some(5_000_000), | |
| 483 | + | "audio size read from the audio column" | |
| 484 | + | ); | |
| 485 | + | assert_eq!( | |
| 486 | + | sizes.cover_file_size_bytes, | |
| 487 | + | Some(250_000), | |
| 488 | + | "cover size read from the cover column" | |
| 489 | + | ); | |
| 490 | + | assert_eq!( | |
| 491 | + | sizes.video_file_size_bytes, | |
| 492 | + | Some(3_000_000), | |
| 493 | + | "video size read from the video column" | |
| 494 | + | ); | |
| 495 | + | ||
| 496 | + | let missing = items::get_item_file_sizes(&db.pool, ItemId::new()) | |
| 497 | + | .await | |
| 498 | + | .expect("a missing item is not an error here"); | |
| 499 | + | assert_eq!( | |
| 500 | + | ( |
Lines truncated
| @@ -1,0 +1,319 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::pending_uploads`, the tracking table that | |
| 2 | + | //! decides whether an abandoned upload's object is reclaimed or leaked. | |
| 3 | + | //! | |
| 4 | + | //! Every function in the module is `pub(crate)`, so its reclaim/expiry half is | |
| 5 | + | //! reached the way production reaches it, through the orphan-upload reaper | |
| 6 | + | //! (`TestHarness::run_orphan_upload_reaper`), which calls | |
| 7 | + | //! `get_stale_pending_uploads` and `delete_pending_uploads` and nothing else. | |
| 8 | + | //! Those two carry the retry machinery this table exists for, and they are what | |
| 9 | + | //! the assertions below are about: the age cutoff on both sides, oldest-first | |
| 10 | + | //! draining under the `STALE_UPLOAD_BATCH` cap, the `(s3_key, bucket)` pair | |
| 11 | + | //! scoping on the delete, and the reclaim case where a live row took the key | |
| 12 | + | //! back and the object must survive while the tracking row goes. | |
| 13 | + | //! | |
| 14 | + | //! Not asserted here, and deliberately not claimed: `record_pending_upload`, | |
| 15 | + | //! `record_pending_multipart_upload`, `declared_size`, `touch_pending_upload`, | |
| 16 | + | //! `remove_pending_upload` and `is_owned`, which an integration test can only | |
| 17 | + | //! reach through the presign handlers. | |
| 18 | + | //! | |
| 19 | + | //! Delete this file and a reaper that either deletes a reclaimed object or | |
| 20 | + | //! leaves an orphan behind forever passes silently. | |
| 21 | + | ||
| 22 | + | use crate::harness::{TestHarness, seed_project}; | |
| 23 | + | use makenotwork::db::items::update_item_file_cas; | |
| 24 | + | use makenotwork::db::{ItemId, ProjectId, UserId}; | |
| 25 | + | use makenotwork::storage::{FileType, StorageBackend}; | |
| 26 | + | // ── helpers ────────────────────────────────────────────────────────────────── | |
| 27 | + | ||
| 28 | + | async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId { | |
| 29 | + | sqlx::query_scalar::<_, ItemId>( | |
| 30 | + | "INSERT INTO items (project_id, title, item_type, price_cents, slug) | |
| 31 | + | VALUES ($1, $2, 'digital', 1000, $3) RETURNING id", | |
| 32 | + | ) | |
| 33 | + | .bind(project) | |
| 34 | + | .bind(format!("Item {slug}")) | |
| 35 | + | .bind(slug) | |
| 36 | + | .fetch_one(pool) | |
| 37 | + | .await | |
| 38 | + | .expect("seed item") | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// The audio pair as stored. | |
| 42 | + | async fn audio_cols(pool: &sqlx::PgPool, item: ItemId) -> (Option<String>, Option<i64>) { | |
| 43 | + | sqlx::query_as("SELECT audio_s3_key, audio_file_size_bytes FROM items WHERE id = $1") | |
| 44 | + | .bind(item) | |
| 45 | + | .fetch_one(pool) | |
| 46 | + | .await | |
| 47 | + | .expect("read audio columns") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ── db::pending_uploads: the expiry and reclaim half ───────────────────────── | |
| 51 | + | // | |
| 52 | + | // Reached through the reaper, which is the only production caller of | |
| 53 | + | // `get_stale_pending_uploads` and `delete_pending_uploads`. | |
| 54 | + | ||
| 55 | + | /// Insert a pending upload aged `hours` old, with its object staged in storage. | |
| 56 | + | async fn pending_upload(h: &TestHarness, user: UserId, key: &str, bucket: &str, hours: i32) { | |
| 57 | + | h.storage | |
| 58 | + | .as_ref() | |
| 59 | + | .expect("with_storage provides a backend") | |
| 60 | + | .put(key, b"orphan".to_vec()); | |
| 61 | + | sqlx::query( | |
| 62 | + | "INSERT INTO pending_uploads (user_id, s3_key, bucket, created_at) | |
| 63 | + | VALUES ($1, $2, $3, NOW() - make_interval(hours => $4))", | |
| 64 | + | ) | |
| 65 | + | .bind(user) | |
| 66 | + | .bind(key) | |
| 67 | + | .bind(bucket) | |
| 68 | + | .bind(hours) | |
| 69 | + | .execute(&h.db) | |
| 70 | + | .await | |
| 71 | + | .expect("insert pending upload"); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | async fn pending_rows(h: &TestHarness, key: &str, bucket: &str) -> i64 { | |
| 75 | + | sqlx::query_scalar("SELECT COUNT(*) FROM pending_uploads WHERE s3_key = $1 AND bucket = $2") | |
| 76 | + | .bind(key) | |
| 77 | + | .bind(bucket) | |
| 78 | + | .fetch_one(&h.db) | |
| 79 | + | .await | |
| 80 | + | .expect("count pending rows") | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// The cutoff is 24 hours and it is a strict age test, so both sides of it are | |
| 84 | + | /// asserted in one run: a 25-hour-old upload is reaped and a 23-hour-old one is | |
| 85 | + | /// left completely alone. A test that only checked the old row could not tell | |
| 86 | + | /// "older than 24h" from "every row". | |
| 87 | + | #[tokio::test] | |
| 88 | + | async fn the_reaper_takes_uploads_past_the_cutoff_and_leaves_younger_ones_untouched() { | |
| 89 | + | let mut h = TestHarness::with_storage().await; | |
| 90 | + | let user = h | |
| 91 | + | .signup("reaper_cutoff", "reaper_cutoff@test.com", "pass1234") | |
| 92 | + | .await; | |
| 93 | + | let storage = h.storage.clone().expect("with_storage provides a backend"); | |
| 94 | + | ||
| 95 | + | pending_upload(&h, user, "staging/too-old.bin", "main", 25).await; | |
| 96 | + | pending_upload(&h, user, "staging/still-young.bin", "main", 23).await; | |
| 97 | + | ||
| 98 | + | h.run_orphan_upload_reaper().await; | |
| 99 | + | ||
| 100 | + | assert_eq!( | |
| 101 | + | pending_rows(&h, "staging/too-old.bin", "main").await, | |
| 102 | + | 0, | |
| 103 | + | "an upload older than the 24h cutoff must lose its tracking row" | |
| 104 | + | ); | |
| 105 | + | assert!( | |
| 106 | + | !storage.object_exists("staging/too-old.bin").await.unwrap(), | |
| 107 | + | "and its object must be deleted" | |
| 108 | + | ); | |
| 109 | + | ||
| 110 | + | assert_eq!( | |
| 111 | + | pending_rows(&h, "staging/still-young.bin", "main").await, | |
| 112 | + | 1, | |
| 113 | + | "an upload younger than the cutoff is still in flight and must be kept" | |
| 114 | + | ); | |
| 115 | + | assert!( | |
| 116 | + | storage | |
| 117 | + | .object_exists("staging/still-young.bin") | |
| 118 | + | .await | |
| 119 | + | .unwrap(), | |
| 120 | + | "deleting a young upload's object would destroy a transfer in progress" | |
| 121 | + | ); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// The reaper is capped at `STALE_UPLOAD_BATCH` (200) rows per tick and drains | |
| 125 | + | /// oldest first, so a backlog cannot wedge the tick. 201 rows put the cap on both | |
| 126 | + | /// sides in one run: exactly one row survives, and it is the youngest, which is | |
| 127 | + | /// what distinguishes oldest-first from an unordered or newest-first scan. | |
| 128 | + | #[tokio::test] | |
| 129 | + | async fn the_reaper_drains_the_oldest_two_hundred_and_leaves_the_rest_for_the_next_tick() { | |
| 130 | + | let mut h = TestHarness::with_storage().await; | |
| 131 | + | let user = h | |
| 132 | + | .signup("reaper_batch", "reaper_batch@test.com", "pass1234") | |
| 133 | + | .await; | |
| 134 | + | let storage = h.storage.clone().expect("with_storage provides a backend"); | |
| 135 | + | ||
| 136 | + | // 201 stale rows, ages 48h down to about 44h40m, so the ordering is total and | |
| 137 | + | // every one of them is past the 24h cutoff. | |
| 138 | + | for i in 0..201i32 { | |
| 139 | + | let key = format!("staging/batch-{i:03}.bin"); | |
| 140 | + | storage.put(&key, b"orphan".to_vec()); | |
| 141 | + | } | |
| 142 | + | sqlx::query( | |
| 143 | + | "INSERT INTO pending_uploads (user_id, s3_key, bucket, created_at) | |
| 144 | + | SELECT $1, 'staging/batch-' || to_char(i, 'FM000') || '.bin', 'main', | |
| 145 | + | NOW() - INTERVAL '48 hours' + make_interval(mins => i) | |
| 146 | + | FROM generate_series(0, 200) AS i", | |
| 147 | + | ) | |
| 148 | + | .bind(user) | |
| 149 | + | .execute(&h.db) | |
| 150 | + | .await | |
| 151 | + | .expect("insert 201 stale uploads"); | |
| 152 | + | ||
| 153 | + | h.run_orphan_upload_reaper().await; | |
| 154 | + | ||
| 155 | + | let remaining: Vec<String> = | |
| 156 | + | sqlx::query_scalar("SELECT s3_key FROM pending_uploads ORDER BY s3_key") | |
| 157 | + | .fetch_all(&h.db) | |
| 158 | + | .await | |
| 159 | + | .expect("read remaining rows"); | |
| 160 | + | assert_eq!( | |
| 161 | + | remaining, | |
| 162 | + | vec!["staging/batch-200.bin".to_string()], | |
| 163 | + | "exactly the youngest row is left for the next tick, got {remaining:?}" | |
| 164 | + | ); | |
| 165 | + | assert!( | |
| 166 | + | storage | |
| 167 | + | .object_exists("staging/batch-200.bin") | |
| 168 | + | .await | |
| 169 | + | .unwrap(), | |
| 170 | + | "the row that was not reaped keeps its object" | |
| 171 | + | ); | |
| 172 | + | assert!( | |
| 173 | + | !storage | |
| 174 | + | .object_exists("staging/batch-000.bin") | |
| 175 | + | .await | |
| 176 | + | .unwrap(), | |
| 177 | + | "the oldest row is the first one drained" | |
| 178 | + | ); | |
| 179 | + | ||
| 180 | + | // The next tick finishes the backlog, which is what makes the cap a drain | |
| 181 | + | // rather than a permanent leak. | |
| 182 | + | h.run_orphan_upload_reaper().await; | |
| 183 | + | let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pending_uploads") | |
| 184 | + | .fetch_one(&h.db) | |
| 185 | + | .await | |
| 186 | + | .expect("count remaining"); | |
| 187 | + | assert_eq!(left, 0, "the second tick drains what the cap held back"); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// The uniqueness key is `(s3_key, bucket)`, so the same key can be pending in | |
| 191 | + | /// two buckets at once and the delete must match the pair. Only the main-bucket | |
| 192 | + | /// row is stale here: a delete keyed on `s3_key` alone would take the synckit | |
| 193 | + | /// row with it and the reaper would forget an upload that is still in flight. | |
| 194 | + | #[tokio::test] | |
| 195 | + | async fn reaping_one_bucket_leaves_another_buckets_row_for_the_same_key() { | |
| 196 | + | let mut h = TestHarness::with_storage().await; | |
| 197 | + | let user = h | |
| 198 | + | .signup("reaper_bucket", "reaper_bucket@test.com", "pass1234") | |
| 199 | + | .await; | |
| 200 | + | ||
| 201 | + | pending_upload(&h, user, "staging/shared-key.bin", "main", 30).await; | |
| 202 | + | // Young, and in a different bucket: not eligible for this reap at all. | |
| 203 | + | sqlx::query( | |
| 204 | + | "INSERT INTO pending_uploads (user_id, s3_key, bucket, created_at) | |
| 205 | + | VALUES ($1, 'staging/shared-key.bin', 'synckit', NOW())", | |
| 206 | + | ) | |
| 207 | + | .bind(user) | |
| 208 | + | .execute(&h.db) | |
| 209 | + | .await | |
| 210 | + | .expect("insert synckit pending row"); | |
| 211 | + | ||
| 212 | + | h.run_orphan_upload_reaper().await; | |
| 213 | + | ||
| 214 | + | assert_eq!( | |
| 215 | + | pending_rows(&h, "staging/shared-key.bin", "main").await, | |
| 216 | + | 0, | |
| 217 | + | "the stale main-bucket row is reaped" | |
| 218 | + | ); | |
| 219 | + | assert_eq!( | |
| 220 | + | pending_rows(&h, "staging/shared-key.bin", "synckit").await, | |
| 221 | + | 1, | |
| 222 | + | "the synckit row shares the key but not the bucket and must survive" | |
| 223 | + | ); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | /// The reclaim path, which is why the reaper routes through the live-key guard. | |
| 227 | + | /// A confirm can take a staging key over onto a live item row before the reaper | |
| 228 | + | /// reaches the stale pending record. The object now belongs to that item, so it | |
| 229 | + | /// must survive; only the tracking row goes. | |
| 230 | + | #[tokio::test] | |
| 231 | + | async fn a_key_reclaimed_by_a_live_item_keeps_its_object_and_only_loses_the_pending_row() { | |
| 232 | + | let mut h = TestHarness::with_storage().await; | |
| 233 | + | let user = h | |
| 234 | + | .signup("reaper_reclaim", "reaper_reclaim@test.com", "pass1234") | |
| 235 | + | .await; | |
| 236 | + | let storage = h.storage.clone().expect("with_storage provides a backend"); | |
| 237 | + | ||
| 238 | + | let project = seed_project(&h.db, user, "reclaim-proj").await; | |
| 239 | + | let item = seed_item(&h.db, project, "reclaim-item").await; | |
| 240 | + | pending_upload(&h, user, "staging/reclaimed.bin", "main", 40).await; | |
| 241 | + | ||
| 242 | + | // The confirm that beat the reaper: the key is now the item's live audio. | |
| 243 | + | update_item_file_cas( | |
| 244 | + | &h.db, | |
| 245 | + | item, | |
| 246 | + | user, | |
| 247 | + | FileType::Audio, | |
| 248 | + | None, | |
| 249 | + | "staging/reclaimed.bin", | |
| 250 | + | 3_300_000, | |
| 251 | + | ) | |
| 252 | + | .await | |
| 253 | + | .expect("confirm reclaims the key"); | |
| 254 | + | ||
| 255 | + | h.run_orphan_upload_reaper().await; | |
| 256 | + | ||
| 257 | + | assert!( | |
| 258 | + | storage | |
| 259 | + | .object_exists("staging/reclaimed.bin") | |
| 260 | + | .await | |
| 261 | + | .unwrap(), | |
| 262 | + | "the object is a live item file now; deleting it would destroy a paid-for upload" | |
| 263 | + | ); | |
| 264 | + | assert_eq!( | |
| 265 | + | pending_rows(&h, "staging/reclaimed.bin", "main").await, | |
| 266 | + | 0, | |
| 267 | + | "the stale tracking row is still cleared, or the reaper retries it forever" | |
| 268 | + | ); | |
| 269 | + | let queued: i64 = | |
| 270 | + | sqlx::query_scalar("SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1") | |
| 271 | + | .bind("staging/reclaimed.bin") | |
| 272 | + | .fetch_one(&h.db) | |
| 273 | + | .await | |
| 274 | + | .expect("count queued deletions"); | |
| 275 | + | assert_eq!( | |
| 276 | + | queued, 0, | |
| 277 | + | "a live key must not be handed to the deletion queue either" | |
| 278 | + | ); | |
| 279 | + | let audio = audio_cols(&h.db, item).await; | |
| 280 | + | assert_eq!( | |
| 281 | + | audio, | |
| 282 | + | (Some("staging/reclaimed.bin".to_string()), Some(3_300_000)), | |
| 283 | + | "and the item still points at it, got {audio:?}" | |
| 284 | + | ); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /// Running the reaper twice over the same backlog must delete each object once. | |
| 288 | + | /// The tracking rows are gone after the first pass, so the second finds nothing: | |
| 289 | + | /// this is the replay guard on the reap itself. | |
| 290 | + | #[tokio::test] | |
| 291 | + | async fn a_second_reaper_pass_over_a_drained_backlog_deletes_nothing_again() { | |
| 292 | + | let mut h = TestHarness::with_storage().await; | |
| 293 | + | let user = h | |
| 294 | + | .signup("reaper_twice", "reaper_twice@test.com", "pass1234") | |
| 295 | + | .await; | |
| 296 | + | let storage = h.storage.clone().expect("with_storage provides a backend"); | |
| 297 | + | ||
| 298 | + | pending_upload(&h, user, "staging/once-a.bin", "main", 26).await; | |
| 299 | + | pending_upload(&h, user, "staging/once-b.bin", "main", 27).await; | |
| 300 | + | ||
| 301 | + | h.run_orphan_upload_reaper().await; | |
| 302 | + | let after_first = storage.faults().calls("delete_object"); | |
| 303 | + | assert_eq!( | |
| 304 | + | after_first, 2, | |
| 305 | + | "the first pass deletes each of the two orphans exactly once" | |
| 306 | + | ); | |
| 307 | + | ||
| 308 | + | h.run_orphan_upload_reaper().await; | |
| 309 | + | assert_eq!( | |
| 310 | + | storage.faults().calls("delete_object"), | |
| 311 | + | after_first, | |
| 312 | + | "the second pass has no rows to act on and must issue no further deletes" | |
| 313 | + | ); | |
| 314 | + | let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pending_uploads") | |
| 315 | + | .fetch_one(&h.db) | |
| 316 | + | .await | |
| 317 | + | .expect("count remaining"); | |
| 318 | + | assert_eq!(left, 0, "nothing is left pending"); | |
| 319 | + | } |
| @@ -1,0 +1,767 @@ | |||
| 1 | + | //! DB-layer contract tests for the money read-models: | |
| 2 | + | //! `db::transactions::revenue_stats` and `db::transactions::seller_contacts`. | |
| 3 | + | //! | |
| 4 | + | //! Revenue is what a creator is told they earned and what the platform reports | |
| 5 | + | //! to itself, so the aggregates are pinned with several distinct, non-round | |
| 6 | + | //! amounts per group: a wrong aggregate operator (max instead of sum, a count | |
| 7 | + | //! standing in for a total, a refunded row leaking into revenue) lands on a | |
| 8 | + | //! different number rather than agreeing by accident. The empty case is pinned | |
| 9 | + | //! separately, because zero rows is the one input where every wrong aggregate | |
| 10 | + | //! agrees with the right one. | |
| 11 | + | //! | |
| 12 | + | //! Contacts are a creator's fan list, which is the data a fan can withdraw: | |
| 13 | + | //! these pin that sharing is opt-in, that a revocation removes a buyer from | |
| 14 | + | //! every contact surface, that the platform-notification query deliberately | |
| 15 | + | //! ignores both, and that the paginated export walks each buyer exactly once. | |
| 16 | + | //! | |
| 17 | + | //! The Fan+ subscription store that used to share this file is | |
| 18 | + | //! `db_fan_plus_layer`. | |
| 19 | + | //! | |
| 20 | + | //! Delete this file and nothing checks that completed-only, | |
| 21 | + | //! currency-separated revenue is what the dashboards read, or that a revoked | |
| 22 | + | //! fan stays revoked. | |
| 23 | + | ||
| 24 | + | use crate::harness::db::TestDb; | |
| 25 | + | use crate::harness::seed_user; | |
| 26 | + | use makenotwork::currency::SettlementCurrency; | |
| 27 | + | use makenotwork::db::{ItemId, ProjectId, UserId, transactions}; | |
| 28 | + | ||
| 29 | + | // ── seeding ── | |
| 30 | + | ||
| 31 | + | /// Insert a project owned by `user` with a distinguishable title. | |
| 32 | + | async fn seed_titled_project( | |
| 33 | + | pool: &sqlx::PgPool, | |
| 34 | + | user: UserId, | |
| 35 | + | slug: &str, | |
| 36 | + | title: &str, | |
| 37 | + | ) -> ProjectId { | |
| 38 | + | sqlx::query_scalar::<_, ProjectId>( | |
| 39 | + | "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, $3) RETURNING id", | |
| 40 | + | ) | |
| 41 | + | .bind(user) | |
| 42 | + | .bind(slug) | |
| 43 | + | .bind(title) | |
| 44 | + | .fetch_one(pool) | |
| 45 | + | .await | |
| 46 | + | .expect("seed project") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId { | |
| 50 | + | sqlx::query_scalar::<_, ItemId>( | |
| 51 | + | "INSERT INTO items (project_id, title, item_type, price_cents, slug) | |
| 52 | + | VALUES ($1, $2, 'digital', 1000, $3) RETURNING id", | |
| 53 | + | ) | |
| 54 | + | .bind(project) | |
| 55 | + | .bind(format!("Item {slug}")) | |
| 56 | + | .bind(slug) | |
| 57 | + | .fetch_one(pool) | |
| 58 | + | .await | |
| 59 | + | .expect("seed item") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// One transaction row, described by the fields these read-models group on. | |
| 63 | + | struct Sale { | |
| 64 | + | buyer: UserId, | |
| 65 | + | seller: UserId, | |
| 66 | + | item: ItemId, | |
| 67 | + | amount_cents: i32, | |
| 68 | + | currency: &'static str, | |
| 69 | + | status: &'static str, | |
| 70 | + | share_contact: bool, | |
| 71 | + | /// How long ago the sale happened. Relative offsets rather than fixed | |
| 72 | + | /// timestamps, so ordering is deterministic without any wall-clock | |
| 73 | + | /// assumption beyond "NOW() moves forward". | |
| 74 | + | hours_ago: i32, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | impl Sale { | |
| 78 | + | /// A completed USD sale, the shape most of these tests vary from. | |
| 79 | + | fn completed(buyer: UserId, seller: UserId, item: ItemId, amount_cents: i32) -> Self { | |
| 80 | + | Sale { | |
| 81 | + | buyer, | |
| 82 | + | seller, | |
| 83 | + | item, | |
| 84 | + | amount_cents, | |
| 85 | + | currency: "usd", | |
| 86 | + | status: "completed", | |
| 87 | + | share_contact: false, | |
| 88 | + | hours_ago: 1, | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | async fn insert(&self, pool: &sqlx::PgPool, session: &str) { | |
| 93 | + | sqlx::query( | |
| 94 | + | "INSERT INTO transactions | |
| 95 | + | (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, currency, | |
| 96 | + | status, stripe_checkout_session_id, item_title, seller_username, share_contact, | |
| 97 | + | created_at, completed_at) | |
| 98 | + | VALUES ($1, $2, $3, $4, 0, $5, $6, $7, 'Item', 'seller', $8, | |
| 99 | + | NOW() - make_interval(hours => $9::int), | |
| 100 | + | CASE WHEN $6 IN ('completed', 'refunded') | |
| 101 | + | THEN NOW() - make_interval(hours => $9::int) END)", | |
| 102 | + | ) | |
| 103 | + | .bind(self.buyer) | |
| 104 | + | .bind(self.seller) | |
| 105 | + | .bind(self.item) | |
| 106 | + | .bind(self.amount_cents) | |
| 107 | + | .bind(self.currency) | |
| 108 | + | .bind(self.status) | |
| 109 | + | .bind(session) | |
| 110 | + | .bind(self.share_contact) | |
| 111 | + | .bind(self.hours_ago) | |
| 112 | + | .execute(pool) | |
| 113 | + | .await | |
| 114 | + | .expect("seed transaction"); | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // ── revenue_stats ── | |
| 119 | + | ||
| 120 | + | #[tokio::test] | |
| 121 | + | async fn project_revenue_sums_completed_sales_and_ignores_every_other_status() { | |
| 122 | + | let db = TestDb::new().await; | |
| 123 | + | let seller = seed_user(&db.pool, "rev_proj_seller").await; | |
| 124 | + | let buyer = seed_user(&db.pool, "rev_proj_buyer").await; | |
| 125 | + | let project = seed_titled_project(&db.pool, seller, "rev-proj", "Rev").await; | |
| 126 | + | let item = seed_item(&db.pool, project, "rev-a").await; | |
| 127 | + | ||
| 128 | + | // Three completed sales at distinct, non-round amounts: 1234 + 5678 + 9012. | |
| 129 | + | // No two of them share a digit pattern that a max/min/first-row read would | |
| 130 | + | // land on, and the total (15924) differs from any single row and from any | |
| 131 | + | // pairwise sum. | |
| 132 | + | // | |
| 133 | + | // One item per completed sale, all three inside this project. The schema | |
| 134 | + | // carries `idx_transactions_buyer_item_completed`, a unique index on | |
| 135 | + | // (buyer_id, item_id) WHERE status = 'completed', so one buyer cannot hold | |
| 136 | + | // two completed rows for the same item; the aggregate groups on the | |
| 137 | + | // project, which is unaffected. | |
| 138 | + | for (i, amount) in [1234, 5678, 9012].into_iter().enumerate() { | |
| 139 | + | let sold = seed_item(&db.pool, project, &format!("rev-sale-{i}")).await; | |
| 140 | + | Sale::completed(buyer, seller, sold, amount) | |
| 141 | + | .insert(&db.pool, &format!("cs_rev_proj_{i}")) | |
| 142 | + | .await; | |
| 143 | + | } | |
| 144 | + | // Three rows that must contribute nothing. Their amounts are distinct from | |
| 145 | + | // each other, so leaking any single one moves the total to a different | |
| 146 | + | // wrong number rather than to a shared one. | |
| 147 | + | for (status, amount) in [("pending", 7777), ("refunded", 4444), ("failed", 3333)] { | |
| 148 | + | Sale { | |
| 149 | + | status, | |
| 150 | + | ..Sale::completed(buyer, seller, item, amount) | |
| 151 | + | } | |
| 152 | + | .insert(&db.pool, &format!("cs_rev_proj_{status}")) | |
| 153 | + | .await; | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | let (revenue, sales) = transactions::get_revenue_by_project(&db.pool, project) | |
| 157 | + | .await | |
| 158 | + | .expect("project revenue query ok"); | |
| 159 | + | ||
| 160 | + | assert_eq!( | |
| 161 | + | revenue.in_currency(SettlementCurrency::Usd), | |
| 162 | + | 15_924, | |
| 163 | + | "completed revenue is the sum of 1234 + 5678 + 9012, with pending, refunded and failed excluded" | |
| 164 | + | ); | |
| 165 | + | assert_eq!( | |
| 166 | + | revenue.currency_count(), | |
| 167 | + | 1, | |
| 168 | + | "one seller settling in one currency reports one currency" | |
| 169 | + | ); | |
| 170 | + | assert_eq!( | |
| 171 | + | sales, 3, | |
| 172 | + | "the sales count counts completed rows only, not all six" | |
| 173 | + | ); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[tokio::test] | |
| 177 | + | async fn project_revenue_is_empty_rather_than_zero_currency_noise_with_no_sales() { | |
| 178 | + | let db = TestDb::new().await; | |
| 179 | + | let seller = seed_user(&db.pool, "rev_empty_seller").await; | |
| 180 | + | let project = seed_titled_project(&db.pool, seller, "rev-empty", "Empty").await; | |
| 181 | + | seed_item(&db.pool, project, "rev-empty-a").await; | |
| 182 | + | ||
| 183 | + | let (revenue, sales) = transactions::get_revenue_by_project(&db.pool, project) | |
| 184 | + | .await | |
| 185 | + | .expect("project revenue query ok"); | |
| 186 | + | ||
| 187 | + | assert!( | |
| 188 | + | revenue.is_empty(), | |
| 189 | + | "a project with no transactions reports no money in any currency" | |
| 190 | + | ); | |
| 191 | + | assert_eq!( | |
| 192 | + | revenue.currency_count(), | |
| 193 | + | 0, | |
| 194 | + | "no sales must not invent a zero-valued currency entry" | |
| 195 | + | ); | |
| 196 | + | assert_eq!(revenue.in_currency(SettlementCurrency::Usd), 0); | |
| 197 | + | assert_eq!(sales, 0, "no completed rows means no sales"); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[tokio::test] | |
| 201 | + | async fn project_revenue_keeps_two_currencies_apart_instead_of_adding_them() { | |
| 202 | + | let db = TestDb::new().await; | |
| 203 | + | let seller = seed_user(&db.pool, "rev_cur_seller").await; | |
| 204 | + | let buyer = seed_user(&db.pool, "rev_cur_buyer").await; | |
| 205 | + | let project = seed_titled_project(&db.pool, seller, "rev-cur", "Cur").await; | |
| 206 | + | ||
| 207 | + | // Two sales in each currency, so both groups exercise the SUM rather than | |
| 208 | + | // reading a single row: usd 2500 + 1725 = 4225, gbp 3175 + 1025 = 4200. | |
| 209 | + | // The two totals are close but unequal, so swapping the groups is visible, | |
| 210 | + | // and their sum (8425) is a third distinct number. | |
| 211 | + | for (i, (amount, currency)) in [(2500, "usd"), (1725, "usd"), (3175, "gbp"), (1025, "gbp")] | |
| 212 | + | .into_iter() | |
| 213 | + | .enumerate() | |
| 214 | + | { | |
| 215 | + | // One item per completed row: the same buyer may hold only one | |
| 216 | + | // completed transaction per item (idx_transactions_buyer_item_completed). | |
| 217 | + | let sold = seed_item(&db.pool, project, &format!("rev-cur-{i}")).await; | |
| 218 | + | Sale { | |
| 219 | + | currency, | |
| 220 | + | ..Sale::completed(buyer, seller, sold, amount) | |
| 221 | + | } | |
| 222 | + | .insert(&db.pool, &format!("cs_rev_cur_{i}")) | |
| 223 | + | .await; | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | let (revenue, sales) = transactions::get_revenue_by_project(&db.pool, project) | |
| 227 | + | .await | |
| 228 | + | .expect("project revenue query ok"); | |
| 229 | + | ||
| 230 | + | assert_eq!( | |
| 231 | + | revenue.currency_count(), | |
| 232 | + | 2, | |
| 233 | + | "pounds and dollars are reported as two totals, never one" | |
| 234 | + | ); | |
| 235 | + | assert_eq!( | |
| 236 | + | revenue.in_currency(SettlementCurrency::Usd), | |
| 237 | + | 4225, | |
| 238 | + | "the dollar total is the dollar rows only" | |
| 239 | + | ); | |
| 240 | + | assert_eq!( | |
| 241 | + | revenue.in_currency(SettlementCurrency::Gbp), | |
| 242 | + | 4200, | |
| 243 | + | "the pound total is the pound rows only" | |
| 244 | + | ); | |
| 245 | + | assert_eq!( | |
| 246 | + | revenue.in_currency(SettlementCurrency::Eur), | |
| 247 | + | 0, | |
| 248 | + | "a currency with no sales reports nothing, not the other currencies' money" | |
| 249 | + | ); | |
| 250 | + | assert_eq!(sales, 4, "the count is currency-free and spans both groups"); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | #[tokio::test] | |
| 254 | + | async fn user_project_revenue_ranks_earning_projects_and_drops_the_rest() { | |
| 255 | + | let db = TestDb::new().await; | |
| 256 | + | let seller = seed_user(&db.pool, "rev_user_seller").await; | |
| 257 | + | let buyer = seed_user(&db.pool, "rev_user_buyer").await; | |
| 258 | + | let other_seller = seed_user(&db.pool, "rev_user_other").await; | |
| 259 | + | ||
| 260 | + | let top = seed_titled_project(&db.pool, seller, "rev-top", "Top Seller").await; | |
| 261 | + | let mid = seed_titled_project(&db.pool, seller, "rev-mid", "Mid Seller").await; | |
| 262 | + | let quiet = seed_titled_project(&db.pool, seller, "rev-quiet", "Quiet").await; | |
| 263 | + | let foreign = seed_titled_project(&db.pool, other_seller, "rev-foreign", "Foreign").await; | |
| 264 | + | ||
| 265 | + | // Two items per earning project, because one buyer may hold only one | |
| 266 | + | // completed transaction per item (idx_transactions_buyer_item_completed). | |
| 267 | + | // Both sales still land in the same project, which is what the roll-up | |
| 268 | + | // groups on. | |
| 269 | + | let top_item_a = seed_item(&db.pool, top, "top-a").await; | |
| 270 | + | let top_item_b = seed_item(&db.pool, top, "top-b").await; | |
| 271 | + | let mid_item_usd = seed_item(&db.pool, mid, "mid-a").await; | |
| 272 | + | let mid_item_gbp = seed_item(&db.pool, mid, "mid-b").await; | |
| 273 | + | let quiet_item = seed_item(&db.pool, quiet, "quiet-a").await; | |
| 274 | + | let foreign_item = seed_item(&db.pool, foreign, "foreign-a").await; | |
| 275 | + | ||
| 276 | + | // Top: 5075 + 2425 = 7500 usd, two rows so the fold is exercised. | |
| 277 | + | Sale::completed(buyer, seller, top_item_a, 5075) | |
| 278 | + | .insert(&db.pool, "cs_rev_user_top_a") | |
| 279 | + | .await; | |
| 280 | + | Sale::completed(buyer, seller, top_item_b, 2425) | |
| 281 | + | .insert(&db.pool, "cs_rev_user_top_b") | |
| 282 | + | .await; | |
| 283 | + | // Mid: two currencies, both below the top project's single total, so the | |
| 284 | + | // ordering cannot be explained by "whichever project has more rows". | |
| 285 | + | Sale::completed(buyer, seller, mid_item_usd, 4200) | |
| 286 | + | .insert(&db.pool, "cs_rev_user_mid_usd") | |
| 287 | + | .await; | |
| 288 | + | Sale { | |
| 289 | + | currency: "gbp", | |
| 290 | + | ..Sale::completed(buyer, seller, mid_item_gbp, 6000) | |
| 291 | + | } | |
| 292 | + | .insert(&db.pool, "cs_rev_user_mid_gbp") | |
| 293 | + | .await; | |
| 294 | + | // Quiet: a pending sale only. The HAVING clause must drop it entirely. | |
| 295 | + | Sale { | |
| 296 | + | status: "pending", | |
| 297 | + | ..Sale::completed(buyer, seller, quiet_item, 9999) | |
| 298 | + | } | |
| 299 | + | .insert(&db.pool, "cs_rev_user_quiet") | |
| 300 | + | .await; | |
| 301 | + | // Another creator's earning project must not appear in this creator's list. | |
| 302 | + | Sale::completed(buyer, other_seller, foreign_item, 8888) | |
| 303 | + | .insert(&db.pool, "cs_rev_user_foreign") | |
| 304 | + | .await; | |
| 305 | + | ||
| 306 | + | let rows = transactions::get_revenue_by_user_projects(&db.pool, seller) | |
| 307 | + | .await | |
| 308 | + | .expect("user project revenue query ok"); | |
| 309 | + | ||
| 310 | + | assert_eq!( | |
| 311 | + | rows.len(), | |
| 312 | + | 2, | |
| 313 | + | "only the two projects with completed revenue are listed, got {:?}", | |
| 314 | + | rows.iter().map(|(_, t, _)| t.clone()).collect::<Vec<_>>() | |
| 315 | + | ); | |
| 316 | + | assert_eq!( | |
| 317 | + | rows[0].0, top, | |
| 318 | + | "the largest single-currency total ranks first" | |
| 319 | + | ); | |
| 320 | + | assert_eq!(rows[0].1, "Top Seller"); | |
| 321 | + | assert_eq!( | |
| 322 | + | rows[0].2.in_currency(SettlementCurrency::Usd), | |
| 323 | + | 7500, | |
| 324 | + | "the top project's two sales are summed, not counted" | |
| 325 | + | ); | |
| 326 | + | assert_eq!(rows[1].0, mid); | |
| 327 | + | assert_eq!( | |
| 328 | + | rows[1].2.currency_count(), | |
| 329 | + | 2, | |
| 330 | + | "a project that sold in two currencies keeps both" | |
| 331 | + | ); | |
| 332 | + | assert_eq!(rows[1].2.in_currency(SettlementCurrency::Gbp), 6000); | |
| 333 | + | assert_eq!(rows[1].2.in_currency(SettlementCurrency::Usd), 4200); | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | #[tokio::test] | |
| 337 | + | async fn platform_revenue_stats_separate_currencies_and_count_refunds_apart() { | |
| 338 | + | let db = TestDb::new().await; | |
| 339 | + | let seller = seed_user(&db.pool, "plat_seller").await; | |
| 340 | + | let buyer = seed_user(&db.pool, "plat_buyer").await; | |
| 341 | + | let project = seed_titled_project(&db.pool, seller, "plat", "Platform").await; | |
| 342 | + | let item = seed_item(&db.pool, project, "plat-a").await; | |
| 343 | + | ||
| 344 | + | // usd completed: 1234 + 5678 = 6912. gbp completed: 3175. | |
| 345 | + | // Refunded rows carry their own distinct amounts (4444, 2222): if a refund | |
| 346 | + | // leaked into revenue the dollar total would be 13578 or 11134, never 6912. | |
| 347 | + | for (i, (amount, currency, status)) in [ | |
| 348 | + | (1234, "usd", "completed"), | |
| 349 | + | (5678, "usd", "completed"), | |
| 350 | + | (3175, "gbp", "completed"), | |
| 351 | + | (4444, "usd", "refunded"), | |
| 352 | + | (2222, "gbp", "refunded"), | |
| 353 | + | (7777, "usd", "pending"), | |
| 354 | + | ] | |
| 355 | + | .into_iter() | |
| 356 | + | .enumerate() | |
| 357 | + | { | |
| 358 | + | // Completed rows get an item each: one buyer may hold only one | |
| 359 | + | // completed transaction per item (idx_transactions_buyer_item_completed). | |
| 360 | + | // Refunded and pending rows are outside that partial index, and this | |
| 361 | + | // roll-up is platform-wide, so which item they name is immaterial. | |
| 362 | + | let sold = if status == "completed" { | |
| 363 | + | seed_item(&db.pool, project, &format!("plat-{i}")).await | |
| 364 | + | } else { | |
| 365 | + | item | |
| 366 | + | }; | |
| 367 | + | Sale { | |
| 368 | + | currency, | |
| 369 | + | status, | |
| 370 | + | ..Sale::completed(buyer, seller, sold, amount) | |
| 371 | + | } | |
| 372 | + | .insert(&db.pool, &format!("cs_plat_{i}")) | |
| 373 | + | .await; | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | let (revenue, completed, refunded) = transactions::get_platform_revenue_stats(&db.pool) | |
| 377 | + | .await | |
| 378 | + | .expect("platform revenue query ok"); | |
| 379 | + | ||
| 380 | + | assert_eq!( | |
| 381 | + | revenue.in_currency(SettlementCurrency::Usd), | |
| 382 | + | 6912, | |
| 383 | + | "dollar revenue counts the two completed dollar sales only" | |
| 384 | + | ); | |
| 385 | + | assert_eq!( | |
| 386 | + | revenue.in_currency(SettlementCurrency::Gbp), | |
| 387 | + | 3175, | |
| 388 | + | "pound revenue counts the completed pound sale only" | |
| 389 | + | ); | |
| 390 | + | assert_eq!( | |
| 391 | + | revenue.currency_count(), | |
| 392 | + | 2, | |
| 393 | + | "the platform roll-up spans currencies without adding them" | |
| 394 | + | ); | |
| 395 | + | assert_eq!( | |
| 396 | + | completed, 3, | |
| 397 | + | "three completed rows across both currencies, counts add where money does not" | |
| 398 | + | ); | |
| 399 | + | assert_eq!( | |
| 400 | + | refunded, 2, | |
| 401 | + | "both refunded rows are counted, in whichever currency they were written" | |
| 402 | + | ); | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | #[tokio::test] | |
| 406 | + | async fn item_sales_list_completed_and_refunded_rows_for_that_seller_newest_first() { | |
| 407 | + | let db = TestDb::new().await; | |
| 408 | + | let seller = seed_user(&db.pool, "item_sales_seller").await; | |
| 409 | + | let rogue = seed_user(&db.pool, "item_sales_rogue").await; | |
| 410 | + | let buyer = seed_user(&db.pool, "item_sales_buyer").await; | |
| 411 | + | // A second buyer for the other seller's row on this item: the same buyer | |
| 412 | + | // may hold only one completed transaction per item | |
| 413 | + | // (idx_transactions_buyer_item_completed), and the point of that row is the | |
| 414 | + | // seller predicate, not the buyer. | |
| 415 | + | let rogue_buyer = seed_user(&db.pool, "item_sales_rogue_buyer").await; | |
| 416 | + | let project = seed_titled_project(&db.pool, seller, "item-sales", "Sales").await; | |
| 417 | + | let item = seed_item(&db.pool, project, "sales-a").await; | |
| 418 | + | let other_item = seed_item(&db.pool, project, "sales-b").await; | |
| 419 | + | ||
| 420 | + | // Newest completed sale, then an older refund: both belong in the tab. | |
| 421 | + | Sale { | |
| 422 | + | hours_ago: 1, | |
| 423 | + | ..Sale::completed(buyer, seller, item, 1500) | |
| 424 | + | } | |
| 425 | + | .insert(&db.pool, "cs_item_sales_new") | |
| 426 | + | .await; | |
| 427 | + | Sale { | |
| 428 | + | status: "refunded", | |
| 429 | + | hours_ago: 5, | |
| 430 | + | ..Sale::completed(buyer, seller, item, 2600) | |
| 431 | + | } | |
| 432 | + | .insert(&db.pool, "cs_item_sales_refunded") | |
| 433 | + | .await; | |
| 434 | + | // Excluded: still pending, a different item, and a row tagged to another seller. | |
| 435 | + | Sale { | |
| 436 | + | status: "pending", | |
| 437 | + | hours_ago: 2, | |
| 438 | + | ..Sale::completed(buyer, seller, item, 3700) | |
| 439 | + | } | |
| 440 | + | .insert(&db.pool, "cs_item_sales_pending") | |
| 441 | + | .await; | |
| 442 | + | Sale { | |
| 443 | + | hours_ago: 3, | |
| 444 | + | ..Sale::completed(buyer, seller, other_item, 4800) | |
| 445 | + | } | |
| 446 | + | .insert(&db.pool, "cs_item_sales_other_item") | |
| 447 | + | .await; | |
| 448 | + | Sale { | |
| 449 | + | hours_ago: 4, | |
| 450 | + | ..Sale::completed(rogue_buyer, rogue, item, 5900) | |
| 451 | + | } | |
| 452 | + | .insert(&db.pool, "cs_item_sales_other_seller") | |
| 453 | + | .await; | |
| 454 | + | ||
| 455 | + | let rows = transactions::get_sales_by_item(&db.pool, item, seller) | |
| 456 | + | .await | |
| 457 | + | .expect("item sales query ok"); | |
| 458 | + | ||
| 459 | + | let amounts: Vec<i64> = rows.iter().map(|t| t.amount_cents.as_i64()).collect(); | |
| 460 | + | assert_eq!( | |
| 461 | + | amounts, | |
| 462 | + | vec![1500, 2600], | |
| 463 | + | "completed and refunded sales of this item by this seller, newest first, got {amounts:?}" | |
| 464 | + | ); | |
| 465 | + | assert!( | |
| 466 | + | rows.iter().all(|t| t.seller_id == Some(seller)), | |
| 467 | + | "another seller's row on the same item is never listed" | |
| 468 | + | ); | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | // ── seller_contacts ── | |
| 472 | + | ||
| 473 | + | #[tokio::test] | |
| 474 | + | async fn seller_contacts_aggregate_only_shared_completed_purchases() { | |
| 475 | + | let db = TestDb::new().await; | |
| 476 | + | let seller = seed_user(&db.pool, "contacts_seller").await; | |
| 477 | + | let sharer = seed_user(&db.pool, "contacts_sharer").await; | |
| 478 | + | let revoker = seed_user(&db.pool, "contacts_revoker").await; | |
| 479 | + | let private_buyer = seed_user(&db.pool, "contacts_private").await; | |
| 480 | + | let project = seed_titled_project(&db.pool, seller, "contacts", "Contacts").await; | |
| 481 | + | let item = seed_item(&db.pool, project, "contacts-a").await; | |
| 482 | + | ||
| 483 | + | // The sharer bought twice: 1975 + 2225 = 4200 across two purchases. One | |
| 484 | + | // item each, because the same buyer may hold only one completed | |
| 485 | + | // transaction per item (idx_transactions_buyer_item_completed); the | |
| 486 | + | // contact roll-up groups by buyer, so the split changes nothing it reads. | |
| 487 | + | for (i, (amount, hours_ago)) in [(1975, 6), (2225, 2)].into_iter().enumerate() { | |
| 488 | + | let sold = seed_item(&db.pool, project, &format!("contacts-sharer-{i}")).await; | |
| 489 | + | Sale { | |
| 490 | + | share_contact: true, | |
| 491 | + | hours_ago, | |
| 492 | + | ..Sale::completed(sharer, seller, sold, amount) | |
| 493 | + | } | |
| 494 | + | .insert(&db.pool, &format!("cs_contacts_sharer_{i}")) | |
| 495 | + | .await; | |
| 496 | + | } | |
| 497 | + | // A pending purchase of theirs must not inflate either figure: 9999 would | |
| 498 | + | // be visible in both the total (14199) and the count (3). | |
| 499 | + | Sale { | |
| 500 | + | share_contact: true, |
Lines truncated
| @@ -1,0 +1,455 @@ | |||
| 1 | + | //! DB-layer contract tests for the sealed access gate in `db::subscriptions`. | |
| 2 | + | //! | |
| 3 | + | //! `subscriptions.rs` is the biggest money module in `db/` and it carried no | |
| 4 | + | //! test of its own: its `#[cfg(test)]` block holds a test-only gate constructor | |
| 5 | + | //! and nothing else. What the gate decides is who may reach paid content, so | |
| 6 | + | //! these pin its clauses one at a time: active AND unpaused AND inside the paid | |
| 7 | + | //! period, scoped to one subscriber and one target, with every non-active | |
| 8 | + | //! status denied on the other side of the boundary. | |
| 9 | + | //! | |
| 10 | + | //! The webhook-driven lifecycle writes of the same module are | |
| 11 | + | //! `db_subscriptions_lifecycle_layer`. | |
| 12 | + | //! | |
| 13 | + | //! Reach: every function in `db::subscriptions` is `pub(crate)`, so the | |
| 14 | + | //! integration test crate cannot call the module directly. Each test below | |
| 15 | + | //! drives the real production call site nearest to the function under test | |
| 16 | + | //! (db::items::check_item_access for the item gate, whose `subscription` field | |
| 17 | + | //! is filled by `SubscriptionGate::check` and nothing else; the subscribe route | |
| 18 | + | //! for the project-scope `has_access`) and asserts on what those calls answer. | |
| 19 | + | //! | |
| 20 | + | //! Delete this file and the gate's period and pause clauses become silently | |
| 21 | + | //! editable: nothing else asserts them at this layer. | |
| 22 | + | ||
| 23 | + | use crate::harness::db::TestDb; | |
| 24 | + | use crate::harness::{TestHarness, seed_project, seed_user}; | |
| 25 | + | use chrono::{DateTime, Duration, Utc}; | |
| 26 | + | use makenotwork::db::{self, ItemId, ProjectId, SubscriptionTierId, UserId}; | |
| 27 | + | use serde_json::Value; | |
| 28 | + | use sqlx::PgPool; | |
| 29 | + | ||
| 30 | + | // ── seeding ── | |
| 31 | + | ||
| 32 | + | /// An item in `project`. `slug` is unique per project, so callers pass one. | |
| 33 | + | async fn seed_item(pool: &PgPool, project: ProjectId, slug: &str) -> ItemId { | |
| 34 | + | sqlx::query_scalar::<_, ItemId>( | |
| 35 | + | "INSERT INTO items (project_id, title, item_type, price_cents, slug) | |
| 36 | + | VALUES ($1, 'Gated Item', 'audio', 1500, $2) RETURNING id", | |
| 37 | + | ) | |
| 38 | + | .bind(project) | |
| 39 | + | .bind(slug) | |
| 40 | + | .fetch_one(pool) | |
| 41 | + | .await | |
| 42 | + | .expect("seed item") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// An item-scoped tier. `tier_exactly_one_target` forbids setting `project_id` | |
| 46 | + | /// as well, which is why the two seeders below are separate. | |
| 47 | + | async fn seed_item_tier(pool: &PgPool, item: ItemId) -> SubscriptionTierId { | |
| 48 | + | sqlx::query_scalar::<_, SubscriptionTierId>( | |
| 49 | + | "INSERT INTO subscription_tiers (item_id, name, price_cents) | |
| 50 | + | VALUES ($1, 'Item Tier', 1500) RETURNING id", | |
| 51 | + | ) | |
| 52 | + | .bind(item) | |
| 53 | + | .fetch_one(pool) | |
| 54 | + | .await | |
| 55 | + | .expect("seed item tier") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | async fn seed_project_tier(pool: &PgPool, project: ProjectId) -> SubscriptionTierId { | |
| 59 | + | sqlx::query_scalar::<_, SubscriptionTierId>( | |
| 60 | + | "INSERT INTO subscription_tiers (project_id, name, price_cents) | |
| 61 | + | VALUES ($1, 'Project Tier', 1500) RETURNING id", | |
| 62 | + | ) | |
| 63 | + | .bind(project) | |
| 64 | + | .fetch_one(pool) | |
| 65 | + | .await | |
| 66 | + | .expect("seed project tier") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// An item-scoped subscription row (`project_id` NULL, per `sub_exactly_one_target`). | |
| 70 | + | async fn seed_item_subscription( | |
| 71 | + | pool: &PgPool, | |
| 72 | + | subscriber: UserId, | |
| 73 | + | tier: SubscriptionTierId, | |
| 74 | + | item: ItemId, | |
| 75 | + | stripe_id: &str, | |
| 76 | + | status: &str, | |
| 77 | + | period_end: Option<DateTime<Utc>>, | |
| 78 | + | ) { | |
| 79 | + | sqlx::query( | |
| 80 | + | "INSERT INTO subscriptions | |
| 81 | + | (subscriber_id, tier_id, item_id, stripe_subscription_id, stripe_customer_id, | |
| 82 | + | status, current_period_start, current_period_end) | |
| 83 | + | VALUES ($1, $2, $3, $4, 'cus_gate_seed', $5, NOW() - interval '1 day', $6)", | |
| 84 | + | ) | |
| 85 | + | .bind(subscriber) | |
| 86 | + | .bind(tier) | |
| 87 | + | .bind(item) | |
| 88 | + | .bind(stripe_id) | |
| 89 | + | .bind(status) | |
| 90 | + | .bind(period_end) | |
| 91 | + | .execute(pool) | |
| 92 | + | .await | |
| 93 | + | .expect("seed item subscription"); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// A project-scoped subscription row (`item_id` NULL). | |
| 97 | + | async fn seed_project_subscription( | |
| 98 | + | pool: &PgPool, | |
| 99 | + | subscriber: UserId, | |
| 100 | + | tier: SubscriptionTierId, | |
| 101 | + | project: ProjectId, | |
| 102 | + | stripe_id: &str, | |
| 103 | + | status: &str, | |
| 104 | + | period_end: Option<DateTime<Utc>>, | |
| 105 | + | ) { | |
| 106 | + | sqlx::query( | |
| 107 | + | "INSERT INTO subscriptions | |
| 108 | + | (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id, | |
| 109 | + | status, current_period_start, current_period_end) | |
| 110 | + | VALUES ($1, $2, $3, $4, 'cus_gate_seed', $5, NOW() - interval '1 day', $6)", | |
| 111 | + | ) | |
| 112 | + | .bind(subscriber) | |
| 113 | + | .bind(tier) | |
| 114 | + | .bind(project) | |
| 115 | + | .bind(stripe_id) | |
| 116 | + | .bind(status) | |
| 117 | + | .bind(period_end) | |
| 118 | + | .execute(pool) | |
| 119 | + | .await | |
| 120 | + | .expect("seed project subscription"); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Does the sealed gate currently grant `user` access to `item`? | |
| 124 | + | /// | |
| 125 | + | /// `check_item_access` asks `SubscriptionGate::check` and nothing else for the | |
| 126 | + | /// `subscription` field, so this reads the sealed predicate and not a copy of it. | |
| 127 | + | async fn item_gate_grants(pool: &PgPool, item: ItemId, user: Option<UserId>) -> bool { | |
| 128 | + | db::items::check_item_access(pool, item, user) | |
| 129 | + | .await | |
| 130 | + | .expect("check_item_access ok") | |
| 131 | + | .expect("item exists") | |
| 132 | + | .subscription | |
| 133 | + | .is_some() | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // ── the sealed access gate: what a lapsed subscriber may still reach ── | |
| 137 | + | ||
| 138 | + | #[tokio::test] | |
| 139 | + | async fn item_gate_grants_access_only_while_the_paid_period_is_unexpired() { | |
| 140 | + | let db = TestDb::new().await; | |
| 141 | + | let creator = seed_user(&db.pool, "gate_period_creator").await; | |
| 142 | + | let project = seed_project(&db.pool, creator, "gate-period").await; | |
| 143 | + | let item = seed_item(&db.pool, project, "gate-period-item").await; | |
| 144 | + | let tier = seed_item_tier(&db.pool, item).await; | |
| 145 | + | let fan = seed_user(&db.pool, "gate_period_fan").await; | |
| 146 | + | ||
| 147 | + | // Paid through: three days of period left. | |
| 148 | + | seed_item_subscription( | |
| 149 | + | &db.pool, | |
| 150 | + | fan, | |
| 151 | + | tier, | |
| 152 | + | item, | |
| 153 | + | "sub_gate_period", | |
| 154 | + | "active", | |
| 155 | + | Some(Utc::now() + Duration::days(3)), | |
| 156 | + | ) | |
| 157 | + | .await; | |
| 158 | + | assert!( | |
| 159 | + | item_gate_grants(&db.pool, item, Some(fan)).await, | |
| 160 | + | "an active subscription three days from renewal must grant access" | |
| 161 | + | ); | |
| 162 | + | ||
| 163 | + | // Three days PAST the period end, still `status = 'active'` because the | |
| 164 | + | // `customer.subscription.deleted` webhook was missed or delayed. The | |
| 165 | + | // `current_period_end > NOW()` half of the predicate is the whole reason | |
| 166 | + | // that case does not keep granting access, so both sides are asserted. | |
| 167 | + | sqlx::query( | |
| 168 | + | "UPDATE subscriptions SET current_period_end = $1 WHERE stripe_subscription_id = $2", | |
| 169 | + | ) | |
| 170 | + | .bind(Utc::now() - Duration::days(3)) | |
| 171 | + | .bind("sub_gate_period") | |
| 172 | + | .execute(&db.pool) | |
| 173 | + | .await | |
| 174 | + | .expect("expire the period"); | |
| 175 | + | assert!( | |
| 176 | + | !item_gate_grants(&db.pool, item, Some(fan)).await, | |
| 177 | + | "an active row whose paid period ended three days ago must NOT grant access" | |
| 178 | + | ); | |
| 179 | + | ||
| 180 | + | // A NULL period is the "Stripe has not told us a period yet" shape and is | |
| 181 | + | // explicitly permitted by the predicate; without this case the test could | |
| 182 | + | // not tell `> NOW()` from `IS NOT NULL AND > NOW()`. | |
| 183 | + | sqlx::query( | |
| 184 | + | "UPDATE subscriptions SET current_period_end = NULL WHERE stripe_subscription_id = $1", | |
| 185 | + | ) | |
| 186 | + | .bind("sub_gate_period") | |
| 187 | + | .execute(&db.pool) | |
| 188 | + | .await | |
| 189 | + | .expect("null the period"); | |
| 190 | + | assert!( | |
| 191 | + | item_gate_grants(&db.pool, item, Some(fan)).await, | |
| 192 | + | "a NULL current_period_end must grant access, not deny it" | |
| 193 | + | ); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | #[tokio::test] | |
| 197 | + | async fn item_gate_denies_a_paused_subscription_and_grants_again_once_resumed() { | |
| 198 | + | let db = TestDb::new().await; | |
| 199 | + | let creator = seed_user(&db.pool, "gate_pause_creator").await; | |
| 200 | + | let project = seed_project(&db.pool, creator, "gate-pause").await; | |
| 201 | + | let item = seed_item(&db.pool, project, "gate-pause-item").await; | |
| 202 | + | let tier = seed_item_tier(&db.pool, item).await; | |
| 203 | + | let fan = seed_user(&db.pool, "gate_pause_fan").await; | |
| 204 | + | ||
| 205 | + | seed_item_subscription( | |
| 206 | + | &db.pool, | |
| 207 | + | fan, | |
| 208 | + | tier, | |
| 209 | + | item, | |
| 210 | + | "sub_gate_pause", | |
| 211 | + | "active", | |
| 212 | + | Some(Utc::now() + Duration::days(20)), | |
| 213 | + | ) | |
| 214 | + | .await; | |
| 215 | + | assert!( | |
| 216 | + | item_gate_grants(&db.pool, item, Some(fan)).await, | |
| 217 | + | "an unpaused in-period subscription grants access" | |
| 218 | + | ); | |
| 219 | + | ||
| 220 | + | // Pausing is what a creator suspension does to every fan subscription: the | |
| 221 | + | // fan stops being billed, so the fan must also stop having access, even | |
| 222 | + | // though status stays 'active' and the period is still open. | |
| 223 | + | sqlx::query("UPDATE subscriptions SET paused_at = NOW() WHERE stripe_subscription_id = $1") | |
| 224 | + | .bind("sub_gate_pause") | |
| 225 | + | .execute(&db.pool) | |
| 226 | + | .await | |
| 227 | + | .expect("pause the subscription"); | |
| 228 | + | assert!( | |
| 229 | + | !item_gate_grants(&db.pool, item, Some(fan)).await, | |
| 230 | + | "a paused subscription must not grant access while the creator is suspended" | |
| 231 | + | ); | |
| 232 | + | ||
| 233 | + | sqlx::query("UPDATE subscriptions SET paused_at = NULL WHERE stripe_subscription_id = $1") | |
| 234 | + | .bind("sub_gate_pause") | |
| 235 | + | .execute(&db.pool) | |
| 236 | + | .await | |
| 237 | + | .expect("resume the subscription"); | |
| 238 | + | assert!( | |
| 239 | + | item_gate_grants(&db.pool, item, Some(fan)).await, | |
| 240 | + | "resuming must restore access rather than leaving the fan locked out" | |
| 241 | + | ); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[tokio::test] | |
| 245 | + | async fn item_gate_grants_on_active_and_denies_every_other_status() { | |
| 246 | + | let db = TestDb::new().await; | |
| 247 | + | let creator = seed_user(&db.pool, "gate_status_creator").await; | |
| 248 | + | let project = seed_project(&db.pool, creator, "gate-status").await; | |
| 249 | + | let item = seed_item(&db.pool, project, "gate-status-item").await; | |
| 250 | + | let tier = seed_item_tier(&db.pool, item).await; | |
| 251 | + | let fan = seed_user(&db.pool, "gate_status_fan").await; | |
| 252 | + | ||
| 253 | + | seed_item_subscription( | |
| 254 | + | &db.pool, | |
| 255 | + | fan, | |
| 256 | + | tier, | |
| 257 | + | item, | |
| 258 | + | "sub_gate_status", | |
| 259 | + | "active", | |
| 260 | + | Some(Utc::now() + Duration::days(9)), | |
| 261 | + | ) | |
| 262 | + | .await; | |
| 263 | + | ||
| 264 | + | // Every non-active status the column can hold. Walking all of them is what | |
| 265 | + | // separates "status = 'active'" from the weaker "status != 'canceled'": | |
| 266 | + | // trialing and past_due would pass the weaker predicate. | |
| 267 | + | for status in [ | |
| 268 | + | "trialing", | |
| 269 | + | "incomplete", | |
| 270 | + | "incomplete_expired", | |
| 271 | + | "past_due", | |
| 272 | + | "unpaid", | |
| 273 | + | "canceled", | |
| 274 | + | ] { | |
| 275 | + | sqlx::query("UPDATE subscriptions SET status = $1 WHERE stripe_subscription_id = $2") | |
| 276 | + | .bind(status) | |
| 277 | + | .bind("sub_gate_status") | |
| 278 | + | .execute(&db.pool) | |
| 279 | + | .await | |
| 280 | + | .expect("set status"); | |
| 281 | + | assert!( | |
| 282 | + | !item_gate_grants(&db.pool, item, Some(fan)).await, | |
| 283 | + | "status '{status}' must not grant access; only 'active' does" | |
| 284 | + | ); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | sqlx::query("UPDATE subscriptions SET status = 'active' WHERE stripe_subscription_id = $1") | |
| 288 | + | .bind("sub_gate_status") | |
| 289 | + | .execute(&db.pool) | |
| 290 | + | .await | |
| 291 | + | .expect("restore active"); | |
| 292 | + | assert!( | |
| 293 | + | item_gate_grants(&db.pool, item, Some(fan)).await, | |
| 294 | + | "'active' grants access, so the loop above measured the status and not the fixture" | |
| 295 | + | ); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | #[tokio::test] | |
| 299 | + | async fn item_gate_is_scoped_to_one_subscriber_one_item_and_never_to_anonymous() { | |
| 300 | + | let db = TestDb::new().await; | |
| 301 | + | let creator = seed_user(&db.pool, "gate_scope_creator").await; | |
| 302 | + | let project = seed_project(&db.pool, creator, "gate-scope").await; | |
| 303 | + | let subscribed_item = seed_item(&db.pool, project, "gate-scope-paid").await; | |
| 304 | + | let other_item = seed_item(&db.pool, project, "gate-scope-other").await; | |
| 305 | + | let item_tier = seed_item_tier(&db.pool, subscribed_item).await; | |
| 306 | + | let project_tier = seed_project_tier(&db.pool, project).await; | |
| 307 | + | let fan = seed_user(&db.pool, "gate_scope_fan").await; | |
| 308 | + | let stranger = seed_user(&db.pool, "gate_scope_stranger").await; | |
| 309 | + | ||
| 310 | + | let period_end = Some(Utc::now() + Duration::days(14)); | |
| 311 | + | seed_item_subscription( | |
| 312 | + | &db.pool, | |
| 313 | + | fan, | |
| 314 | + | item_tier, | |
| 315 | + | subscribed_item, | |
| 316 | + | "sub_gate_scope_item", | |
| 317 | + | "active", | |
| 318 | + | period_end, | |
| 319 | + | ) | |
| 320 | + | .await; | |
| 321 | + | // A live PROJECT subscription held by the same fan. The item arm of the gate | |
| 322 | + | // keys on item_id, so this row must not leak access to a sibling item; if it | |
| 323 | + | // did, an item-priced work would be readable by anyone subscribed to the | |
| 324 | + | // project at any tier. | |
| 325 | + | seed_project_subscription( | |
| 326 | + | &db.pool, | |
| 327 | + | fan, | |
| 328 | + | project_tier, | |
| 329 | + | project, | |
| 330 | + | "sub_gate_scope_project", | |
| 331 | + | "active", | |
| 332 | + | period_end, | |
| 333 | + | ) | |
| 334 | + | .await; | |
| 335 | + | ||
| 336 | + | assert!( | |
| 337 | + | item_gate_grants(&db.pool, subscribed_item, Some(fan)).await, | |
| 338 | + | "the fan's own item subscription grants access to that item" | |
| 339 | + | ); | |
| 340 | + | assert!( | |
| 341 | + | !item_gate_grants(&db.pool, other_item, Some(fan)).await, | |
| 342 | + | "a subscription to one item must not grant access to a sibling item" | |
| 343 | + | ); | |
| 344 | + | assert!( | |
| 345 | + | !item_gate_grants(&db.pool, subscribed_item, Some(stranger)).await, | |
| 346 | + | "another user must not inherit the fan's item subscription" | |
| 347 | + | ); | |
| 348 | + | assert!( | |
| 349 | + | !item_gate_grants(&db.pool, subscribed_item, None).await, | |
| 350 | + | "an anonymous viewer holds no subscription and must never be granted one" | |
| 351 | + | ); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // ── project-scope `has_access`: a lapsed subscriber is offered checkout again ── | |
| 355 | + | ||
| 356 | + | #[tokio::test] | |
| 357 | + | async fn a_lapsed_project_subscriber_is_sent_back_to_checkout_and_a_current_one_is_not() { | |
| 358 | + | let mut h = TestHarness::with_mocks().await; | |
| 359 | + | ||
| 360 | + | let creator = h | |
| 361 | + | .signup("gatecreator", "gatecreator@test.com", "password123") | |
| 362 | + | .await; | |
| 363 | + | h.grant_creator(creator).await; | |
| 364 | + | h.connect_stripe(creator, "acct_gate_route").await; | |
| 365 | + | h.client.post_form("/logout", "").await; | |
| 366 | + | h.login("gatecreator", "password123").await; | |
| 367 | + | ||
| 368 | + | let resp = h | |
| 369 | + | .client | |
| 370 | + | .post_form("/api/projects", "slug=gateroute&title=Gate+Route") | |
| 371 | + | .await; | |
| 372 | + | assert_eq!(resp.status, 200, "create project failed: {}", resp.text); | |
| 373 | + | let project: Value = resp.json(); | |
| 374 | + | let project_id = project["id"].as_str().expect("project id").to_string(); | |
| 375 | + | let project_uuid: ProjectId = project_id.parse().expect("project id parses"); | |
| 376 | + | ||
| 377 | + | let tier_id: SubscriptionTierId = sqlx::query_scalar( | |
| 378 | + | "INSERT INTO subscription_tiers | |
| 379 | + | (project_id, name, price_cents, is_active, stripe_product_id, stripe_price_id) | |
| 380 | + | VALUES ($1, 'Gold', 1500, true, 'prod_gate', 'price_gate') RETURNING id", | |
| 381 | + | ) | |
| 382 | + | .bind(project_uuid) | |
| 383 | + | .fetch_one(&h.db) | |
| 384 | + | .await | |
| 385 | + | .expect("seed tier"); | |
| 386 | + | ||
| 387 | + | h.client.post_form("/logout", "").await; | |
| 388 | + | let fan = h.signup("gatefan", "gatefan@test.com", "password123").await; | |
| 389 | + | ||
| 390 | + | // A subscription that is paid up for another 30 days. | |
| 391 | + | seed_project_subscription( | |
| 392 | + | &h.db, | |
| 393 | + | fan, | |
| 394 | + | tier_id, | |
| 395 | + | project_uuid, | |
| 396 | + | "sub_gate_route", | |
| 397 | + | "active", | |
| 398 | + | Some(Utc::now() + Duration::days(30)), | |
| 399 | + | ) | |
| 400 | + | .await; | |
| 401 | + | ||
| 402 | + | let resp = h | |
| 403 | + | .client | |
| 404 | + | .post_form(&format!("/stripe/subscribe/{tier_id}"), "") | |
| 405 | + | .await; | |
| 406 | + | assert_eq!( | |
| 407 | + | resp.status, 303, | |
| 408 | + | "subscribe should redirect, got {}: {}", | |
| 409 | + | resp.status, resp.text | |
| 410 | + | ); | |
| 411 | + | assert_eq!( | |
| 412 | + | resp.header("location"), | |
| 413 | + | Some("/p/gateroute"), | |
| 414 | + | "an already-subscribed fan is bounced to the project page, not to Stripe: {}", | |
| 415 | + | resp.text | |
| 416 | + | ); | |
| 417 | + | let checkouts = h.mock_stripe.as_ref().expect("mock stripe").checkouts(); | |
| 418 | + | assert!( | |
| 419 | + | checkouts.is_empty(), | |
| 420 | + | "no second checkout session may be created for a current subscriber, found {checkouts:?}" | |
| 421 | + | ); | |
| 422 | + | ||
| 423 | + | // Same row, period ended yesterday: the gate no longer grants access, so the | |
| 424 | + | // fan must be able to buy again. The two halves together are what stop both | |
| 425 | + | // a double charge and a permanent lockout. | |
| 426 | + | sqlx::query( | |
| 427 | + | "UPDATE subscriptions SET current_period_end = $1 WHERE stripe_subscription_id = $2", | |
| 428 | + | ) | |
| 429 | + | .bind(Utc::now() - Duration::days(1)) | |
| 430 | + | .bind("sub_gate_route") | |
| 431 | + | .execute(&h.db) | |
| 432 | + | .await | |
| 433 | + | .expect("expire the period"); | |
| 434 | + | ||
| 435 | + | let resp = h | |
| 436 | + | .client | |
| 437 | + | .post_form(&format!("/stripe/subscribe/{tier_id}"), "") | |
| 438 | + | .await; | |
| 439 | + | assert_eq!( | |
| 440 | + | resp.status, 303, | |
| 441 | + | "subscribe should redirect, got {}: {}", | |
| 442 | + | resp.status, resp.text | |
| 443 | + | ); | |
| 444 | + | let location = resp.header("location").unwrap_or_default().to_string(); | |
| 445 | + | assert!( | |
| 446 | + | location.starts_with("https://checkout.stripe.com/"), | |
| 447 | + | "a lapsed subscriber must be sent to a fresh checkout, went to {location} instead" | |
| 448 | + | ); | |
| 449 | + | let checkouts = h.mock_stripe.as_ref().expect("mock stripe").checkouts(); | |
| 450 | + | assert_eq!( | |
| 451 | + | checkouts.len(), | |
| 452 | + | 1, | |
| 453 | + | "exactly one checkout session belongs to the lapsed attempt, got {checkouts:?}" | |
| 454 | + | ); | |
| 455 | + | } |
| @@ -1,0 +1,505 @@ | |||
| 1 | + | //! DB-layer contract tests for the webhook-driven lifecycle writes in | |
| 2 | + | //! `db::subscriptions`. | |
| 3 | + | //! | |
| 4 | + | //! These are the writes Stripe drives, and Stripe redelivers, so what is pinned | |
| 5 | + | //! is what a second delivery must not do: | |
| 6 | + | //! | |
| 7 | + | //! - `create_subscription`'s single-live-row cleanup cancels exactly the | |
| 8 | + | //! lingering `past_due`/`trialing`/`incomplete` rows of the SAME subscriber | |
| 9 | + | //! and project, and nobody else's, | |
| 10 | + | //! - `cancel_subscription` under redelivery keeps the first `canceled_at` | |
| 11 | + | //! rather than restamping it, which is what `COALESCE(canceled_at, NOW())` | |
| 12 | + | //! is for, | |
| 13 | + | //! - `apply_stripe_update`'s period funnel writes a zero-length Stripe window | |
| 14 | + | //! and drops an inverted one while the status still lands. | |
| 15 | + | //! | |
| 16 | + | //! The access gate of the same module is `db_subscriptions_layer`. | |
| 17 | + | //! | |
| 18 | + | //! Reach: every function in `db::subscriptions` is `pub(crate)`, so these drive | |
| 19 | + | //! the Stripe webhook route, the module's own production caller, and assert on | |
| 20 | + | //! the rows the functions leave behind. | |
| 21 | + | //! | |
| 22 | + | //! Delete this file and a redelivered cancellation could slide the cancellation | |
| 23 | + | //! date forward, and the cleanup's WHERE could widen to another fan's rows, | |
| 24 | + | //! with nothing at this layer noticing. | |
| 25 | + | ||
| 26 | + | use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload}; | |
| 27 | + | use crate::harness::{TestHarness, seed_project, seed_user}; | |
| 28 | + | use chrono::{DateTime, Duration, Utc}; | |
| 29 | + | use makenotwork::db::{ProjectId, SubscriptionTierId, UserId}; | |
| 30 | + | use serde_json::Value; | |
| 31 | + | use sqlx::PgPool; | |
| 32 | + | use std::collections::HashMap; | |
| 33 | + | ||
| 34 | + | // ── seeding ── | |
| 35 | + | ||
| 36 | + | async fn seed_project_tier(pool: &PgPool, project: ProjectId) -> SubscriptionTierId { | |
| 37 | + | sqlx::query_scalar::<_, SubscriptionTierId>( | |
| 38 | + | "INSERT INTO subscription_tiers (project_id, name, price_cents) | |
| 39 | + | VALUES ($1, 'Project Tier', 1500) RETURNING id", | |
| 40 | + | ) | |
| 41 | + | .bind(project) | |
| 42 | + | .fetch_one(pool) | |
| 43 | + | .await | |
| 44 | + | .expect("seed project tier") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// A project-scoped subscription row (`item_id` NULL). | |
| 48 | + | async fn seed_project_subscription( | |
| 49 | + | pool: &PgPool, | |
| 50 | + | subscriber: UserId, | |
| 51 | + | tier: SubscriptionTierId, | |
| 52 | + | project: ProjectId, | |
| 53 | + | stripe_id: &str, | |
| 54 | + | status: &str, | |
| 55 | + | period_end: Option<DateTime<Utc>>, | |
| 56 | + | ) { | |
| 57 | + | sqlx::query( | |
| 58 | + | "INSERT INTO subscriptions | |
| 59 | + | (subscriber_id, tier_id, project_id, stripe_subscription_id, stripe_customer_id, | |
| 60 | + | status, current_period_start, current_period_end) | |
| 61 | + | VALUES ($1, $2, $3, $4, 'cus_gate_seed', $5, NOW() - interval '1 day', $6)", | |
| 62 | + | ) | |
| 63 | + | .bind(subscriber) | |
| 64 | + | .bind(tier) | |
| 65 | + | .bind(project) | |
| 66 | + | .bind(stripe_id) | |
| 67 | + | .bind(status) | |
| 68 | + | .bind(period_end) | |
| 69 | + | .execute(pool) | |
| 70 | + | .await | |
| 71 | + | .expect("seed project subscription"); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Read one subscription row's status, canceled_at and period as text-free values. | |
| 75 | + | async fn read_row( | |
| 76 | + | pool: &PgPool, | |
| 77 | + | stripe_id: &str, | |
| 78 | + | ) -> ( | |
| 79 | + | String, | |
| 80 | + | Option<DateTime<Utc>>, | |
| 81 | + | Option<DateTime<Utc>>, | |
| 82 | + | Option<DateTime<Utc>>, | |
| 83 | + | ) { | |
| 84 | + | sqlx::query_as( | |
| 85 | + | "SELECT status, canceled_at, current_period_start, current_period_end | |
| 86 | + | FROM subscriptions WHERE stripe_subscription_id = $1", | |
| 87 | + | ) | |
| 88 | + | .bind(stripe_id) | |
| 89 | + | .fetch_one(pool) | |
| 90 | + | .await | |
| 91 | + | .unwrap_or_else(|e| panic!("read subscription {stripe_id}: {e}")) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | async fn status_of(pool: &PgPool, stripe_id: &str) -> String { | |
| 95 | + | read_row(pool, stripe_id).await.0 | |
| 96 | + | } | |
| 97 | + | // ── webhook-driven lifecycle ── | |
| 98 | + | ||
| 99 | + | /// Sign a Stripe event and POST it to the webhook endpoint. | |
| 100 | + | async fn post_event( | |
| 101 | + | h: &mut TestHarness, | |
| 102 | + | event_id: &str, | |
| 103 | + | event_type: &str, | |
| 104 | + | object: Value, | |
| 105 | + | ) -> crate::harness::client::TestResponse { | |
| 106 | + | let payload = serde_json::json!({ | |
| 107 | + | "id": event_id, | |
| 108 | + | "type": event_type, | |
| 109 | + | "data": {"object": object}, | |
| 110 | + | }) | |
| 111 | + | .to_string(); | |
| 112 | + | let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET); | |
| 113 | + | h.client | |
| 114 | + | .request_with_headers( | |
| 115 | + | "POST", | |
| 116 | + | "/stripe/webhook", | |
| 117 | + | Some(&payload), | |
| 118 | + | &[ | |
| 119 | + | ("stripe-signature", &signature), | |
| 120 | + | ("content-type", "application/json"), | |
| 121 | + | ], | |
| 122 | + | ) | |
| 123 | + | .await | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// A `customer.subscription.updated` / `.deleted` object with one item carrying | |
| 127 | + | /// the given raw Stripe period. | |
| 128 | + | fn subscription_object(stripe_sub_id: &str, status: &str, period: Option<(i64, i64)>) -> Value { | |
| 129 | + | let items = match period { | |
| 130 | + | Some((start, end)) => serde_json::json!([{ | |
| 131 | + | "id": "si_dbsl", | |
| 132 | + | "object": "subscription_item", | |
| 133 | + | "subscription": stripe_sub_id, | |
| 134 | + | "current_period_start": start, | |
| 135 | + | "current_period_end": end, | |
| 136 | + | "metadata": {}, | |
| 137 | + | }]), | |
| 138 | + | None => serde_json::json!([]), | |
| 139 | + | }; | |
| 140 | + | serde_json::json!({ | |
| 141 | + | "id": stripe_sub_id, | |
| 142 | + | "object": "subscription", | |
| 143 | + | "status": status, | |
| 144 | + | "cancel_at_period_end": false, | |
| 145 | + | "items": {"object": "list", "data": items}, | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Creator with a project and a tier, plus a fan. Returns | |
| 150 | + | /// `(fan_id, project_id, tier_id)`. | |
| 151 | + | async fn webhook_fixture( | |
| 152 | + | h: &mut TestHarness, | |
| 153 | + | tag: &str, | |
| 154 | + | ) -> (UserId, ProjectId, SubscriptionTierId) { | |
| 155 | + | let creator = h | |
| 156 | + | .signup( | |
| 157 | + | &format!("wcreator_{tag}"), | |
| 158 | + | &format!("wcreator_{tag}@test.com"), | |
| 159 | + | "password123", | |
| 160 | + | ) | |
| 161 | + | .await; | |
| 162 | + | h.grant_creator(creator).await; | |
| 163 | + | h.client.post_form("/logout", "").await; | |
| 164 | + | h.login(&format!("wcreator_{tag}"), "password123").await; | |
| 165 | + | ||
| 166 | + | let resp = h | |
| 167 | + | .client | |
| 168 | + | .post_form( | |
| 169 | + | "/api/projects", | |
| 170 | + | &format!("slug=whook-{tag}&title=Webhook+{tag}"), | |
| 171 | + | ) | |
| 172 | + | .await; | |
| 173 | + | assert_eq!(resp.status, 200, "create project failed: {}", resp.text); | |
| 174 | + | let project: Value = resp.json(); | |
| 175 | + | let project_uuid: ProjectId = project["id"] | |
| 176 | + | .as_str() | |
| 177 | + | .expect("project id") | |
| 178 | + | .parse() | |
| 179 | + | .expect("project id parses"); | |
| 180 | + | let tier = seed_project_tier(&h.db, project_uuid).await; | |
| 181 | + | ||
| 182 | + | h.client.post_form("/logout", "").await; | |
| 183 | + | let fan = h | |
| 184 | + | .signup( | |
| 185 | + | &format!("wfan_{tag}"), | |
| 186 | + | &format!("wfan_{tag}@test.com"), | |
| 187 | + | "password123", | |
| 188 | + | ) | |
| 189 | + | .await; | |
| 190 | + | h.client.post_form("/logout", "").await; | |
| 191 | + | ||
| 192 | + | (fan, project_uuid, tier) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | #[tokio::test] | |
| 196 | + | async fn a_new_subscription_cancels_only_the_same_fans_lingering_rows_for_that_project() { | |
| 197 | + | let mut h = TestHarness::with_stripe().await; | |
| 198 | + | let (fan, project, tier) = webhook_fixture(&mut h, "cleanup").await; | |
| 199 | + | ||
| 200 | + | // A second project and a second fan, so the cleanup's WHERE has something | |
| 201 | + | // to get wrong in each direction. Who owns the second project is irrelevant | |
| 202 | + | // here; only the (subscriber, project) pair is. | |
| 203 | + | let other_project = seed_project(&h.db, fan, "cleanup-other").await; | |
| 204 | + | let other_tier = seed_project_tier(&h.db, other_project).await; | |
| 205 | + | let other_fan = seed_user(&h.db, "cleanup_other_fan").await; | |
| 206 | + | ||
| 207 | + | let period = Some(Utc::now() + Duration::days(5)); | |
| 208 | + | // Cleaned up: this fan, this project, a status in the cleanup set. | |
| 209 | + | seed_project_subscription( | |
| 210 | + | &h.db, | |
| 211 | + | fan, | |
| 212 | + | tier, | |
| 213 | + | project, | |
| 214 | + | "sub_stale_past_due", | |
| 215 | + | "past_due", | |
| 216 | + | period, | |
| 217 | + | ) | |
| 218 | + | .await; | |
| 219 | + | seed_project_subscription( | |
| 220 | + | &h.db, | |
| 221 | + | fan, | |
| 222 | + | tier, | |
| 223 | + | project, | |
| 224 | + | "sub_stale_trialing", | |
| 225 | + | "trialing", | |
| 226 | + | period, | |
| 227 | + | ) | |
| 228 | + | .await; | |
| 229 | + | // Left alone: 'unpaid' is deliberately NOT in the cleanup set, so this row | |
| 230 | + | // is what tells "cancel the three named statuses" apart from "cancel | |
| 231 | + | // everything that is not active". | |
| 232 | + | seed_project_subscription( | |
| 233 | + | &h.db, | |
| 234 | + | fan, | |
| 235 | + | tier, | |
| 236 | + | project, | |
| 237 | + | "sub_stale_unpaid", | |
| 238 | + | "unpaid", | |
| 239 | + | period, | |
| 240 | + | ) | |
| 241 | + | .await; | |
| 242 | + | // Left alone: another fan, same project. | |
| 243 | + | seed_project_subscription( | |
| 244 | + | &h.db, | |
| 245 | + | other_fan, | |
| 246 | + | tier, | |
| 247 | + | project, | |
| 248 | + | "sub_other_fan", | |
| 249 | + | "past_due", | |
| 250 | + | period, | |
| 251 | + | ) | |
| 252 | + | .await; | |
| 253 | + | // Left alone: same fan, another project. | |
| 254 | + | seed_project_subscription( | |
| 255 | + | &h.db, | |
| 256 | + | fan, | |
| 257 | + | other_tier, | |
| 258 | + | other_project, | |
| 259 | + | "sub_other_project", | |
| 260 | + | "past_due", | |
| 261 | + | period, | |
| 262 | + | ) | |
| 263 | + | .await; | |
| 264 | + | ||
| 265 | + | let mut meta = HashMap::new(); | |
| 266 | + | meta.insert("checkout_type".to_string(), "subscription".to_string()); | |
| 267 | + | meta.insert("subscriber_id".to_string(), fan.to_string()); | |
| 268 | + | meta.insert("project_id".to_string(), project.to_string()); | |
| 269 | + | meta.insert("tier_id".to_string(), tier.to_string()); | |
| 270 | + | let session = serde_json::json!({ | |
| 271 | + | "id": "cs_dbsl_cleanup", | |
| 272 | + | "object": "checkout_session", | |
| 273 | + | "mode": "subscription", | |
| 274 | + | "metadata": meta, | |
| 275 | + | "subscription": "sub_fresh_cleanup", | |
| 276 | + | "customer": "cus_fresh_cleanup", | |
| 277 | + | }); | |
| 278 | + | ||
| 279 | + | let resp = post_event( | |
| 280 | + | &mut h, | |
| 281 | + | "evt_dbsl_cleanup", | |
| 282 | + | "checkout.session.completed", | |
| 283 | + | session, | |
| 284 | + | ) | |
| 285 | + | .await; | |
| 286 | + | assert_eq!( | |
| 287 | + | resp.status.as_u16(), | |
| 288 | + | 200, | |
| 289 | + | "subscription checkout webhook failed: {}", | |
| 290 | + | resp.text | |
| 291 | + | ); | |
| 292 | + | ||
| 293 | + | let (fresh_status, _, _, _) = read_row(&h.db, "sub_fresh_cleanup").await; | |
| 294 | + | assert_eq!( | |
| 295 | + | fresh_status, "active", | |
| 296 | + | "the new subscription is created active" | |
| 297 | + | ); | |
| 298 | + | ||
| 299 | + | for stale in ["sub_stale_past_due", "sub_stale_trialing"] { | |
| 300 | + | let (status, canceled_at, _, _) = read_row(&h.db, stale).await; | |
| 301 | + | assert_eq!( | |
| 302 | + | status, "canceled", | |
| 303 | + | "{stale} is a lingering live row for the resubscribing fan and must be canceled" | |
| 304 | + | ); | |
| 305 | + | assert!( | |
| 306 | + | canceled_at.is_some(), | |
| 307 | + | "{stale} was canceled, so canceled_at must be stamped, got {canceled_at:?}" | |
| 308 | + | ); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | assert_eq!( | |
| 312 | + | status_of(&h.db, "sub_stale_unpaid").await, | |
| 313 | + | "unpaid", | |
| 314 | + | "'unpaid' is outside the cleanup set and must survive untouched" | |
| 315 | + | ); | |
| 316 | + | assert_eq!( | |
| 317 | + | status_of(&h.db, "sub_other_fan").await, | |
| 318 | + | "past_due", | |
| 319 | + | "another fan's row on the same project must not be canceled" | |
| 320 | + | ); | |
| 321 | + | assert_eq!( | |
| 322 | + | status_of(&h.db, "sub_other_project").await, | |
| 323 | + | "past_due", | |
| 324 | + | "the same fan's row on a different project must not be canceled" | |
| 325 | + | ); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | #[tokio::test] | |
| 329 | + | async fn a_redelivered_cancellation_keeps_the_first_cancellation_time() { | |
| 330 | + | let mut h = TestHarness::with_stripe().await; | |
| 331 | + | let (fan, project, tier) = webhook_fixture(&mut h, "cancel").await; | |
| 332 | + | ||
| 333 | + | // Already canceled, with a known cancellation instant: this is the row a | |
| 334 | + | // Stripe redelivery of `customer.subscription.deleted` lands on. | |
| 335 | + | let first_cancel = DateTime::parse_from_rfc3339("2026-01-05T06:07:08Z") | |
| 336 | + | .expect("fixed timestamp parses") | |
| 337 | + | .with_timezone(&Utc); | |
| 338 | + | seed_project_subscription( | |
| 339 | + | &h.db, | |
| 340 | + | fan, | |
| 341 | + | tier, | |
| 342 | + | project, | |
| 343 | + | "sub_cancel_replay", | |
| 344 | + | "canceled", | |
| 345 | + | Some(Utc::now() - Duration::days(10)), | |
| 346 | + | ) | |
| 347 | + | .await; | |
| 348 | + | sqlx::query("UPDATE subscriptions SET canceled_at = $1 WHERE stripe_subscription_id = $2") | |
| 349 | + | .bind(first_cancel) | |
| 350 | + | .bind("sub_cancel_replay") | |
| 351 | + | .execute(&h.db) | |
| 352 | + | .await | |
| 353 | + | .expect("stamp the original cancellation time"); | |
| 354 | + | ||
| 355 | + | // A live row, so the same event type is shown to have an effect at all. | |
| 356 | + | seed_project_subscription( | |
| 357 | + | &h.db, | |
| 358 | + | fan, | |
| 359 | + | tier, | |
| 360 | + | project, | |
| 361 | + | "sub_cancel_fresh", | |
| 362 | + | "active", | |
| 363 | + | Some(Utc::now() + Duration::days(11)), | |
| 364 | + | ) | |
| 365 | + | .await; | |
| 366 | + | ||
| 367 | + | let resp = post_event( | |
| 368 | + | &mut h, | |
| 369 | + | "evt_dbsl_cancel_replay", | |
| 370 | + | "customer.subscription.deleted", | |
| 371 | + | subscription_object( | |
| 372 | + | "sub_cancel_replay", | |
| 373 | + | "canceled", | |
| 374 | + | Some((1_700_000_000, 1_702_592_000)), | |
| 375 | + | ), | |
| 376 | + | ) | |
| 377 | + | .await; | |
| 378 | + | assert_eq!( | |
| 379 | + | resp.status.as_u16(), | |
| 380 | + | 200, | |
| 381 | + | "redelivered cancellation webhook failed: {}", | |
| 382 | + | resp.text | |
| 383 | + | ); | |
| 384 | + | ||
| 385 | + | let (status, canceled_at, _, _) = read_row(&h.db, "sub_cancel_replay").await; | |
| 386 | + | assert_eq!(status, "canceled", "the row stays canceled on redelivery"); | |
| 387 | + | assert_eq!( | |
| 388 | + | canceled_at, | |
| 389 | + | Some(first_cancel), | |
| 390 | + | "COALESCE(canceled_at, NOW()) must keep the FIRST cancellation time; a restamp would \ | |
| 391 | + | move a fan's end-of-access date forward on every Stripe retry" | |
| 392 | + | ); | |
| 393 | + | ||
| 394 | + | let before = Utc::now(); | |
| 395 | + | let resp = post_event( | |
| 396 | + | &mut h, | |
| 397 | + | "evt_dbsl_cancel_fresh", | |
| 398 | + | "customer.subscription.deleted", | |
| 399 | + | subscription_object( | |
| 400 | + | "sub_cancel_fresh", | |
| 401 | + | "canceled", | |
| 402 | + | Some((1_700_000_000, 1_702_592_000)), | |
| 403 | + | ), | |
| 404 | + | ) | |
| 405 | + | .await; | |
| 406 | + | assert_eq!( | |
| 407 | + | resp.status.as_u16(), | |
| 408 | + | 200, | |
| 409 | + | "first cancellation webhook failed: {}", | |
| 410 | + | resp.text | |
| 411 | + | ); | |
| 412 | + | let (status, canceled_at, _, _) = read_row(&h.db, "sub_cancel_fresh").await; | |
| 413 | + | assert_eq!( | |
| 414 | + | status, "canceled", | |
| 415 | + | "a live row is canceled by the same event" | |
| 416 | + | ); | |
| 417 | + | let canceled_at = canceled_at.expect("a first cancellation stamps canceled_at"); | |
| 418 | + | assert!( | |
| 419 | + | canceled_at >= before, | |
| 420 | + | "a row with no prior canceled_at is stamped now, got {canceled_at} (test began {before})" | |
| 421 | + | ); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | #[tokio::test] | |
| 425 | + | async fn a_zero_length_stripe_period_is_written_and_an_inverted_one_is_dropped() { | |
| 426 | + | let mut h = TestHarness::with_stripe().await; | |
| 427 | + | let (fan, project, tier) = webhook_fixture(&mut h, "period").await; | |
| 428 | + | ||
| 429 | + | seed_project_subscription( | |
| 430 | + | &h.db, | |
| 431 | + | fan, | |
| 432 | + | tier, | |
| 433 | + | project, | |
| 434 | + | "sub_period_funnel", | |
| 435 | + | "active", | |
| 436 | + | Some(Utc::now() + Duration::days(4)), | |
| 437 | + | ) | |
| 438 | + | .await; | |
| 439 | + | ||
| 440 | + | // start == end is the boundary the funnel accepts (`end > 0 && start <= end`). | |
| 441 | + | // Picking equal values is what separates `<=` from `<`. | |
| 442 | + | let boundary = 1_767_225_600_i64; // 2026-01-01T00:00:00Z | |
| 443 | + | let resp = post_event( | |
| 444 | + | &mut h, | |
| 445 | + | "evt_dbsl_period_equal", | |
| 446 | + | "customer.subscription.updated", | |
| 447 | + | subscription_object("sub_period_funnel", "active", Some((boundary, boundary))), | |
| 448 | + | ) | |
| 449 | + | .await; | |
| 450 | + | assert_eq!( | |
| 451 | + | resp.status.as_u16(), | |
| 452 | + | 200, | |
| 453 | + | "zero-length period webhook failed: {}", | |
| 454 | + | resp.text | |
| 455 | + | ); | |
| 456 | + | ||
| 457 | + | let (status, _, start, end) = read_row(&h.db, "sub_period_funnel").await; | |
| 458 | + | assert_eq!(status, "active", "the status update lands"); | |
| 459 | + | assert_eq!( | |
| 460 | + | start.map(|t| t.timestamp()), | |
| 461 | + | Some(boundary), | |
| 462 | + | "a zero-length window is a legal Stripe shape and must be written, got {start:?}" | |
| 463 | + | ); | |
| 464 | + | assert_eq!( | |
| 465 | + | end.map(|t| t.timestamp()), | |
| 466 | + | Some(boundary), | |
| 467 | + | "a zero-length window is a legal Stripe shape and must be written, got {end:?}" | |
| 468 | + | ); | |
| 469 | + | ||
| 470 | + | // Inverted: end one second BEFORE start. The funnel drops the period, and | |
| 471 | + | // COALESCE keeps what is already there, but the status half still applies. | |
| 472 | + | let resp = post_event( | |
| 473 | + | &mut h, | |
| 474 | + | "evt_dbsl_period_inverted", | |
| 475 | + | "customer.subscription.updated", | |
| 476 | + | subscription_object( | |
| 477 | + | "sub_period_funnel", | |
| 478 | + | "past_due", | |
| 479 | + | Some((boundary + 86_400, boundary + 86_399)), | |
| 480 | + | ), | |
| 481 | + | ) | |
| 482 | + | .await; | |
| 483 | + | assert_eq!( | |
| 484 | + | resp.status.as_u16(), | |
| 485 | + | 200, | |
| 486 | + | "inverted period webhook failed: {}", | |
| 487 | + | resp.text | |
| 488 | + | ); | |
| 489 | + | ||
| 490 | + | let (status, _, start, end) = read_row(&h.db, "sub_period_funnel").await; | |
| 491 | + | assert_eq!( | |
| 492 | + | status, "past_due", | |
| 493 | + | "an inverted period drops only the period; the status still lands" | |
| 494 | + | ); | |
| 495 | + | assert_eq!( | |
| 496 | + | start.map(|t| t.timestamp()), | |
| 497 | + | Some(boundary), | |
| 498 | + | "an inverted window must leave the existing period alone, got {start:?}" | |
| 499 | + | ); | |
| 500 | + | assert_eq!( |
Lines truncated
| @@ -1,0 +1,747 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::synckit::blobs`, the SyncKit blob rows and | |
| 2 | + | //! the storage accounting hung off them. | |
| 3 | + | //! | |
| 4 | + | //! `db_synckit_layer` touches two blob cases in passing (owner scoping and the | |
| 5 | + | //! internal re-confirm). What had no test anywhere is the arithmetic those rows | |
| 6 | + | //! feed, which is where being wrong costs a developer money or hands a user | |
| 7 | + | //! free storage: | |
| 8 | + | //! | |
| 9 | + | //! - `storage_used_bytes` sums the authoritative rows rather than a counter, | |
| 10 | + | //! - `confirm_internal_blob` compares `used + size` against the subscription | |
| 11 | + | //! cap with `>`, so a blob landing exactly on the cap is allowed and one | |
| 12 | + | //! byte more is refused, and a NULL cap fails closed at zero, | |
| 13 | + | //! - `confirm_developer_blob` enforces the bulk app-wide cap, the per-key cap, | |
| 14 | + | //! and the defensive app aggregate that catches a key which is itself empty, | |
| 15 | + | //! - both confirm paths are idempotent under redelivery: a repeated confirm | |
| 16 | + | //! of the same `(app, user, hash)` returns `AlreadyStored` and moves no | |
| 17 | + | //! counter, because an upload retry must not eat the cap twice, | |
| 18 | + | //! - `delete_sync_blob` is the only shrink path: it refunds the app-wide and | |
| 19 | + | //! per-key counters by exactly the blob's size, dead-letters the S3 object | |
| 20 | + | //! in the same transaction, and a second delete is a `NotFound` no-op that | |
| 21 | + | //! refunds nothing a second time, | |
| 22 | + | //! - `count_sync_devices` counts one `(app, user)` pair and no other. | |
| 23 | + | //! | |
| 24 | + | //! Delete this file and the caps stop being tested at their boundaries: an | |
| 25 | + | //! off-by-one in either direction, a double refund on a repeated delete, and a | |
| 26 | + | //! redelivered confirm charging twice all pass silently. | |
| 27 | + | ||
| 28 | + | use crate::harness::db::TestDb; | |
| 29 | + | use crate::harness::seed_user; | |
| 30 | + | ||
| 31 | + | use makenotwork::db::synckit; | |
| 32 | + | use makenotwork::db::synckit::{BlobConfirm, BlobDelete}; | |
| 33 | + | use makenotwork::db::{SyncAppId, SyncEnforcementMode, UserId}; | |
| 34 | + | ||
| 35 | + | /// One gibibyte. Written out rather than read back from | |
| 36 | + | /// `synckit_billing::storage_cap_bytes`, so the boundary cases below compare | |
| 37 | + | /// against an independent number instead of re-deriving the one under test. | |
| 38 | + | const GIB: i64 = 1024 * 1024 * 1024; | |
| 39 | + | ||
| 40 | + | /// Seed a sync app owned by `user`, with the `sync_app_usage_current` row the | |
| 41 | + | /// developer-billing path locks `FOR UPDATE`. | |
| 42 | + | async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 43 | + | synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None) | |
| 44 | + | .await | |
| 45 | + | .expect("seed sync app") | |
| 46 | + | .id | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Mark an app first-party, so the end-user subscription model applies. | |
| 50 | + | async fn make_internal(pool: &sqlx::PgPool, app: SyncAppId) { | |
| 51 | + | sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1") | |
| 52 | + | .bind(app) | |
| 53 | + | .execute(pool) | |
| 54 | + | .await | |
| 55 | + | .expect("mark internal"); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Give `user` an active end-user subscription on `app` capped at `limit_bytes`. | |
| 59 | + | async fn seed_subscription( | |
| 60 | + | pool: &sqlx::PgPool, | |
| 61 | + | user: UserId, | |
| 62 | + | app: SyncAppId, | |
| 63 | + | sub_id: &str, | |
| 64 | + | limit_bytes: Option<i64>, | |
| 65 | + | ) { | |
| 66 | + | sqlx::query( | |
| 67 | + | "INSERT INTO app_sync_subscriptions | |
| 68 | + | (user_id, app_id, stripe_subscription_id, stripe_customer_id, tier, | |
| 69 | + | status, storage_limit_bytes) | |
| 70 | + | VALUES ($1, $2, $3, 'cus_test', 'monthly', 'active', $4)", | |
| 71 | + | ) | |
| 72 | + | .bind(user) | |
| 73 | + | .bind(app) | |
| 74 | + | .bind(sub_id) | |
| 75 | + | .bind(limit_bytes) | |
| 76 | + | .execute(pool) | |
| 77 | + | .await | |
| 78 | + | .expect("seed subscription"); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// The app-wide running counter the developer-billing path maintains. | |
| 82 | + | async fn app_bytes(pool: &sqlx::PgPool, app: SyncAppId) -> i64 { | |
| 83 | + | sqlx::query_scalar::<_, i64>( | |
| 84 | + | "SELECT bytes_stored FROM sync_app_usage_current WHERE app_id = $1", | |
| 85 | + | ) | |
| 86 | + | .bind(app) | |
| 87 | + | .fetch_one(pool) | |
| 88 | + | .await | |
| 89 | + | .expect("read app bytes_stored") | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// The per-key running counter, or `None` when the key has no row yet. | |
| 93 | + | async fn key_bytes(pool: &sqlx::PgPool, app: SyncAppId, key: &str) -> Option<i64> { | |
| 94 | + | sqlx::query_scalar::<_, i64>( | |
| 95 | + | "SELECT bytes_stored FROM sync_key_usage_current WHERE app_id = $1 AND key = $2", | |
| 96 | + | ) | |
| 97 | + | .bind(app) | |
| 98 | + | .bind(key) | |
| 99 | + | .fetch_optional(pool) | |
| 100 | + | .await | |
| 101 | + | .expect("read key bytes_stored") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Count of blob rows for one `(app, user)`. | |
| 105 | + | async fn blob_rows(pool: &sqlx::PgPool, app: SyncAppId, user: UserId) -> i64 { | |
| 106 | + | sqlx::query_scalar::<_, i64>( | |
| 107 | + | "SELECT COUNT(*) FROM sync_blobs WHERE app_id = $1 AND user_id = $2", | |
| 108 | + | ) | |
| 109 | + | .bind(app) | |
| 110 | + | .bind(user) | |
| 111 | + | .fetch_one(pool) | |
| 112 | + | .await | |
| 113 | + | .expect("count blob rows") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Every dead-lettered S3 key queued so far, with its bucket and source. | |
| 117 | + | async fn pending_deletions(pool: &sqlx::PgPool) -> Vec<(String, String, String)> { | |
| 118 | + | sqlx::query_as::<_, (String, String, String)>( | |
| 119 | + | "SELECT s3_key, bucket, source FROM pending_s3_deletions ORDER BY s3_key", | |
| 120 | + | ) | |
| 121 | + | .fetch_all(pool) | |
| 122 | + | .await | |
| 123 | + | .expect("read pending deletions") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Confirm a blob on a bulk-billed developer app with the given GB cap. | |
| 127 | + | #[allow(clippy::too_many_arguments)] | |
| 128 | + | async fn confirm_bulk( | |
| 129 | + | pool: &sqlx::PgPool, | |
| 130 | + | app: SyncAppId, | |
| 131 | + | user: UserId, | |
| 132 | + | hash: &str, | |
| 133 | + | size: i64, | |
| 134 | + | key: &str, | |
| 135 | + | gb_cap: i32, | |
| 136 | + | ) -> BlobConfirm { | |
| 137 | + | synckit::confirm_developer_blob( | |
| 138 | + | pool, | |
| 139 | + | app, | |
| 140 | + | user, | |
| 141 | + | hash, | |
| 142 | + | size, | |
| 143 | + | &format!("s3/{hash}"), | |
| 144 | + | key, | |
| 145 | + | SyncEnforcementMode::Bulk, | |
| 146 | + | Some(gb_cap), | |
| 147 | + | None, | |
| 148 | + | None, | |
| 149 | + | ) | |
| 150 | + | .await | |
| 151 | + | .expect("bulk confirm") | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// Confirm a blob on a per-key-billed developer app: `key_cap` keys, `gb_per_key` | |
| 155 | + | /// GB each. | |
| 156 | + | #[allow(clippy::too_many_arguments)] | |
| 157 | + | async fn confirm_per_key( | |
| 158 | + | pool: &sqlx::PgPool, | |
| 159 | + | app: SyncAppId, | |
| 160 | + | user: UserId, | |
| 161 | + | hash: &str, | |
| 162 | + | size: i64, | |
| 163 | + | key: &str, | |
| 164 | + | key_cap: i32, | |
| 165 | + | gb_per_key: i32, | |
| 166 | + | ) -> BlobConfirm { | |
| 167 | + | synckit::confirm_developer_blob( | |
| 168 | + | pool, | |
| 169 | + | app, | |
| 170 | + | user, | |
| 171 | + | hash, | |
| 172 | + | size, | |
| 173 | + | &format!("s3/{hash}"), | |
| 174 | + | key, | |
| 175 | + | SyncEnforcementMode::PerKey, | |
| 176 | + | None, | |
| 177 | + | Some(key_cap), | |
| 178 | + | Some(gb_per_key), | |
| 179 | + | ) | |
| 180 | + | .await | |
| 181 | + | .expect("per-key confirm") | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // ── storage_used_bytes ────────────────────────────────────────────────────── | |
| 185 | + | ||
| 186 | + | /// Three distinct, non-round sizes, so a sum is distinguishable from a count, a | |
| 187 | + | /// max, or the size of the first row. | |
| 188 | + | #[tokio::test] | |
| 189 | + | async fn storage_used_bytes_sums_every_blob_of_one_user_and_no_one_elses() { | |
| 190 | + | let db = TestDb::new().await; | |
| 191 | + | let alice = seed_user(&db.pool, "skblobs_sum_alice").await; | |
| 192 | + | let bob = seed_user(&db.pool, "skblobs_sum_bob").await; | |
| 193 | + | let app = seed_app(&db.pool, alice, "blobsum").await; | |
| 194 | + | make_internal(&db.pool, app).await; | |
| 195 | + | seed_subscription(&db.pool, alice, app, "sub_sum_alice", Some(100_000)).await; | |
| 196 | + | seed_subscription(&db.pool, bob, app, "sub_sum_bob", Some(100_000)).await; | |
| 197 | + | ||
| 198 | + | for (hash, size) in [("h-a", 3000), ("h-b", 700), ("h-c", 41)] { | |
| 199 | + | let out = | |
| 200 | + | synckit::confirm_internal_blob(&db.pool, app, alice, hash, size, "s3/a", "default") | |
| 201 | + | .await | |
| 202 | + | .expect("confirm"); | |
| 203 | + | assert_eq!(out, BlobConfirm::Stored, "seeding {hash} must store"); | |
| 204 | + | } | |
| 205 | + | let out = synckit::confirm_internal_blob(&db.pool, app, bob, "h-d", 500, "s3/d", "default") | |
| 206 | + | .await | |
| 207 | + | .expect("confirm"); | |
| 208 | + | assert_eq!(out, BlobConfirm::Stored, "bob's own blob stores"); | |
| 209 | + | ||
| 210 | + | assert_eq!( | |
| 211 | + | synckit::storage_used_bytes(&db.pool, app, alice) | |
| 212 | + | .await | |
| 213 | + | .expect("alice usage"), | |
| 214 | + | 3741, | |
| 215 | + | "3000 + 700 + 41: the sum of the rows, not their count or their max" | |
| 216 | + | ); | |
| 217 | + | assert_eq!( | |
| 218 | + | synckit::storage_used_bytes(&db.pool, app, bob) | |
| 219 | + | .await | |
| 220 | + | .expect("bob usage"), | |
| 221 | + | 500, | |
| 222 | + | "bob is charged for his row alone" | |
| 223 | + | ); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // ── confirm_internal_blob: the end-user cap ───────────────────────────────── | |
| 227 | + | ||
| 228 | + | /// The gate is `used + size > limit`, so exactly-on-the-cap is allowed and one | |
| 229 | + | /// byte past it is refused. Both sides are asserted: a test that only checked | |
| 230 | + | /// the refusal could not tell `>` from `>=`. | |
| 231 | + | #[tokio::test] | |
| 232 | + | async fn an_internal_blob_landing_exactly_on_the_cap_is_stored_and_one_byte_more_is_refused() { | |
| 233 | + | let db = TestDb::new().await; | |
| 234 | + | let user = seed_user(&db.pool, "skblobs_cap").await; | |
| 235 | + | let app = seed_app(&db.pool, user, "blobcap").await; | |
| 236 | + | make_internal(&db.pool, app).await; | |
| 237 | + | seed_subscription(&db.pool, user, app, "sub_cap", Some(5000)).await; | |
| 238 | + | ||
| 239 | + | let first = synckit::confirm_internal_blob(&db.pool, app, user, "h-1", 3000, "s3/1", "default") | |
| 240 | + | .await | |
| 241 | + | .expect("first confirm"); | |
| 242 | + | assert_eq!(first, BlobConfirm::Stored, "3000 of a 5000 cap fits"); | |
| 243 | + | ||
| 244 | + | // 3000 + 2000 == 5000: the boundary itself, which must be allowed. | |
| 245 | + | let exact = synckit::confirm_internal_blob(&db.pool, app, user, "h-2", 2000, "s3/2", "default") | |
| 246 | + | .await | |
| 247 | + | .expect("boundary confirm"); | |
| 248 | + | assert_eq!( | |
| 249 | + | exact, | |
| 250 | + | BlobConfirm::Stored, | |
| 251 | + | "a blob that fills the cap exactly is within it" | |
| 252 | + | ); | |
| 253 | + | ||
| 254 | + | // One byte past it is not. | |
| 255 | + | let over = synckit::confirm_internal_blob(&db.pool, app, user, "h-3", 1, "s3/3", "default") | |
| 256 | + | .await | |
| 257 | + | .expect("over-cap confirm"); | |
| 258 | + | assert_eq!( | |
| 259 | + | over, | |
| 260 | + | BlobConfirm::QuotaExceeded { | |
| 261 | + | dimension: "storage", | |
| 262 | + | used: 5000, | |
| 263 | + | limit: 5000, | |
| 264 | + | key: None, | |
| 265 | + | }, | |
| 266 | + | "one byte past a full cap is refused, and the reason names the real numbers" | |
| 267 | + | ); | |
| 268 | + | ||
| 269 | + | assert!( | |
| 270 | + | synckit::get_sync_blob_by_hash(&db.pool, app, user, "h-3") | |
| 271 | + | .await | |
| 272 | + | .expect("lookup") | |
| 273 | + | .is_none(), | |
| 274 | + | "a refused confirm writes no row" | |
| 275 | + | ); | |
| 276 | + | assert_eq!( | |
| 277 | + | synckit::storage_used_bytes(&db.pool, app, user) | |
| 278 | + | .await | |
| 279 | + | .expect("usage"), | |
| 280 | + | 5000, | |
| 281 | + | "and charges nothing" | |
| 282 | + | ); | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /// A NULL `storage_limit_bytes` becomes a zero cap (`unwrap_or(0)`), so the path | |
| 286 | + | /// fails closed rather than treating "no cap recorded" as unlimited. | |
| 287 | + | #[tokio::test] | |
| 288 | + | async fn an_internal_blob_is_refused_when_the_subscription_records_no_cap() { | |
| 289 | + | let db = TestDb::new().await; | |
| 290 | + | let user = seed_user(&db.pool, "skblobs_nullcap").await; | |
| 291 | + | let app = seed_app(&db.pool, user, "blobnullcap").await; | |
| 292 | + | make_internal(&db.pool, app).await; | |
| 293 | + | seed_subscription(&db.pool, user, app, "sub_nullcap", None).await; | |
| 294 | + | ||
| 295 | + | let out = synckit::confirm_internal_blob(&db.pool, app, user, "h-n", 4096, "s3/n", "default") | |
| 296 | + | .await | |
| 297 | + | .expect("confirm"); | |
| 298 | + | assert_eq!( | |
| 299 | + | out, | |
| 300 | + | BlobConfirm::QuotaExceeded { | |
| 301 | + | dimension: "storage", | |
| 302 | + | used: 0, | |
| 303 | + | limit: 0, | |
| 304 | + | key: None, | |
| 305 | + | }, | |
| 306 | + | "a missing cap is zero, not infinity" | |
| 307 | + | ); | |
| 308 | + | assert_eq!( | |
| 309 | + | blob_rows(&db.pool, app, user).await, | |
| 310 | + | 0, | |
| 311 | + | "nothing was written" | |
| 312 | + | ); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | /// Paid-only: no subscription row at all, and a row that is not `active`, are | |
| 316 | + | /// both `NoSubscription`, and neither writes. | |
| 317 | + | #[tokio::test] | |
| 318 | + | async fn an_internal_blob_needs_an_active_subscription_to_be_stored() { | |
| 319 | + | let db = TestDb::new().await; | |
| 320 | + | let user = seed_user(&db.pool, "skblobs_nosub").await; | |
| 321 | + | let app = seed_app(&db.pool, user, "blobnosub").await; | |
| 322 | + | make_internal(&db.pool, app).await; | |
| 323 | + | ||
| 324 | + | let missing = | |
| 325 | + | synckit::confirm_internal_blob(&db.pool, app, user, "h-m", 2048, "s3/m", "default") | |
| 326 | + | .await | |
| 327 | + | .expect("confirm without a subscription"); | |
| 328 | + | assert_eq!( | |
| 329 | + | missing, | |
| 330 | + | BlobConfirm::NoSubscription, | |
| 331 | + | "no subscription row means no write" | |
| 332 | + | ); | |
| 333 | + | ||
| 334 | + | seed_subscription(&db.pool, user, app, "sub_nosub", Some(100_000)).await; | |
| 335 | + | synckit::update_app_sync_subscription_status(&db.pool, "sub_nosub", "canceled", None) | |
| 336 | + | .await | |
| 337 | + | .expect("cancel"); | |
| 338 | + | ||
| 339 | + | let canceled = | |
| 340 | + | synckit::confirm_internal_blob(&db.pool, app, user, "h-m", 2048, "s3/m", "default") | |
| 341 | + | .await | |
| 342 | + | .expect("confirm on a canceled subscription"); | |
| 343 | + | assert_eq!( | |
| 344 | + | canceled, | |
| 345 | + | BlobConfirm::NoSubscription, | |
| 346 | + | "a canceled subscription closes writes even though the row exists" | |
| 347 | + | ); | |
| 348 | + | assert_eq!( | |
| 349 | + | blob_rows(&db.pool, app, user).await, | |
| 350 | + | 0, | |
| 351 | + | "neither refusal wrote a row" | |
| 352 | + | ); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // ── confirm_developer_blob: bulk mode ─────────────────────────────────────── | |
| 356 | + | ||
| 357 | + | /// Bulk mode caps the app as a whole. Same boundary pair as the internal path, | |
| 358 | + | /// against a cap expressed in GB, plus the two counters the path maintains. | |
| 359 | + | #[tokio::test] | |
| 360 | + | async fn bulk_mode_fills_the_app_cap_exactly_then_refuses_the_next_byte() { | |
| 361 | + | let db = TestDb::new().await; | |
| 362 | + | let dev = seed_user(&db.pool, "skblobs_bulk_dev").await; | |
| 363 | + | let user = seed_user(&db.pool, "skblobs_bulk_user").await; | |
| 364 | + | let app = seed_app(&db.pool, dev, "blobbulk").await; | |
| 365 | + | let cap = 2 * GIB; | |
| 366 | + | ||
| 367 | + | // Two blobs on two different keys, so the app-wide counter is visibly the | |
| 368 | + | // sum of the keys rather than either one of them. | |
| 369 | + | let first = confirm_bulk(&db.pool, app, user, "b-1", cap - 500, "alpha", 2).await; | |
| 370 | + | assert_eq!(first, BlobConfirm::Stored, "the first blob fits under 2 GB"); | |
| 371 | + | ||
| 372 | + | let exact = confirm_bulk(&db.pool, app, user, "b-2", 500, "beta", 2).await; | |
| 373 | + | assert_eq!( | |
| 374 | + | exact, | |
| 375 | + | BlobConfirm::Stored, | |
| 376 | + | "the blob that fills the cap exactly is within it" | |
| 377 | + | ); | |
| 378 | + | ||
| 379 | + | let over = confirm_bulk(&db.pool, app, user, "b-3", 1, "beta", 2).await; | |
| 380 | + | assert_eq!( | |
| 381 | + | over, | |
| 382 | + | BlobConfirm::QuotaExceeded { | |
| 383 | + | dimension: "storage", | |
| 384 | + | used: cap, | |
| 385 | + | limit: cap, | |
| 386 | + | key: None, | |
| 387 | + | }, | |
| 388 | + | "bulk mode reports the app dimension with no key attached" | |
| 389 | + | ); | |
| 390 | + | ||
| 391 | + | assert_eq!( | |
| 392 | + | app_bytes(&db.pool, app).await, | |
| 393 | + | cap, | |
| 394 | + | "the app counter is the sum of both blobs" | |
| 395 | + | ); | |
| 396 | + | assert_eq!( | |
| 397 | + | key_bytes(&db.pool, app, "alpha").await, | |
| 398 | + | Some(cap - 500), | |
| 399 | + | "each key is charged its own blob" | |
| 400 | + | ); | |
| 401 | + | assert_eq!(key_bytes(&db.pool, app, "beta").await, Some(500)); | |
| 402 | + | assert_eq!( | |
| 403 | + | blob_rows(&db.pool, app, user).await, | |
| 404 | + | 2, | |
| 405 | + | "the refused confirm wrote no third row" | |
| 406 | + | ); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | /// Stripe-style redelivery: the same confirm arriving twice must be a no-op the | |
| 410 | + | /// second time, not a second charge against the cap. | |
| 411 | + | #[tokio::test] | |
| 412 | + | async fn a_redelivered_developer_confirm_is_a_no_op_and_charges_nothing_twice() { | |
| 413 | + | let db = TestDb::new().await; | |
| 414 | + | let dev = seed_user(&db.pool, "skblobs_replay_dev").await; | |
| 415 | + | let user = seed_user(&db.pool, "skblobs_replay_user").await; | |
| 416 | + | let app = seed_app(&db.pool, dev, "blobreplay").await; | |
| 417 | + | ||
| 418 | + | let first = confirm_bulk(&db.pool, app, user, "r-1", 7000, "alpha", 10).await; | |
| 419 | + | assert_eq!(first, BlobConfirm::Stored); | |
| 420 | + | ||
| 421 | + | // The identical delivery again, and a third that claims a different size and | |
| 422 | + | // key: the hash is the identity, so both are refused a second charge. | |
| 423 | + | let second = confirm_bulk(&db.pool, app, user, "r-1", 7000, "alpha", 10).await; | |
| 424 | + | assert_eq!( | |
| 425 | + | second, | |
| 426 | + | BlobConfirm::AlreadyStored, | |
| 427 | + | "a redelivery of the same (app, user, hash) is idempotent" | |
| 428 | + | ); | |
| 429 | + | let third = confirm_bulk(&db.pool, app, user, "r-1", 3300, "beta", 10).await; | |
| 430 | + | assert_eq!( | |
| 431 | + | third, | |
| 432 | + | BlobConfirm::AlreadyStored, | |
| 433 | + | "the hash decides, so a redelivery carrying different metadata is still a no-op" | |
| 434 | + | ); | |
| 435 | + | ||
| 436 | + | assert_eq!( | |
| 437 | + | app_bytes(&db.pool, app).await, | |
| 438 | + | 7000, | |
| 439 | + | "the app counter moved once, not three times" | |
| 440 | + | ); | |
| 441 | + | assert_eq!( | |
| 442 | + | key_bytes(&db.pool, app, "alpha").await, | |
| 443 | + | Some(7000), | |
| 444 | + | "and the key counter with it" | |
| 445 | + | ); | |
| 446 | + | assert_eq!( | |
| 447 | + | key_bytes(&db.pool, app, "beta").await, | |
| 448 | + | None, | |
| 449 | + | "the redelivery's other key was never charged" | |
| 450 | + | ); | |
| 451 | + | assert_eq!(blob_rows(&db.pool, app, user).await, 1); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // ── confirm_developer_blob: per-key mode ──────────────────────────────────── | |
| 455 | + | ||
| 456 | + | /// Per-key mode caps each key at `gb_per_key`. A key that fills its own cap is | |
| 457 | + | /// refused while a different key keeps working, which is the whole point of the | |
| 458 | + | /// mode: one full key must not degrade the app. | |
| 459 | + | #[tokio::test] | |
| 460 | + | async fn per_key_mode_refuses_the_key_that_is_full_and_leaves_the_others_writable() { | |
| 461 | + | let db = TestDb::new().await; | |
| 462 | + | let dev = seed_user(&db.pool, "skblobs_pk_dev").await; | |
| 463 | + | let user = seed_user(&db.pool, "skblobs_pk_user").await; | |
| 464 | + | let app = seed_app(&db.pool, dev, "blobperkey").await; | |
| 465 | + | // 3 keys x 1 GB: per-key limit 1 GiB, app aggregate 3 GiB. | |
| 466 | + | let (key_cap, gb) = (3, 1); | |
| 467 | + | ||
| 468 | + | let first = confirm_per_key(&db.pool, app, user, "p-1", GIB - 700, "alpha", key_cap, gb).await; | |
| 469 | + | assert_eq!(first, BlobConfirm::Stored, "under alpha's 1 GB"); | |
| 470 | + | ||
| 471 | + | let exact = confirm_per_key(&db.pool, app, user, "p-2", 700, "alpha", key_cap, gb).await; | |
| 472 | + | assert_eq!( | |
| 473 | + | exact, | |
| 474 | + | BlobConfirm::Stored, | |
| 475 | + | "a blob filling the key cap exactly is within it" | |
| 476 | + | ); | |
| 477 | + | ||
| 478 | + | let over = confirm_per_key(&db.pool, app, user, "p-3", 1, "alpha", key_cap, gb).await; | |
| 479 | + | assert_eq!( | |
| 480 | + | over, | |
| 481 | + | BlobConfirm::QuotaExceeded { | |
| 482 | + | dimension: "storage_per_key", | |
| 483 | + | used: GIB, | |
| 484 | + | limit: GIB, | |
| 485 | + | key: Some("alpha".to_string()), | |
| 486 | + | }, | |
| 487 | + | "the refusal names the key it applies to, so the caller can tell the developer which one" | |
| 488 | + | ); | |
| 489 | + | ||
| 490 | + | // The app aggregate still has 2 GiB free, so another key writes fine. | |
| 491 | + | let other = confirm_per_key(&db.pool, app, user, "p-4", 4096, "beta", key_cap, gb).await; | |
| 492 | + | assert_eq!( | |
| 493 | + | other, | |
| 494 | + | BlobConfirm::Stored, | |
| 495 | + | "a full key must not close the whole app" | |
| 496 | + | ); | |
| 497 | + | ||
| 498 | + | assert_eq!(key_bytes(&db.pool, app, "alpha").await, Some(GIB)); | |
| 499 | + | assert_eq!(key_bytes(&db.pool, app, "beta").await, Some(4096)); | |
| 500 | + | assert_eq!( |
Lines truncated
| @@ -1,0 +1,779 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::synckit::devices` and `db::synckit::keys`, | |
| 2 | + | //! the device registry a paired client is recognised by and the encrypted | |
| 3 | + | //! master-key record every one of that user's rows is readable through. | |
| 4 | + | //! | |
| 5 | + | //! Both files carry query-shaped contracts that no route test can see, because | |
| 6 | + | //! a route test authenticates as one user against one app and so cannot tell a | |
| 7 | + | //! correctly scoped query from an unscoped one, nor `MIN` from `MAX`, nor a | |
| 8 | + | //! window from no window. What is pinned here: | |
| 9 | + | //! | |
| 10 | + | //! - devices: the upsert replacing platform and reported SDK version on | |
| 11 | + | //! re-registration, the listing's newest-seen-first order, a touch moving | |
| 12 | + | //! `last_seen_at` without moving the pull cursor, a pull upgrading the | |
| 13 | + | //! recorded client version, and `client_version_distribution`'s grouping, | |
| 14 | + | //! `MAX(last_seen_at)` per group, its window on both sides, and its tie | |
| 15 | + | //! ordering with the never-reported bucket last. | |
| 16 | + | //! - keys: key generations advancing strictly by one and never rewinding under | |
| 17 | + | //! a stale expected version, a pending rotation being reported beside the | |
| 18 | + | //! still-active key rather than in place of it, the per-app device and log | |
| 19 | + | //! counts a creator's dashboard reads, and the two log-shrinking paths, | |
| 20 | + | //! retention pruning and cursor compaction, at both their age and their | |
| 21 | + | //! sequence boundaries. | |
| 22 | + | //! | |
| 23 | + | //! Delete this file and the surviving synckit coverage | |
| 24 | + | //! (`db_synckit_accounts_layer`, `db_synckit_layer`) still asserts owner | |
| 25 | + | //! scoping and the monotonic cursor, but nothing asserts that compaction stops | |
| 26 | + | //! at the slowest device rather than the fastest, that pruning spares an entry | |
| 27 | + | //! inside the horizon, or that the field-version readout is a window at all. | |
| 28 | + | //! Each of those is a silent data-loss or wrong-answer bug. | |
| 29 | + | ||
| 30 | + | use crate::harness::db::TestDb; | |
| 31 | + | use crate::harness::seed_user; | |
| 32 | + | ||
| 33 | + | use makenotwork::db::synckit; | |
| 34 | + | use makenotwork::db::{SyncAppId, SyncDeviceId, SyncPlatform, UserId}; | |
| 35 | + | use uuid::Uuid; | |
| 36 | + | ||
| 37 | + | /// Seed a sync app owned by `user`, with its usage row. | |
| 38 | + | async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 39 | + | synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None) | |
| 40 | + | .await | |
| 41 | + | .expect("seed sync app") | |
| 42 | + | .id | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | async fn seed_device( | |
| 46 | + | pool: &sqlx::PgPool, | |
| 47 | + | app: SyncAppId, | |
| 48 | + | user: UserId, | |
| 49 | + | name: &str, | |
| 50 | + | ) -> SyncDeviceId { | |
| 51 | + | synckit::upsert_sync_device(pool, app, user, name, SyncPlatform::Macos, None) | |
| 52 | + | .await | |
| 53 | + | .expect("seed device") | |
| 54 | + | .id | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Register a device already reporting an SDK version. | |
| 58 | + | async fn seed_device_on_version( | |
| 59 | + | pool: &sqlx::PgPool, | |
| 60 | + | app: SyncAppId, | |
| 61 | + | user: UserId, | |
| 62 | + | name: &str, | |
| 63 | + | version: Option<&str>, | |
| 64 | + | ) -> SyncDeviceId { | |
| 65 | + | synckit::upsert_sync_device(pool, app, user, name, SyncPlatform::Linux, version) | |
| 66 | + | .await | |
| 67 | + | .expect("seed device") | |
| 68 | + | .id | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Move a device's `last_seen_at` back by `hours`, returning the stamp written. | |
| 72 | + | /// Every last-seen assertion here compares against a value the database itself | |
| 73 | + | /// produced, so no test depends on the host clock agreeing with Postgres. | |
| 74 | + | async fn set_last_seen( | |
| 75 | + | pool: &sqlx::PgPool, | |
| 76 | + | device: SyncDeviceId, | |
| 77 | + | hours: i32, | |
| 78 | + | ) -> chrono::DateTime<chrono::Utc> { | |
| 79 | + | sqlx::query_scalar( | |
| 80 | + | "UPDATE sync_devices SET last_seen_at = NOW() - make_interval(hours => $2::int) | |
| 81 | + | WHERE id = $1 RETURNING last_seen_at", | |
| 82 | + | ) | |
| 83 | + | .bind(device) | |
| 84 | + | .bind(hours) | |
| 85 | + | .fetch_one(pool) | |
| 86 | + | .await | |
| 87 | + | .expect("backdate last_seen_at") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | async fn read_last_seen( | |
| 91 | + | pool: &sqlx::PgPool, | |
| 92 | + | device: SyncDeviceId, | |
| 93 | + | ) -> chrono::DateTime<chrono::Utc> { | |
| 94 | + | sqlx::query_scalar("SELECT last_seen_at FROM sync_devices WHERE id = $1") | |
| 95 | + | .bind(device) | |
| 96 | + | .fetch_one(pool) | |
| 97 | + | .await | |
| 98 | + | .expect("read last_seen_at") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | async fn read_cursor(pool: &sqlx::PgPool, device: SyncDeviceId) -> i64 { | |
| 102 | + | sqlx::query_scalar("SELECT last_pulled_seq FROM sync_devices WHERE id = $1") | |
| 103 | + | .bind(device) | |
| 104 | + | .fetch_one(pool) | |
| 105 | + | .await | |
| 106 | + | .expect("read cursor") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | async fn read_client_version(pool: &sqlx::PgPool, device: SyncDeviceId) -> Option<String> { | |
| 110 | + | sqlx::query_scalar("SELECT client_version FROM sync_devices WHERE id = $1") | |
| 111 | + | .bind(device) | |
| 112 | + | .fetch_one(pool) | |
| 113 | + | .await | |
| 114 | + | .expect("read client_version") | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// One INSERT change tuple in the shape `push_sync_changes` expects. | |
| 118 | + | fn change( | |
| 119 | + | table: &str, | |
| 120 | + | row: &str, | |
| 121 | + | ) -> ( | |
| 122 | + | String, | |
| 123 | + | String, | |
| 124 | + | String, | |
| 125 | + | chrono::DateTime<chrono::Utc>, | |
| 126 | + | Option<serde_json::Value>, | |
| 127 | + | ) { | |
| 128 | + | ( | |
| 129 | + | table.to_string(), | |
| 130 | + | "INSERT".to_string(), | |
| 131 | + | row.to_string(), | |
| 132 | + | chrono::Utc::now(), | |
| 133 | + | Some(serde_json::json!({ "row": row })), | |
| 134 | + | ) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// Push `n` entries one batch at a time, returning their seqs in order. | |
| 138 | + | async fn push_entries( | |
| 139 | + | pool: &sqlx::PgPool, | |
| 140 | + | app: SyncAppId, | |
| 141 | + | user: UserId, | |
| 142 | + | device: SyncDeviceId, | |
| 143 | + | n: usize, | |
| 144 | + | ) -> Vec<i64> { | |
| 145 | + | let mut seqs = Vec::with_capacity(n); | |
| 146 | + | for i in 0..n { | |
| 147 | + | let seq = synckit::push_sync_changes( | |
| 148 | + | pool, | |
| 149 | + | app, | |
| 150 | + | user, | |
| 151 | + | device, | |
| 152 | + | Uuid::new_v4(), | |
| 153 | + | &[change("tasks", &format!("row{i}"))], | |
| 154 | + | ) | |
| 155 | + | .await | |
| 156 | + | .expect("push change"); | |
| 157 | + | seqs.push(seq); | |
| 158 | + | } | |
| 159 | + | seqs | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// Age a single log entry by `days`, so retention and compaction margins can be | |
| 163 | + | /// asserted on both sides of their boundary without sleeping. | |
| 164 | + | async fn backdate_entry(pool: &sqlx::PgPool, seq: i64, days: i32) { | |
| 165 | + | sqlx::query( | |
| 166 | + | "UPDATE sync_log SET created_at = NOW() - make_interval(days => $2::int) WHERE seq = $1", | |
| 167 | + | ) | |
| 168 | + | .bind(seq) | |
| 169 | + | .bind(days) | |
| 170 | + | .execute(pool) | |
| 171 | + | .await | |
| 172 | + | .expect("backdate log entry"); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | async fn surviving_seqs(pool: &sqlx::PgPool, app: SyncAppId, user: UserId) -> Vec<i64> { | |
| 176 | + | sqlx::query_scalar("SELECT seq FROM sync_log WHERE app_id = $1 AND user_id = $2 ORDER BY seq") | |
| 177 | + | .bind(app) | |
| 178 | + | .bind(user) | |
| 179 | + | .fetch_all(pool) | |
| 180 | + | .await | |
| 181 | + | .expect("read surviving seqs") | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // ── devices ───────────────────────────────────────────────────────────────── | |
| 185 | + | ||
| 186 | + | #[tokio::test] | |
| 187 | + | async fn re_registering_a_device_replaces_its_platform_and_reported_version() { | |
| 188 | + | let db = TestDb::new().await; | |
| 189 | + | let user = seed_user(&db.pool, "skdk_reregister").await; | |
| 190 | + | let app = seed_app(&db.pool, user, "reregister").await; | |
| 191 | + | ||
| 192 | + | let first = synckit::upsert_sync_device( | |
| 193 | + | &db.pool, | |
| 194 | + | app, | |
| 195 | + | user, | |
| 196 | + | "workstation", | |
| 197 | + | SyncPlatform::Macos, | |
| 198 | + | Some("1.2.0"), | |
| 199 | + | ) | |
| 200 | + | .await | |
| 201 | + | .expect("first registration"); | |
| 202 | + | ||
| 203 | + | // The same device name re-registering after an OS reinstall and an SDK | |
| 204 | + | // upgrade. `EXCLUDED` wins for both columns, so a client that moved from | |
| 205 | + | // macOS to Linux is not still reported as a Mac running 1.2.0. | |
| 206 | + | let second = synckit::upsert_sync_device( | |
| 207 | + | &db.pool, | |
| 208 | + | app, | |
| 209 | + | user, | |
| 210 | + | "workstation", | |
| 211 | + | SyncPlatform::Linux, | |
| 212 | + | Some("1.5.0"), | |
| 213 | + | ) | |
| 214 | + | .await | |
| 215 | + | .expect("re-registration"); | |
| 216 | + | ||
| 217 | + | assert_eq!( | |
| 218 | + | second.id, first.id, | |
| 219 | + | "the unique (app, user, device_name) key means a re-register updates the \ | |
| 220 | + | existing row, got first {first:?} then {second:?}" | |
| 221 | + | ); | |
| 222 | + | assert_eq!( | |
| 223 | + | second.platform, | |
| 224 | + | SyncPlatform::Linux, | |
| 225 | + | "the new platform replaces the old one, row: {second:?}" | |
| 226 | + | ); | |
| 227 | + | assert_eq!( | |
| 228 | + | second.client_version.as_deref(), | |
| 229 | + | Some("1.5.0"), | |
| 230 | + | "and a reported upgrade replaces the recorded version, row: {second:?}" | |
| 231 | + | ); | |
| 232 | + | ||
| 233 | + | // A different name is a different device, not another update of this one. | |
| 234 | + | let other = synckit::upsert_sync_device( | |
| 235 | + | &db.pool, | |
| 236 | + | app, | |
| 237 | + | user, | |
| 238 | + | "phone", | |
| 239 | + | SyncPlatform::Ios, | |
| 240 | + | Some("1.5.0"), | |
| 241 | + | ) | |
| 242 | + | .await | |
| 243 | + | .expect("second device"); | |
| 244 | + | assert_ne!( | |
| 245 | + | other.id, first.id, | |
| 246 | + | "distinct device names are distinct rows, got {other:?} against {first:?}" | |
| 247 | + | ); | |
| 248 | + | let devices = synckit::get_sync_devices(&db.pool, app, user) | |
| 249 | + | .await | |
| 250 | + | .expect("list devices"); | |
| 251 | + | assert_eq!( | |
| 252 | + | devices.len(), | |
| 253 | + | 2, | |
| 254 | + | "two names, two devices, got {:?}", | |
| 255 | + | devices.iter().map(|d| &d.device_name).collect::<Vec<_>>() | |
| 256 | + | ); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | #[tokio::test] | |
| 260 | + | async fn device_listing_is_newest_seen_first_and_scoped_to_one_owner_and_app() { | |
| 261 | + | let db = TestDb::new().await; | |
| 262 | + | let alice = seed_user(&db.pool, "skdk_list_alice").await; | |
| 263 | + | let bob = seed_user(&db.pool, "skdk_list_bob").await; | |
| 264 | + | let app = seed_app(&db.pool, alice, "listapp").await; | |
| 265 | + | let other_app = seed_app(&db.pool, alice, "listapp2").await; | |
| 266 | + | ||
| 267 | + | // Three distinct last-seen stamps, so DESC and ASC give different answers | |
| 268 | + | // and neither can be mistaken for the insertion order: the middle device is | |
| 269 | + | // registered first and the oldest is registered last. | |
| 270 | + | let laptop = seed_device(&db.pool, app, alice, "laptop").await; | |
| 271 | + | set_last_seen(&db.pool, laptop, 72).await; | |
| 272 | + | let phone = seed_device(&db.pool, app, alice, "phone").await; | |
| 273 | + | set_last_seen(&db.pool, phone, 1).await; | |
| 274 | + | let tablet = seed_device(&db.pool, app, alice, "tablet").await; | |
| 275 | + | set_last_seen(&db.pool, tablet, 240).await; | |
| 276 | + | ||
| 277 | + | // Rows that must not appear: another user in this app, and this user in | |
| 278 | + | // another app. | |
| 279 | + | seed_device(&db.pool, app, bob, "bob-laptop").await; | |
| 280 | + | seed_device(&db.pool, other_app, alice, "alice-elsewhere").await; | |
| 281 | + | ||
| 282 | + | let listed = synckit::get_sync_devices(&db.pool, app, alice) | |
| 283 | + | .await | |
| 284 | + | .expect("list devices"); | |
| 285 | + | let ids = listed.iter().map(|d| d.id).collect::<Vec<_>>(); | |
| 286 | + | assert_eq!( | |
| 287 | + | ids, | |
| 288 | + | vec![phone, laptop, tablet], | |
| 289 | + | "devices come back most-recently-seen first, got {:?}", | |
| 290 | + | listed | |
| 291 | + | .iter() | |
| 292 | + | .map(|d| (&d.device_name, d.last_seen_at)) | |
| 293 | + | .collect::<Vec<_>>() | |
| 294 | + | ); | |
| 295 | + | ||
| 296 | + | let bobs = synckit::get_sync_devices(&db.pool, app, bob) | |
| 297 | + | .await | |
| 298 | + | .expect("list bob's devices"); | |
| 299 | + | assert_eq!( | |
| 300 | + | bobs.len(), | |
| 301 | + | 1, | |
| 302 | + | "bob sees only his own device in this app, got {:?}", | |
| 303 | + | bobs.iter().map(|d| &d.device_name).collect::<Vec<_>>() | |
| 304 | + | ); | |
| 305 | + | assert_eq!( | |
| 306 | + | bobs[0].device_name, "bob-laptop", | |
| 307 | + | "and it is his, got {bobs:?}" | |
| 308 | + | ); | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | #[tokio::test] | |
| 312 | + | async fn touching_a_device_moves_last_seen_without_moving_its_cursor() { | |
| 313 | + | let db = TestDb::new().await; | |
| 314 | + | let user = seed_user(&db.pool, "skdk_touch").await; | |
| 315 | + | let app = seed_app(&db.pool, user, "touchapp").await; | |
| 316 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 317 | + | ||
| 318 | + | // A cursor well past 1: an off-by-one or a reset to zero is visible. | |
| 319 | + | synckit::touch_and_advance_cursor(&db.pool, device, 37, None) | |
| 320 | + | .await | |
| 321 | + | .expect("advance cursor"); | |
| 322 | + | let stale = set_last_seen(&db.pool, device, 96).await; | |
| 323 | + | ||
| 324 | + | synckit::touch_sync_device(&db.pool, device) | |
| 325 | + | .await | |
| 326 | + | .expect("touch device"); | |
| 327 | + | ||
| 328 | + | let seen = read_last_seen(&db.pool, device).await; | |
| 329 | + | assert!( | |
| 330 | + | seen > stale, | |
| 331 | + | "a touch refreshes last_seen_at, was {stale}, now {seen}" | |
| 332 | + | ); | |
| 333 | + | assert_eq!( | |
| 334 | + | read_cursor(&db.pool, device).await, | |
| 335 | + | 37, | |
| 336 | + | "and a touch is not a pull, so the cursor is untouched" | |
| 337 | + | ); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[tokio::test] | |
| 341 | + | async fn a_pull_upgrades_the_recorded_client_version_and_keeps_the_cursor_forward() { | |
| 342 | + | let db = TestDb::new().await; | |
| 343 | + | let user = seed_user(&db.pool, "skdk_pullver").await; | |
| 344 | + | let app = seed_app(&db.pool, user, "pullver").await; | |
| 345 | + | let device = seed_device_on_version(&db.pool, app, user, "laptop", Some("1.0.0")).await; | |
| 346 | + | ||
| 347 | + | // The device upgrades its SDK and pulls: the stored version must follow the | |
| 348 | + | // request, which `COALESCE($3, client_version)` does and the reversed | |
| 349 | + | // `COALESCE(client_version, $3)` would not. | |
| 350 | + | synckit::touch_and_advance_cursor(&db.pool, device, 12, Some("2.0.0")) | |
| 351 | + | .await | |
| 352 | + | .expect("pull with a new version"); | |
| 353 | + | assert_eq!( | |
| 354 | + | read_client_version(&db.pool, device).await.as_deref(), | |
| 355 | + | Some("2.0.0"), | |
| 356 | + | "a reported upgrade replaces the recorded version" | |
| 357 | + | ); | |
| 358 | + | assert_eq!(read_cursor(&db.pool, device).await, 12); | |
| 359 | + | ||
| 360 | + | // A later pull from a build that sends no User-Agent leaves the version | |
| 361 | + | // alone rather than blanking a fleet readout. | |
| 362 | + | synckit::touch_and_advance_cursor(&db.pool, device, 29, None) | |
| 363 | + | .await | |
| 364 | + | .expect("pull without a version"); | |
| 365 | + | assert_eq!( | |
| 366 | + | read_client_version(&db.pool, device).await.as_deref(), | |
| 367 | + | Some("2.0.0"), | |
| 368 | + | "a version-less pull is quiet, not a downgrade to unknown" | |
| 369 | + | ); | |
| 370 | + | assert_eq!( | |
| 371 | + | read_cursor(&db.pool, device).await, | |
| 372 | + | 29, | |
| 373 | + | "and the cursor still advanced" | |
| 374 | + | ); | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | #[tokio::test] | |
| 378 | + | async fn version_distribution_groups_by_version_inside_the_window_and_orders_by_fleet_size() { | |
| 379 | + | let db = TestDb::new().await; | |
| 380 | + | let user = seed_user(&db.pool, "skdk_dist").await; | |
| 381 | + | let app = seed_app(&db.pool, user, "distapp").await; | |
| 382 | + | ||
| 383 | + | // Two devices on 1.4.0 inside the window, seen at different times so MAX | |
| 384 | + | // and MIN over the group give different answers. | |
| 385 | + | let recent_a = seed_device_on_version(&db.pool, app, user, "a1", Some("1.4.0")).await; | |
| 386 | + | let newest_1_4 = set_last_seen(&db.pool, recent_a, 24).await; | |
| 387 | + | let recent_b = seed_device_on_version(&db.pool, app, user, "a2", Some("1.4.0")).await; | |
| 388 | + | set_last_seen(&db.pool, recent_b, 72).await; | |
| 389 | + | ||
| 390 | + | // A third 1.4.0 device that has not synced in 31 days. It is outside a | |
| 391 | + | // 30-day window, so its presence changes the group's count from 2 to 3 if | |
| 392 | + | // the window is missing or inverted. | |
| 393 | + | let lapsed = seed_device_on_version(&db.pool, app, user, "a3", Some("1.4.0")).await; | |
| 394 | + | set_last_seen(&db.pool, lapsed, 31 * 24).await; | |
| 395 | + | ||
| 396 | + | // Two on 1.2.0, tying 1.4.0's in-window count, so the version tiebreak is | |
| 397 | + | // the only thing that can order them. | |
| 398 | + | for name in ["c1", "c2"] { | |
| 399 | + | let d = seed_device_on_version(&db.pool, app, user, name, Some("1.2.0")).await; | |
| 400 | + | set_last_seen(&db.pool, d, 5).await; | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | // Just inside the window: 29 days. | |
| 404 | + | let edge = seed_device_on_version(&db.pool, app, user, "d1", Some("1.3.0")).await; | |
| 405 | + | set_last_seen(&db.pool, edge, 29 * 24).await; | |
| 406 | + | ||
| 407 | + | // A client old enough to send no version at all. | |
| 408 | + | let quiet = seed_device_on_version(&db.pool, app, user, "e1", None).await; | |
| 409 | + | set_last_seen(&db.pool, quiet, 2).await; | |
| 410 | + | ||
| 411 | + | let rows = synckit::client_version_distribution(&db.pool, 30) | |
| 412 | + | .await | |
| 413 | + | .expect("version distribution"); | |
| 414 | + | let shape = rows | |
| 415 | + | .iter() | |
| 416 | + | .map(|r| (r.client_version.clone(), r.devices)) | |
| 417 | + | .collect::<Vec<_>>(); | |
| 418 | + | assert_eq!( | |
| 419 | + | shape, | |
| 420 | + | vec![ | |
| 421 | + | (Some("1.4.0".to_string()), 2), | |
| 422 | + | (Some("1.2.0".to_string()), 2), | |
| 423 | + | (Some("1.3.0".to_string()), 1), | |
| 424 | + | (None, 1), | |
| 425 | + | ], | |
| 426 | + | "counts are per version inside the window, ties break on the higher \ | |
| 427 | + | version, and the never-reported bucket sorts last, got {rows:?}" | |
| 428 | + | ); | |
| 429 | + | assert_eq!( | |
| 430 | + | rows[0].last_seen_at, newest_1_4, | |
| 431 | + | "the stamp on a group is the most recent sync in it, not the oldest, got {:?}", | |
| 432 | + | rows[0] | |
| 433 | + | ); | |
| 434 | + | ||
| 435 | + | // Widening the window past the lapsed device brings it back into its group, | |
| 436 | + | // which is the other side of the same boundary. | |
| 437 | + | let wide = synckit::client_version_distribution(&db.pool, 60) | |
| 438 | + | .await | |
| 439 | + | .expect("wide distribution"); | |
| 440 | + | let lapsed_group = wide | |
| 441 | + | .iter() | |
| 442 | + | .find(|r| r.client_version.as_deref() == Some("1.4.0")) | |
| 443 | + | .unwrap_or_else(|| panic!("1.4.0 group missing from {wide:?}")); | |
| 444 | + | assert_eq!( | |
| 445 | + | lapsed_group.devices, 3, | |
| 446 | + | "a 60-day window includes the device last seen at 31 days, got {wide:?}" | |
| 447 | + | ); | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // ── keys ──────────────────────────────────────────────────────────────────── | |
| 451 | + | ||
| 452 | + | #[tokio::test] | |
| 453 | + | async fn key_generations_advance_by_one_in_order_and_a_stale_writer_cannot_rewind_them() { | |
| 454 | + | let db = TestDb::new().await; | |
| 455 | + | let user = seed_user(&db.pool, "skdk_gens").await; | |
| 456 | + | let app = seed_app(&db.pool, user, "gensapp").await; | |
| 457 | + | ||
| 458 | + | // Walk three generations past the insert, so the sequence 1, 2, 3, 4 is | |
| 459 | + | // long enough that a version that stuck, doubled, or counted down would | |
| 460 | + | // read differently. Two generations cannot tell +1 from *2. | |
| 461 | + | let mut versions = Vec::new(); | |
| 462 | + | for (n, envelope) in ["env_g0", "env_g1", "env_g2", "env_g3"].iter().enumerate() { | |
| 463 | + | let expected = i32::try_from(n).expect("small generation"); | |
| 464 | + | assert!( | |
| 465 | + | synckit::upsert_sync_key(&db.pool, app, user, envelope, expected) | |
| 466 | + | .await | |
| 467 | + | .expect("upsert key"), | |
| 468 | + | "generation {n} expected version {expected} and should have written {envelope}" | |
| 469 | + | ); | |
| 470 | + | let info = synckit::get_sync_key(&db.pool, app, user) | |
| 471 | + | .await | |
| 472 | + | .expect("read key") | |
| 473 | + | .expect("key exists"); | |
| 474 | + | assert_eq!( | |
| 475 | + | info.encrypted_key, *envelope, | |
| 476 | + | "the envelope just written is the one read back at generation {n}" | |
| 477 | + | ); | |
| 478 | + | versions.push(info.key_version); | |
| 479 | + | } | |
| 480 | + | assert_eq!( | |
| 481 | + | versions, | |
| 482 | + | vec![1, 2, 3, 4], | |
| 483 | + | "each accepted rotation advances the generation by exactly one, got {versions:?}" | |
| 484 | + | ); | |
| 485 | + | assert!( | |
| 486 | + | versions.windows(2).all(|w| w[1] > w[0]), | |
| 487 | + | "and generations are strictly increasing, got {versions:?}" | |
| 488 | + | ); | |
| 489 | + | ||
| 490 | + | // A device that slept through two rotations tries to write against the | |
| 491 | + | // generation it remembers. The WHERE clause on the upsert refuses it. | |
| 492 | + | assert!( | |
| 493 | + | !synckit::upsert_sync_key(&db.pool, app, user, "env_stale", 2) | |
| 494 | + | .await | |
| 495 | + | .expect("stale upsert"), | |
| 496 | + | "an expected version of 2 against a stored 4 is a conflict, not a write" | |
| 497 | + | ); | |
| 498 | + | let after = synckit::get_sync_key(&db.pool, app, user) | |
| 499 | + | .await | |
| 500 | + | .expect("read key") |
Lines truncated
| @@ -1,0 +1,686 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::synckit::log`: the pull side, where a | |
| 2 | + | //! dropped entry is invisible. | |
| 3 | + | //! | |
| 4 | + | //! The change log is the sync engine's source of truth, so the failure this | |
| 5 | + | //! file exists to catch is the silent one: a pull that drops an entry at a page | |
| 6 | + | //! boundary, a cursor that is inclusive at one end and exclusive at the other, | |
| 7 | + | //! or a filter that turns a full drain into a partial one. Every pagination | |
| 8 | + | //! test here asserts the same thing from two directions, that draining the log | |
| 9 | + | //! a page at a time yields exactly what one unpaginated pull yields, because | |
| 10 | + | //! that equality is what a client's convergence depends on and neither half | |
| 11 | + | //! alone would notice a missing row. | |
| 12 | + | //! | |
| 13 | + | //! Pinned here: cursor exclusivity at both ends, the empty-list table filter | |
| 14 | + | //! meaning "nothing" rather than "everything", `since` being inclusive of its | |
| 15 | + | //! own boundary, pull ordering being arrival order and not the client clock, | |
| 16 | + | //! and the two filters composing as AND rather than OR. | |
| 17 | + | //! | |
| 18 | + | //! The write side of the same module is `db_synckit_log_push_layer`, and the | |
| 19 | + | //! audit trail is `db_synckit_security_layer`. Neighbouring ground is | |
| 20 | + | //! deliberately not repeated: `db_synckit_layer` pins append order, per-user | |
| 21 | + | //! scoping, blobs and keys; `db_synckit_groups` pins group membership and the | |
| 22 | + | //! structural isolation of `sync_group_log` from personal pulls. | |
| 23 | + | //! | |
| 24 | + | //! Delete this file and the pagination contract of the sync protocol is | |
| 25 | + | //! unasserted at this layer. | |
| 26 | + | ||
| 27 | + | use crate::harness::db::TestDb; | |
| 28 | + | use crate::harness::seed_user; | |
| 29 | + | ||
| 30 | + | use makenotwork::db::synckit; | |
| 31 | + | use makenotwork::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 32 | + | use uuid::Uuid; | |
| 33 | + | ||
| 34 | + | /// One change tuple in the shape `push_sync_changes` and `push_group_changes` | |
| 35 | + | /// both take. | |
| 36 | + | type Change = ( | |
| 37 | + | String, | |
| 38 | + | String, | |
| 39 | + | String, | |
| 40 | + | chrono::DateTime<chrono::Utc>, | |
| 41 | + | Option<serde_json::Value>, | |
| 42 | + | ); | |
| 43 | + | ||
| 44 | + | /// Seed a sync app owned by `user`. | |
| 45 | + | async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 46 | + | sqlx::query_scalar::<_, SyncAppId>( | |
| 47 | + | "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) | |
| 48 | + | VALUES ($1, $2, $3, $4) RETURNING id", | |
| 49 | + | ) | |
| 50 | + | .bind(user) | |
| 51 | + | .bind(name) | |
| 52 | + | .bind(format!("hash_{name}")) | |
| 53 | + | .bind(&name[..name.len().min(8)]) | |
| 54 | + | .fetch_one(pool) | |
| 55 | + | .await | |
| 56 | + | .expect("seed sync app") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Seed a device row for a user within an app. | |
| 60 | + | async fn seed_device( | |
| 61 | + | pool: &sqlx::PgPool, | |
| 62 | + | app: SyncAppId, | |
| 63 | + | user: UserId, | |
| 64 | + | name: &str, | |
| 65 | + | ) -> SyncDeviceId { | |
| 66 | + | sqlx::query_scalar::<_, SyncDeviceId>( | |
| 67 | + | "INSERT INTO sync_devices (app_id, user_id, device_name, platform) | |
| 68 | + | VALUES ($1, $2, $3, 'macos') RETURNING id", | |
| 69 | + | ) | |
| 70 | + | .bind(app) | |
| 71 | + | .bind(user) | |
| 72 | + | .bind(name) | |
| 73 | + | .fetch_one(pool) | |
| 74 | + | .await | |
| 75 | + | .expect("seed device") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// A fixed instant offset by whole hours. Fixed rather than `Utc::now()` so a | |
| 79 | + | /// `since` boundary can be asserted for equality without depending on the | |
| 80 | + | /// wall clock. | |
| 81 | + | fn ts(offset_hours: i64) -> chrono::DateTime<chrono::Utc> { | |
| 82 | + | "2024-03-01T12:00:00Z" | |
| 83 | + | .parse::<chrono::DateTime<chrono::Utc>>() | |
| 84 | + | .expect("fixed base timestamp parses") | |
| 85 | + | + chrono::Duration::hours(offset_hours) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// An INSERT change on `table` for `row`, client-stamped at `ts(offset_hours)`. | |
| 89 | + | fn change_at(table: &str, row: &str, offset_hours: i64) -> Change { | |
| 90 | + | ( | |
| 91 | + | table.to_string(), | |
| 92 | + | "INSERT".to_string(), | |
| 93 | + | row.to_string(), | |
| 94 | + | ts(offset_hours), | |
| 95 | + | Some(serde_json::json!({ "row": row })), | |
| 96 | + | ) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// An INSERT change on `table` for `row` at the base instant. | |
| 100 | + | fn change(table: &str, row: &str) -> Change { | |
| 101 | + | change_at(table, row, 0) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Push `changes` as one fresh batch, returning the cursor the push reports. | |
| 105 | + | async fn push( | |
| 106 | + | pool: &sqlx::PgPool, | |
| 107 | + | app: SyncAppId, | |
| 108 | + | user: UserId, | |
| 109 | + | device: SyncDeviceId, | |
| 110 | + | changes: &[Change], | |
| 111 | + | ) -> i64 { | |
| 112 | + | synckit::push_sync_changes(pool, app, user, device, Uuid::new_v4(), changes) | |
| 113 | + | .await | |
| 114 | + | .expect("push sync changes") | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Walk the personal log `limit` entries at a time the way a client does, | |
| 118 | + | /// carrying the last returned seq forward as the next cursor. Returns the | |
| 119 | + | /// row ids in the order they were handed over. | |
| 120 | + | /// | |
| 121 | + | /// The iteration guard is what makes a non-terminating cursor fail as a test | |
| 122 | + | /// rather than hang the suite. | |
| 123 | + | async fn drain( | |
| 124 | + | pool: &sqlx::PgPool, | |
| 125 | + | app: SyncAppId, | |
| 126 | + | user: UserId, | |
| 127 | + | limit: i64, | |
| 128 | + | tables: Option<&[String]>, | |
| 129 | + | since: Option<chrono::DateTime<chrono::Utc>>, | |
| 130 | + | ) -> Vec<String> { | |
| 131 | + | let mut seen: Vec<String> = Vec::new(); | |
| 132 | + | let mut cursor = 0i64; | |
| 133 | + | let mut pages = 0; | |
| 134 | + | loop { | |
| 135 | + | let page = | |
| 136 | + | synckit::pull_sync_changes_filtered(pool, app, user, cursor, limit, tables, since) | |
| 137 | + | .await | |
| 138 | + | .expect("paginated pull"); | |
| 139 | + | if page.is_empty() { | |
| 140 | + | break; | |
| 141 | + | } | |
| 142 | + | assert!( | |
| 143 | + | i64::try_from(page.len()).expect("page length fits i64") <= limit, | |
| 144 | + | "a page must never exceed the limit it was asked for: got {} for limit {limit}", | |
| 145 | + | page.len() | |
| 146 | + | ); | |
| 147 | + | cursor = page.last().expect("non-empty page has a last entry").seq; | |
| 148 | + | seen.extend(page.into_iter().map(|e| e.row_id)); | |
| 149 | + | pages += 1; | |
| 150 | + | assert!( | |
| 151 | + | pages <= 64, | |
| 152 | + | "the cursor did not terminate after 64 pages of limit {limit}; seen so far: {seen:?}" | |
| 153 | + | ); | |
| 154 | + | } | |
| 155 | + | seen | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | fn names(v: &[String]) -> Vec<&str> { | |
| 159 | + | v.iter().map(String::as_str).collect() | |
| 160 | + | } | |
| 161 | + | // ── log: pagination and cursor termination ────────────────────────────────── | |
| 162 | + | ||
| 163 | + | #[tokio::test] | |
| 164 | + | async fn a_paginated_drain_yields_exactly_what_one_unpaginated_pull_yields() { | |
| 165 | + | let db = TestDb::new().await; | |
| 166 | + | let user = seed_user(&db.pool, "sklogp_drain").await; | |
| 167 | + | let app = seed_app(&db.pool, user, "logdrain").await; | |
| 168 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 169 | + | ||
| 170 | + | // Seven entries across three batches of unequal size, so the batch | |
| 171 | + | // boundaries do not line up with any of the page sizes below. | |
| 172 | + | push( | |
| 173 | + | &db.pool, | |
| 174 | + | app, | |
| 175 | + | user, | |
| 176 | + | device, | |
| 177 | + | &[change("tasks", "r0"), change("tasks", "r1")], | |
| 178 | + | ) | |
| 179 | + | .await; | |
| 180 | + | push( | |
| 181 | + | &db.pool, | |
| 182 | + | app, | |
| 183 | + | user, | |
| 184 | + | device, | |
| 185 | + | &[ | |
| 186 | + | change("tasks", "r2"), | |
| 187 | + | change("tasks", "r3"), | |
| 188 | + | change("tasks", "r4"), | |
| 189 | + | ], | |
| 190 | + | ) | |
| 191 | + | .await; | |
| 192 | + | push( | |
| 193 | + | &db.pool, | |
| 194 | + | app, | |
| 195 | + | user, | |
| 196 | + | device, | |
| 197 | + | &[change("tasks", "r5"), change("tasks", "r6")], | |
| 198 | + | ) | |
| 199 | + | .await; | |
| 200 | + | ||
| 201 | + | let whole = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 1000, None, None) | |
| 202 | + | .await | |
| 203 | + | .unwrap(); | |
| 204 | + | let whole_rows: Vec<String> = whole.iter().map(|e| e.row_id.clone()).collect(); | |
| 205 | + | assert_eq!( | |
| 206 | + | names(&whole_rows), | |
| 207 | + | ["r0", "r1", "r2", "r3", "r4", "r5", "r6"], | |
| 208 | + | "one unpaginated pull is the reference: {whole_rows:?}" | |
| 209 | + | ); | |
| 210 | + | ||
| 211 | + | // 2 and 3 leave a short final page; 7 is an exact multiple, which is the | |
| 212 | + | // case where a client only stops because the pull after the last full page | |
| 213 | + | // comes back empty. | |
| 214 | + | for limit in [2i64, 3, 7] { | |
| 215 | + | let paged = drain(&db.pool, app, user, limit, None, None).await; | |
| 216 | + | assert_eq!( | |
| 217 | + | paged, whole_rows, | |
| 218 | + | "draining {limit} at a time must lose and duplicate nothing: {paged:?}" | |
| 219 | + | ); | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[tokio::test] | |
| 224 | + | async fn the_pull_cursor_is_exclusive_at_both_ends() { | |
| 225 | + | let db = TestDb::new().await; | |
| 226 | + | let user = seed_user(&db.pool, "sklogp_cursor").await; | |
| 227 | + | let app = seed_app(&db.pool, user, "logcursor").await; | |
| 228 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 229 | + | ||
| 230 | + | push( | |
| 231 | + | &db.pool, | |
| 232 | + | app, | |
| 233 | + | user, | |
| 234 | + | device, | |
| 235 | + | &[ | |
| 236 | + | change("tasks", "r0"), | |
| 237 | + | change("tasks", "r1"), | |
| 238 | + | change("tasks", "r2"), | |
| 239 | + | change("tasks", "r3"), | |
| 240 | + | change("tasks", "r4"), | |
| 241 | + | ], | |
| 242 | + | ) | |
| 243 | + | .await; | |
| 244 | + | ||
| 245 | + | let all = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) | |
| 246 | + | .await | |
| 247 | + | .unwrap(); | |
| 248 | + | assert_eq!(all.len(), 5, "five entries were pushed: {all:?}"); | |
| 249 | + | ||
| 250 | + | // Resuming at the seq of r1 must hand back r2 onward. An inclusive `>=` | |
| 251 | + | // would re-deliver r1 and return four entries, which is why the count and | |
| 252 | + | // the first row id are both asserted. | |
| 253 | + | let after_second = | |
| 254 | + | synckit::pull_sync_changes_filtered(&db.pool, app, user, all[1].seq, 100, None, None) | |
| 255 | + | .await | |
| 256 | + | .unwrap(); | |
| 257 | + | let rows: Vec<&str> = after_second.iter().map(|e| e.row_id.as_str()).collect(); | |
| 258 | + | assert_eq!( | |
| 259 | + | rows, | |
| 260 | + | ["r2", "r3", "r4"], | |
| 261 | + | "a cursor is the last entry already seen, not the next one to send: {rows:?}" | |
| 262 | + | ); | |
| 263 | + | ||
| 264 | + | // The cursor a client holds after a full drain returns nothing, which is | |
| 265 | + | // the only reason the drain loop above terminates. | |
| 266 | + | let at_end = synckit::pull_sync_changes_filtered( | |
| 267 | + | &db.pool, | |
| 268 | + | app, | |
| 269 | + | user, | |
| 270 | + | all.last().unwrap().seq, | |
| 271 | + | 100, | |
| 272 | + | None, | |
| 273 | + | None, | |
| 274 | + | ) | |
| 275 | + | .await | |
| 276 | + | .unwrap(); | |
| 277 | + | assert!( | |
| 278 | + | at_end.is_empty(), | |
| 279 | + | "the final cursor must drain empty, got {at_end:?}" | |
| 280 | + | ); | |
| 281 | + | ||
| 282 | + | // A cursor past the end (a log truncated behind a client, or a client that | |
| 283 | + | // held a cursor from another app) is empty rather than an error or a rewind. | |
| 284 | + | let past_end = synckit::pull_sync_changes_filtered( | |
| 285 | + | &db.pool, | |
| 286 | + | app, | |
| 287 | + | user, | |
| 288 | + | all.last().unwrap().seq + 5_000, | |
| 289 | + | 100, | |
| 290 | + | None, | |
| 291 | + | None, | |
| 292 | + | ) | |
| 293 | + | .await | |
| 294 | + | .unwrap(); | |
| 295 | + | assert!( | |
| 296 | + | past_end.is_empty(), | |
| 297 | + | "a cursor beyond the highest seq returns nothing, got {past_end:?}" | |
| 298 | + | ); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[tokio::test] | |
| 302 | + | async fn a_limit_bounds_one_page_and_a_zero_limit_returns_no_page_at_all() { | |
| 303 | + | let db = TestDb::new().await; | |
| 304 | + | let user = seed_user(&db.pool, "sklogp_limit").await; | |
| 305 | + | let app = seed_app(&db.pool, user, "loglimit").await; | |
| 306 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 307 | + | ||
| 308 | + | push( | |
| 309 | + | &db.pool, | |
| 310 | + | app, | |
| 311 | + | user, | |
| 312 | + | device, | |
| 313 | + | &[ | |
| 314 | + | change("tasks", "r0"), | |
| 315 | + | change("tasks", "r1"), | |
| 316 | + | change("tasks", "r2"), | |
| 317 | + | change("tasks", "r3"), | |
| 318 | + | change("tasks", "r4"), | |
| 319 | + | ], | |
| 320 | + | ) | |
| 321 | + | .await; | |
| 322 | + | ||
| 323 | + | let page = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 3, None, None) | |
| 324 | + | .await | |
| 325 | + | .unwrap(); | |
| 326 | + | let rows: Vec<&str> = page.iter().map(|e| e.row_id.as_str()).collect(); | |
| 327 | + | assert_eq!( | |
| 328 | + | rows, | |
| 329 | + | ["r0", "r1", "r2"], | |
| 330 | + | "a limit takes the lowest seqs, not an arbitrary three: {rows:?}" | |
| 331 | + | ); | |
| 332 | + | ||
| 333 | + | // A limit larger than the log is not an error and does not pad. | |
| 334 | + | let over = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 500, None, None) | |
| 335 | + | .await | |
| 336 | + | .unwrap(); | |
| 337 | + | assert_eq!(over.len(), 5, "a generous limit returns all five: {over:?}"); | |
| 338 | + | ||
| 339 | + | // Pinned because it is a live hazard for a caller that computes a page | |
| 340 | + | // size: a zero limit is an empty page, indistinguishable from "drained", | |
| 341 | + | // so a client that ever asks for zero silently stops syncing. | |
| 342 | + | let none = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 0, None, None) | |
| 343 | + | .await | |
| 344 | + | .unwrap(); | |
| 345 | + | assert!( | |
| 346 | + | none.is_empty(), | |
| 347 | + | "a zero limit reads as a finished drain, got {none:?}" | |
| 348 | + | ); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[tokio::test] | |
| 352 | + | async fn the_unfiltered_pull_matches_the_filtered_pull_with_no_filters() { | |
| 353 | + | let db = TestDb::new().await; | |
| 354 | + | let user = seed_user(&db.pool, "sklogp_parity").await; | |
| 355 | + | let app = seed_app(&db.pool, user, "logparity").await; | |
| 356 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 357 | + | ||
| 358 | + | push( | |
| 359 | + | &db.pool, | |
| 360 | + | app, | |
| 361 | + | user, | |
| 362 | + | device, | |
| 363 | + | &[ | |
| 364 | + | change("tasks", "r0"), | |
| 365 | + | change("notes", "r1"), | |
| 366 | + | change("tasks", "r2"), | |
| 367 | + | change("tags", "r3"), | |
| 368 | + | ], | |
| 369 | + | ) | |
| 370 | + | .await; | |
| 371 | + | ||
| 372 | + | let legacy = synckit::pull_sync_changes(&db.pool, app, user, 0, 100) | |
| 373 | + | .await | |
| 374 | + | .unwrap(); | |
| 375 | + | let filtered = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) | |
| 376 | + | .await | |
| 377 | + | .unwrap(); | |
| 378 | + | ||
| 379 | + | let legacy_rows: Vec<&str> = legacy.iter().map(|e| e.row_id.as_str()).collect(); | |
| 380 | + | let filtered_rows: Vec<&str> = filtered.iter().map(|e| e.row_id.as_str()).collect(); | |
| 381 | + | assert_eq!( | |
| 382 | + | legacy_rows, filtered_rows, | |
| 383 | + | "passing no filters is documented as identical to the older pull: {legacy_rows:?} vs {filtered_rows:?}" | |
| 384 | + | ); | |
| 385 | + | assert_eq!( | |
| 386 | + | legacy.iter().map(|e| e.seq).collect::<Vec<_>>(), | |
| 387 | + | filtered.iter().map(|e| e.seq).collect::<Vec<_>>(), | |
| 388 | + | "and hands back the same cursors" | |
| 389 | + | ); | |
| 390 | + | assert_eq!(legacy_rows, ["r0", "r1", "r2", "r3"]); | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | #[tokio::test] | |
| 394 | + | async fn pull_order_is_arrival_order_and_not_the_client_clock() { | |
| 395 | + | let db = TestDb::new().await; | |
| 396 | + | let user = seed_user(&db.pool, "sklogp_order").await; | |
| 397 | + | let app = seed_app(&db.pool, user, "logorder").await; | |
| 398 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 399 | + | ||
| 400 | + | // Client timestamps descend as the entries arrive, so ordering by | |
| 401 | + | // client_timestamp would reverse this list. A device with a skewed clock | |
| 402 | + | // is the real case: its entries must still replay in the order the server | |
| 403 | + | // accepted them. | |
| 404 | + | push( | |
| 405 | + | &db.pool, | |
| 406 | + | app, | |
| 407 | + | user, | |
| 408 | + | device, | |
| 409 | + | &[ | |
| 410 | + | change_at("tasks", "arrived-first", 9), | |
| 411 | + | change_at("tasks", "arrived-second", 5), | |
| 412 | + | change_at("tasks", "arrived-third", 1), | |
| 413 | + | ], | |
| 414 | + | ) | |
| 415 | + | .await; | |
| 416 | + | ||
| 417 | + | let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) | |
| 418 | + | .await | |
| 419 | + | .unwrap(); | |
| 420 | + | let rows: Vec<&str> = entries.iter().map(|e| e.row_id.as_str()).collect(); | |
| 421 | + | assert_eq!( | |
| 422 | + | rows, | |
| 423 | + | ["arrived-first", "arrived-second", "arrived-third"], | |
| 424 | + | "seq order, not client_timestamp order: {rows:?}" | |
| 425 | + | ); | |
| 426 | + | assert!( | |
| 427 | + | entries.windows(2).all(|w| w[0].seq < w[1].seq), | |
| 428 | + | "and seq is strictly increasing: {:?}", | |
| 429 | + | entries.iter().map(|e| e.seq).collect::<Vec<_>>() | |
| 430 | + | ); | |
| 431 | + | } | |
| 432 | + | // ── log: filters ──────────────────────────────────────────────────────────── | |
| 433 | + | ||
| 434 | + | #[tokio::test] | |
| 435 | + | async fn the_table_filter_selects_the_named_tables_and_an_empty_list_selects_none() { | |
| 436 | + | let db = TestDb::new().await; | |
| 437 | + | let user = seed_user(&db.pool, "sklogp_tables").await; | |
| 438 | + | let app = seed_app(&db.pool, user, "logtables").await; | |
| 439 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 440 | + | ||
| 441 | + | push( | |
| 442 | + | &db.pool, | |
| 443 | + | app, | |
| 444 | + | user, | |
| 445 | + | device, | |
| 446 | + | &[ | |
| 447 | + | change("tasks", "t0"), | |
| 448 | + | change("notes", "n0"), | |
| 449 | + | change("tasks", "t1"), | |
| 450 | + | change("tags", "g0"), | |
| 451 | + | change("notes", "n1"), | |
| 452 | + | change("tasks", "t2"), | |
| 453 | + | ], | |
| 454 | + | ) | |
| 455 | + | .await; | |
| 456 | + | ||
| 457 | + | let tasks = vec!["tasks".to_string()]; | |
| 458 | + | let picked = synckit::pull_sync_changes_filtered( | |
| 459 | + | &db.pool, | |
| 460 | + | app, | |
| 461 | + | user, | |
| 462 | + | 0, | |
| 463 | + | 100, | |
| 464 | + | Some(tasks.as_slice()), | |
| 465 | + | None, | |
| 466 | + | ) | |
| 467 | + | .await | |
| 468 | + | .unwrap(); | |
| 469 | + | let rows: Vec<&str> = picked.iter().map(|e| e.row_id.as_str()).collect(); | |
| 470 | + | assert_eq!( | |
| 471 | + | rows, | |
| 472 | + | ["t0", "t1", "t2"], | |
| 473 | + | "one named table yields three of the six: {rows:?}" | |
| 474 | + | ); | |
| 475 | + | ||
| 476 | + | // Two names, so a filter that only ever honoured the first element would | |
| 477 | + | // return three here instead of four. | |
| 478 | + | let two = vec!["tasks".to_string(), "tags".to_string()]; | |
| 479 | + | let picked_two = synckit::pull_sync_changes_filtered( | |
| 480 | + | &db.pool, | |
| 481 | + | app, | |
| 482 | + | user, | |
| 483 | + | 0, | |
| 484 | + | 100, | |
| 485 | + | Some(two.as_slice()), | |
| 486 | + | None, | |
| 487 | + | ) | |
| 488 | + | .await | |
| 489 | + | .unwrap(); | |
| 490 | + | let rows_two: Vec<&str> = picked_two.iter().map(|e| e.row_id.as_str()).collect(); | |
| 491 | + | assert_eq!( | |
| 492 | + | rows_two, | |
| 493 | + | ["t0", "t1", "g0", "t2"], | |
| 494 | + | "every named table is honoured, in seq order: {rows_two:?}" | |
| 495 | + | ); | |
| 496 | + | ||
| 497 | + | // An empty list is not NULL, so it means "no table matches", not "no | |
| 498 | + | // filter". A caller that builds the list from a user's selection and lets | |
| 499 | + | // it come back empty gets nothing, not everything. | |
| 500 | + | let empty: Vec<String> = Vec::new(); |
Lines truncated
| @@ -1,0 +1,610 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::synckit::log`: the write side, personal | |
| 2 | + | //! and group. | |
| 3 | + | //! | |
| 4 | + | //! A push is what a client's local edit becomes, so a push that appends a | |
| 5 | + | //! second copy under redelivery, or reports a cursor the client then stores as | |
| 6 | + | //! its own, corrupts or rewinds a device with no error anywhere. Pinned here: a | |
| 7 | + | //! replayed batch appends nothing and reports that batch's own cursor rather | |
| 8 | + | //! than the log's current maximum, an empty push writes nothing, entries carry | |
| 9 | + | //! the key generation active at push time, a `None` payload lands as JSON null | |
| 10 | + | //! rather than SQL NULL so `operation` stays the only delete signal, and the | |
| 11 | + | //! group log's twin of each, including entries keeping the generation they were | |
| 12 | + | //! sealed under across a rotation. | |
| 13 | + | //! | |
| 14 | + | //! The read side of the same module is `db_synckit_log_layer`. | |
| 15 | + | //! `db_synckit_groups` pins group membership and the structural isolation of | |
| 16 | + | //! `sync_group_log` from personal pulls, and is not repeated. | |
| 17 | + | //! | |
| 18 | + | //! Delete this file and replay safety on the write path stops being asserted at | |
| 19 | + | //! this layer: a redelivered batch could append a second copy of every change, | |
| 20 | + | //! and a rotation could re-stamp entries that are no longer readable. | |
| 21 | + | ||
| 22 | + | use crate::harness::db::TestDb; | |
| 23 | + | use crate::harness::seed_user; | |
| 24 | + | ||
| 25 | + | use makenotwork::db::synckit; | |
| 26 | + | use makenotwork::db::{SyncAppId, SyncDeviceId, SyncGroupId, SyncOperation, UserId}; | |
| 27 | + | use uuid::Uuid; | |
| 28 | + | ||
| 29 | + | /// One change tuple in the shape `push_sync_changes` and `push_group_changes` | |
| 30 | + | /// both take. | |
| 31 | + | type Change = ( | |
| 32 | + | String, | |
| 33 | + | String, | |
| 34 | + | String, | |
| 35 | + | chrono::DateTime<chrono::Utc>, | |
| 36 | + | Option<serde_json::Value>, | |
| 37 | + | ); | |
| 38 | + | ||
| 39 | + | /// Seed a sync app owned by `user`. | |
| 40 | + | async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 41 | + | sqlx::query_scalar::<_, SyncAppId>( | |
| 42 | + | "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) | |
| 43 | + | VALUES ($1, $2, $3, $4) RETURNING id", | |
| 44 | + | ) | |
| 45 | + | .bind(user) | |
| 46 | + | .bind(name) | |
| 47 | + | .bind(format!("hash_{name}")) | |
| 48 | + | .bind(&name[..name.len().min(8)]) | |
| 49 | + | .fetch_one(pool) | |
| 50 | + | .await | |
| 51 | + | .expect("seed sync app") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Seed a device row for a user within an app. | |
| 55 | + | async fn seed_device( | |
| 56 | + | pool: &sqlx::PgPool, | |
| 57 | + | app: SyncAppId, | |
| 58 | + | user: UserId, | |
| 59 | + | name: &str, | |
| 60 | + | ) -> SyncDeviceId { | |
| 61 | + | sqlx::query_scalar::<_, SyncDeviceId>( | |
| 62 | + | "INSERT INTO sync_devices (app_id, user_id, device_name, platform) | |
| 63 | + | VALUES ($1, $2, $3, 'macos') RETURNING id", | |
| 64 | + | ) | |
| 65 | + | .bind(app) | |
| 66 | + | .bind(user) | |
| 67 | + | .bind(name) | |
| 68 | + | .fetch_one(pool) | |
| 69 | + | .await | |
| 70 | + | .expect("seed device") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// A fixed instant offset by whole hours. Fixed rather than `Utc::now()` so a | |
| 74 | + | /// `since` boundary can be asserted for equality without depending on the | |
| 75 | + | /// wall clock. | |
| 76 | + | fn ts(offset_hours: i64) -> chrono::DateTime<chrono::Utc> { | |
| 77 | + | "2024-03-01T12:00:00Z" | |
| 78 | + | .parse::<chrono::DateTime<chrono::Utc>>() | |
| 79 | + | .expect("fixed base timestamp parses") | |
| 80 | + | + chrono::Duration::hours(offset_hours) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// An INSERT change on `table` for `row`, client-stamped at `ts(offset_hours)`. | |
| 84 | + | fn change_at(table: &str, row: &str, offset_hours: i64) -> Change { | |
| 85 | + | ( | |
| 86 | + | table.to_string(), | |
| 87 | + | "INSERT".to_string(), | |
| 88 | + | row.to_string(), | |
| 89 | + | ts(offset_hours), | |
| 90 | + | Some(serde_json::json!({ "row": row })), | |
| 91 | + | ) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// An INSERT change on `table` for `row` at the base instant. | |
| 95 | + | fn change(table: &str, row: &str) -> Change { | |
| 96 | + | change_at(table, row, 0) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// Push `changes` as one fresh batch, returning the cursor the push reports. | |
| 100 | + | async fn push( | |
| 101 | + | pool: &sqlx::PgPool, | |
| 102 | + | app: SyncAppId, | |
| 103 | + | user: UserId, | |
| 104 | + | device: SyncDeviceId, | |
| 105 | + | changes: &[Change], | |
| 106 | + | ) -> i64 { | |
| 107 | + | synckit::push_sync_changes(pool, app, user, device, Uuid::new_v4(), changes) | |
| 108 | + | .await | |
| 109 | + | .expect("push sync changes") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// The group-log twin of [`drain`]. | |
| 113 | + | async fn drain_group( | |
| 114 | + | pool: &sqlx::PgPool, | |
| 115 | + | app: SyncAppId, | |
| 116 | + | group: SyncGroupId, | |
| 117 | + | limit: i64, | |
| 118 | + | tables: Option<&[String]>, | |
| 119 | + | since: Option<chrono::DateTime<chrono::Utc>>, | |
| 120 | + | ) -> Vec<String> { | |
| 121 | + | let mut seen: Vec<String> = Vec::new(); | |
| 122 | + | let mut cursor = 0i64; | |
| 123 | + | let mut pages = 0; | |
| 124 | + | loop { | |
| 125 | + | let page = | |
| 126 | + | synckit::pull_group_changes_filtered(pool, app, group, cursor, limit, tables, since) | |
| 127 | + | .await | |
| 128 | + | .expect("paginated group pull"); | |
| 129 | + | if page.is_empty() { | |
| 130 | + | break; | |
| 131 | + | } | |
| 132 | + | assert!( | |
| 133 | + | i64::try_from(page.len()).expect("page length fits i64") <= limit, | |
| 134 | + | "a group page must never exceed the limit it was asked for: got {} for limit {limit}", | |
| 135 | + | page.len() | |
| 136 | + | ); | |
| 137 | + | cursor = page.last().expect("non-empty page has a last entry").seq; | |
| 138 | + | seen.extend(page.into_iter().map(|e| e.row_id)); | |
| 139 | + | pages += 1; | |
| 140 | + | assert!( | |
| 141 | + | pages <= 64, | |
| 142 | + | "the group cursor did not terminate after 64 pages of limit {limit}; seen: {seen:?}" | |
| 143 | + | ); | |
| 144 | + | } | |
| 145 | + | seen | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | fn names(v: &[String]) -> Vec<&str> { | |
| 149 | + | v.iter().map(String::as_str).collect() | |
| 150 | + | } | |
| 151 | + | // ── log: push semantics ───────────────────────────────────────────────────── | |
| 152 | + | ||
| 153 | + | #[tokio::test] | |
| 154 | + | async fn a_replayed_batch_appends_nothing_and_returns_that_batch_own_cursor() { | |
| 155 | + | let db = TestDb::new().await; | |
| 156 | + | let user = seed_user(&db.pool, "sklogp_replay").await; | |
| 157 | + | let app = seed_app(&db.pool, user, "logreplay").await; | |
| 158 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 159 | + | ||
| 160 | + | let first_batch = Uuid::new_v4(); | |
| 161 | + | let first_changes = [change("tasks", "a0"), change("tasks", "a1")]; | |
| 162 | + | let first_cursor = | |
| 163 | + | synckit::push_sync_changes(&db.pool, app, user, device, first_batch, &first_changes) | |
| 164 | + | .await | |
| 165 | + | .unwrap(); | |
| 166 | + | ||
| 167 | + | // A later batch moves the log's maximum past the first batch, so the | |
| 168 | + | // replay below can tell "the cursor of the replayed batch" apart from | |
| 169 | + | // "whatever the highest seq happens to be now". | |
| 170 | + | let later_cursor = push( | |
| 171 | + | &db.pool, | |
| 172 | + | app, | |
| 173 | + | user, | |
| 174 | + | device, | |
| 175 | + | &[ | |
| 176 | + | change("tasks", "b0"), | |
| 177 | + | change("tasks", "b1"), | |
| 178 | + | change("tasks", "b2"), | |
| 179 | + | ], | |
| 180 | + | ) | |
| 181 | + | .await; | |
| 182 | + | assert!( | |
| 183 | + | later_cursor > first_cursor, | |
| 184 | + | "the second batch must sit above the first: {later_cursor} vs {first_cursor}" | |
| 185 | + | ); | |
| 186 | + | ||
| 187 | + | // The redelivery carries different payloads under the same batch id, the | |
| 188 | + | // way a client that retried after mutating its outbox would. Nothing from | |
| 189 | + | // it may land. | |
| 190 | + | let replay_changes = [change("tasks", "tampered0"), change("tasks", "tampered1")]; | |
| 191 | + | let replayed = | |
| 192 | + | synckit::push_sync_changes(&db.pool, app, user, device, first_batch, &replay_changes) | |
| 193 | + | .await | |
| 194 | + | .unwrap(); | |
| 195 | + | assert_eq!( | |
| 196 | + | replayed, first_cursor, | |
| 197 | + | "a replay reports the cursor the batch already had, not the log's maximum ({later_cursor})" | |
| 198 | + | ); | |
| 199 | + | ||
| 200 | + | let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) | |
| 201 | + | .await | |
| 202 | + | .unwrap(); | |
| 203 | + | let rows: Vec<&str> = entries.iter().map(|e| e.row_id.as_str()).collect(); | |
| 204 | + | assert_eq!( | |
| 205 | + | rows, | |
| 206 | + | ["a0", "a1", "b0", "b1", "b2"], | |
| 207 | + | "the redelivery is a no-op, not a second write: {rows:?}" | |
| 208 | + | ); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | #[tokio::test] | |
| 212 | + | async fn an_empty_push_writes_nothing_and_reports_zero_rather_than_the_current_cursor() { | |
| 213 | + | let db = TestDb::new().await; | |
| 214 | + | let user = seed_user(&db.pool, "sklogp_empty").await; | |
| 215 | + | let app = seed_app(&db.pool, user, "logempty").await; | |
| 216 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 217 | + | ||
| 218 | + | let real_cursor = push( | |
| 219 | + | &db.pool, | |
| 220 | + | app, | |
| 221 | + | user, | |
| 222 | + | device, | |
| 223 | + | &[ | |
| 224 | + | change("tasks", "r0"), | |
| 225 | + | change("tasks", "r1"), | |
| 226 | + | change("tasks", "r2"), | |
| 227 | + | change("tasks", "r3"), | |
| 228 | + | ], | |
| 229 | + | ) | |
| 230 | + | .await; | |
| 231 | + | assert!( | |
| 232 | + | real_cursor > 0, | |
| 233 | + | "four entries were pushed, so the cursor is above zero: {real_cursor}" | |
| 234 | + | ); | |
| 235 | + | ||
| 236 | + | let empty = synckit::push_sync_changes(&db.pool, app, user, device, Uuid::new_v4(), &[]) | |
| 237 | + | .await | |
| 238 | + | .unwrap(); | |
| 239 | + | // Pinned as the contract it has, not the one a caller might want: an empty | |
| 240 | + | // push answers 0, so a client that stores the reply as its cursor rewinds | |
| 241 | + | // to the start of the log and re-pulls everything. | |
| 242 | + | assert_eq!( | |
| 243 | + | empty, 0, | |
| 244 | + | "an empty push reports 0, not the log's current cursor ({real_cursor})" | |
| 245 | + | ); | |
| 246 | + | ||
| 247 | + | let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) | |
| 248 | + | .await | |
| 249 | + | .unwrap(); | |
| 250 | + | assert_eq!( | |
| 251 | + | entries.len(), | |
| 252 | + | 4, | |
| 253 | + | "and it wrote nothing: {:?}", | |
| 254 | + | entries | |
| 255 | + | .iter() | |
| 256 | + | .map(|e| e.row_id.as_str()) | |
| 257 | + | .collect::<Vec<_>>() | |
| 258 | + | ); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[tokio::test] | |
| 262 | + | async fn pushed_entries_are_stamped_with_the_key_id_active_at_push_time() { | |
| 263 | + | let db = TestDb::new().await; | |
| 264 | + | let user = seed_user(&db.pool, "sklogp_keyid").await; | |
| 265 | + | let app = seed_app(&db.pool, user, "logkeyid").await; | |
| 266 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 267 | + | ||
| 268 | + | // Before any key exists the stamp is NULL, which is what marks an entry as | |
| 269 | + | // predating encryption setup. | |
| 270 | + | push(&db.pool, app, user, device, &[change("tasks", "pre-key")]).await; | |
| 271 | + | ||
| 272 | + | synckit::upsert_sync_key(&db.pool, app, user, "env_v1", 0) | |
| 273 | + | .await | |
| 274 | + | .unwrap(); | |
| 275 | + | // key_id 4 rather than the default 1, so a stamp that hardcoded 1 or | |
| 276 | + | // reused key_version would disagree. | |
| 277 | + | sqlx::query("UPDATE sync_keys SET key_id = 4 WHERE app_id = $1 AND user_id = $2") | |
| 278 | + | .bind(app) | |
| 279 | + | .bind(user) | |
| 280 | + | .execute(&db.pool) | |
| 281 | + | .await | |
| 282 | + | .unwrap(); | |
| 283 | + | ||
| 284 | + | push(&db.pool, app, user, device, &[change("tasks", "post-key")]).await; | |
| 285 | + | ||
| 286 | + | let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) | |
| 287 | + | .await | |
| 288 | + | .unwrap(); | |
| 289 | + | assert_eq!(entries.len(), 2, "two entries were pushed: {entries:?}"); | |
| 290 | + | assert_eq!(entries[0].row_id, "pre-key"); | |
| 291 | + | assert_eq!( | |
| 292 | + | entries[0].key_id, None, | |
| 293 | + | "an entry pushed before any key exists carries no key id: {:?}", | |
| 294 | + | entries[0] | |
| 295 | + | ); | |
| 296 | + | assert_eq!(entries[1].row_id, "post-key"); | |
| 297 | + | assert_eq!( | |
| 298 | + | entries[1].key_id, | |
| 299 | + | Some(4), | |
| 300 | + | "and one pushed after carries the active key id: {:?}", | |
| 301 | + | entries[1] | |
| 302 | + | ); | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | #[tokio::test] | |
| 306 | + | async fn a_change_with_no_payload_is_stored_as_json_null_and_keeps_its_operation() { | |
| 307 | + | let db = TestDb::new().await; | |
| 308 | + | let user = seed_user(&db.pool, "sklogp_payload").await; | |
| 309 | + | let app = seed_app(&db.pool, user, "logpayload").await; | |
| 310 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 311 | + | ||
| 312 | + | let body = serde_json::json!({ "cipher": "abc", "n": 42 }); | |
| 313 | + | push( | |
| 314 | + | &db.pool, | |
| 315 | + | app, | |
| 316 | + | user, | |
| 317 | + | device, | |
| 318 | + | &[ | |
| 319 | + | ( | |
| 320 | + | "tasks".to_string(), | |
| 321 | + | "UPDATE".to_string(), | |
| 322 | + | "kept".to_string(), | |
| 323 | + | ts(0), | |
| 324 | + | Some(body.clone()), | |
| 325 | + | ), | |
| 326 | + | ( | |
| 327 | + | "tasks".to_string(), | |
| 328 | + | "DELETE".to_string(), | |
| 329 | + | "gone".to_string(), | |
| 330 | + | ts(1), | |
| 331 | + | None, | |
| 332 | + | ), | |
| 333 | + | ], | |
| 334 | + | ) | |
| 335 | + | .await; | |
| 336 | + | ||
| 337 | + | let entries = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) | |
| 338 | + | .await | |
| 339 | + | .unwrap(); | |
| 340 | + | assert_eq!(entries.len(), 2, "both changes landed: {entries:?}"); | |
| 341 | + | ||
| 342 | + | assert_eq!(entries[0].operation, SyncOperation::Update); | |
| 343 | + | assert_eq!( | |
| 344 | + | entries[0].data, | |
| 345 | + | Some(body), | |
| 346 | + | "an encrypted payload round-trips byte for byte: {:?}", | |
| 347 | + | entries[0].data | |
| 348 | + | ); | |
| 349 | + | assert_eq!(entries[0].client_timestamp, ts(0)); | |
| 350 | + | ||
| 351 | + | assert_eq!(entries[1].operation, SyncOperation::Delete); | |
| 352 | + | // The push converts a missing payload to JSON null, so this reads back as | |
| 353 | + | // Some(Null) and not None. A client that tests for a SQL NULL to detect a | |
| 354 | + | // delete would never fire; the operation column is the signal. | |
| 355 | + | assert_eq!( | |
| 356 | + | entries[1].data, | |
| 357 | + | Some(serde_json::Value::Null), | |
| 358 | + | "a None payload is stored as JSON null, not SQL NULL: {:?}", | |
| 359 | + | entries[1].data | |
| 360 | + | ); | |
| 361 | + | } | |
| 362 | + | // ── log: the group changelog ──────────────────────────────────────────────── | |
| 363 | + | ||
| 364 | + | #[tokio::test] | |
| 365 | + | async fn a_paginated_group_drain_yields_exactly_what_one_unpaginated_group_pull_yields() { | |
| 366 | + | let db = TestDb::new().await; | |
| 367 | + | let admin = seed_user(&db.pool, "skglogp_drain").await; | |
| 368 | + | let app = seed_app(&db.pool, admin, "glogdrain").await; | |
| 369 | + | let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk") | |
| 370 | + | .await | |
| 371 | + | .unwrap(); | |
| 372 | + | let device = seed_device(&db.pool, app, admin, "laptop").await; | |
| 373 | + | ||
| 374 | + | for chunk in [ | |
| 375 | + | vec![change("tasks", "g0"), change("tasks", "g1")], | |
| 376 | + | vec![ | |
| 377 | + | change("tasks", "g2"), | |
| 378 | + | change("tasks", "g3"), | |
| 379 | + | change("tasks", "g4"), | |
| 380 | + | ], | |
| 381 | + | ] { | |
| 382 | + | synckit::push_group_changes( | |
| 383 | + | &db.pool, | |
| 384 | + | app, | |
| 385 | + | group.id, | |
| 386 | + | admin, | |
| 387 | + | device, | |
| 388 | + | Uuid::new_v4(), | |
| 389 | + | &chunk, | |
| 390 | + | ) | |
| 391 | + | .await | |
| 392 | + | .unwrap(); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | let whole = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 1000, None, None) | |
| 396 | + | .await | |
| 397 | + | .unwrap(); | |
| 398 | + | let whole_rows: Vec<String> = whole.iter().map(|e| e.row_id.clone()).collect(); | |
| 399 | + | assert_eq!( | |
| 400 | + | names(&whole_rows), | |
| 401 | + | ["g0", "g1", "g2", "g3", "g4"], | |
| 402 | + | "the group reference pull: {whole_rows:?}" | |
| 403 | + | ); | |
| 404 | + | ||
| 405 | + | for limit in [2i64, 5] { | |
| 406 | + | let paged = drain_group(&db.pool, app, group.id, limit, None, None).await; | |
| 407 | + | assert_eq!( | |
| 408 | + | paged, whole_rows, | |
| 409 | + | "a group drain at {limit} per page must equal the whole pull: {paged:?}" | |
| 410 | + | ); | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | #[tokio::test] | |
| 415 | + | async fn a_replayed_group_batch_returns_that_batch_own_cursor_and_appends_nothing() { | |
| 416 | + | let db = TestDb::new().await; | |
| 417 | + | let admin = seed_user(&db.pool, "skglogp_replay").await; | |
| 418 | + | let app = seed_app(&db.pool, admin, "glogreplay").await; | |
| 419 | + | let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk") | |
| 420 | + | .await | |
| 421 | + | .unwrap(); | |
| 422 | + | let device = seed_device(&db.pool, app, admin, "laptop").await; | |
| 423 | + | ||
| 424 | + | let first_batch = Uuid::new_v4(); | |
| 425 | + | let first_cursor = synckit::push_group_changes( | |
| 426 | + | &db.pool, | |
| 427 | + | app, | |
| 428 | + | group.id, | |
| 429 | + | admin, | |
| 430 | + | device, | |
| 431 | + | first_batch, | |
| 432 | + | &[change("tasks", "a0"), change("tasks", "a1")], | |
| 433 | + | ) | |
| 434 | + | .await | |
| 435 | + | .unwrap(); | |
| 436 | + | ||
| 437 | + | let later_cursor = synckit::push_group_changes( | |
| 438 | + | &db.pool, | |
| 439 | + | app, | |
| 440 | + | group.id, | |
| 441 | + | admin, | |
| 442 | + | device, | |
| 443 | + | Uuid::new_v4(), | |
| 444 | + | &[change("tasks", "b0"), change("tasks", "b1")], | |
| 445 | + | ) | |
| 446 | + | .await | |
| 447 | + | .unwrap(); | |
| 448 | + | assert!( | |
| 449 | + | later_cursor > first_cursor, | |
| 450 | + | "the second group batch sits above the first: {later_cursor} vs {first_cursor}" | |
| 451 | + | ); | |
| 452 | + | ||
| 453 | + | let replayed = synckit::push_group_changes( | |
| 454 | + | &db.pool, | |
| 455 | + | app, | |
| 456 | + | group.id, | |
| 457 | + | admin, | |
| 458 | + | device, | |
| 459 | + | first_batch, | |
| 460 | + | &[change("tasks", "tampered")], | |
| 461 | + | ) | |
| 462 | + | .await | |
| 463 | + | .unwrap(); | |
| 464 | + | assert_eq!( | |
| 465 | + | replayed, first_cursor, | |
| 466 | + | "a group replay reports its own cursor, not the log maximum ({later_cursor})" | |
| 467 | + | ); | |
| 468 | + | ||
| 469 | + | let entries = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None) | |
| 470 | + | .await | |
| 471 | + | .unwrap(); | |
| 472 | + | let rows: Vec<&str> = entries.iter().map(|e| e.row_id.as_str()).collect(); | |
| 473 | + | assert_eq!( | |
| 474 | + | rows, | |
| 475 | + | ["a0", "a1", "b0", "b1"], | |
| 476 | + | "the redelivery wrote nothing: {rows:?}" | |
| 477 | + | ); | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | #[tokio::test] | |
| 481 | + | async fn group_entries_keep_the_generation_they_were_sealed_under_across_a_rotation() { | |
| 482 | + | let db = TestDb::new().await; | |
| 483 | + | let admin = seed_user(&db.pool, "skglogp_gen").await; | |
| 484 | + | let app = seed_app(&db.pool, admin, "gloggen").await; | |
| 485 | + | let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk") | |
| 486 | + | .await | |
| 487 | + | .unwrap(); | |
| 488 | + | let device = seed_device(&db.pool, app, admin, "laptop").await; | |
| 489 | + | assert_eq!(group.gck_version, 1, "a new group starts at generation 1"); | |
| 490 | + | ||
| 491 | + | synckit::push_group_changes( | |
| 492 | + | &db.pool, | |
| 493 | + | app, | |
| 494 | + | group.id, | |
| 495 | + | admin, | |
| 496 | + | device, | |
| 497 | + | Uuid::new_v4(), | |
| 498 | + | &[change("tasks", "under-1a"), change("tasks", "under-1b")], | |
| 499 | + | ) | |
| 500 | + | .await |
Lines truncated
| @@ -1,0 +1,245 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::synckit::security`, the audit trail and the | |
| 2 | + | //! sync-token revocation line. | |
| 3 | + | //! | |
| 4 | + | //! An audit log that dedupes reports a repeated attack as one attempt, and a | |
| 5 | + | //! revocation that misses is a stolen token that keeps working. Pinned here: | |
| 6 | + | //! the trail is append-only under an argument-for-argument repeat, it is scoped | |
| 7 | + | //! to one app, a row outlives the user it names (the subject is nulled rather | |
| 8 | + | //! than the row cascaded away with the account, which is what would erase the | |
| 9 | + | //! evidence), revoking sync tokens leaves the website session's own stamp | |
| 10 | + | //! alone, and a second revocation moves the line forward so a token minted | |
| 11 | + | //! between the two cannot survive it. | |
| 12 | + | //! | |
| 13 | + | //! `db_synckit_accounts_layer` pins the audit log's basic append and the | |
| 14 | + | //! cross-user token revocation, and is not repeated here. | |
| 15 | + | //! | |
| 16 | + | //! Delete this file and the trail could start deduping, cascade away with the | |
| 17 | + | //! account it describes, or stop advancing, none of it visible to a route | |
| 18 | + | //! test. | |
| 19 | + | ||
| 20 | + | use crate::harness::db::TestDb; | |
| 21 | + | use crate::harness::seed_user; | |
| 22 | + | ||
| 23 | + | use makenotwork::db::synckit; | |
| 24 | + | use makenotwork::db::{SyncAppId, UserId}; | |
| 25 | + | ||
| 26 | + | /// Seed a sync app owned by `user`. | |
| 27 | + | async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 28 | + | sqlx::query_scalar::<_, SyncAppId>( | |
| 29 | + | "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) | |
| 30 | + | VALUES ($1, $2, $3, $4) RETURNING id", | |
| 31 | + | ) | |
| 32 | + | .bind(user) | |
| 33 | + | .bind(name) | |
| 34 | + | .bind(format!("hash_{name}")) | |
| 35 | + | .bind(&name[..name.len().min(8)]) | |
| 36 | + | .fetch_one(pool) | |
| 37 | + | .await | |
| 38 | + | .expect("seed sync app") | |
| 39 | + | } | |
| 40 | + | // ── security ──────────────────────────────────────────────────────────────── | |
| 41 | + | ||
| 42 | + | #[tokio::test] | |
| 43 | + | async fn the_audit_log_appends_an_identical_event_twice_and_scopes_it_to_one_app() { | |
| 44 | + | let db = TestDb::new().await; | |
| 45 | + | let user = seed_user(&db.pool, "sksecp_append").await; | |
| 46 | + | let app = seed_app(&db.pool, user, "secappend").await; | |
| 47 | + | let other_app = seed_app(&db.pool, user, "secother").await; | |
| 48 | + | ||
| 49 | + | // The same event twice, argument for argument. An audit log that deduped | |
| 50 | + | // would hide a repeated attack as one attempt. | |
| 51 | + | for _ in 0..2 { | |
| 52 | + | synckit::record_security_event( | |
| 53 | + | &db.pool, | |
| 54 | + | app, | |
| 55 | + | Some(user), | |
| 56 | + | synckit::sync_security_event::AUTH_FAILURE, | |
| 57 | + | Some(serde_json::json!({ "attempt": "same" })), | |
| 58 | + | Some("198.51.100.7"), | |
| 59 | + | ) | |
| 60 | + | .await | |
| 61 | + | .unwrap(); | |
| 62 | + | } | |
| 63 | + | synckit::record_security_event( | |
| 64 | + | &db.pool, | |
| 65 | + | other_app, | |
| 66 | + | Some(user), | |
| 67 | + | synckit::sync_security_event::KEY_ROTATION_COMPLETED, | |
| 68 | + | None, | |
| 69 | + | None, | |
| 70 | + | ) | |
| 71 | + | .await | |
| 72 | + | .unwrap(); | |
| 73 | + | ||
| 74 | + | let here: Vec<(String, Option<String>)> = sqlx::query_as( | |
| 75 | + | "SELECT event_type, ip FROM sync_security_events WHERE app_id = $1 ORDER BY id", | |
| 76 | + | ) | |
| 77 | + | .bind(app) | |
| 78 | + | .fetch_all(&db.pool) | |
| 79 | + | .await | |
| 80 | + | .unwrap(); | |
| 81 | + | assert_eq!( | |
| 82 | + | here.len(), | |
| 83 | + | 2, | |
| 84 | + | "append-only: two identical events are two rows: {here:?}" | |
| 85 | + | ); | |
| 86 | + | assert!( | |
| 87 | + | here.iter() | |
| 88 | + | .all(|(e, ip)| e == "auth_failure" && ip.as_deref() == Some("198.51.100.7")), | |
| 89 | + | "both rows keep what was recorded: {here:?}" | |
| 90 | + | ); | |
| 91 | + | ||
| 92 | + | let there: Vec<(String, Option<String>)> = sqlx::query_as( | |
| 93 | + | "SELECT event_type, ip FROM sync_security_events WHERE app_id = $1 ORDER BY id", | |
| 94 | + | ) | |
| 95 | + | .bind(other_app) | |
| 96 | + | .fetch_all(&db.pool) | |
| 97 | + | .await | |
| 98 | + | .unwrap(); | |
| 99 | + | assert_eq!( | |
| 100 | + | there.len(), | |
| 101 | + | 1, | |
| 102 | + | "another app's audit trail is its own: {there:?}" | |
| 103 | + | ); | |
| 104 | + | // The literal strings are the contract with whoever queries this table by | |
| 105 | + | // hand, so they are asserted as literals rather than against the constant. | |
| 106 | + | assert_eq!(there[0].0, "key_rotation_completed", "{there:?}"); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | #[tokio::test] | |
| 110 | + | async fn an_audit_row_outlives_the_user_it_names() { | |
| 111 | + | let db = TestDb::new().await; | |
| 112 | + | let owner = seed_user(&db.pool, "sksecp_owner").await; | |
| 113 | + | let subject = seed_user(&db.pool, "sksecp_subject").await; | |
| 114 | + | // The app belongs to someone else, so removing the subject cannot take the | |
| 115 | + | // app (and its events) down by cascade. | |
| 116 | + | let app = seed_app(&db.pool, owner, "secoutlive").await; | |
| 117 | + | ||
| 118 | + | synckit::record_security_event( | |
| 119 | + | &db.pool, | |
| 120 | + | app, | |
| 121 | + | Some(subject), | |
| 122 | + | synckit::sync_security_event::DEVICE_REMOVED, | |
| 123 | + | Some(serde_json::json!({ "device": "old-laptop" })), | |
| 124 | + | Some("203.0.113.4"), | |
| 125 | + | ) | |
| 126 | + | .await | |
| 127 | + | .unwrap(); | |
| 128 | + | ||
| 129 | + | sqlx::query("DELETE FROM users WHERE id = $1") | |
| 130 | + | .bind(subject) | |
| 131 | + | .execute(&db.pool) | |
| 132 | + | .await | |
| 133 | + | .expect("remove the audited user"); | |
| 134 | + | ||
| 135 | + | let rows: Vec<(String, Option<UserId>, Option<serde_json::Value>)> = sqlx::query_as( | |
| 136 | + | "SELECT event_type, user_id, detail FROM sync_security_events WHERE app_id = $1", | |
| 137 | + | ) | |
| 138 | + | .bind(app) | |
| 139 | + | .fetch_all(&db.pool) | |
| 140 | + | .await | |
| 141 | + | .unwrap(); | |
| 142 | + | assert_eq!( | |
| 143 | + | rows.len(), | |
| 144 | + | 1, | |
| 145 | + | "the trail survives the account it describes: {rows:?}" | |
| 146 | + | ); | |
| 147 | + | assert_eq!(rows[0].0, "device_removed"); | |
| 148 | + | assert_eq!( | |
| 149 | + | rows[0].1, None, | |
| 150 | + | "the subject is nulled rather than the row deleted: {rows:?}" | |
| 151 | + | ); | |
| 152 | + | assert_eq!( | |
| 153 | + | rows[0].2, | |
| 154 | + | Some(serde_json::json!({ "device": "old-laptop" })), | |
| 155 | + | "and the detail an operator would investigate is still there: {rows:?}" | |
| 156 | + | ); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[tokio::test] | |
| 160 | + | async fn revoking_sync_tokens_leaves_the_website_session_alone() { | |
| 161 | + | let db = TestDb::new().await; | |
| 162 | + | let user = seed_user(&db.pool, "sksecp_split").await; | |
| 163 | + | ||
| 164 | + | let before: ( | |
| 165 | + | Option<chrono::DateTime<chrono::Utc>>, | |
| 166 | + | Option<chrono::DateTime<chrono::Utc>>, | |
| 167 | + | ) = sqlx::query_as( | |
| 168 | + | "SELECT sync_jwt_invalidated_at, jwt_invalidated_at FROM users WHERE id = $1", | |
| 169 | + | ) | |
| 170 | + | .bind(user) | |
| 171 | + | .fetch_one(&db.pool) | |
| 172 | + | .await | |
| 173 | + | .unwrap(); | |
| 174 | + | assert_eq!( | |
| 175 | + | (before.0, before.1), | |
| 176 | + | (None, None), | |
| 177 | + | "a fresh user has neither stamp set" | |
| 178 | + | ); | |
| 179 | + | ||
| 180 | + | synckit::invalidate_user_sync_tokens(&db.pool, user) | |
| 181 | + | .await | |
| 182 | + | .unwrap(); | |
| 183 | + | ||
| 184 | + | let after: ( | |
| 185 | + | Option<chrono::DateTime<chrono::Utc>>, | |
| 186 | + | Option<chrono::DateTime<chrono::Utc>>, | |
| 187 | + | ) = sqlx::query_as( | |
| 188 | + | "SELECT sync_jwt_invalidated_at, jwt_invalidated_at FROM users WHERE id = $1", | |
| 189 | + | ) | |
| 190 | + | .bind(user) | |
| 191 | + | .fetch_one(&db.pool) | |
| 192 | + | .await | |
| 193 | + | .unwrap(); | |
| 194 | + | assert!( | |
| 195 | + | after.0.is_some(), | |
| 196 | + | "the sync sessions must be forced to re-authenticate: {after:?}" | |
| 197 | + | ); | |
| 198 | + | // The two stamps are deliberately separate: removing a sync device must | |
| 199 | + | // not log the creator out of the website they are working in. | |
| 200 | + | assert_eq!( | |
| 201 | + | after.1, None, | |
| 202 | + | "and the website session stamp must not be touched: {after:?}" | |
| 203 | + | ); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[tokio::test] | |
| 207 | + | async fn revoking_sync_tokens_again_moves_the_stamp_forward() { | |
| 208 | + | let db = TestDb::new().await; | |
| 209 | + | let user = seed_user(&db.pool, "sksecp_forward").await; | |
| 210 | + | ||
| 211 | + | // Backdated by a day so the comparison below cannot turn on clock | |
| 212 | + | // resolution: the second stamp has to be later by roughly that day. | |
| 213 | + | sqlx::query( | |
| 214 | + | "UPDATE users SET sync_jwt_invalidated_at = NOW() - INTERVAL '1 day' WHERE id = $1", | |
| 215 | + | ) | |
| 216 | + | .bind(user) | |
| 217 | + | .execute(&db.pool) | |
| 218 | + | .await | |
| 219 | + | .unwrap(); | |
| 220 | + | let old: Option<chrono::DateTime<chrono::Utc>> = | |
| 221 | + | sqlx::query_scalar("SELECT sync_jwt_invalidated_at FROM users WHERE id = $1") | |
| 222 | + | .bind(user) | |
| 223 | + | .fetch_one(&db.pool) | |
| 224 | + | .await | |
| 225 | + | .unwrap(); | |
| 226 | + | let old = old.expect("the backdated stamp is set"); | |
| 227 | + | ||
| 228 | + | synckit::invalidate_user_sync_tokens(&db.pool, user) | |
| 229 | + | .await | |
| 230 | + | .unwrap(); | |
| 231 | + | ||
| 232 | + | let new: Option<chrono::DateTime<chrono::Utc>> = | |
| 233 | + | sqlx::query_scalar("SELECT sync_jwt_invalidated_at FROM users WHERE id = $1") | |
| 234 | + | .bind(user) | |
| 235 | + | .fetch_one(&db.pool) | |
| 236 | + | .await | |
| 237 | + | .unwrap(); | |
| 238 | + | let new = new.expect("the stamp is still set"); | |
| 239 | + | // A second revocation has to move the line forward, or a token issued | |
| 240 | + | // between the two revocations would survive the second one. | |
| 241 | + | assert!( | |
| 242 | + | new > old, | |
| 243 | + | "the revocation line must advance: {new} is not after {old}" | |
| 244 | + | ); | |
| 245 | + | } |
| @@ -1,0 +1,695 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::synckit::subscriptions`, the end-user | |
| 2 | + | //! entitlement for first-party sync. | |
| 3 | + | //! | |
| 4 | + | //! This module decides who is allowed to sync and what storage they hold, and | |
| 5 | + | //! its writes all arrive from Stripe webhooks, which redeliver. The pieces | |
| 6 | + | //! pinned here are the ones a wrong answer costs money or data on: | |
| 7 | + | //! | |
| 8 | + | //! - `internal_write_allowed` is a status-only gate: exactly `active` opens | |
| 9 | + | //! writes, every other lifecycle status (`trialing`, `past_due`, `unpaid`, | |
| 10 | + | //! `incomplete`, `canceled`) denies, and the boundary is asserted on both | |
| 11 | + | //! sides. A lapsed `current_period_end` is deliberately NOT consulted, so | |
| 12 | + | //! that is pinned too rather than assumed. | |
| 13 | + | //! - the gate is scoped to the exact (app, user) pair: another user's | |
| 14 | + | //! subscription, or the same user's subscription on a different first-party | |
| 15 | + | //! app, opens nothing. | |
| 16 | + | //! - `create_app_sync_subscription` is the checkout webhook's write, so it is | |
| 17 | + | //! exercised under replay: an identical redelivery is a no-op that returns | |
| 18 | + | //! `false` and adds no second row, while each arm of the guard (a new Stripe | |
| 19 | + | //! subscription id, or a non-active row) does reactivate. | |
| 20 | + | //! - `update_app_sync_subscription_status`, `set_pending_storage_cap`, | |
| 21 | + | //! `set_storage_cap_now` and `apply_pending_storage_cap` are asserted to hit | |
| 22 | + | //! one row and leave every sibling row alone, which a missing key predicate | |
| 23 | + | //! would break silently. | |
| 24 | + | //! | |
| 25 | + | //! Deleting this file would leave the paid-sync gate and the cap-change ladder | |
| 26 | + | //! asserted only through HTTP and webhook flows, where a wrong row or a second | |
| 27 | + | //! credit is invisible. | |
| 28 | + | //! | |
| 29 | + | //! Not covered here: `get_user_app_subscription`'s per-user/per-app scoping and | |
| 30 | + | //! the terminal-`canceled` and epoch-period guards on the status setter, which | |
| 31 | + | //! `db_synckit_accounts_layer` already asserts. | |
| 32 | + | ||
| 33 | + | use crate::harness::db::TestDb; | |
| 34 | + | use crate::harness::seed_user; | |
| 35 | + | ||
| 36 | + | use makenotwork::db::synckit; | |
| 37 | + | use makenotwork::db::synckit::NewAppSyncSubscription; | |
| 38 | + | use makenotwork::db::{SyncAppId, UserId}; | |
| 39 | + | ||
| 40 | + | /// Seed a first-party (internal) sync app owned by `user`. Only internal apps | |
| 41 | + | /// use the end-user subscription model, so every gate test needs one. | |
| 42 | + | async fn seed_internal_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 43 | + | let app = | |
| 44 | + | synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None) | |
| 45 | + | .await | |
| 46 | + | .expect("seed sync app") | |
| 47 | + | .id; | |
| 48 | + | sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1") | |
| 49 | + | .bind(app) | |
| 50 | + | .execute(pool) | |
| 51 | + | .await | |
| 52 | + | .expect("mark app first-party"); | |
| 53 | + | app | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Insert a subscription through the production checkout write, asserting the | |
| 57 | + | /// first delivery is a real insert. | |
| 58 | + | async fn subscribe( | |
| 59 | + | pool: &sqlx::PgPool, | |
| 60 | + | user: UserId, | |
| 61 | + | app: SyncAppId, | |
| 62 | + | sub_id: &str, | |
| 63 | + | interval: &str, | |
| 64 | + | limit_bytes: i64, | |
| 65 | + | ) { | |
| 66 | + | let created = synckit::create_app_sync_subscription( | |
| 67 | + | pool, | |
| 68 | + | &NewAppSyncSubscription { | |
| 69 | + | user_id: user, | |
| 70 | + | app_id: app, | |
| 71 | + | stripe_subscription_id: sub_id, | |
| 72 | + | stripe_customer_id: "cus_seed", | |
| 73 | + | interval, | |
| 74 | + | storage_limit_bytes: limit_bytes, | |
| 75 | + | }, | |
| 76 | + | ) | |
| 77 | + | .await | |
| 78 | + | .expect("seed subscription"); | |
| 79 | + | assert!(created, "the first checkout delivery inserts a row"); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// How many subscription rows exist for the (user, app) pair. Used to prove a | |
| 83 | + | /// replay adds nothing rather than merely returning `false`. | |
| 84 | + | async fn row_count(pool: &sqlx::PgPool, user: UserId, app: SyncAppId) -> i64 { | |
| 85 | + | sqlx::query_scalar::<_, i64>( | |
| 86 | + | "SELECT COUNT(*) FROM app_sync_subscriptions WHERE user_id = $1 AND app_id = $2", | |
| 87 | + | ) | |
| 88 | + | .bind(user) | |
| 89 | + | .bind(app) | |
| 90 | + | .fetch_one(pool) | |
| 91 | + | .await | |
| 92 | + | .expect("count subscription rows") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Read `canceled_at`, which the struct returned by `get_user_app_subscription` | |
| 96 | + | /// does not carry but reactivation is contractually required to clear. | |
| 97 | + | async fn canceled_at_is_set(pool: &sqlx::PgPool, sub_id: &str) -> bool { | |
| 98 | + | sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>( | |
| 99 | + | "SELECT canceled_at FROM app_sync_subscriptions WHERE stripe_subscription_id = $1", | |
| 100 | + | ) | |
| 101 | + | .bind(sub_id) | |
| 102 | + | .fetch_one(pool) | |
| 103 | + | .await | |
| 104 | + | .expect("read canceled_at") | |
| 105 | + | .is_some() | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // ── internal_write_allowed: the entitlement boundary, both sides ───────────── | |
| 109 | + | ||
| 110 | + | #[tokio::test] | |
| 111 | + | async fn only_the_active_status_opens_writes() { | |
| 112 | + | let db = TestDb::new().await; | |
| 113 | + | let user = seed_user(&db.pool, "sks_status").await; | |
| 114 | + | let app = seed_internal_app(&db.pool, user, "sksstatus").await; | |
| 115 | + | subscribe(&db.pool, user, app, "sub_status", "monthly", 25_000_000_000).await; | |
| 116 | + | ||
| 117 | + | assert!( | |
| 118 | + | synckit::internal_write_allowed(&db.pool, app, user) | |
| 119 | + | .await | |
| 120 | + | .expect("gate query ok"), | |
| 121 | + | "an active subscription is the one status that syncs" | |
| 122 | + | ); | |
| 123 | + | ||
| 124 | + | // Every other Stripe lifecycle status sits on the closed side of the gate. | |
| 125 | + | // `trialing` and `past_due` are the ones a plausible "not canceled" reading | |
| 126 | + | // of the rule would let through, so they are asserted individually rather | |
| 127 | + | // than as a family. | |
| 128 | + | for lapsed in ["trialing", "past_due", "unpaid", "incomplete", "canceled"] { | |
| 129 | + | synckit::update_app_sync_subscription_status(&db.pool, "sub_status", lapsed, None) | |
| 130 | + | .await | |
| 131 | + | .expect("status write ok"); | |
| 132 | + | assert!( | |
| 133 | + | !synckit::internal_write_allowed(&db.pool, app, user) | |
| 134 | + | .await | |
| 135 | + | .expect("gate query ok"), | |
| 136 | + | "status {lapsed} must not open first-party sync" | |
| 137 | + | ); | |
| 138 | + | // `canceled` is terminal, so it has to be the last status tried: nothing | |
| 139 | + | // after it could move the row back. | |
| 140 | + | if lapsed == "canceled" { | |
| 141 | + | break; | |
| 142 | + | } | |
| 143 | + | synckit::update_app_sync_subscription_status(&db.pool, "sub_status", "active", None) | |
| 144 | + | .await | |
| 145 | + | .expect("restore active ok"); | |
| 146 | + | assert!( | |
| 147 | + | synckit::internal_write_allowed(&db.pool, app, user) | |
| 148 | + | .await | |
| 149 | + | .expect("gate query ok"), | |
| 150 | + | "returning to active reopens writes after {lapsed}" | |
| 151 | + | ); | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[tokio::test] | |
| 156 | + | async fn a_lapsed_billing_period_does_not_close_the_gate_on_its_own() { | |
| 157 | + | let db = TestDb::new().await; | |
| 158 | + | let user = seed_user(&db.pool, "sks_period").await; | |
| 159 | + | let app = seed_internal_app(&db.pool, user, "sksperiod").await; | |
| 160 | + | subscribe(&db.pool, user, app, "sub_period", "annual", 7_500_000_000).await; | |
| 161 | + | ||
| 162 | + | // 2020-09-13, long past. The gate reads `status` alone, so entitlement | |
| 163 | + | // survives a stale period and it is Stripe flipping the status (past_due, | |
| 164 | + | // then canceled) that ends sync. Pinned because the alternative reading, | |
| 165 | + | // "deny once the period end is behind us", would lock out every user during | |
| 166 | + | // the ordinary gap between a renewal and its webhook. | |
| 167 | + | synckit::update_app_sync_subscription_status( | |
| 168 | + | &db.pool, | |
| 169 | + | "sub_period", | |
| 170 | + | "active", | |
| 171 | + | Some(1_600_000_000), | |
| 172 | + | ) | |
| 173 | + | .await | |
| 174 | + | .expect("stamp a past period"); | |
| 175 | + | ||
| 176 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 177 | + | .await | |
| 178 | + | .expect("read subscription") | |
| 179 | + | .expect("row exists"); | |
| 180 | + | assert_eq!( | |
| 181 | + | sub.current_period_end, | |
| 182 | + | chrono::DateTime::from_timestamp(1_600_000_000, 0), | |
| 183 | + | "the raw Stripe seconds round-trip exactly" | |
| 184 | + | ); | |
| 185 | + | assert!( | |
| 186 | + | synckit::internal_write_allowed(&db.pool, app, user) | |
| 187 | + | .await | |
| 188 | + | .expect("gate query ok"), | |
| 189 | + | "an active row with a lapsed period still syncs: status is the gate" | |
| 190 | + | ); | |
| 191 | + | ||
| 192 | + | // And the far side of the boundary: the same row, one status change later. | |
| 193 | + | synckit::update_app_sync_subscription_status(&db.pool, "sub_period", "past_due", None) | |
| 194 | + | .await | |
| 195 | + | .expect("status write ok"); | |
| 196 | + | assert!( | |
| 197 | + | !synckit::internal_write_allowed(&db.pool, app, user) | |
| 198 | + | .await | |
| 199 | + | .expect("gate query ok"), | |
| 200 | + | "the status is what closes it" | |
| 201 | + | ); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[tokio::test] | |
| 205 | + | async fn one_subscription_entitles_exactly_one_user_on_exactly_one_app() { | |
| 206 | + | let db = TestDb::new().await; | |
| 207 | + | let payer = seed_user(&db.pool, "sks_payer").await; | |
| 208 | + | let freeloader = seed_user(&db.pool, "sks_freeloader").await; | |
| 209 | + | let paid_app = seed_internal_app(&db.pool, payer, "skspaid").await; | |
| 210 | + | let other_app = seed_internal_app(&db.pool, payer, "sksother").await; | |
| 211 | + | ||
| 212 | + | subscribe( | |
| 213 | + | &db.pool, | |
| 214 | + | payer, | |
| 215 | + | paid_app, | |
| 216 | + | "sub_scoped", | |
| 217 | + | "monthly", | |
| 218 | + | 25_000_000_000, | |
| 219 | + | ) | |
| 220 | + | .await; | |
| 221 | + | ||
| 222 | + | assert!( | |
| 223 | + | synckit::internal_write_allowed(&db.pool, paid_app, payer) | |
| 224 | + | .await | |
| 225 | + | .expect("gate query ok"), | |
| 226 | + | "the payer syncs the app they paid for" | |
| 227 | + | ); | |
| 228 | + | // A gate that dropped the user predicate (EXISTS any active sub on the app) | |
| 229 | + | // would let this through, and a gate that dropped the app predicate would | |
| 230 | + | // let the next one through. Both are one deleted line away. | |
| 231 | + | assert!( | |
| 232 | + | !synckit::internal_write_allowed(&db.pool, paid_app, freeloader) | |
| 233 | + | .await | |
| 234 | + | .expect("gate query ok"), | |
| 235 | + | "another user's subscription entitles nobody else" | |
| 236 | + | ); | |
| 237 | + | assert!( | |
| 238 | + | !synckit::internal_write_allowed(&db.pool, other_app, payer) | |
| 239 | + | .await | |
| 240 | + | .expect("gate query ok"), | |
| 241 | + | "paying for one first-party app entitles nothing on another" | |
| 242 | + | ); | |
| 243 | + | ||
| 244 | + | // The freeloader subscribing opens only their own pair, and leaves the | |
| 245 | + | // payer's row untouched. | |
| 246 | + | subscribe( | |
| 247 | + | &db.pool, | |
| 248 | + | freeloader, | |
| 249 | + | paid_app, | |
| 250 | + | "sub_scoped_two", | |
| 251 | + | "monthly", | |
| 252 | + | 7_500_000_000, | |
| 253 | + | ) | |
| 254 | + | .await; | |
| 255 | + | assert!( | |
| 256 | + | synckit::internal_write_allowed(&db.pool, paid_app, freeloader) | |
| 257 | + | .await | |
| 258 | + | .expect("gate query ok") | |
| 259 | + | ); | |
| 260 | + | assert!( | |
| 261 | + | !synckit::internal_write_allowed(&db.pool, other_app, freeloader) | |
| 262 | + | .await | |
| 263 | + | .expect("gate query ok"), | |
| 264 | + | "the second subscription is scoped the same way as the first" | |
| 265 | + | ); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // ── create_app_sync_subscription: the checkout webhook, under replay ───────── | |
| 269 | + | ||
| 270 | + | #[tokio::test] | |
| 271 | + | async fn a_redelivered_checkout_webhook_writes_nothing_a_second_time() { | |
| 272 | + | let db = TestDb::new().await; | |
| 273 | + | let user = seed_user(&db.pool, "sks_replay").await; | |
| 274 | + | let app = seed_internal_app(&db.pool, user, "sksreplay").await; | |
| 275 | + | let params = NewAppSyncSubscription { | |
| 276 | + | user_id: user, | |
| 277 | + | app_id: app, | |
| 278 | + | stripe_subscription_id: "sub_replay", | |
| 279 | + | stripe_customer_id: "cus_replay", | |
| 280 | + | interval: "annual", | |
| 281 | + | storage_limit_bytes: 25_000_000_000, | |
| 282 | + | }; | |
| 283 | + | ||
| 284 | + | assert!( | |
| 285 | + | synckit::create_app_sync_subscription(&db.pool, ¶ms) | |
| 286 | + | .await | |
| 287 | + | .expect("first checkout ok"), | |
| 288 | + | "the first delivery inserts" | |
| 289 | + | ); | |
| 290 | + | // Stripe redelivers; the guard WHERE makes an unchanged active row a no-op. | |
| 291 | + | assert!( | |
| 292 | + | !synckit::create_app_sync_subscription(&db.pool, ¶ms) | |
| 293 | + | .await | |
| 294 | + | .expect("replay ok"), | |
| 295 | + | "a redelivered checkout for an unchanged active row writes nothing" | |
| 296 | + | ); | |
| 297 | + | assert!( | |
| 298 | + | !synckit::create_app_sync_subscription(&db.pool, ¶ms) | |
| 299 | + | .await | |
| 300 | + | .expect("second replay ok"), | |
| 301 | + | "and it stays a no-op however many times Stripe retries" | |
| 302 | + | ); | |
| 303 | + | ||
| 304 | + | assert_eq!( | |
| 305 | + | row_count(&db.pool, user, app).await, | |
| 306 | + | 1, | |
| 307 | + | "the replays added no second subscription row" | |
| 308 | + | ); | |
| 309 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 310 | + | .await | |
| 311 | + | .expect("read subscription") | |
| 312 | + | .expect("row exists"); | |
| 313 | + | assert_eq!(sub.stripe_subscription_id, "sub_replay"); | |
| 314 | + | assert_eq!(sub.interval, "annual"); | |
| 315 | + | assert_eq!(sub.status, "active"); | |
| 316 | + | assert_eq!( | |
| 317 | + | sub.storage_limit_bytes, | |
| 318 | + | Some(25_000_000_000), | |
| 319 | + | "the cap the user paid for is unchanged by the replays" | |
| 320 | + | ); | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | #[tokio::test] | |
| 324 | + | async fn a_cap_change_delivered_as_a_checkout_replay_is_refused() { | |
| 325 | + | let db = TestDb::new().await; | |
| 326 | + | let user = seed_user(&db.pool, "sks_capreplay").await; | |
| 327 | + | let app = seed_internal_app(&db.pool, user, "skscapreplay").await; | |
| 328 | + | subscribe( | |
| 329 | + | &db.pool, | |
| 330 | + | user, | |
| 331 | + | app, | |
| 332 | + | "sub_capreplay", | |
| 333 | + | "monthly", | |
| 334 | + | 25_000_000_000, | |
| 335 | + | ) | |
| 336 | + | .await; | |
| 337 | + | ||
| 338 | + | // Same active row, same Stripe subscription, different cap and interval. | |
| 339 | + | // Neither arm of the guard fires, so the checkout path declines to move the | |
| 340 | + | // cap: cap changes are the `set_storage_cap_now` / `set_pending_storage_cap` | |
| 341 | + | // ladder's job, and letting a stale redelivery rewrite a cap would undo a | |
| 342 | + | // change the user already paid for. | |
| 343 | + | let changed = synckit::create_app_sync_subscription( | |
| 344 | + | &db.pool, | |
| 345 | + | &NewAppSyncSubscription { | |
| 346 | + | user_id: user, | |
| 347 | + | app_id: app, | |
| 348 | + | stripe_subscription_id: "sub_capreplay", | |
| 349 | + | stripe_customer_id: "cus_seed", | |
| 350 | + | interval: "annual", | |
| 351 | + | storage_limit_bytes: 7_500_000_000, | |
| 352 | + | }, | |
| 353 | + | ) | |
| 354 | + | .await | |
| 355 | + | .expect("checkout write ok"); | |
| 356 | + | assert!(!changed, "an unchanged-subscription checkout is a no-op"); | |
| 357 | + | ||
| 358 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 359 | + | .await | |
| 360 | + | .expect("read subscription") | |
| 361 | + | .expect("row exists"); | |
| 362 | + | assert_eq!( | |
| 363 | + | sub.storage_limit_bytes, | |
| 364 | + | Some(25_000_000_000), | |
| 365 | + | "the cap on record is the one the ladder set, not the replayed one" | |
| 366 | + | ); | |
| 367 | + | assert_eq!(sub.interval, "monthly", "and the interval is untouched too"); | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | #[tokio::test] | |
| 371 | + | async fn a_new_stripe_subscription_replaces_the_row_it_conflicts_with() { | |
| 372 | + | let db = TestDb::new().await; | |
| 373 | + | let user = seed_user(&db.pool, "sks_swap").await; | |
| 374 | + | let app = seed_internal_app(&db.pool, user, "sksswap").await; | |
| 375 | + | subscribe( | |
| 376 | + | &db.pool, | |
| 377 | + | user, | |
| 378 | + | app, | |
| 379 | + | "sub_swap_old", | |
| 380 | + | "monthly", | |
| 381 | + | 7_500_000_000, | |
| 382 | + | ) | |
| 383 | + | .await; | |
| 384 | + | ||
| 385 | + | // The user re-checks-out on a different Stripe subscription (the old one | |
| 386 | + | // ended at Stripe's side). The id differs, so the first arm of the guard | |
| 387 | + | // fires and every billing field moves together. | |
| 388 | + | let replaced = synckit::create_app_sync_subscription( | |
| 389 | + | &db.pool, | |
| 390 | + | &NewAppSyncSubscription { | |
| 391 | + | user_id: user, | |
| 392 | + | app_id: app, | |
| 393 | + | stripe_subscription_id: "sub_swap_new", | |
| 394 | + | stripe_customer_id: "cus_swap_new", | |
| 395 | + | interval: "annual", | |
| 396 | + | storage_limit_bytes: 25_000_000_000, | |
| 397 | + | }, | |
| 398 | + | ) | |
| 399 | + | .await | |
| 400 | + | .expect("re-checkout ok"); | |
| 401 | + | assert!( | |
| 402 | + | replaced, | |
| 403 | + | "a different Stripe subscription id is a real write" | |
| 404 | + | ); | |
| 405 | + | ||
| 406 | + | assert_eq!( | |
| 407 | + | row_count(&db.pool, user, app).await, | |
| 408 | + | 1, | |
| 409 | + | "the (user, app) pair still holds exactly one subscription" | |
| 410 | + | ); | |
| 411 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 412 | + | .await | |
| 413 | + | .expect("read subscription") | |
| 414 | + | .expect("row exists"); | |
| 415 | + | assert_eq!(sub.stripe_subscription_id, "sub_swap_new"); | |
| 416 | + | assert_eq!(sub.interval, "annual"); | |
| 417 | + | assert_eq!(sub.storage_limit_bytes, Some(25_000_000_000)); | |
| 418 | + | ||
| 419 | + | // The old id is no longer a route to this row, and the new one is. | |
| 420 | + | assert_eq!( | |
| 421 | + | synckit::get_subscription_by_stripe_id(&db.pool, "sub_swap_old") | |
| 422 | + | .await | |
| 423 | + | .expect("lookup ok"), | |
| 424 | + | None, | |
| 425 | + | "a webhook on the retired subscription finds nothing to update" | |
| 426 | + | ); | |
| 427 | + | assert_eq!( | |
| 428 | + | synckit::get_subscription_by_stripe_id(&db.pool, "sub_swap_new") | |
| 429 | + | .await | |
| 430 | + | .expect("lookup ok"), | |
| 431 | + | Some((user, app)) | |
| 432 | + | ); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | #[tokio::test] | |
| 436 | + | async fn re_subscribing_after_a_cancellation_reactivates_at_checkout() { | |
| 437 | + | let db = TestDb::new().await; | |
| 438 | + | let user = seed_user(&db.pool, "sks_resub").await; | |
| 439 | + | let app = seed_internal_app(&db.pool, user, "sksresub").await; | |
| 440 | + | subscribe(&db.pool, user, app, "sub_resub", "monthly", 7_500_000_000).await; | |
| 441 | + | ||
| 442 | + | synckit::update_app_sync_subscription_status(&db.pool, "sub_resub", "canceled", None) | |
| 443 | + | .await | |
| 444 | + | .expect("cancel ok"); | |
| 445 | + | assert!( | |
| 446 | + | canceled_at_is_set(&db.pool, "sub_resub").await, | |
| 447 | + | "cancellation stamps canceled_at" | |
| 448 | + | ); | |
| 449 | + | assert!( | |
| 450 | + | !synckit::internal_write_allowed(&db.pool, app, user) | |
| 451 | + | .await | |
| 452 | + | .expect("gate query ok"), | |
| 453 | + | "a canceled subscription closes writes" | |
| 454 | + | ); | |
| 455 | + | ||
| 456 | + | // The user pays again on the same Stripe subscription id. The second arm of | |
| 457 | + | // the guard (status != 'active') fires, so the row reactivates at checkout | |
| 458 | + | // rather than waiting for a later `customer.subscription.updated`. | |
| 459 | + | let revived = synckit::create_app_sync_subscription( | |
| 460 | + | &db.pool, | |
| 461 | + | &NewAppSyncSubscription { | |
| 462 | + | user_id: user, | |
| 463 | + | app_id: app, | |
| 464 | + | stripe_subscription_id: "sub_resub", | |
| 465 | + | stripe_customer_id: "cus_seed", | |
| 466 | + | interval: "annual", | |
| 467 | + | storage_limit_bytes: 25_000_000_000, | |
| 468 | + | }, | |
| 469 | + | ) | |
| 470 | + | .await | |
| 471 | + | .expect("re-subscribe ok"); | |
| 472 | + | assert!(revived, "a paid re-subscribe is a write"); | |
| 473 | + | ||
| 474 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 475 | + | .await | |
| 476 | + | .expect("read subscription") | |
| 477 | + | .expect("row exists"); | |
| 478 | + | assert_eq!(sub.status, "active"); | |
| 479 | + | assert_eq!( | |
| 480 | + | sub.interval, "annual", | |
| 481 | + | "the reactivating checkout carries the new interval" | |
| 482 | + | ); | |
| 483 | + | assert_eq!(sub.storage_limit_bytes, Some(25_000_000_000)); | |
| 484 | + | assert!( | |
| 485 | + | !canceled_at_is_set(&db.pool, "sub_resub").await, | |
| 486 | + | "reactivation clears canceled_at rather than leaving a canceled-looking row" | |
| 487 | + | ); | |
| 488 | + | assert!( | |
| 489 | + | synckit::internal_write_allowed(&db.pool, app, user) | |
| 490 | + | .await | |
| 491 | + | .expect("gate query ok"), | |
| 492 | + | "and sync is open again" | |
| 493 | + | ); | |
| 494 | + | ||
| 495 | + | // The very next redelivery of that same checkout is a no-op again. | |
| 496 | + | assert!( | |
| 497 | + | !synckit::create_app_sync_subscription( | |
| 498 | + | &db.pool, | |
| 499 | + | &NewAppSyncSubscription { | |
| 500 | + | user_id: user, |
Lines truncated
| @@ -1,0 +1,786 @@ | |||
| 1 | + | //! Route-layer contract tests for `routes::storage::downloads` and | |
| 2 | + | //! `routes::storage::versions`, | |
| 3 | + | //! the handlers that decide who is handed the bytes of a creator's file and what | |
| 4 | + | //! a version upload costs the creator's storage quota. | |
| 5 | + | //! | |
| 6 | + | //! Pinned here: the presign window (twice the track duration, clamped to an hour | |
| 7 | + | //! at the floor and a day at the ceiling, 3600 when unknown); the entitlement | |
| 8 | + | //! ladder on both download routes (anonymous on a paid item is 401, a logged-in | |
| 9 | + | //! non-buyer is 403, a completed transaction opens the gate, an unpublished item | |
| 10 | + | //! is 404 for everyone but its creator whether or not they paid); the scan gate | |
| 11 | + | //! (a creator previews their own held file, nobody gets a quarantined one); | |
| 12 | + | //! `version_download`'s 303 and its counters; and `version_confirm_upload` under | |
| 13 | + | //! redelivery, where the same key twice charges storage once and a replacement | |
| 14 | + | //! swaps the charge rather than adding to it. | |
| 15 | + | //! | |
| 16 | + | //! Delete this file and nothing checks that a stranger is refused a paid file, | |
| 17 | + | //! that a replayed confirm is a no-op, or that a replaced file stops being | |
| 18 | + | //! billed. `streaming.rs` covers the stream response shape and the middle of the | |
| 19 | + | //! expiry range; this file covers the clamps and the version half. | |
| 20 | + | ||
| 21 | + | use crate::harness::client::TestResponse; | |
| 22 | + | use crate::harness::{CreatorSetup, TestHarness}; | |
| 23 | + | use makenotwork::db::UserId; | |
| 24 | + | use serde_json::{Value, json}; | |
| 25 | + | ||
| 26 | + | /// A creator with a published item carrying one confirmed version file. | |
| 27 | + | struct VersionFixture { | |
| 28 | + | creator: UserId, | |
| 29 | + | item_id: String, | |
| 30 | + | version_id: String, | |
| 31 | + | s3_key: String, | |
| 32 | + | /// The download route for this version, spelled once. | |
| 33 | + | url: String, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Log out whoever is on `h.client` and log the named seeded user back in. | |
| 37 | + | /// Every user in this file is seeded with the same password. | |
| 38 | + | async fn become_user(h: &mut TestHarness, username: &str) { | |
| 39 | + | h.client.post_form("/logout", "").await; | |
| 40 | + | h.login(username, "password123").await; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Sign up a second account and leave it logged in on `h.client`. | |
| 44 | + | async fn seed_stranger(h: &mut TestHarness, username: &str) -> UserId { | |
| 45 | + | h.client.post_form("/logout", "").await; | |
| 46 | + | let id = h | |
| 47 | + | .signup(username, &format!("{username}@test.com"), "password123") | |
| 48 | + | .await; | |
| 49 | + | h.login(username, "password123").await; | |
| 50 | + | id | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// Presign a version upload, put `size` bytes at the returned key, and confirm. | |
| 54 | + | /// Returns the staging key. Goes through the real routes. | |
| 55 | + | async fn upload_version_file(h: &mut TestHarness, version_id: &str, size: usize) -> String { | |
| 56 | + | let resp = h | |
| 57 | + | .client | |
| 58 | + | .post_json( | |
| 59 | + | &format!("/api/versions/{version_id}/upload/presign"), | |
| 60 | + | &json!({"file_name": "release.zip", "content_type": "application/zip"}).to_string(), | |
| 61 | + | ) | |
| 62 | + | .await; | |
| 63 | + | assert_eq!(resp.status, 200, "presign failed: {}", resp.text); | |
| 64 | + | let body: Value = resp.json(); | |
| 65 | + | let s3_key = body["s3_key"] | |
| 66 | + | .as_str() | |
| 67 | + | .expect("presign returns s3_key") | |
| 68 | + | .to_string(); | |
| 69 | + | ||
| 70 | + | h.storage.as_ref().unwrap().put(&s3_key, vec![7u8; size]); | |
| 71 | + | ||
| 72 | + | let resp = confirm_version(h, version_id, &s3_key).await; | |
| 73 | + | assert_eq!(resp.status, 200, "confirm failed: {}", resp.text); | |
| 74 | + | s3_key | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Creator (trusted, Small Files tier) + published project and item + one | |
| 78 | + | /// version whose file is uploaded, confirmed, and marked clean. The verdict is | |
| 79 | + | /// set directly because the download tests vary it independently of the upload. | |
| 80 | + | async fn published_item_with_version( | |
| 81 | + | h: &mut TestHarness, | |
| 82 | + | username: &str, | |
| 83 | + | price_cents: i64, | |
| 84 | + | ) -> VersionFixture { | |
| 85 | + | let (setup, version_id) = creator_with_version(h, username, price_cents, true).await; | |
| 86 | + | let s3_key = upload_version_file(h, &version_id, 4_096).await; | |
| 87 | + | set_version_scan_status(h, &version_id, "clean").await; | |
| 88 | + | h.publish_project_and_item(&setup.project_id, &setup.item_id) | |
| 89 | + | .await; | |
| 90 | + | ||
| 91 | + | VersionFixture { | |
| 92 | + | creator: setup.user_id, | |
| 93 | + | item_id: setup.item_id, | |
| 94 | + | url: format!("/api/versions/{version_id}/download"), | |
| 95 | + | version_id, | |
| 96 | + | s3_key, | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// A creator on the Small Files tier (the cheapest tier allowed to upload) with | |
| 101 | + | /// one item and one empty version row. `trusted` decides the verdict a later | |
| 102 | + | /// confirm assigns: with no scanner, a trusted upload lands clean, others held. | |
| 103 | + | async fn creator_with_version( | |
| 104 | + | h: &mut TestHarness, | |
| 105 | + | username: &str, | |
| 106 | + | price_cents: i64, | |
| 107 | + | trusted: bool, | |
| 108 | + | ) -> (CreatorSetup, String) { | |
| 109 | + | let setup = h | |
| 110 | + | .create_creator_with_item(username, "digital", price_cents) | |
| 111 | + | .await; | |
| 112 | + | if trusted { | |
| 113 | + | h.trust_user(setup.user_id).await; | |
| 114 | + | } | |
| 115 | + | h.grant_tier(setup.user_id, "small_files").await; | |
| 116 | + | ||
| 117 | + | let resp = h | |
| 118 | + | .client | |
| 119 | + | .post_json( | |
| 120 | + | &format!("/api/items/{}/versions", setup.item_id), | |
| 121 | + | r#"{"version_number": "1.0.0", "changelog": "First cut"}"#, | |
| 122 | + | ) | |
| 123 | + | .await; | |
| 124 | + | assert_eq!(resp.status, 200, "create version failed: {}", resp.text); | |
| 125 | + | let version_id = resp.json::<Value>()["id"] | |
| 126 | + | .as_str() | |
| 127 | + | .expect("version id") | |
| 128 | + | .to_string(); | |
| 129 | + | (setup, version_id) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// A creator with a published audio item whose object is in storage, for the | |
| 133 | + | /// stream tests, which need an item with bytes behind it. | |
| 134 | + | async fn published_audio_item( | |
| 135 | + | h: &mut TestHarness, | |
| 136 | + | username: &str, | |
| 137 | + | price_cents: i64, | |
| 138 | + | ) -> (CreatorSetup, String) { | |
| 139 | + | let setup = h | |
| 140 | + | .create_creator_with_item(username, "audio", price_cents) | |
| 141 | + | .await; | |
| 142 | + | let key = format!("test/{}/audio/track.mp3", setup.item_id); | |
| 143 | + | sqlx::query("UPDATE items SET audio_s3_key = $1, scan_status = 'clean' WHERE id = $2::uuid") | |
| 144 | + | .bind(&key) | |
| 145 | + | .bind(&setup.item_id) | |
| 146 | + | .execute(&h.db) | |
| 147 | + | .await | |
| 148 | + | .unwrap(); | |
| 149 | + | h.storage.as_ref().unwrap().put(&key, b"audio".to_vec()); | |
| 150 | + | h.publish_project_and_item(&setup.project_id, &setup.item_id) | |
| 151 | + | .await; | |
| 152 | + | (setup, key) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// The version row's key, size and scan verdict in one read. | |
| 156 | + | async fn version_row(h: &TestHarness, version_id: &str) -> (Option<String>, Option<i64>, String) { | |
| 157 | + | sqlx::query_as("SELECT s3_key, file_size_bytes, scan_status FROM versions WHERE id = $1::uuid") | |
| 158 | + | .bind(version_id) | |
| 159 | + | .fetch_one(&h.db) | |
| 160 | + | .await | |
| 161 | + | .unwrap() | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// POST the confirm for `version_id` naming `key`. | |
| 165 | + | async fn confirm_version(h: &mut TestHarness, version_id: &str, key: &str) -> TestResponse { | |
| 166 | + | h.client | |
| 167 | + | .post_json( | |
| 168 | + | &format!("/api/versions/{version_id}/upload/confirm"), | |
| 169 | + | &json!({ "s3_key": key }).to_string(), | |
| 170 | + | ) | |
| 171 | + | .await | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// Set a version's scan verdict directly, which no route lets a test do. | |
| 175 | + | async fn set_version_scan_status(h: &TestHarness, version_id: &str, status: &str) { | |
| 176 | + | sqlx::query("UPDATE versions SET scan_status = $1 WHERE id = $2::uuid") | |
| 177 | + | .bind(status) | |
| 178 | + | .bind(version_id) | |
| 179 | + | .execute(&h.db) | |
| 180 | + | .await | |
| 181 | + | .unwrap(); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// Set the item's duration and read back the `expires_in` the stream route | |
| 185 | + | /// answers with. | |
| 186 | + | async fn stream_expires_in(h: &mut TestHarness, item_id: &str, duration: Option<i32>) -> u64 { | |
| 187 | + | sqlx::query("UPDATE items SET duration_seconds = $1 WHERE id = $2::uuid") | |
| 188 | + | .bind(duration) | |
| 189 | + | .bind(item_id) | |
| 190 | + | .execute(&h.db) | |
| 191 | + | .await | |
| 192 | + | .unwrap(); | |
| 193 | + | let resp = h.client.get(&format!("/api/stream/{item_id}")).await; | |
| 194 | + | assert_eq!( | |
| 195 | + | resp.status, 200, | |
| 196 | + | "stream of a free clean item: {}", | |
| 197 | + | resp.text | |
| 198 | + | ); | |
| 199 | + | let body: Value = resp.json(); | |
| 200 | + | body["expires_in"] | |
| 201 | + | .as_u64() | |
| 202 | + | .unwrap_or_else(|| panic!("expires_in must be a number, body: {}", resp.text)) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /// Record a completed purchase of `item_id` by `buyer`. This is what | |
| 206 | + | /// `check_item_access` reads as `has_purchased`. | |
| 207 | + | async fn record_purchase(h: &TestHarness, buyer: UserId, seller: UserId, item_id: &str) { | |
| 208 | + | sqlx::query( | |
| 209 | + | "INSERT INTO transactions | |
| 210 | + | (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, | |
| 211 | + | stripe_checkout_session_id, status, completed_at, item_title, | |
| 212 | + | seller_username, share_contact) | |
| 213 | + | VALUES ($1, $2, $3::uuid, 1500, 0, $4, 'completed', NOW(), 'Test Item', | |
| 214 | + | 'seller', false)", | |
| 215 | + | ) | |
| 216 | + | .bind(buyer) | |
| 217 | + | .bind(seller) | |
| 218 | + | .bind(item_id) | |
| 219 | + | .bind(format!("cs_{buyer}")) | |
| 220 | + | .execute(&h.db) | |
| 221 | + | .await | |
| 222 | + | .expect("record purchase"); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | async fn storage_used(h: &TestHarness, user: UserId) -> i64 { | |
| 226 | + | sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1") | |
| 227 | + | .bind(user) | |
| 228 | + | .fetch_one(&h.db) | |
| 229 | + | .await | |
| 230 | + | .unwrap() | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | async fn queued_deletions(h: &TestHarness, key: &str) -> i64 { | |
| 234 | + | sqlx::query_scalar("SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1") | |
| 235 | + | .bind(key) | |
| 236 | + | .fetch_one(&h.db) | |
| 237 | + | .await | |
| 238 | + | .unwrap() | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | async fn pending_uploads(h: &TestHarness, user: UserId, key: &str) -> i64 { | |
| 242 | + | sqlx::query_scalar("SELECT COUNT(*) FROM pending_uploads WHERE user_id = $1 AND s3_key = $2") | |
| 243 | + | .bind(user) | |
| 244 | + | .bind(key) | |
| 245 | + | .fetch_one(&h.db) | |
| 246 | + | .await | |
| 247 | + | .unwrap() | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // routes::storage::downloads, stream_url | |
| 251 | + | ||
| 252 | + | /// The presign window is `duration * 2`, clamped to [3600, 86400]. Each duration | |
| 253 | + | /// is chosen so a wrong operator answers differently: 5400 doubles to 10800, | |
| 254 | + | /// which no clamp touches and which `duration + 3600`, `duration` and | |
| 255 | + | /// `duration * 3` all miss; 1200 doubles under the floor, 50000 over the | |
| 256 | + | /// ceiling, so a missing clamp shows up on either side. | |
| 257 | + | #[tokio::test] | |
| 258 | + | async fn stream_expiry_is_twice_the_duration_clamped_between_an_hour_and_a_day() { | |
| 259 | + | let mut h = TestHarness::with_storage().await; | |
| 260 | + | let (setup, _key) = published_audio_item(&mut h, "expclamp", 0).await; | |
| 261 | + | let item_id = setup.item_id.clone(); | |
| 262 | + | ||
| 263 | + | assert_eq!( | |
| 264 | + | stream_expires_in(&mut h, &item_id, Some(5400)).await, | |
| 265 | + | 10_800, | |
| 266 | + | "a 90 minute track presigns for twice its duration" | |
| 267 | + | ); | |
| 268 | + | assert_eq!( | |
| 269 | + | stream_expires_in(&mut h, &item_id, Some(1200)).await, | |
| 270 | + | 3_600, | |
| 271 | + | "2400s is below the one hour floor and must be lifted to it" | |
| 272 | + | ); | |
| 273 | + | assert_eq!( | |
| 274 | + | stream_expires_in(&mut h, &item_id, Some(50_000)).await, | |
| 275 | + | 86_400, | |
| 276 | + | "100000s is above the 24 hour ceiling and must be cut to it" | |
| 277 | + | ); | |
| 278 | + | assert_eq!( | |
| 279 | + | stream_expires_in(&mut h, &item_id, None).await, | |
| 280 | + | 3_600, | |
| 281 | + | "an unknown duration presigns for the default hour" | |
| 282 | + | ); | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /// The entitlement ladder on the stream route. The distinction between 401 and | |
| 286 | + | /// 403 is the contract: anonymous means "logging in might help", a logged-in | |
| 287 | + | /// non-buyer means it will not. | |
| 288 | + | #[tokio::test] | |
| 289 | + | async fn stream_refuses_the_anonymous_and_the_unpaid_and_admits_the_buyer() { | |
| 290 | + | let mut h = TestHarness::with_storage().await; | |
| 291 | + | // 1250 cents, chosen away from 0 and 1 so a price read as a boolean or an | |
| 292 | + | // off-by-one free check still reads as paid. | |
| 293 | + | let (setup, key) = published_audio_item(&mut h, "paidstream", 1250).await; | |
| 294 | + | let item_id = setup.item_id.clone(); | |
| 295 | + | let stream = format!("/api/stream/{item_id}"); | |
| 296 | + | ||
| 297 | + | // The creator always reaches their own paid file. | |
| 298 | + | let resp = h.client.get(&stream).await; | |
| 299 | + | assert_eq!( | |
| 300 | + | resp.status, 200, | |
| 301 | + | "creator reaches own paid item: {}", | |
| 302 | + | resp.text | |
| 303 | + | ); | |
| 304 | + | ||
| 305 | + | let mut anon = h.client.fork_fresh(); | |
| 306 | + | let resp = anon.get(&stream).await; | |
| 307 | + | assert_eq!( | |
| 308 | + | resp.status, 401, | |
| 309 | + | "anon on a paid item is 401: {}", | |
| 310 | + | resp.text | |
| 311 | + | ); | |
| 312 | + | ||
| 313 | + | let buyer = seed_stranger(&mut h, "paidstranger").await; | |
| 314 | + | let resp = h.client.get(&stream).await; | |
| 315 | + | assert_eq!(resp.status, 403, "non-buyer is 403: {}", resp.text); | |
| 316 | + | ||
| 317 | + | record_purchase(&h, buyer, setup.user_id, &item_id).await; | |
| 318 | + | let resp = h.client.get(&stream).await; | |
| 319 | + | assert_eq!(resp.status, 200, "a purchase opens the gate: {}", resp.text); | |
| 320 | + | let body: Value = resp.json(); | |
| 321 | + | assert!( | |
| 322 | + | body["stream_url"] | |
| 323 | + | .as_str() | |
| 324 | + | .is_some_and(|u| u.contains(&key)), | |
| 325 | + | "the presigned URL must reference the item's own key, got: {}", | |
| 326 | + | resp.text | |
| 327 | + | ); | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // routes::storage::downloads, version_download | |
| 331 | + | ||
| 332 | + | /// The redirect and the counters. Two downloads by one user must move the two | |
| 333 | + | /// aggregate counters twice and the per-user record once, which is the whole | |
| 334 | + | /// difference between "downloads" and "people who downloaded". | |
| 335 | + | #[tokio::test] | |
| 336 | + | async fn version_download_redirects_and_counts_every_download_but_one_downloader() { | |
| 337 | + | let mut h = TestHarness::with_storage().await; | |
| 338 | + | let fx = published_item_with_version(&mut h, "dlcount", 0).await; | |
| 339 | + | ||
| 340 | + | for attempt in 1..=2 { | |
| 341 | + | let resp = h.client.get(&fx.url).await; | |
| 342 | + | assert_eq!( | |
| 343 | + | resp.status, 303, | |
| 344 | + | "download {attempt} answers 303, not JSON: {}", | |
| 345 | + | resp.text | |
| 346 | + | ); | |
| 347 | + | let location = resp | |
| 348 | + | .header("location") | |
| 349 | + | .unwrap_or_else(|| panic!("303 must carry a Location header, body: {}", resp.text)); | |
| 350 | + | assert!( | |
| 351 | + | location.contains(&fx.s3_key), | |
| 352 | + | "Location must point at this version's own object, got {location}" | |
| 353 | + | ); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | let version_count: i32 = | |
| 357 | + | sqlx::query_scalar("SELECT download_count FROM versions WHERE id = $1::uuid") | |
| 358 | + | .bind(&fx.version_id) | |
| 359 | + | .fetch_one(&h.db) | |
| 360 | + | .await | |
| 361 | + | .unwrap(); | |
| 362 | + | assert_eq!( | |
| 363 | + | version_count, 2, | |
| 364 | + | "each download increments the version counter, so two downloads is 2" | |
| 365 | + | ); | |
| 366 | + | ||
| 367 | + | let item_count: i32 = | |
| 368 | + | sqlx::query_scalar("SELECT download_count FROM items WHERE id = $1::uuid") | |
| 369 | + | .bind(&fx.item_id) | |
| 370 | + | .fetch_one(&h.db) | |
| 371 | + | .await | |
| 372 | + | .unwrap(); | |
| 373 | + | assert_eq!( | |
| 374 | + | item_count, 2, | |
| 375 | + | "the item-level counter tracks the version counter" | |
| 376 | + | ); | |
| 377 | + | ||
| 378 | + | let per_user: i64 = sqlx::query_scalar( | |
| 379 | + | "SELECT COUNT(*) FROM user_downloads WHERE user_id = $1 AND version_id = $2::uuid", | |
| 380 | + | ) | |
| 381 | + | .bind(fx.creator) | |
| 382 | + | .bind(&fx.version_id) | |
| 383 | + | .fetch_one(&h.db) | |
| 384 | + | .await | |
| 385 | + | .unwrap(); | |
| 386 | + | assert_eq!( | |
| 387 | + | per_user, 1, | |
| 388 | + | "the per-user download row is recorded once however often the file is fetched" | |
| 389 | + | ); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | /// Payment and publication are separate gates, and unpublishing revokes a file | |
| 393 | + | /// the buyer already paid for: a gate that only consulted the purchase would | |
| 394 | + | /// still hand over the bytes. | |
| 395 | + | #[tokio::test] | |
| 396 | + | async fn version_download_gates_on_payment_and_on_publication_separately() { | |
| 397 | + | let mut h = TestHarness::with_storage().await; | |
| 398 | + | let fx = published_item_with_version(&mut h, "dlgate", 1500).await; | |
| 399 | + | ||
| 400 | + | let mut anon = h.client.fork_fresh(); | |
| 401 | + | let resp = anon.get(&fx.url).await; | |
| 402 | + | assert_eq!( | |
| 403 | + | resp.status, 401, | |
| 404 | + | "anon on a paid version is 401: {}", | |
| 405 | + | resp.text | |
| 406 | + | ); | |
| 407 | + | ||
| 408 | + | let buyer = seed_stranger(&mut h, "dlgatefan").await; | |
| 409 | + | let resp = h.client.get(&fx.url).await; | |
| 410 | + | assert_eq!(resp.status, 403, "non-buyer is 403: {}", resp.text); | |
| 411 | + | ||
| 412 | + | record_purchase(&h, buyer, fx.creator, &fx.item_id).await; | |
| 413 | + | let resp = h.client.get(&fx.url).await; | |
| 414 | + | assert_eq!( | |
| 415 | + | resp.status, 303, | |
| 416 | + | "the buyer gets the redirect: {}", | |
| 417 | + | resp.text | |
| 418 | + | ); | |
| 419 | + | ||
| 420 | + | // The creator unpublishes. The purchase is untouched, but the file is no | |
| 421 | + | // longer public, and only the creator may still reach it. | |
| 422 | + | sqlx::query("UPDATE items SET is_public = false WHERE id = $1::uuid") | |
| 423 | + | .bind(&fx.item_id) | |
| 424 | + | .execute(&h.db) | |
| 425 | + | .await | |
| 426 | + | .unwrap(); | |
| 427 | + | ||
| 428 | + | let resp = h.client.get(&fx.url).await; | |
| 429 | + | assert_eq!( | |
| 430 | + | resp.status, 404, | |
| 431 | + | "unpublished is 404 even for a buyer: {}", | |
| 432 | + | resp.text | |
| 433 | + | ); | |
| 434 | + | ||
| 435 | + | become_user(&mut h, "dlgate").await; | |
| 436 | + | let resp = h.client.get(&fx.url).await; | |
| 437 | + | assert_eq!( | |
| 438 | + | resp.status, 303, | |
| 439 | + | "creator previews own draft: {}", | |
| 440 | + | resp.text | |
| 441 | + | ); | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | /// The scan gate is not symmetric. A creator may pull their own file back while | |
| 445 | + | /// it waits for review, but a quarantined verdict is final for everyone, the | |
| 446 | + | /// creator included, so a confirmed-malicious object is never served. | |
| 447 | + | #[tokio::test] | |
| 448 | + | async fn version_download_lets_a_creator_preview_a_held_file_but_never_a_quarantined_one() { | |
| 449 | + | let mut h = TestHarness::with_storage().await; | |
| 450 | + | // Free item, so nothing but the scan status can explain a refusal. | |
| 451 | + | let fx = published_item_with_version(&mut h, "dlscan", 0).await; | |
| 452 | + | ||
| 453 | + | set_version_scan_status(&h, &fx.version_id, "held_for_review").await; | |
| 454 | + | let resp = h.client.get(&fx.url).await; | |
| 455 | + | assert_eq!( | |
| 456 | + | resp.status, 303, | |
| 457 | + | "creator previews own held file: {}", | |
| 458 | + | resp.text | |
| 459 | + | ); | |
| 460 | + | ||
| 461 | + | let mut anon = h.client.fork_fresh(); | |
| 462 | + | let resp = anon.get(&fx.url).await; | |
| 463 | + | assert_eq!( | |
| 464 | + | resp.status, 404, | |
| 465 | + | "a held file is 404 to others: {}", | |
| 466 | + | resp.text | |
| 467 | + | ); | |
| 468 | + | ||
| 469 | + | set_version_scan_status(&h, &fx.version_id, "quarantined").await; | |
| 470 | + | let resp = h.client.get(&fx.url).await; | |
| 471 | + | assert_eq!( | |
| 472 | + | resp.status, 404, | |
| 473 | + | "quarantine is 404 to its creator: {}", | |
| 474 | + | resp.text | |
| 475 | + | ); | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | // routes::storage::versions | |
| 479 | + | ||
| 480 | + | /// Presign proves ownership through version -> item -> project, and leaves the | |
| 481 | + | /// `pending_uploads` row that is the only thing authorizing the later confirm | |
| 482 | + | /// of an opaque `staging/{uuid}` key. | |
| 483 | + | #[tokio::test] | |
| 484 | + | async fn version_presign_records_a_pending_upload_and_refuses_a_stranger() { | |
| 485 | + | let mut h = TestHarness::with_storage().await; | |
| 486 | + | let (setup, version_id) = creator_with_version(&mut h, "vpresign", 0, false).await; | |
| 487 | + | ||
| 488 | + | let resp = h | |
| 489 | + | .client | |
| 490 | + | .post_json( | |
| 491 | + | &format!("/api/versions/{version_id}/upload/presign"), | |
| 492 | + | &json!({ | |
| 493 | + | "file_name": "bundle.zip", | |
| 494 | + | "content_type": "application/zip", | |
| 495 | + | "file_size_bytes": 12_345, | |
| 496 | + | }) | |
| 497 | + | .to_string(), | |
| 498 | + | ) | |
| 499 | + | .await; | |
| 500 | + | assert_eq!(resp.status, 200, "the owner may presign: {}", resp.text); |
Lines truncated
| @@ -1,0 +1,696 @@ | |||
| 1 | + | //! Route-layer contract tests for the buyer-facing purchase handlers: | |
| 2 | + | //! `routes::stripe::checkout::item` and `routes::stripe::checkout::cart`. | |
| 3 | + | //! | |
| 4 | + | //! These two routes decide, from a form post, how many cents a buyer is about | |
| 5 | + | //! to be charged. Every test pins that number exactly: the pending row a handler | |
| 6 | + | //! writes before redirecting to the provider must carry the amount the provider | |
| 7 | + | //! session was built from, in integer cents. A handler that shipped dollars | |
| 8 | + | //! where cents were meant, or the listed price where the buyer's own | |
| 9 | + | //! pay-what-you-want amount was meant, would pass a test that only asserted "a | |
| 10 | + | //! session was created" and fails every test below. | |
| 11 | + | //! | |
| 12 | + | //! Oracle note: the mock provider records a session id and URL but not the | |
| 13 | + | //! amount, so the amount is read back from the row the handler wrote in the same | |
| 14 | + | //! breath (transactions.amount_cents) and tied to the provider by asserting that | |
| 15 | + | //! row's session id is the session the buyer was redirected to. Both halves are | |
| 16 | + | //! needed: the amount alone would not prove it belongs to this session, the | |
| 17 | + | //! session alone would not prove the amount. | |
| 18 | + | //! | |
| 19 | + | //! The tip handler is `stripe_tip_checkout_routes`. | |
| 20 | + | //! | |
| 21 | + | //! Delete this file and two things stop being checked anywhere: the per-line | |
| 22 | + | //! price fidelity of a multi-item cart, and that re-submitting a checkout form | |
| 23 | + | //! does not open a second charge for the same item. | |
| 24 | + | ||
| 25 | + | use crate::harness::TestHarness; | |
| 26 | + | use crate::harness::client::TestResponse; | |
| 27 | + | use crate::harness::stripe::MockCheckout; | |
| 28 | + | use makenotwork::db; | |
| 29 | + | use serde_json::Value; | |
| 30 | + | ||
| 31 | + | // Helpers | |
| 32 | + | ||
| 33 | + | /// Assert an exact status, reporting the body when it does not hold. | |
| 34 | + | fn assert_status(resp: &TestResponse, expected: u16, what: &str) { | |
| 35 | + | assert_eq!( | |
| 36 | + | resp.status, expected, | |
| 37 | + | "{what}: got {} with body {}", | |
| 38 | + | resp.status, resp.text | |
| 39 | + | ); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// Assert the exact redirect target, reporting the body when it does not hold. | |
| 43 | + | fn assert_redirect(resp: &TestResponse, target: &str, what: &str) { | |
| 44 | + | assert_eq!( | |
| 45 | + | resp.header("location"), | |
| 46 | + | Some(target), | |
| 47 | + | "{what}, body: {}", | |
| 48 | + | resp.text | |
| 49 | + | ); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Sessions the mock provider was asked to open, in call order. | |
| 53 | + | fn sessions(h: &TestHarness) -> Vec<MockCheckout> { | |
| 54 | + | h.mock_stripe | |
| 55 | + | .as_ref() | |
| 56 | + | .expect("mock stripe configured") | |
| 57 | + | .checkouts() | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Assert how many provider sessions have been opened so far. | |
| 61 | + | fn assert_sessions(h: &TestHarness, expected: usize, what: &str) { | |
| 62 | + | let opened = sessions(h); | |
| 63 | + | assert_eq!(opened.len(), expected, "{what}, got {opened:?}"); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// A buyer's open charge amounts, ascending. Ascending rather than insertion | |
| 67 | + | /// order so a test can state the exact multiset of line prices without | |
| 68 | + | /// depending on which row landed first. | |
| 69 | + | async fn pending_amounts(h: &TestHarness, buyer_id: db::UserId) -> Vec<i32> { | |
| 70 | + | sqlx::query_scalar( | |
| 71 | + | "SELECT amount_cents FROM transactions \ | |
| 72 | + | WHERE buyer_id = $1 AND status = 'pending' ORDER BY amount_cents", | |
| 73 | + | ) | |
| 74 | + | .bind(buyer_id) | |
| 75 | + | .fetch_all(&h.db) | |
| 76 | + | .await | |
| 77 | + | .expect("read pending amounts") | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Assert the buyer's open charges are exactly these cent amounts. | |
| 81 | + | async fn assert_pending(h: &TestHarness, buyer_id: db::UserId, expected: &[i32], what: &str) { | |
| 82 | + | assert_eq!(pending_amounts(h, buyer_id).await, expected, "{what}"); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// The provider session id recorded on a buyer's single pending row. | |
| 86 | + | async fn pending_session_id(h: &TestHarness, buyer_id: db::UserId) -> String { | |
| 87 | + | sqlx::query_scalar( | |
| 88 | + | "SELECT stripe_checkout_session_id FROM transactions \ | |
| 89 | + | WHERE buyer_id = $1 AND status = 'pending'", | |
| 90 | + | ) | |
| 91 | + | .bind(buyer_id) | |
| 92 | + | .fetch_one(&h.db) | |
| 93 | + | .await | |
| 94 | + | .expect("read pending session id") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Create a Stripe-connected creator with one published paid item, then log | |
| 98 | + | /// out. Returns `(seller_id, project_id, item_id)`. | |
| 99 | + | async fn connected_seller_with_item( | |
| 100 | + | h: &mut TestHarness, | |
| 101 | + | username: &str, | |
| 102 | + | price_cents: i64, | |
| 103 | + | ) -> (db::UserId, String, String) { | |
| 104 | + | let setup = h | |
| 105 | + | .create_creator_with_item(username, "audio", price_cents) | |
| 106 | + | .await; | |
| 107 | + | h.connect_stripe(setup.user_id, &format!("acct_test_{username}")) | |
| 108 | + | .await; | |
| 109 | + | h.publish_project_and_item(&setup.project_id, &setup.item_id) | |
| 110 | + | .await; | |
| 111 | + | h.client.post_form("/logout", "").await; | |
| 112 | + | (setup.user_id, setup.project_id, setup.item_id) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Add a second published item to an existing project. The owning creator must | |
| 116 | + | /// be logged in. | |
| 117 | + | async fn add_published_item( | |
| 118 | + | h: &mut TestHarness, | |
| 119 | + | project_id: &str, | |
| 120 | + | title: &str, | |
| 121 | + | price_cents: i64, | |
| 122 | + | ) -> String { | |
| 123 | + | let resp = h | |
| 124 | + | .client | |
| 125 | + | .post_form( | |
| 126 | + | &format!("/api/projects/{project_id}/items"), | |
| 127 | + | &format!("title={title}&item_type=audio&price_cents={price_cents}"), | |
| 128 | + | ) | |
| 129 | + | .await; | |
| 130 | + | assert_status(&resp, 200, "create extra item"); | |
| 131 | + | let item: Value = resp.json(); | |
| 132 | + | let item_id = item["id"] | |
| 133 | + | .as_str() | |
| 134 | + | .expect("item id in response") | |
| 135 | + | .to_string(); | |
| 136 | + | ||
| 137 | + | let resp = h | |
| 138 | + | .client | |
| 139 | + | .put_form(&format!("/api/items/{item_id}"), "is_public=true") | |
| 140 | + | .await; | |
| 141 | + | assert_status(&resp, 200, "publish extra item"); | |
| 142 | + | item_id | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Turn an existing item into a pay-what-you-want item with the given minimum. | |
| 146 | + | async fn make_pwyw(h: &TestHarness, item_id: &str, min_cents: i32) { | |
| 147 | + | sqlx::query("UPDATE items SET pwyw_enabled = true, pwyw_min_cents = $2 WHERE id = $1::uuid") | |
| 148 | + | .bind(item_id) | |
| 149 | + | .bind(min_cents) | |
| 150 | + | .execute(&h.db) | |
| 151 | + | .await | |
| 152 | + | .expect("enable pwyw"); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// Add one item to the logged-in buyer's cart. | |
| 156 | + | async fn add_to_cart(h: &mut TestHarness, item_id: &str) { | |
| 157 | + | let resp = h | |
| 158 | + | .client | |
| 159 | + | .post_form(&format!("/api/cart/{item_id}"), "") | |
| 160 | + | .await; | |
| 161 | + | assert_status(&resp, 200, "add to cart"); | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // routes::stripe::checkout::item | |
| 165 | + | ||
| 166 | + | /// The pending charge is the item's price in cents, unrounded and unscaled, and | |
| 167 | + | /// it belongs to the session the buyer was redirected to. 1234 is chosen so a | |
| 168 | + | /// dollars/cents mix-up (12 or 123400) and a round to the nearest dollar (1200) | |
| 169 | + | /// each give a different answer than the correct one. | |
| 170 | + | #[tokio::test] | |
| 171 | + | async fn item_checkout_charges_the_listed_price_in_cents() { | |
| 172 | + | let mut h = TestHarness::with_mocks().await; | |
| 173 | + | let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "itemseller", 1234).await; | |
| 174 | + | let buyer_id = h | |
| 175 | + | .signup("itembuyer", "itembuyer@test.com", "pass1234") | |
| 176 | + | .await; | |
| 177 | + | ||
| 178 | + | let resp = h | |
| 179 | + | .client | |
| 180 | + | .post_form( | |
| 181 | + | &format!("/stripe/checkout/{item_id}"), | |
| 182 | + | "share_contact=false", | |
| 183 | + | ) | |
| 184 | + | .await; | |
| 185 | + | assert_status(&resp, 303, "paid item checkout must redirect to Stripe"); | |
| 186 | + | ||
| 187 | + | let opened = sessions(&h); | |
| 188 | + | assert_sessions(&h, 1, "one submission opens exactly one provider session"); | |
| 189 | + | assert_redirect( | |
| 190 | + | &resp, | |
| 191 | + | &opened[0].url, | |
| 192 | + | "buyer goes to the session just opened", | |
| 193 | + | ); | |
| 194 | + | assert_pending( | |
| 195 | + | &h, | |
| 196 | + | buyer_id, | |
| 197 | + | &[1234], | |
| 198 | + | "the charge is the item's exact cent price", | |
| 199 | + | ) | |
| 200 | + | .await; | |
| 201 | + | assert_eq!( | |
| 202 | + | pending_session_id(&h, buyer_id).await, | |
| 203 | + | opened[0].id, | |
| 204 | + | "the pending row must reference the session the buyer was sent to" | |
| 205 | + | ); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// A pay-what-you-want item is charged at the buyer's amount, not at the listed | |
| 209 | + | /// `price_cents`. The two differ (763 vs 1234) so reading the wrong field shows. | |
| 210 | + | #[tokio::test] | |
| 211 | + | async fn pwyw_checkout_charges_the_buyer_chosen_amount_not_the_listed_price() { | |
| 212 | + | let mut h = TestHarness::with_mocks().await; | |
| 213 | + | let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "pwywseller", 1234).await; | |
| 214 | + | make_pwyw(&h, &item_id, 500).await; | |
| 215 | + | let buyer_id = h | |
| 216 | + | .signup("pwywbuyer", "pwywbuyer@test.com", "pass1234") | |
| 217 | + | .await; | |
| 218 | + | ||
| 219 | + | let path = format!("/stripe/checkout/{item_id}"); | |
| 220 | + | let resp = h | |
| 221 | + | .client | |
| 222 | + | .post_form(&path, "share_contact=false&amount_cents=763") | |
| 223 | + | .await; | |
| 224 | + | assert_status(&resp, 303, "pwyw checkout above the minimum must redirect"); | |
| 225 | + | assert_pending( | |
| 226 | + | &h, | |
| 227 | + | buyer_id, | |
| 228 | + | &[763], | |
| 229 | + | "pwyw charges the buyer's amount, not the list", | |
| 230 | + | ) | |
| 231 | + | .await; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// Both sides of the creator's pay-what-you-want floor: one cent under is | |
| 235 | + | /// refused with no row written, the floor itself goes through at that amount. | |
| 236 | + | #[tokio::test] | |
| 237 | + | async fn pwyw_checkout_refuses_below_the_creator_minimum_and_accepts_the_minimum_itself() { | |
| 238 | + | let mut h = TestHarness::with_mocks().await; | |
| 239 | + | let (_seller, _project, item_id) = | |
| 240 | + | connected_seller_with_item(&mut h, "pwywminsell", 1234).await; | |
| 241 | + | make_pwyw(&h, &item_id, 500).await; | |
| 242 | + | let buyer_id = h | |
| 243 | + | .signup("pwywminbuy", "pwywminbuy@test.com", "pass1234") | |
| 244 | + | .await; | |
| 245 | + | let url = format!("/stripe/checkout/{item_id}"); | |
| 246 | + | ||
| 247 | + | let resp = h | |
| 248 | + | .client | |
| 249 | + | .post_form(&url, "share_contact=false&amount_cents=499") | |
| 250 | + | .await; | |
| 251 | + | assert_status( | |
| 252 | + | &resp, | |
| 253 | + | 400, | |
| 254 | + | "one cent under the pwyw minimum must be refused", | |
| 255 | + | ); | |
| 256 | + | assert_pending(&h, buyer_id, &[], "a refused pwyw amount writes no charge").await; | |
| 257 | + | ||
| 258 | + | let resp = h | |
| 259 | + | .client | |
| 260 | + | .post_form(&url, "share_contact=false&amount_cents=500") | |
| 261 | + | .await; | |
| 262 | + | assert_status(&resp, 303, "the pwyw minimum itself must be accepted"); | |
| 263 | + | assert_pending( | |
| 264 | + | &h, | |
| 265 | + | buyer_id, | |
| 266 | + | &[500], | |
| 267 | + | "the accepted amount is exact, not rounded", | |
| 268 | + | ) | |
| 269 | + | .await; | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// Both sides of the $10,000 pay-what-you-want ceiling. The cap exists so a | |
| 273 | + | /// mistyped amount cannot open a mega-charge, so the cent above it must fail | |
| 274 | + | /// while the cap itself still succeeds. | |
| 275 | + | #[tokio::test] | |
| 276 | + | async fn pwyw_checkout_refuses_above_the_ten_thousand_dollar_cap_and_accepts_the_cap_itself() { | |
| 277 | + | let mut h = TestHarness::with_mocks().await; | |
| 278 | + | let (_seller, _project, item_id) = | |
| 279 | + | connected_seller_with_item(&mut h, "pwywcapsell", 1234).await; | |
| 280 | + | make_pwyw(&h, &item_id, 500).await; | |
| 281 | + | let buyer_id = h | |
| 282 | + | .signup("pwywcapbuy", "pwywcapbuy@test.com", "pass1234") | |
| 283 | + | .await; | |
| 284 | + | let url = format!("/stripe/checkout/{item_id}"); | |
| 285 | + | ||
| 286 | + | let resp = h | |
| 287 | + | .client | |
| 288 | + | .post_form(&url, "share_contact=false&amount_cents=1000001") | |
| 289 | + | .await; | |
| 290 | + | assert_status(&resp, 400, "one cent over the $10,000 cap must be refused"); | |
| 291 | + | assert_pending( | |
| 292 | + | &h, | |
| 293 | + | buyer_id, | |
| 294 | + | &[], | |
| 295 | + | "a refused over-cap amount writes no charge", | |
| 296 | + | ) | |
| 297 | + | .await; | |
| 298 | + | ||
| 299 | + | let resp = h | |
| 300 | + | .client | |
| 301 | + | .post_form(&url, "share_contact=false&amount_cents=1000000") | |
| 302 | + | .await; | |
| 303 | + | assert_status(&resp, 303, "the cap itself must be accepted"); | |
| 304 | + | assert_pending( | |
| 305 | + | &h, | |
| 306 | + | buyer_id, | |
| 307 | + | &[1_000_000], | |
| 308 | + | "the cap amount is charged exactly", | |
| 309 | + | ) | |
| 310 | + | .await; | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | /// Both sides of the 50 cent USD provider minimum. Under it the buyer gets a | |
| 314 | + | /// clean refusal and no pending row; at it the charge opens at 50 cents. | |
| 315 | + | #[tokio::test] | |
| 316 | + | async fn item_checkout_refuses_a_price_under_the_stripe_minimum_and_accepts_the_minimum_itself() { | |
| 317 | + | let mut h = TestHarness::with_mocks().await; | |
| 318 | + | let (_seller, project_id, cheap_item) = connected_seller_with_item(&mut h, "minsell", 49).await; | |
| 319 | + | ||
| 320 | + | // Second item priced at exactly the minimum, under the same seller. | |
| 321 | + | h.login("minsell", "password123").await; | |
| 322 | + | let at_min_item = add_published_item(&mut h, &project_id, "At+Minimum", 50).await; | |
| 323 | + | h.client.post_form("/logout", "").await; | |
| 324 | + | let buyer_id = h.signup("minbuyer", "minbuyer@test.com", "pass1234").await; | |
| 325 | + | ||
| 326 | + | let path = format!("/stripe/checkout/{cheap_item}"); | |
| 327 | + | let resp = h.client.post_form(&path, "share_contact=false").await; | |
| 328 | + | assert_status(&resp, 400, "49 cents is under the USD provider minimum"); | |
| 329 | + | assert_pending(&h, buyer_id, &[], "a sub-minimum item writes no charge").await; | |
| 330 | + | ||
| 331 | + | let path = format!("/stripe/checkout/{at_min_item}"); | |
| 332 | + | let resp = h.client.post_form(&path, "share_contact=false").await; | |
| 333 | + | assert_status( | |
| 334 | + | &resp, | |
| 335 | + | 303, | |
| 336 | + | "50 cents is exactly the minimum and is accepted", | |
| 337 | + | ); | |
| 338 | + | assert_pending( | |
| 339 | + | &h, | |
| 340 | + | buyer_id, | |
| 341 | + | &[50], | |
| 342 | + | "the minimum-priced item is charged at 50", | |
| 343 | + | ) | |
| 344 | + | .await; | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | /// Replay of the buyer-side submission: a double-posted checkout form (a | |
| 348 | + | /// refresh, a double click, a retried request) must leave exactly one open | |
| 349 | + | /// charge. The second post is answered with the purchase page rather than a | |
| 350 | + | /// second Stripe redirect, and the surviving row still points at the first | |
| 351 | + | /// session. | |
| 352 | + | #[tokio::test] | |
| 353 | + | async fn repeated_item_checkout_leaves_exactly_one_pending_charge() { | |
| 354 | + | let mut h = TestHarness::with_mocks().await; | |
| 355 | + | let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "dupeseller", 1234).await; | |
| 356 | + | let buyer_id = h | |
| 357 | + | .signup("dupebuyer", "dupebuyer@test.com", "pass1234") | |
| 358 | + | .await; | |
| 359 | + | let url = format!("/stripe/checkout/{item_id}"); | |
| 360 | + | ||
| 361 | + | let first = h.client.post_form(&url, "share_contact=false").await; | |
| 362 | + | assert_status(&first, 303, "first checkout must redirect to Stripe"); | |
| 363 | + | let first_session = pending_session_id(&h, buyer_id).await; | |
| 364 | + | ||
| 365 | + | let second = h.client.post_form(&url, "share_contact=false").await; | |
| 366 | + | assert_status(&second, 303, "the replay answers with a redirect"); | |
| 367 | + | assert_redirect( | |
| 368 | + | &second, | |
| 369 | + | &format!("/purchase/{item_id}"), | |
| 370 | + | "the replay lands on the purchase page, not a second session", | |
| 371 | + | ); | |
| 372 | + | assert_pending(&h, buyer_id, &[1234], "a replay opens no second charge").await; | |
| 373 | + | assert_eq!( | |
| 374 | + | pending_session_id(&h, buyer_id).await, | |
| 375 | + | first_session, | |
| 376 | + | "the surviving pending row must still be the first session's" | |
| 377 | + | ); | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | /// Cancelling the pending checkout is what lets a buyer start over: the row is | |
| 381 | + | /// gone afterwards, and a fresh submission opens a new charge at the same price | |
| 382 | + | /// against a different session. | |
| 383 | + | #[tokio::test] | |
| 384 | + | async fn cancelling_a_pending_item_checkout_lets_the_buyer_start_a_fresh_one() { | |
| 385 | + | let mut h = TestHarness::with_mocks().await; | |
| 386 | + | let (_seller, _project, item_id) = connected_seller_with_item(&mut h, "cancelsell", 1234).await; | |
| 387 | + | let buyer_id = h | |
| 388 | + | .signup("cancelbuy", "cancelbuy@test.com", "pass1234") | |
| 389 | + | .await; | |
| 390 | + | let url = format!("/stripe/checkout/{item_id}"); | |
| 391 | + | ||
| 392 | + | let resp = h.client.post_form(&url, "share_contact=false").await; | |
| 393 | + | assert_status(&resp, 303, "first checkout must redirect"); | |
| 394 | + | let first_session = pending_session_id(&h, buyer_id).await; | |
| 395 | + | ||
| 396 | + | let resp = h | |
| 397 | + | .client | |
| 398 | + | .post_form(&format!("{url}/cancel-pending"), "") | |
| 399 | + | .await; | |
| 400 | + | assert_status(&resp, 303, "cancel-pending redirects to the purchase page"); | |
| 401 | + | assert_pending(&h, buyer_id, &[], "cancel-pending clears the open charge").await; | |
| 402 | + | ||
| 403 | + | let resp = h.client.post_form(&url, "share_contact=false").await; | |
| 404 | + | assert_status( | |
| 405 | + | &resp, | |
| 406 | + | 303, | |
| 407 | + | "a fresh checkout after cancelling must redirect", | |
| 408 | + | ); | |
| 409 | + | assert_pending(&h, buyer_id, &[1234], "the fresh checkout opens one charge").await; | |
| 410 | + | assert_ne!( | |
| 411 | + | pending_session_id(&h, buyer_id).await, | |
| 412 | + | first_session, | |
| 413 | + | "the fresh checkout must be a new session, not the cancelled one" | |
| 414 | + | ); | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | // routes::stripe::checkout::cart | |
| 418 | + | ||
| 419 | + | /// Two lines from one seller become one pending row each, at that line's own | |
| 420 | + | /// price, inside a single session. The prices are distinct and neither divides | |
| 421 | + | /// the other, so duplicating a line (1234 twice), averaging them (900) or | |
| 422 | + | /// charging the sum once all differ from the expected pair. | |
| 423 | + | #[tokio::test] | |
| 424 | + | async fn cart_checkout_creates_one_pending_row_per_line_at_that_lines_exact_price() { | |
| 425 | + | let mut h = TestHarness::with_mocks().await; | |
| 426 | + | let (seller_id, project_id, item_a) = | |
| 427 | + | connected_seller_with_item(&mut h, "cartlines", 1234).await; | |
| 428 | + | h.login("cartlines", "password123").await; | |
| 429 | + | let item_b = add_published_item(&mut h, &project_id, "Second+Line", 567).await; | |
| 430 | + | h.client.post_form("/logout", "").await; | |
| 431 | + | ||
| 432 | + | let buyer_id = h | |
| 433 | + | .signup("cartlinebuy", "cartlinebuy@test.com", "pass1234") | |
| 434 | + | .await; | |
| 435 | + | add_to_cart(&mut h, &item_a).await; | |
| 436 | + | add_to_cart(&mut h, &item_b).await; | |
| 437 | + | ||
| 438 | + | let body = format!("seller_id={seller_id}&share_contact=false"); | |
| 439 | + | let resp = h.client.post_form("/stripe/checkout/cart", &body).await; | |
| 440 | + | assert_status(&resp, 303, "cart checkout must redirect to Stripe"); | |
| 441 | + | ||
| 442 | + | let opened = sessions(&h); | |
| 443 | + | assert_sessions(&h, 1, "one seller's cart is one provider session"); | |
| 444 | + | assert_redirect( | |
| 445 | + | &resp, | |
| 446 | + | &opened[0].url, | |
| 447 | + | "buyer goes to the session just opened", | |
| 448 | + | ); | |
| 449 | + | assert_pending( | |
| 450 | + | &h, | |
| 451 | + | buyer_id, | |
| 452 | + | &[567, 1234], | |
| 453 | + | "each line is charged at its own price", | |
| 454 | + | ) | |
| 455 | + | .await; | |
| 456 | + | ||
| 457 | + | let rows: Vec<String> = sqlx::query_scalar( | |
| 458 | + | "SELECT DISTINCT stripe_checkout_session_id FROM transactions \ | |
| 459 | + | WHERE buyer_id = $1 AND status = 'pending'", | |
| 460 | + | ) | |
| 461 | + | .bind(buyer_id) | |
| 462 | + | .fetch_all(&h.db) | |
| 463 | + | .await | |
| 464 | + | .expect("read pending sessions"); | |
| 465 | + | assert_eq!( | |
| 466 | + | rows, | |
| 467 | + | vec![opened[0].id.clone()], | |
| 468 | + | "both lines must hang off the single session the buyer was sent to" | |
| 469 | + | ); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | /// A free line is claimed outright at zero and never reaches the provider; the | |
| 473 | + | /// paid line is charged alone. Asserting the free row is zero and the paid row | |
| 474 | + | /// is the full 1234 separates "claimed the free item" from "folded it into the | |
| 475 | + | /// charge". | |
| 476 | + | #[tokio::test] | |
| 477 | + | async fn cart_checkout_claims_a_free_line_at_zero_and_charges_only_the_paid_line() { | |
| 478 | + | let mut h = TestHarness::with_mocks().await; | |
| 479 | + | let (seller_id, project_id, paid_item) = | |
| 480 | + | connected_seller_with_item(&mut h, "cartfree", 1234).await; | |
| 481 | + | h.login("cartfree", "password123").await; | |
| 482 | + | let free_item = add_published_item(&mut h, &project_id, "Free+Line", 0).await; | |
| 483 | + | h.client.post_form("/logout", "").await; | |
| 484 | + | ||
| 485 | + | let buyer_id = h | |
| 486 | + | .signup("cartfreebuy", "cartfreebuy@test.com", "pass1234") | |
| 487 | + | .await; | |
| 488 | + | add_to_cart(&mut h, &paid_item).await; | |
| 489 | + | add_to_cart(&mut h, &free_item).await; | |
| 490 | + | ||
| 491 | + | let body = format!("seller_id={seller_id}&share_contact=false"); | |
| 492 | + | let resp = h.client.post_form("/stripe/checkout/cart", &body).await; | |
| 493 | + | assert_status(&resp, 303, "mixed free/paid cart checkout must redirect"); | |
| 494 | + | assert_pending( | |
| 495 | + | &h, | |
| 496 | + | buyer_id, | |
| 497 | + | &[1234], | |
| 498 | + | "only the paid line is charged, at its price", | |
| 499 | + | ) | |
| 500 | + | .await; |
Lines truncated
| @@ -1,0 +1,319 @@ | |||
| 1 | + | //! Route-layer contract tests for `routes::stripe::checkout::tips`, the | |
| 2 | + | //! buyer-facing tip handler. | |
| 3 | + | //! | |
| 4 | + | //! A tip is the one checkout whose amount the payer types in whole dollars, so | |
| 5 | + | //! the conversion to integer cents is the contract: a handler that shipped the | |
| 6 | + | //! typed number straight through would charge a hundredth of the tip. Every | |
| 7 | + | //! test pins the exact cents figure on the pending tip row the handler writes, | |
| 8 | + | //! and ties that row to the provider by asserting its session id is the session | |
| 9 | + | //! the tipper was redirected to. Both halves are needed: the amount alone would | |
| 10 | + | //! not prove it belongs to this session, the session alone would not prove the | |
| 11 | + | //! amount. | |
| 12 | + | //! | |
| 13 | + | //! Also pinned: both ends of the one-to-ten-thousand-dollar range, inclusive; | |
| 14 | + | //! the 280-character message truncation; the project-ownership check; refusal | |
| 15 | + | //! of a disabled recipient and of a self-tip; and that each submission records | |
| 16 | + | //! its own pending tip, because a tip is a fresh intent to give rather than an | |
| 17 | + | //! idempotent purchase, which is the opposite of the item and cart contract. | |
| 18 | + | //! | |
| 19 | + | //! The item and cart handlers are `stripe_checkout_routes`. | |
| 20 | + | //! | |
| 21 | + | //! Delete this file and the dollars-to-cents conversion stops being checked | |
| 22 | + | //! anywhere. | |
| 23 | + | ||
| 24 | + | use crate::harness::TestHarness; | |
| 25 | + | use crate::harness::client::TestResponse; | |
| 26 | + | use crate::harness::stripe::MockCheckout; | |
| 27 | + | use makenotwork::db; | |
| 28 | + | ||
| 29 | + | // Helpers | |
| 30 | + | ||
| 31 | + | /// Assert an exact status, reporting the body when it does not hold. | |
| 32 | + | fn assert_status(resp: &TestResponse, expected: u16, what: &str) { | |
| 33 | + | assert_eq!( | |
| 34 | + | resp.status, expected, | |
| 35 | + | "{what}: got {} with body {}", | |
| 36 | + | resp.status, resp.text | |
| 37 | + | ); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Assert the exact redirect target, reporting the body when it does not hold. | |
| 41 | + | fn assert_redirect(resp: &TestResponse, target: &str, what: &str) { | |
| 42 | + | assert_eq!( | |
| 43 | + | resp.header("location"), | |
| 44 | + | Some(target), | |
| 45 | + | "{what}, body: {}", | |
| 46 | + | resp.text | |
| 47 | + | ); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Sessions the mock provider was asked to open, in call order. | |
| 51 | + | fn sessions(h: &TestHarness) -> Vec<MockCheckout> { | |
| 52 | + | h.mock_stripe | |
| 53 | + | .as_ref() | |
| 54 | + | .expect("mock stripe configured") | |
| 55 | + | .checkouts() | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Assert how many provider sessions have been opened so far. | |
| 59 | + | fn assert_sessions(h: &TestHarness, expected: usize, what: &str) { | |
| 60 | + | let opened = sessions(h); | |
| 61 | + | assert_eq!(opened.len(), expected, "{what}, got {opened:?}"); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Create a Stripe-connected creator with one published paid item, then log | |
| 65 | + | /// out. Returns `(seller_id, project_id, item_id)`. | |
| 66 | + | async fn connected_seller_with_item( | |
| 67 | + | h: &mut TestHarness, | |
| 68 | + | username: &str, | |
| 69 | + | price_cents: i64, | |
| 70 | + | ) -> (db::UserId, String, String) { | |
| 71 | + | let setup = h | |
| 72 | + | .create_creator_with_item(username, "audio", price_cents) | |
| 73 | + | .await; | |
| 74 | + | h.connect_stripe(setup.user_id, &format!("acct_test_{username}")) | |
| 75 | + | .await; | |
| 76 | + | h.publish_project_and_item(&setup.project_id, &setup.item_id) | |
| 77 | + | .await; | |
| 78 | + | h.client.post_form("/logout", "").await; | |
| 79 | + | (setup.user_id, setup.project_id, setup.item_id) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // routes::stripe::checkout::item | |
| 83 | + | ||
| 84 | + | /// The pending charge is the item's price in cents, unrounded and unscaled, and | |
| 85 | + | /// it belongs to the session the buyer was redirected to. 1234 is chosen so a | |
| 86 | + | /// dollars/cents mix-up (12 or 123400) and a round to the nearest dollar (1200) | |
| 87 | + | /// Create a Stripe-connected creator who accepts tips, then log out. Returns | |
| 88 | + | /// `(recipient_id, project_id)`. | |
| 89 | + | async fn tip_recipient(h: &mut TestHarness, username: &str) -> (db::UserId, String) { | |
| 90 | + | let setup = h.create_creator_with_item(username, "audio", 1234).await; | |
| 91 | + | h.connect_stripe(setup.user_id, &format!("acct_test_{username}")) | |
| 92 | + | .await; | |
| 93 | + | sqlx::query("UPDATE users SET tips_enabled = true WHERE id = $1") | |
| 94 | + | .bind(setup.user_id) | |
| 95 | + | .execute(&h.db) | |
| 96 | + | .await | |
| 97 | + | .expect("enable tips"); | |
| 98 | + | h.client.post_form("/logout", "").await; | |
| 99 | + | (setup.user_id, setup.project_id) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// How many pending tips a tipper has. | |
| 103 | + | async fn tip_count(h: &TestHarness, tipper_id: db::UserId) -> i64 { | |
| 104 | + | sqlx::query_scalar("SELECT COUNT(*) FROM tips WHERE tipper_id = $1") | |
| 105 | + | .bind(tipper_id) | |
| 106 | + | .fetch_one(&h.db) | |
| 107 | + | .await | |
| 108 | + | .expect("count tips") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Tips are submitted in whole dollars and stored in cents. Seven dollars is | |
| 112 | + | /// chosen because 700 differs from every plausible wrong operator: 7 (no | |
| 113 | + | /// conversion), 107 (addition), 0 (integer division), 100 (a constant). | |
| 114 | + | #[tokio::test] | |
| 115 | + | async fn tip_checkout_converts_whole_dollars_to_cents() { | |
| 116 | + | let mut h = TestHarness::with_mocks().await; | |
| 117 | + | let (recipient_id, _project) = tip_recipient(&mut h, "tipgetter").await; | |
| 118 | + | let tipper_id = h.signup("tipper", "tipper@test.com", "pass1234").await; | |
| 119 | + | ||
| 120 | + | let path = format!("/stripe/checkout/tip/{recipient_id}"); | |
| 121 | + | let resp = h.client.post_form(&path, "amount_dollars=7").await; | |
| 122 | + | assert_status(&resp, 303, "tip checkout must redirect to Stripe"); | |
| 123 | + | let opened = sessions(&h); | |
| 124 | + | assert_sessions(&h, 1, "one tip submission opens one provider session"); | |
| 125 | + | assert_redirect(&resp, &opened[0].url, "tipper goes to the session opened"); | |
| 126 | + | ||
| 127 | + | let (amount, session_id): (i32, String) = sqlx::query_as( | |
| 128 | + | "SELECT amount_cents, stripe_checkout_session_id FROM tips \ | |
| 129 | + | WHERE tipper_id = $1 AND recipient_id = $2", | |
| 130 | + | ) | |
| 131 | + | .bind(tipper_id) | |
| 132 | + | .bind(recipient_id) | |
| 133 | + | .fetch_one(&h.db) | |
| 134 | + | .await | |
| 135 | + | .expect("pending tip row"); | |
| 136 | + | assert_eq!(amount, 700, "a $7 tip is 700 cents"); | |
| 137 | + | assert_eq!( | |
| 138 | + | session_id, opened[0].id, | |
| 139 | + | "the tip row must reference the session the tipper was sent to" | |
| 140 | + | ); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Both sides of both ends of the accepted range: $0 and $10,001 are refused, | |
| 144 | + | /// $1 and $10,000 go through at 100 and 1,000,000 cents. | |
| 145 | + | #[tokio::test] | |
| 146 | + | async fn tip_checkout_refuses_amounts_outside_the_one_to_ten_thousand_dollar_range() { | |
| 147 | + | let mut h = TestHarness::with_mocks().await; | |
| 148 | + | let (recipient_id, _project) = tip_recipient(&mut h, "boundsgetter").await; | |
| 149 | + | let tipper_id = h | |
| 150 | + | .signup("boundstipper", "boundstipper@test.com", "pass1234") | |
| 151 | + | .await; | |
| 152 | + | let url = format!("/stripe/checkout/tip/{recipient_id}"); | |
| 153 | + | ||
| 154 | + | let resp = h.client.post_form(&url, "amount_dollars=0").await; | |
| 155 | + | assert_status(&resp, 400, "a $0 tip is under the $1 minimum"); | |
| 156 | + | let resp = h.client.post_form(&url, "amount_dollars=10001").await; | |
| 157 | + | assert_status(&resp, 400, "a $10,001 tip is over the $10,000 maximum"); | |
| 158 | + | let resp = h.client.post_form(&url, "amount_dollars=1").await; | |
| 159 | + | assert_status(&resp, 303, "the $1 minimum itself is accepted"); | |
| 160 | + | let resp = h.client.post_form(&url, "amount_dollars=10000").await; | |
| 161 | + | assert_status(&resp, 303, "the $10,000 maximum itself is accepted"); | |
| 162 | + | ||
| 163 | + | let amounts: Vec<i32> = sqlx::query_scalar( | |
| 164 | + | "SELECT amount_cents FROM tips WHERE tipper_id = $1 ORDER BY amount_cents", | |
| 165 | + | ) | |
| 166 | + | .bind(tipper_id) | |
| 167 | + | .fetch_all(&h.db) | |
| 168 | + | .await | |
| 169 | + | .expect("read tips"); | |
| 170 | + | assert_eq!( | |
| 171 | + | amounts, | |
| 172 | + | vec![100, 1_000_000], | |
| 173 | + | "only the two in-range tips may exist, each converted to cents" | |
| 174 | + | ); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// A tip may name a project only when that project belongs to the recipient: | |
| 178 | + | /// the project decides which revenue splits the webhook credits, so an | |
| 179 | + | /// unrelated project would pay strangers out of this creator's tip. | |
| 180 | + | #[tokio::test] | |
| 181 | + | async fn tip_checkout_refuses_a_project_that_belongs_to_another_creator() { | |
| 182 | + | let mut h = TestHarness::with_mocks().await; | |
| 183 | + | let (recipient_id, own_project) = tip_recipient(&mut h, "projgetter").await; | |
| 184 | + | let (_other, other_project, _item) = | |
| 185 | + | connected_seller_with_item(&mut h, "otherowner", 1234).await; | |
| 186 | + | let tipper_id = h | |
| 187 | + | .signup("projtipper", "projtipper@test.com", "pass1234") | |
| 188 | + | .await; | |
| 189 | + | let url = format!("/stripe/checkout/tip/{recipient_id}"); | |
| 190 | + | ||
| 191 | + | let form = format!("amount_dollars=7&project_id={other_project}"); | |
| 192 | + | let resp = h.client.post_form(&url, &form).await; | |
| 193 | + | assert_status(&resp, 400, "a project owned by someone else is refused"); | |
| 194 | + | assert_eq!( | |
| 195 | + | tip_count(&h, tipper_id).await, | |
| 196 | + | 0, | |
| 197 | + | "a refused tip must not leave a pending row behind" | |
| 198 | + | ); | |
| 199 | + | ||
| 200 | + | let form = format!("amount_dollars=7&project_id={own_project}"); | |
| 201 | + | let resp = h.client.post_form(&url, &form).await; | |
| 202 | + | assert_status(&resp, 303, "the recipient's own project is accepted"); | |
| 203 | + | let (amount, project): (i32, String) = | |
| 204 | + | sqlx::query_as("SELECT amount_cents, project_id::text FROM tips WHERE tipper_id = $1") | |
| 205 | + | .bind(tipper_id) | |
| 206 | + | .fetch_one(&h.db) | |
| 207 | + | .await | |
| 208 | + | .expect("pending tip row"); | |
| 209 | + | assert_eq!(amount, 700, "the accepted tip is still 700 cents"); | |
| 210 | + | assert_eq!( | |
| 211 | + | project, own_project, | |
| 212 | + | "the tip must be attributed to the project that was submitted" | |
| 213 | + | ); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Tip messages are truncated at 280 characters rather than rejected, and the | |
| 217 | + | /// kept part is the leading 280, not an arbitrary slice. | |
| 218 | + | #[tokio::test] | |
| 219 | + | async fn tip_checkout_truncates_a_message_at_two_hundred_eighty_characters() { | |
| 220 | + | let mut h = TestHarness::with_mocks().await; | |
| 221 | + | let (recipient_id, _project) = tip_recipient(&mut h, "msggetter").await; | |
| 222 | + | let tipper_id = h | |
| 223 | + | .signup("msgtipper", "msgtipper@test.com", "pass1234") | |
| 224 | + | .await; | |
| 225 | + | ||
| 226 | + | // 285 characters, distinguishable by position: the 281st onward is the part | |
| 227 | + | // that must be dropped. | |
| 228 | + | let sent: String = std::iter::repeat_n('a', 280) | |
| 229 | + | .chain(std::iter::repeat_n('z', 5)) | |
| 230 | + | .collect(); | |
| 231 | + | let path = format!("/stripe/checkout/tip/{recipient_id}"); | |
| 232 | + | let resp = h | |
| 233 | + | .client | |
| 234 | + | .post_form(&path, &format!("amount_dollars=7&message={sent}")) | |
| 235 | + | .await; | |
| 236 | + | assert_status(&resp, 303, "an over-long message is truncated, not refused"); | |
| 237 | + | ||
| 238 | + | let stored: String = sqlx::query_scalar("SELECT message FROM tips WHERE tipper_id = $1") | |
| 239 | + | .bind(tipper_id) | |
| 240 | + | .fetch_one(&h.db) | |
| 241 | + | .await | |
| 242 | + | .expect("pending tip row"); | |
| 243 | + | assert_eq!( | |
| 244 | + | stored.chars().count(), | |
| 245 | + | 280, | |
| 246 | + | "the stored message must be exactly 280 characters, got {stored:?}" | |
| 247 | + | ); | |
| 248 | + | assert_eq!( | |
| 249 | + | stored, | |
| 250 | + | sent.chars().take(280).collect::<String>(), | |
| 251 | + | "truncation must keep the leading 280 characters" | |
| 252 | + | ); | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /// Tips are not deduplicated: each submission is a separate intent to give, so | |
| 256 | + | /// two identical posts leave two pending tips against two distinct sessions. | |
| 257 | + | /// Pinned because the opposite reading (collapsing them) would silently drop a | |
| 258 | + | /// tip the giver meant to send. | |
| 259 | + | #[tokio::test] | |
| 260 | + | async fn each_tip_submission_records_its_own_pending_tip() { | |
| 261 | + | let mut h = TestHarness::with_mocks().await; | |
| 262 | + | let (recipient_id, _project) = tip_recipient(&mut h, "twicegetter").await; | |
| 263 | + | let tipper_id = h | |
| 264 | + | .signup("twicetipper", "twicetipper@test.com", "pass1234") | |
| 265 | + | .await; | |
| 266 | + | let url = format!("/stripe/checkout/tip/{recipient_id}"); | |
| 267 | + | ||
| 268 | + | for attempt in 1..=2 { | |
| 269 | + | let resp = h.client.post_form(&url, "amount_dollars=7").await; | |
| 270 | + | assert_status(&resp, 303, &format!("tip submission {attempt} redirects")); | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | let rows: Vec<(i32, String)> = sqlx::query_as( | |
| 274 | + | "SELECT amount_cents, stripe_checkout_session_id FROM tips WHERE tipper_id = $1", | |
| 275 | + | ) | |
| 276 | + | .bind(tipper_id) | |
| 277 | + | .fetch_all(&h.db) | |
| 278 | + | .await | |
| 279 | + | .expect("read tips"); | |
| 280 | + | assert_eq!(rows.len(), 2, "each submission records its own tip"); | |
| 281 | + | assert!( | |
| 282 | + | rows.iter().all(|(amount, _)| *amount == 700), | |
| 283 | + | "both tips are 700 cents, not one doubled row: {rows:?}" | |
| 284 | + | ); | |
| 285 | + | assert_ne!( | |
| 286 | + | rows[0].1, rows[1].1, | |
| 287 | + | "the two tips must hang off distinct provider sessions: {rows:?}" | |
| 288 | + | ); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// A creator who has not turned tips on cannot be tipped, and nobody can tip | |
| 292 | + | /// themselves. Both refusals must happen before any row is written. | |
| 293 | + | #[tokio::test] | |
| 294 | + | async fn tip_checkout_refuses_a_disabled_recipient_and_a_self_tip() { | |
| 295 | + | let mut h = TestHarness::with_mocks().await; | |
| 296 | + | let (recipient_id, _project) = tip_recipient(&mut h, "offgetter").await; | |
| 297 | + | sqlx::query("UPDATE users SET tips_enabled = false WHERE id = $1") | |
| 298 | + | .bind(recipient_id) | |
| 299 | + | .execute(&h.db) | |
| 300 | + | .await | |
| 301 | + | .expect("disable tips"); | |
| 302 | + | let tipper_id = h | |
| 303 | + | .signup("offtipper", "offtipper@test.com", "pass1234") | |
| 304 | + | .await; | |
| 305 | + | ||
| 306 | + | let path = format!("/stripe/checkout/tip/{recipient_id}"); | |
| 307 | + | let resp = h.client.post_form(&path, "amount_dollars=7").await; | |
| 308 | + | assert_status(&resp, 400, "a creator not accepting tips is refused"); | |
| 309 | + | ||
| 310 | + | let path = format!("/stripe/checkout/tip/{tipper_id}"); | |
| 311 | + | let resp = h.client.post_form(&path, "amount_dollars=7").await; | |
| 312 | + | assert_status(&resp, 400, "a self-tip is refused"); | |
| 313 | + | assert_eq!( | |
| 314 | + | tip_count(&h, tipper_id).await, | |
| 315 | + | 0, | |
| 316 | + | "neither refusal may leave a pending tip behind" | |
| 317 | + | ); | |
| 318 | + | assert_sessions(&h, 0, "no provider session may be opened for a refused tip"); | |
| 319 | + | } |