- 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]>
183 lines
5.7 KiB
Rust
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;
|
|
}
|