Teams:
- GET /v1/teams/{id}/analytics (admin) — per-card visits/qr/saves/clicks across
members, sortable (whitelisted columns); …/analytics.csv exports RFC-4180 CSV
- Central template library (migration 0004 team_templates):
POST|DELETE /v1/teams/{id}/templates[/{templateId}] (admin), GET (member)
GDPR (§18.3):
- GET /v1/account/export — full JSON dump via Postgres json_agg (no password hash)
- DELETE /v1/account — hard delete, FK cascade removes all owned data
107 backend tests; fmt + clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
407 lines
12 KiB
Rust
407 lines
12 KiB
Rust
//! Team data layer + membership management (PRD §21 Phase 4).
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use serde_json::json;
|
|
|
|
use common::TestApp;
|
|
|
|
/// Register a user, force them to Team tier, and create a team. Returns
|
|
/// (token, userId, teamId).
|
|
async fn team_owner(app: &TestApp, seat_limit: Option<i32>) -> (String, String, String) {
|
|
let (token, user_id) = app.register_and_user().await;
|
|
app.set_tier(&user_id, "team").await;
|
|
let body = match seat_limit {
|
|
Some(n) => json!({ "name": "RedClaw", "seat_limit": n }),
|
|
None => json!({ "name": "RedClaw" }),
|
|
};
|
|
let (status, team) = app
|
|
.request("POST", "/v1/teams", Some(&token), Some(body))
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK, "create team failed: {team}");
|
|
(token, user_id, team["id"].as_str().unwrap().to_string())
|
|
}
|
|
|
|
/// Register a plain user and return (token, email).
|
|
async fn member_account(app: &TestApp) -> (String, String) {
|
|
let (_token, user_id) = app.register_and_user().await;
|
|
let email = app_email(app, &user_id).await;
|
|
(user_id, email)
|
|
}
|
|
|
|
async fn app_email(app: &TestApp, user_id: &str) -> String {
|
|
let id = uuid::Uuid::parse_str(user_id).unwrap();
|
|
let (email,): (String,) = sqlx::query_as("SELECT email FROM users WHERE id = $1")
|
|
.bind(id)
|
|
.fetch_one(&app.db)
|
|
.await
|
|
.unwrap();
|
|
email
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn free_tier_cannot_create_team() {
|
|
let app = require_app!();
|
|
let token = app.register_and_token().await; // free
|
|
let (status, body) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/teams",
|
|
Some(&token),
|
|
Some(json!({ "name": "Nope" })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
|
|
assert_eq!(body["code"], "tier_limit");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_team_makes_owner_a_member() {
|
|
let app = require_app!();
|
|
let (token, owner_id, team_id) = team_owner(&app, None).await;
|
|
|
|
let (status, members) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let arr = members.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1);
|
|
assert_eq!(arr[0]["userId"], owner_id);
|
|
assert_eq!(arr[0]["role"], "owner");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_and_remove_member() {
|
|
let app = require_app!();
|
|
let (token, _, team_id) = team_owner(&app, None).await;
|
|
let (member_id, member_email) = member_account(&app).await;
|
|
|
|
let (add_status, _) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&token),
|
|
Some(json!({ "email": member_email, "role": "admin" })),
|
|
)
|
|
.await;
|
|
assert_eq!(add_status, StatusCode::OK);
|
|
|
|
let (_, members) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(members.as_array().unwrap().len(), 2);
|
|
// The invitee got a notification email.
|
|
assert!(app
|
|
.email
|
|
.sent
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|m| m.to == member_email));
|
|
|
|
let (rm_status, _) = app
|
|
.request(
|
|
"DELETE",
|
|
&format!("/v1/teams/{team_id}/members/{member_id}"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(rm_status, StatusCode::OK);
|
|
let (_, after) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(after.as_array().unwrap().len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_member_unknown_email_is_404() {
|
|
let app = require_app!();
|
|
let (token, _, team_id) = team_owner(&app, None).await;
|
|
let (status, _) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&token),
|
|
Some(json!({ "email": "[email protected]", "role": "member" })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn seat_limit_is_enforced() {
|
|
let app = require_app!();
|
|
// seat_limit = 1; the owner already occupies it.
|
|
let (token, _, team_id) = team_owner(&app, Some(1)).await;
|
|
let (_, member_email) = member_account(&app).await;
|
|
|
|
let (status, body) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&token),
|
|
Some(json!({ "email": member_email, "role": "member" })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
|
|
assert_eq!(body["code"], "tier_limit");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_member_cannot_view_or_admin_team() {
|
|
let app = require_app!();
|
|
let (_, _, team_id) = team_owner(&app, None).await;
|
|
let intruder = app.register_and_token().await;
|
|
|
|
// Non-member: team existence is hidden (404).
|
|
let (get_status, _) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/teams/{team_id}"),
|
|
Some(&intruder),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(get_status, StatusCode::NOT_FOUND);
|
|
|
|
let (add_status, _) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&intruder),
|
|
Some(json!({ "email": "[email protected]", "role": "member" })),
|
|
)
|
|
.await;
|
|
assert_eq!(add_status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn team_aggregate_analytics_covers_members_cards() {
|
|
let app = require_app!();
|
|
let (token, _, team_id) = team_owner(&app, None).await;
|
|
|
|
// The owner publishes a card and gets a couple of profile visits.
|
|
let handle = common::unique_handle();
|
|
let def = json!({
|
|
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
|
"back": { "layers": [] }
|
|
});
|
|
let (_, card) = app
|
|
.request(
|
|
"POST",
|
|
"/v1/cards",
|
|
Some(&token),
|
|
Some(json!({ "handle": handle, "definition": def })),
|
|
)
|
|
.await;
|
|
let card_id = card["id"].as_str().unwrap();
|
|
app.request(
|
|
"POST",
|
|
&format!("/v1/cards/{card_id}/publish"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
|
.await;
|
|
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
|
.await;
|
|
|
|
// JSON aggregate (admin/owner only) includes that card with 2 visits.
|
|
let (status, rows) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/teams/{team_id}/analytics?sort=visits"),
|
|
Some(&token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let arr = rows.as_array().unwrap();
|
|
let row = arr
|
|
.iter()
|
|
.find(|r| r["handle"] == handle)
|
|
.expect("card present");
|
|
assert_eq!(row["visits"], 2);
|
|
|
|
// CSV export.
|
|
let (csv_status, content_type, body) = app
|
|
.request_raw(
|
|
"GET",
|
|
&format!("/v1/teams/{team_id}/analytics.csv"),
|
|
Some(&token),
|
|
)
|
|
.await;
|
|
assert_eq!(csv_status, StatusCode::OK);
|
|
assert!(content_type.starts_with("text/csv"));
|
|
assert!(body.starts_with("handle,owner,visits,qr_scans,contact_saves,link_clicks"));
|
|
assert!(body.contains(&handle));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn team_analytics_requires_admin() {
|
|
let app = require_app!();
|
|
let (owner_token, _, team_id) = team_owner(&app, None).await;
|
|
let (member_token, member_id) = app.register_and_user().await;
|
|
let member_email = app_email(&app, &member_id).await;
|
|
app.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&owner_token),
|
|
Some(json!({ "email": member_email, "role": "member" })),
|
|
)
|
|
.await;
|
|
|
|
// A plain member cannot view team analytics.
|
|
let (status, _) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/teams/{team_id}/analytics"),
|
|
Some(&member_token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_manages_templates_members_can_list() {
|
|
let app = require_app!();
|
|
let (owner_token, _, team_id) = team_owner(&app, None).await;
|
|
|
|
// Add a plain member.
|
|
let (member_token, member_id) = app.register_and_user().await;
|
|
let member_email = app_email(&app, &member_id).await;
|
|
app.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&owner_token),
|
|
Some(json!({ "email": member_email, "role": "member" })),
|
|
)
|
|
.await;
|
|
|
|
let template = json!({
|
|
"name": "Corporate",
|
|
"definition": {
|
|
"face": { "layers": [], "background": { "type": "solid", "value": "#1b1b2f" } },
|
|
"back": { "layers": [] }
|
|
}
|
|
});
|
|
|
|
// Admin/owner creates a template.
|
|
let (create_status, created) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/templates"),
|
|
Some(&owner_token),
|
|
Some(template.clone()),
|
|
)
|
|
.await;
|
|
assert_eq!(create_status, StatusCode::OK);
|
|
let template_id = created["id"].as_str().unwrap().to_string();
|
|
assert_eq!(created["name"], "Corporate");
|
|
|
|
// A plain member can list it.
|
|
let (list_status, list) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/teams/{team_id}/templates"),
|
|
Some(&member_token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(list_status, StatusCode::OK);
|
|
assert_eq!(list.as_array().unwrap().len(), 1);
|
|
|
|
// A plain member cannot create one.
|
|
let (forbidden, _) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/templates"),
|
|
Some(&member_token),
|
|
Some(template),
|
|
)
|
|
.await;
|
|
assert_eq!(forbidden, StatusCode::FORBIDDEN);
|
|
|
|
// Admin deletes it.
|
|
let (del_status, _) = app
|
|
.request(
|
|
"DELETE",
|
|
&format!("/v1/teams/{team_id}/templates/{template_id}"),
|
|
Some(&owner_token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(del_status, StatusCode::OK);
|
|
let (_, after) = app
|
|
.request(
|
|
"GET",
|
|
&format!("/v1/teams/{team_id}/templates"),
|
|
Some(&owner_token),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(after.as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn template_definition_must_be_object() {
|
|
let app = require_app!();
|
|
let (token, _, team_id) = team_owner(&app, None).await;
|
|
let (status, body) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/templates"),
|
|
Some(&token),
|
|
Some(json!({ "name": "Bad", "definition": "not-an-object" })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert_eq!(body["code"], "validation");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plain_member_cannot_add_members() {
|
|
let app = require_app!();
|
|
let (owner_token, _, team_id) = team_owner(&app, None).await;
|
|
// Create a plain-member account and add them with role member.
|
|
let (member_token, member_id) = app.register_and_user().await;
|
|
let member_email = app_email(&app, &member_id).await;
|
|
app.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&owner_token),
|
|
Some(json!({ "email": member_email, "role": "member" })),
|
|
)
|
|
.await;
|
|
|
|
// That member (role=member) may not add others → 403 Forbidden.
|
|
let (status, _) = app
|
|
.request(
|
|
"POST",
|
|
&format!("/v1/teams/{team_id}/members"),
|
|
Some(&member_token),
|
|
Some(json!({ "email": "[email protected]", "role": "member" })),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
}
|