Skip to main content

max / makenotwork

28.1 KB · 768 lines History Blame Raw
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,
501 status: "pending",
502 ..Sale::completed(sharer, seller, item, 9999)
503 }
504 .insert(&db.pool, "cs_contacts_sharer_pending")
505 .await;
506 // Shared, then revoked: the buyer disappears from the list entirely.
507 Sale {
508 share_contact: true,
509 ..Sale::completed(revoker, seller, item, 3050)
510 }
511 .insert(&db.pool, "cs_contacts_revoker")
512 .await;
513 // Never shared at all.
514 Sale::completed(private_buyer, seller, item, 4100)
515 .insert(&db.pool, "cs_contacts_private")
516 .await;
517
518 transactions::revoke_contact_sharing(&db.pool, revoker, seller)
519 .await
520 .expect("revoke ok");
521
522 let contacts = transactions::get_seller_contacts(&db.pool, seller)
523 .await
524 .expect("seller contacts query ok");
525
526 let usernames: Vec<&str> = contacts.iter().map(|c| c.username.as_str()).collect();
527 assert_eq!(
528 usernames,
529 vec!["contacts_sharer"],
530 "only the un-revoked opted-in buyer is a contact, got {usernames:?}"
531 );
532 assert_eq!(
533 contacts[0].total_purchases, 2,
534 "the pending purchase is not a purchase"
535 );
536 assert_eq!(
537 contacts[0].total_spent_cents, 4200,
538 "spend is 1975 + 2225, with the pending 9999 excluded"
539 );
540 assert_eq!(contacts[0].email, "contacts_sharer@test.com");
541 }
542
543 #[tokio::test]
544 async fn revoking_contact_sharing_is_idempotent_and_reversible() {
545 let db = TestDb::new().await;
546 let seller = seed_user(&db.pool, "revoke_seller").await;
547 let buyer = seed_user(&db.pool, "revoke_buyer").await;
548 let project = seed_titled_project(&db.pool, seller, "revoke", "Revoke").await;
549 let item = seed_item(&db.pool, project, "revoke-a").await;
550 Sale {
551 share_contact: true,
552 ..Sale::completed(buyer, seller, item, 2750)
553 }
554 .insert(&db.pool, "cs_revoke_1")
555 .await;
556
557 assert_eq!(
558 transactions::get_seller_contacts(&db.pool, seller)
559 .await
560 .unwrap()
561 .len(),
562 1,
563 "the buyer starts out as a contact"
564 );
565
566 // Revoking twice is one revocation: the second call hits ON CONFLICT DO
567 // NOTHING, so it neither errors nor writes a second row.
568 transactions::revoke_contact_sharing(&db.pool, buyer, seller)
569 .await
570 .expect("first revoke ok");
571 transactions::revoke_contact_sharing(&db.pool, buyer, seller)
572 .await
573 .expect("repeat revoke ok");
574
575 let revocations: i64 =
576 sqlx::query_scalar("SELECT COUNT(*) FROM contact_revocations WHERE buyer_id = $1")
577 .bind(buyer)
578 .fetch_one(&db.pool)
579 .await
580 .unwrap();
581 assert_eq!(revocations, 1, "a repeated revocation stays one row");
582 assert!(
583 transactions::get_seller_contacts(&db.pool, seller)
584 .await
585 .unwrap()
586 .is_empty(),
587 "a revoked buyer is not a contact"
588 );
589
590 // Clearing the revocation (the fan shares again on a new purchase) restores
591 // the contact with its history intact.
592 transactions::clear_contact_revocation(&db.pool, buyer, seller)
593 .await
594 .expect("clear ok");
595 let restored = transactions::get_seller_contacts(&db.pool, seller)
596 .await
597 .unwrap();
598 assert_eq!(
599 restored.len(),
600 1,
601 "clearing the revocation restores the contact"
602 );
603 assert_eq!(
604 restored[0].total_spent_cents, 2750,
605 "the restored contact keeps the purchase history it always had"
606 );
607 }
608
609 #[tokio::test]
610 async fn shared_creators_list_the_fan_still_shares_with_and_no_others() {
611 let db = TestDb::new().await;
612 let buyer = seed_user(&db.pool, "shared_buyer").await;
613 let kept = seed_user(&db.pool, "shared_kept").await;
614 let dropped = seed_user(&db.pool, "shared_dropped").await;
615 let never = seed_user(&db.pool, "shared_never").await;
616
617 for (seller, share, tag) in [
618 (kept, true, "kept"),
619 (dropped, true, "dropped"),
620 (never, false, "never"),
621 ] {
622 let project = seed_titled_project(&db.pool, seller, &format!("shared-{tag}"), "S").await;
623 let item = seed_item(&db.pool, project, &format!("shared-{tag}-a")).await;
624 Sale {
625 share_contact: share,
626 ..Sale::completed(buyer, seller, item, 3300)
627 }
628 .insert(&db.pool, &format!("cs_shared_{tag}"))
629 .await;
630 }
631 transactions::revoke_contact_sharing(&db.pool, buyer, dropped)
632 .await
633 .expect("revoke ok");
634
635 let creators = transactions::get_shared_creators(&db.pool, buyer)
636 .await
637 .expect("shared creators query ok");
638 let names: Vec<&str> = creators.iter().map(|c| c.username.as_str()).collect();
639 assert_eq!(
640 names,
641 vec!["shared_kept"],
642 "only the creator the fan shares with, and still shares with, is listed, got {names:?}"
643 );
644 assert_eq!(creators[0].seller_id, kept);
645 }
646
647 #[tokio::test]
648 async fn platform_notification_buyers_ignore_sharing_and_honour_the_limit() {
649 let db = TestDb::new().await;
650 let seller = seed_user(&db.pool, "notify_seller").await;
651 let other_seller = seed_user(&db.pool, "notify_other_seller").await;
652 let project = seed_titled_project(&db.pool, seller, "notify", "Notify").await;
653 let item = seed_item(&db.pool, project, "notify-a").await;
654
655 // Three buyers, none of whom shared contact, one of whom also revoked:
656 // platform notices about content removal reach all of them anyway.
657 let buyers = [
658 seed_user(&db.pool, "notify_b1").await,
659 seed_user(&db.pool, "notify_b2").await,
660 seed_user(&db.pool, "notify_b3").await,
661 ];
662 for (i, amount) in [1107, 1214, 1321].into_iter().enumerate() {
663 Sale::completed(buyers[i], seller, item, amount)
664 .insert(&db.pool, &format!("cs_notify_{i}"))
665 .await;
666 }
667 transactions::revoke_contact_sharing(&db.pool, buyers[0], seller)
668 .await
669 .expect("revoke ok");
670 // A pending purchase is not a purchase, and another creator's buyer is not
671 // this creator's buyer.
672 let pending_buyer = seed_user(&db.pool, "notify_pending").await;
673 Sale {
674 status: "pending",
675 ..Sale::completed(pending_buyer, seller, item, 2100)
676 }
677 .insert(&db.pool, "cs_notify_pending")
678 .await;
679 let foreign_buyer = seed_user(&db.pool, "notify_foreign").await;
680 let foreign_project =
681 seed_titled_project(&db.pool, other_seller, "notify-foreign", "Foreign").await;
682 let foreign_item = seed_item(&db.pool, foreign_project, "notify-foreign-a").await;
683 Sale::completed(foreign_buyer, other_seller, foreign_item, 2200)
684 .insert(&db.pool, "cs_notify_foreign")
685 .await;
686
687 let all = transactions::get_all_buyers_for_seller(&db.pool, seller, 100)
688 .await
689 .expect("buyer notification query ok");
690 let mut emails: Vec<String> = all.into_iter().map(|r| r.email).collect();
691 emails.sort();
692 assert_eq!(
693 emails,
694 vec![
695 "notify_b1@test.com".to_string(),
696 "notify_b2@test.com".to_string(),
697 "notify_b3@test.com".to_string(),
698 ],
699 "every completed buyer of this seller is reachable, sharing preference and revocation notwithstanding"
700 );
701
702 // The cap is what bounds outbound mail volume, and the caller detects it by
703 // the returned length, so a limit below the population must return exactly
704 // that many rows.
705 let capped = transactions::get_all_buyers_for_seller(&db.pool, seller, 2)
706 .await
707 .expect("capped query ok");
708 assert_eq!(capped.len(), 2, "the limit is a row cap, not a suggestion");
709 }
710
711 #[tokio::test]
712 async fn contacts_export_pages_walk_each_buyer_once_newest_first() {
713 let db = TestDb::new().await;
714 let seller = seed_user(&db.pool, "page_seller").await;
715 let project = seed_titled_project(&db.pool, seller, "page", "Page").await;
716 let item = seed_item(&db.pool, project, "page-a").await;
717
718 // Three contacts with distinct last-purchase times: newest first is
719 // page_c (1h), then page_b (4h), then page_a (9h). Seeded in a different
720 // order than they should come back, so "insertion order" cannot pass.
721 for (username, hours_ago, amount) in [
722 ("page_b", 4, 2600),
723 ("page_a", 9, 1350),
724 ("page_c", 1, 3900),
725 ] {
726 let buyer = seed_user(&db.pool, username).await;
727 Sale {
728 share_contact: true,
729 hours_ago,
730 ..Sale::completed(buyer, seller, item, amount)
731 }
732 .insert(&db.pool, &format!("cs_page_{username}"))
733 .await;
734 }
735
736 let first = transactions::get_seller_contacts_page(&db.pool, seller, 2, 0)
737 .await
738 .expect("first page ok");
739 let second = transactions::get_seller_contacts_page(&db.pool, seller, 2, 2)
740 .await
741 .expect("second page ok");
742 let third = transactions::get_seller_contacts_page(&db.pool, seller, 2, 4)
743 .await
744 .expect("third page ok");
745
746 let names = |rows: &[transactions::DbContactRow]| -> Vec<String> {
747 rows.iter().map(|r| r.username.clone()).collect()
748 };
749 assert_eq!(
750 names(&first),
751 vec!["page_c".to_string(), "page_b".to_string()],
752 "the first page is the two most recent purchasers, newest first"
753 );
754 assert_eq!(
755 names(&second),
756 vec!["page_a".to_string()],
757 "the second page continues where the first stopped, with no repeat"
758 );
759 assert!(
760 third.is_empty(),
761 "an offset past the end is an empty page, not a wrap-around"
762 );
763 assert_eq!(
764 second[0].total_spent_cents, 1350,
765 "each paged row carries that buyer's own spend"
766 );
767 }
768