Files
clawmates/crates/cm-api/tests/team_tabs.rs
T
Omar SobhandClaude Fable 5 f3f08a8edd R4 backend: team org-chart + leaderboard, Stripe credits, workspace apps
- GET /api/team/orgchart — each member grouped with the claws they manage
  (agents.managed_by); GET /api/team/leaderboard — every claw ranked by
  its real usage_events rollup (credits/tokens/runs, zeros included).
  Both tested against real Postgres.
- Stripe Buy-credits (the Slack/Clerk integration pattern): [billing]
  config (stripe keys + price + webhook secret + credits_per_pack);
  POST /api/credits/checkout opens a real Checkout Session; POST
  /api/billing/stripe verifies Stripe's t=,v1= HMAC (constant-time) and
  grants one credit lot, idempotent on the session id; GET
  /api/billing/config gates the button (honest degradation when unset).
  Offline tests: signed grant + replay no-double-grant + forged-sig 400 +
  config flag. Live checkout creation deferred to a CM_LIVE_STRIPE test.
- /apps global page support: clawId now optional on connect + directory;
  absent => workspace-wide connection (app_connections.agent_id NULL) via
  new connections::list_for_workspace.
- ApiError gains a From<sqlx::Error> so inline queries use ? cleanly.

cm-api 10 test files incl. team_tabs (2) + stripe_billing (3); clippy clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 19:58:09 -05:00

183 lines
5.7 KiB
Rust

//! The Team page's new tabs (R4): the org chart (members → the claws they
//! manage) and the leaderboard (agents ranked by real usage_events).
use std::sync::Arc;
use cm_api::AppState;
use cm_auth::AuthService;
use cm_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
};
use cm_llm::ScriptedProvider;
use cm_runtime::{Runtime, RuntimeConfig};
use serde_json::{json, Value};
async fn serve(pool: sqlx::PgPool) -> (String, reqwest::Client) {
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig::basic("scripted", 1024),
);
let app = cm_api::router(AppState::new(pool, runtime));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{addr}"), reqwest::Client::new())
}
async fn owner(pool: &sqlx::PgPool, ws: WorkspaceId, name: &str) -> User {
let user = User {
id: UserId::new(),
workspace_id: ws,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: name.into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(pool, &user).await.unwrap();
user
}
async fn claw(pool: &sqlx::PgPool, ws: WorkspaceId, manager: UserId, name: &str) -> AgentId {
let agent = Agent {
id: AgentId::new(),
workspace_id: ws,
name: name.into(),
job_title: "Analyst".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: "#f96565".into(),
wallpaper: String::new(),
managed_by: manager,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.unwrap();
agent.id
}
async fn login(base: &str, client: &reqwest::Client, email: &str) -> String {
client
.post(format!("{base}/api/auth/login"))
.json(&json!({"email": email, "password": "pw"}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_owned()
}
#[tokio::test]
async fn orgchart_groups_each_member_with_the_claws_they_manage() {
let pool = cm_testkit::test_pool().await;
let (base, client) = serve(pool.clone()).await;
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let ada = owner(&pool, ws.id, "Ada").await;
let ben = owner(&pool, ws.id, "Ben").await;
AuthService::new(pool.clone())
.set_password(ada.id, "pw")
.await
.unwrap();
let scout = claw(&pool, ws.id, ada.id, "Scout").await;
let quill = claw(&pool, ws.id, ada.id, "Quill").await;
let _solo = claw(&pool, ws.id, ben.id, "Atlas").await;
let token = login(&base, &client, &ada.email).await;
let chart: Value = client
.get(format!("{base}/api/team/orgchart"))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let nodes = chart.as_array().unwrap();
assert_eq!(nodes.len(), 2, "two managers");
let ada_node = nodes
.iter()
.find(|n| n["user"]["display_name"] == "Ada")
.unwrap();
let managed: Vec<&str> = ada_node["claws"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(managed.contains(&"Scout") && managed.contains(&"Quill"));
assert_eq!(managed.len(), 2);
// The claw ids are real (usable to deep-link into a chat).
assert!(ada_node["claws"][0]["id"].as_str().is_some());
let _ = (scout, quill);
}
#[tokio::test]
async fn leaderboard_ranks_claws_by_real_usage() {
let pool = cm_testkit::test_pool().await;
let (base, client) = serve(pool.clone()).await;
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let ada = owner(&pool, ws.id, "Ada").await;
AuthService::new(pool.clone())
.set_password(ada.id, "pw")
.await
.unwrap();
let busy = claw(&pool, ws.id, ada.id, "Busy").await;
let idle = claw(&pool, ws.id, ada.id, "Idle").await;
// Two real usage events for Busy, none for Idle.
for (tin, tout, credits) in [(1000i64, 500i64, 2i64), (3000, 1000, 4)] {
sqlx::query(
"INSERT INTO usage_events (workspace_id, agent_id, kind, tokens_in, tokens_out, credits)
VALUES ($1, $2, 'llm_tokens', $3, $4, $5)",
)
.bind(ws.id.as_uuid())
.bind(busy.as_uuid())
.bind(tin)
.bind(tout)
.bind(sqlx::types::BigDecimal::from(credits))
.execute(&pool)
.await
.unwrap();
}
let token = login(&base, &client, &ada.email).await;
let board: Value = client
.get(format!("{base}/api/team/leaderboard"))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let rows = board.as_array().unwrap();
assert_eq!(rows.len(), 2);
// Busy ranks first with the summed usage; Idle is present at zero.
assert_eq!(rows[0]["name"], "Busy");
assert_eq!(rows[0]["credits"], 6);
assert_eq!(rows[0]["tokens"], 5500);
assert_eq!(rows[0]["runs"], 2);
assert_eq!(rows[1]["name"], "Idle");
assert_eq!(rows[1]["credits"], 0);
assert_eq!(rows[1]["runs"], 0);
let _ = idle;
}