mod common; use common::{ category_id_by_slug, client, login_admin, register_customer, spawn_app, TestApp, }; use serial_test::serial; use uuid::Uuid; // Merchant onboarding suite: each test provisions its own applicant and only // asserts on rows it created; the shared test database is never truncated. fn personal(category_id: &str, email: &str) -> serde_json::Value { serde_json::json!({ "entity_type": "personal", "real_name": "Jane Applicant", "category_ids": [category_id], "contact": { "name": "Jane Applicant", "phone": "13800000000", "email": email, "address": "1 Applicant Way" }, "qualification": { "identity_document_url": "https://example.com/id-card.png", "extra_materials": ["https://example.com/extra-1.pdf"] } }) } fn enterprise(category_id: &str, email: &str) -> serde_json::Value { serde_json::json!({ "entity_type": "enterprise", "company_name": "Acme Trading Co", "category_ids": [category_id], "contact": { "name": "Ann Manager", "phone": "13900000000", "email": email }, "qualification": { "business_license_url": "https://example.com/license.pdf", "business_license_no": "91310000MA1FL0XXXX", "extra_materials": ["https://example.com/tax.pdf"] } }) } async fn submit(app: &TestApp, token: &str, body: &serde_json::Value) -> reqwest::Response { client() .post(app.url("/api/merchant/applications")) .bearer_auth(token) .json(body) .send() .await .unwrap() } async fn mine(app: &TestApp, token: &str) -> Vec { let res = client() .get(app.url("/api/merchant/applications")) .bearer_auth(token) .send() .await .unwrap(); assert_eq!(res.status(), 200, "{:?}", res.text().await); res.json().await.unwrap() } async fn admin_reject( app: &TestApp, admin: &str, id: &str, reason: &str, ) -> reqwest::Response { client() .post(app.url(&format!("/api/admin/merchant/applications/{id}/reject"))) .bearer_auth(admin) .json(&serde_json::json!({ "reason": reason })) .send() .await .unwrap() } async fn admin_approve(app: &TestApp, admin: &str, id: &str) -> reqwest::Response { client() .post(app.url(&format!("/api/admin/merchant/applications/{id}/approve"))) .bearer_auth(admin) .send() .await .unwrap() } async fn contact_email(label: &str) -> String { format!("{label}-{}@biz.test", Uuid::new_v4()) } #[tokio::test] #[serial] async fn submissions_are_accepted_for_both_kinds() { let app = spawn_app().await; let admin = login_admin(&app).await; let cat = category_id_by_slug(&app, "electronics").await; let (seller, _) = register_customer(&app, "mo-enterprise").await; let mail = contact_email("enterprise").await; let res = submit(&app, &seller, &enterprise(&cat, &mail)).await; assert_eq!(res.status(), 201, "{:?}", res.text().await); let created: serde_json::Value = res.json().await.unwrap(); assert_eq!(created["entity_type"], "enterprise"); assert_eq!(created["status"], "pending"); assert_eq!(created["company_name"], "Acme Trading Co"); assert_eq!(created["contact"]["email"], mail); assert_eq!( created["qualification"]["business_license_no"], "91310000MA1FL0XXXX" ); assert_eq!(created["categories"][0]["id"], cat); assert_eq!(created["category_ids"][0], cat); assert!(created["created_shop_id"].is_null()); assert!(created["rejection_reason"].is_null()); let (buyer, _) = register_customer(&app, "mo-personal").await; let res = submit(&app, &buyer, &personal(&cat, &contact_email("personal").await)).await; assert_eq!(res.status(), 201, "{:?}", res.text().await); let personal_row: serde_json::Value = res.json().await.unwrap(); assert_eq!(personal_row["entity_type"], "personal"); assert_eq!(personal_row["real_name"], "Jane Applicant"); assert_eq!( personal_row["qualification"]["identity_document_url"], "https://example.com/id-card.png" ); assert!(personal_row["company_name"].is_null()); // The admin queue filters by status and includes both fresh rows. let listed: serde_json::Value = client() .get(app.url("/api/admin/merchant/applications")) .query(&[("status", "pending"), ("per_page", "100")]) .bearer_auth(&admin) .send() .await .unwrap() .json() .await .unwrap(); let ids: Vec<&str> = listed["items"] .as_array() .unwrap() .iter() .map(|a| a["id"].as_str().unwrap()) .collect(); assert!(ids.contains(&created["id"].as_str().unwrap())); assert!(ids.contains(&personal_row["id"].as_str().unwrap())); } #[tokio::test] #[serial] async fn submission_validation_rejects_bad_input() { let app = spawn_app().await; let cat = category_id_by_slug(&app, "electronics").await; let (seller, _) = register_customer(&app, "mo-invalid").await; // Anonymous submissions are refused. let anon = client() .post(app.url("/api/merchant/applications")) .json(&personal(&cat, &contact_email("anon").await)) .send() .await .unwrap(); assert_eq!(anon.status(), 401); // Personal without the identity document. let mut body = personal(&cat, &contact_email("nodoc").await); body["qualification"] = serde_json::json!({ "extra_materials": [] }); assert_eq!(submit(&app, &seller, &body).await.status(), 400); // Malformed qualification URL. let mut body = personal(&cat, &contact_email("badurl").await); body["qualification"]["identity_document_url"] = serde_json::json!("not-a-url"); assert_eq!(submit(&app, &seller, &body).await.status(), 400); // Enterprise missing its company name. let mut body = enterprise(&cat, &contact_email("nocname").await); body["company_name"] = serde_json::json!(" "); assert_eq!(submit(&app, &seller, &body).await.status(), 400); // No operating categories. let mut body = enterprise(&cat, &contact_email("nocat").await); body["category_ids"] = serde_json::json!([]); assert_eq!(submit(&app, &seller, &body).await.status(), 400); // Unknown category reference. let mut body = enterprise(&cat, &contact_email("unknowncat").await); body["category_ids"] = serde_json::json!([Uuid::new_v4()]); assert_eq!(submit(&app, &seller, &body).await.status(), 400); // Invalid contact email. let mut body = enterprise(&cat, &contact_email("badmail").await); body["contact"]["email"] = serde_json::json!("not-an-email"); assert_eq!(submit(&app, &seller, &body).await.status(), 400); // None of the rejected attempts stored a row. assert!(mine(&app, &seller).await.is_empty()); } #[tokio::test] #[serial] async fn one_active_application_per_user_and_reapply_after_rejection() { let app = spawn_app().await; let admin = login_admin(&app).await; let cat = category_id_by_slug(&app, "electronics").await; let (seller, _) = register_customer(&app, "mo-dedupe").await; let body = enterprise(&cat, &contact_email("dedupe").await); let first: serde_json::Value = submit(&app, &seller, &body) .await .json() .await .unwrap(); let id = first["id"].as_str().unwrap().to_string(); // A second live application is a conflict. let res = submit(&app, &seller, &body).await; assert_eq!(res.status(), 409, "{:?}", res.text().await); assert_eq!(mine(&app, &seller).await.len(), 1); // Rejection records the reason, then re-applying is allowed. let res = admin_reject(&app, &admin, &id, "qualification unreadable").await; assert_eq!(res.status(), 200, "{:?}", res.text().await); let rejected: serde_json::Value = res.json().await.unwrap(); assert_eq!(rejected["status"], "rejected"); assert_eq!(rejected["rejection_reason"], "qualification unreadable"); assert!(rejected["reviewed_at"].is_string()); let res = submit(&app, &seller, &body).await; assert_eq!(res.status(), 201, "{:?}", res.text().await); let reapplied: serde_json::Value = res.json().await.unwrap(); assert_ne!(reapplied["id"], first["id"]); assert_eq!(reapplied["status"], "pending"); assert_eq!(mine(&app, &seller).await.len(), 2); } #[tokio::test] #[serial] async fn reviews_are_guarded_and_rejection_needs_a_reason() { let app = spawn_app().await; let admin = login_admin(&app).await; let cat = category_id_by_slug(&app, "fashion").await; let (seller, _) = register_customer(&app, "mo-guard").await; let created: serde_json::Value = submit(&app, &seller, &personal(&cat, &contact_email("guard").await)) .await .json() .await .unwrap(); let id = created["id"].as_str().unwrap().to_string(); // Blank / whitespace reasons never transition. assert_eq!(admin_reject(&app, &admin, &id, "").await.status(), 400); assert_eq!(admin_reject(&app, &admin, &id, " ").await.status(), 400); assert_eq!( admin_reject(&app, &admin, &id, "material incomplete") .await .status(), 200 ); // Terminal states are immutable for both actions. assert_eq!(admin_approve(&app, &admin, &id).await.status(), 409); assert_eq!( admin_reject(&app, &admin, &id, "again").await.status(), 409 ); } #[tokio::test] #[serial] async fn approval_provisions_shop_and_owner_with_one_time_credentials() { let app = spawn_app().await; let admin = login_admin(&app).await; let cat = category_id_by_slug(&app, "home-living").await; let (seller, _) = register_customer(&app, "mo-approve").await; let mail = contact_email("approve").await; let created: serde_json::Value = submit(&app, &seller, &enterprise(&cat, &mail)) .await .json() .await .unwrap(); let id = created["id"].as_str().unwrap().to_string(); let res = admin_approve(&app, &admin, &id).await; assert_eq!(res.status(), 200, "{:?}", res.text().await); let approved: serde_json::Value = res.json().await.unwrap(); assert_eq!(approved["application"]["status"], "approved"); let shop_id = approved["application"]["created_shop_id"] .as_str() .unwrap() .to_string(); assert!(!shop_id.is_empty()); let credentials = &approved["credentials"]; assert_eq!(credentials["email"], mail); assert_eq!(credentials["shop_id"], shop_id); let password = credentials["initial_password"].as_str().unwrap().to_string(); assert!(password.len() >= 8); let slug = credentials["shop_slug"].as_str().unwrap().to_string(); assert!(slug.starts_with("acme-trading-co-"), "unexpected slug {slug}"); // The password is not retrievable from any later read. let detail: serde_json::Value = client() .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) .bearer_auth(&admin) .send() .await .unwrap() .json() .await .unwrap(); assert!(detail.get("credentials").is_none()); assert!(detail["initial_password"].is_null()); assert_eq!(detail["status"], "approved"); // The generated owner can log in and manage the linked shop. let res = client() .post(app.url("/api/auth/login")) .json(&serde_json::json!({ "email": mail, "password": password })) .send() .await .unwrap(); assert_eq!(res.status(), 200, "{:?}", res.text().await); let session: serde_json::Value = res.json().await.unwrap(); assert_eq!(session["user"]["role"], "shop_owner"); assert_eq!(session["user"]["shop_id"], shop_id); let owner_token = session["token"].as_str().unwrap().to_string(); let res = client() .get(app.url("/api/shop/profile")) .bearer_auth(&owner_token) .send() .await .unwrap(); assert_eq!(res.status(), 200, "{:?}", res.text().await); assert_eq!(res.json::().await.unwrap()["id"], shop_id); // One review only. assert_eq!(admin_approve(&app, &admin, &id).await.status(), 409); } #[tokio::test] #[serial] async fn concurrent_reviews_transition_exactly_once() { let app = spawn_app().await; let admin = login_admin(&app).await; let cat = category_id_by_slug(&app, "electronics").await; let (seller, _) = register_customer(&app, "mo-race").await; let created: serde_json::Value = submit(&app, &seller, &enterprise(&cat, &contact_email("race").await)) .await .json() .await .unwrap(); let id = created["id"].as_str().unwrap().to_string(); let (a, b) = tokio::join!( admin_approve(&app, &admin, &id), admin_approve(&app, &admin, &id) ); let statuses = [a.status().as_u16(), b.status().as_u16()]; assert!( statuses.contains(&200) && statuses.contains(&409), "expected one success and one conflict, got {statuses:?}" ); // Exactly one shop owner was provisioned for the created shop. let detail: serde_json::Value = client() .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) .bearer_auth(&admin) .send() .await .unwrap() .json() .await .unwrap(); let shop_id = detail["created_shop_id"].as_str().unwrap().to_string(); let owners: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM users WHERE shop_id = $1::uuid AND role = 'shop_owner'", ) .bind(&shop_id) .fetch_one(&app.db) .await .unwrap(); assert_eq!(owners, 1); } #[tokio::test] #[serial] async fn concurrent_submissions_create_one_application() { let app = spawn_app().await; let cat = category_id_by_slug(&app, "fashion").await; let (seller, _) = register_customer(&app, "mo-race-submit").await; let body = personal(&cat, &contact_email("race-submit").await); let (a, b) = tokio::join!(submit(&app, &seller, &body), submit(&app, &seller, &body)); let statuses = [a.status().as_u16(), b.status().as_u16()]; assert!( statuses.contains(&201) && statuses.contains(&409), "expected one success and one conflict, got {statuses:?}" ); assert_eq!(mine(&app, &seller).await.len(), 1); } #[tokio::test] #[serial] async fn approval_rolls_back_when_provisioning_fails() { let app = spawn_app().await; let admin = login_admin(&app).await; let cat = category_id_by_slug(&app, "electronics").await; let (seller, _) = register_customer(&app, "mo-rollback").await; let mail = contact_email("rollback").await; // A fixed entity name makes the derived slug predictable. let mut body = enterprise(&cat, &mail); body["company_name"] = serde_json::json!("Rollback Mart"); let created: serde_json::Value = submit(&app, &seller, &body) .await .json() .await .unwrap(); let id = created["id"].as_str().unwrap().to_string(); let expected_slug = format!("rollback-mart-{}", &id.replace('-', "")[..6]); // Claim the slug so the approval's shop insert fails mid-transaction. sqlx::query("INSERT INTO shops (name, slug) VALUES ($1, $2)") .bind(serde_json::json!({"en": "Squatter", "zh": "占位"})) .bind(&expected_slug) .execute(&app.db) .await .unwrap(); let res = admin_approve(&app, &admin, &id).await; assert_eq!(res.status(), 409, "{:?}", res.text().await); // The application stayed pending and nothing else was provisioned. let detail: serde_json::Value = client() .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) .bearer_auth(&admin) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(detail["status"], "pending"); assert!(detail["created_shop_id"].is_null()); assert!(detail["reviewed_at"].is_null()); let shops: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM shops WHERE slug = $1") .bind(&expected_slug) .fetch_one(&app.db) .await .unwrap(); assert_eq!(shops, 1, "only the squatter shop may exist"); let owners: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = $1") .bind(&mail) .fetch_one(&app.db) .await .unwrap(); assert_eq!(owners, 0, "no owner account may have been provisioned"); } #[tokio::test] #[serial] async fn applications_are_user_isolated() { let app = spawn_app().await; let admin = login_admin(&app).await; let cat = category_id_by_slug(&app, "electronics").await; let (alice, _) = register_customer(&app, "mo-alice").await; let (bob, _) = register_customer(&app, "mo-bob").await; let created: serde_json::Value = submit(&app, &alice, &personal(&cat, &contact_email("alice").await)) .await .json() .await .unwrap(); let id = created["id"].as_str().unwrap().to_string(); // Bob sees no trace of Alice's application. assert!(mine(&app, &bob).await.is_empty()); assert_eq!(mine(&app, &alice).await.len(), 1); // Customer routes expose no cross-user read, and admin routes need the role. assert_eq!( client() .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) .bearer_auth(&bob) .send() .await .unwrap() .status(), 403 ); assert_eq!( client() .get(app.url("/api/admin/merchant/applications")) .bearer_auth(&alice) .send() .await .unwrap() .status(), 403 ); assert_eq!( client() .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) .bearer_auth(&admin) .send() .await .unwrap() .status(), 200 ); }