Skip to main content

max / makenotwork

8.3 KB · 210 lines History Blame Raw
1 //! Export workflow tests: projects JSON, sales CSV, purchases CSV, followers CSV.
2 //! Skips content export (needs S3).
3
4 use crate::harness::TestHarness;
5 use makenotwork::db::{ItemId, ProjectId, UserId};
6 use serde_json::Value;
7 use sqlx::PgPool;
8 use std::sync::atomic::{AtomicU32, Ordering};
9
10 /// Monotonic counter for unique buyer usernames.
11 static BUYER_COUNTER: AtomicU32 = AtomicU32::new(1000);
12
13 /// Create a unique buyer via direct SQL.
14 async fn create_buyer(pool: &PgPool) -> UserId {
15 let n = BUYER_COUNTER.fetch_add(1, Ordering::Relaxed);
16 let id = UserId::new();
17 sqlx::query(
18 "INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, 'not-a-real-hash')",
19 )
20 .bind(id)
21 .bind(format!("expbuyer{n}"))
22 .bind(format!("expbuyer{n}@test.com"))
23 .execute(pool)
24 .await
25 .expect("create buyer");
26 id
27 }
28
29 /// Insert a completed transaction.
30 async fn insert_transaction(
31 pool: &PgPool,
32 buyer_id: UserId,
33 seller_id: UserId,
34 item_id: ItemId,
35 amount_cents: i32,
36 share_contact: bool,
37 ) {
38 sqlx::query(
39 r#"
40 INSERT INTO transactions
41 (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
42 stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact)
43 VALUES ($1, $2, $3, $4, 0, $5, 'completed', NOW(), 'Test Item', 'expseller', $6)
44 "#,
45 )
46 .bind(buyer_id)
47 .bind(seller_id)
48 .bind(item_id)
49 .bind(amount_cents)
50 .bind(format!("exp-{}-{}", buyer_id, amount_cents))
51 .bind(share_contact)
52 .execute(pool)
53 .await
54 .expect("insert transaction");
55 }
56
57 /// Insert a follow relationship.
58 async fn insert_follow(pool: &PgPool, follower_id: UserId, target_id: uuid::Uuid) {
59 sqlx::query(
60 "INSERT INTO follows (follower_id, target_type, target_id) VALUES ($1, 'user', $2)",
61 )
62 .bind(follower_id)
63 .bind(target_id)
64 .execute(pool)
65 .await
66 .expect("insert follow");
67 }
68
69
70 #[tokio::test]
71 async fn export_projects_json() {
72 let mut h = TestHarness::new().await;
73 let _ = h.create_creator_with_item("expseller", "digital", 1000).await;
74
75 let resp = h.client.post_form("/api/export/projects", "").await;
76 assert!(resp.status.is_success(), "Export projects failed: {} {}", resp.status, resp.text);
77
78 // Verify Content-Disposition header
79 let disposition = resp.header("content-disposition").expect("should have Content-Disposition");
80 assert!(disposition.contains("makenot-work-projects.json"), "Filename should be in Content-Disposition");
81
82 // Verify JSON structure
83 let export: Value = resp.json();
84 assert!(export["exported_at"].is_string(), "Should have exported_at");
85 let projects = export["projects"].as_array().expect("projects array");
86 assert_eq!(projects.len(), 1);
87 assert_eq!(projects[0]["slug"].as_str().unwrap(), "expseller-proj");
88 assert_eq!(projects[0]["title"].as_str().unwrap(), "Test Project");
89
90 let items = projects[0]["items"].as_array().expect("items array");
91 assert_eq!(items.len(), 1);
92 assert_eq!(items[0]["title"].as_str().unwrap(), "Test Item");
93 assert_eq!(items[0]["price_cents"].as_i64().unwrap(), 1000);
94 }
95
96 #[tokio::test]
97 async fn export_sales_csv() {
98 let mut h = TestHarness::new().await;
99 let setup = h.create_creator_with_item("expseller", "digital", 1000).await;
100 let seller_id = setup.user_id;
101 let item_id_str = setup.item_id;
102
103 // Insert a transaction via direct SQL
104 let item_id: ItemId = item_id_str.parse().unwrap();
105 let buyer_id = create_buyer(&h.db).await;
106 insert_transaction(&h.db, buyer_id, seller_id, item_id, 2999, true).await;
107
108 let resp = h.client.post_form("/api/export/sales", "").await;
109 assert!(resp.status.is_success(), "Export sales failed: {} {}", resp.status, resp.text);
110
111 let disposition = resp.header("content-disposition").expect("should have Content-Disposition");
112 assert!(disposition.contains("makenot-work-sales.csv"));
113
114 // Verify CSV header and data
115 assert!(resp.text.starts_with("Date,Item ID,Item Title,Amount,Status,Buyer Email"), "CSV should have correct header");
116 assert!(resp.text.contains("29.99"), "CSV should contain the amount");
117 assert!(resp.text.contains("completed"), "CSV should contain the status");
118 }
119
120 #[tokio::test]
121 async fn export_purchases_csv() {
122 let mut h = TestHarness::new().await;
123
124 // Create a seller with an item via SQL for the transaction
125 let seller_id = UserId::new();
126 sqlx::query("INSERT INTO users (id, username, email, password_hash) VALUES ($1, 'exps2', 'exps2@test.com', 'not-a-real-hash')")
127 .bind(seller_id)
128 .execute(&h.db)
129 .await
130 .unwrap();
131
132 let project_id = ProjectId::new();
133 let item_id = ItemId::new();
134 sqlx::query("INSERT INTO projects (id, user_id, slug, title) VALUES ($1, $2, 'purchase-proj', 'Purchase Proj')")
135 .bind(project_id)
136 .bind(seller_id)
137 .execute(&h.db)
138 .await
139 .unwrap();
140 sqlx::query("INSERT INTO items (id, project_id, title, price_cents, item_type, slug) VALUES ($1, $2, 'Purchase Item', 4999, 'digital', 'purchase-item')")
141 .bind(item_id)
142 .bind(project_id)
143 .execute(&h.db)
144 .await
145 .unwrap();
146
147 // Sign up buyer
148 let buyer_id = h.signup("expbuyer_p", "expbuyer_p@test.com", "password123").await;
149
150 // Insert transaction with buyer as purchaser
151 insert_transaction(&h.db, buyer_id, seller_id, item_id, 4999, false).await;
152
153 let resp = h.client.post_form("/api/export/purchases", "").await;
154 assert!(resp.status.is_success(), "Export purchases failed: {} {}", resp.status, resp.text);
155
156 let disposition = resp.header("content-disposition").expect("should have Content-Disposition");
157 assert!(disposition.contains("makenot-work-purchases.csv"));
158
159 assert!(resp.text.starts_with("Date,Item ID,Item Title,Amount,Status"), "CSV should have correct header");
160 assert!(resp.text.contains("49.99"), "CSV should contain the purchase amount");
161 }
162
163 #[tokio::test]
164 async fn export_followers_csv() {
165 let mut h = TestHarness::new().await;
166 let seller_id = h.create_creator_with_item("expseller", "digital", 1000).await.user_id;
167
168 // Insert a follow via direct SQL
169 let follower_id = create_buyer(&h.db).await;
170 let seller_uuid: uuid::Uuid = seller_id.into();
171 insert_follow(&h.db, follower_id, seller_uuid).await;
172
173 let resp = h.client.post_form("/api/export/followers", "").await;
174 assert!(resp.status.is_success(), "Export followers failed: {} {}", resp.status, resp.text);
175
176 let disposition = resp.header("content-disposition").expect("should have Content-Disposition");
177 assert!(disposition.contains("makenot-work-followers.csv"));
178
179 assert!(resp.text.starts_with("Section,Username,Display Name,Email,Type,Status,Since"), "CSV should have correct header");
180 assert!(resp.text.contains("Follower"), "CSV should contain follower rows");
181 }
182
183 #[tokio::test]
184 async fn export_empty_returns_valid_response() {
185 let mut h = TestHarness::new().await;
186 let _ = h.create_creator_with_item("expseller", "digital", 1000).await;
187
188 // Export projects (has data but no transactions/followers)
189 let resp = h.client.post_form("/api/export/projects", "").await;
190 assert!(resp.status.is_success(), "Empty projects export failed: {}", resp.text);
191 let export: Value = resp.json();
192 assert!(export["projects"].as_array().is_some());
193
194 // Export sales — no transactions
195 let resp = h.client.post_form("/api/export/sales", "").await;
196 assert!(resp.status.is_success(), "Empty sales export failed: {}", resp.text);
197 assert!(resp.text.starts_with("Date,Item ID"), "Should have CSV header even when empty");
198
199 // Export purchases — no purchases
200 let resp = h.client.post_form("/api/export/purchases", "").await;
201 assert!(resp.status.is_success(), "Empty purchases export failed: {}", resp.text);
202 assert!(resp.text.starts_with("Date,Item ID"), "Should have CSV header even when empty");
203
204 // Export followers — use different IP to avoid rate limit (burst 3, this is request 4)
205 h.client.set_forwarded_ip("10.0.0.99");
206 let resp = h.client.post_form("/api/export/followers", "").await;
207 assert!(resp.status.is_success(), "Empty followers export failed: {} {}", resp.status, resp.text);
208 assert!(resp.text.starts_with("Section,Username,Display Name,Email"), "Should have CSV header even when empty");
209 }
210