P0: workspace scaffold, CI gates, tc-domain, tc-config, tc-db vs real Postgres
- Cargo workspace with 1250-line and no-placeholder CI gates wired first - tc-domain: id newtypes, SessionKey codec (proptest round-trip), Role, GatedCategory (spec §15), AccessPolicy, core entities - tc-config: figment TOML+env config, DeployTarget/provider/auth selection with semantic validation - migrations/0001: full spec §14 schema incl. DB-enforced append-only audit_log - tc-db: compile-time-checked sqlx repos (workspaces, users, agents+policies, credits, audit) with committed .sqlx offline metadata - tc-testkit: per-test real-Postgres databases (testcontainers or TC_TEST_DATABASE_URL), embedded migrations Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use tc_db::repo::{agents, audit, credits, users, workspaces};
|
||||
use tc_db::DbError;
|
||||
use tc_domain::{
|
||||
AccessPolicy, Agent, AgentId, AgentScope, AgentStatus, HumanScope, Role, User, UserId,
|
||||
Workspace, WorkspaceId,
|
||||
};
|
||||
|
||||
fn workspace() -> Workspace {
|
||||
Workspace {
|
||||
id: WorkspaceId::new(),
|
||||
name: "Acme".into(),
|
||||
plan: "team".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user_in(ws: &Workspace, role: Role) -> User {
|
||||
User {
|
||||
id: UserId::new(),
|
||||
workspace_id: ws.id,
|
||||
email: format!("{}@acme.test", UserId::new()),
|
||||
role,
|
||||
display_name: "Test User".into(),
|
||||
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_in(ws: &Workspace, owner: &User) -> Agent {
|
||||
Agent {
|
||||
id: AgentId::new(),
|
||||
workspace_id: ws.id,
|
||||
name: "Scout".into(),
|
||||
job_title: "Research Analyst".into(),
|
||||
system_prompt: "You research things.".into(),
|
||||
avatar: "scout-1".into(),
|
||||
accent: "#f96565".into(),
|
||||
wallpaper: "dunes".into(),
|
||||
managed_by: owner.id,
|
||||
status: AgentStatus::Provisioning,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_round_trips() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
let found = workspaces::get(&pool, ws.id).await.unwrap();
|
||||
assert_eq!(found, ws);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_workspace_is_not_found() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let err = workspaces::get(&pool, WorkspaceId::new())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DbError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_round_trips_and_finds_by_email() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
let user = user_in(&ws, Role::Owner);
|
||||
users::insert(&pool, &user).await.unwrap();
|
||||
|
||||
let by_id = users::get(&pool, user.id).await.unwrap();
|
||||
assert_eq!(by_id.email, user.email);
|
||||
assert_eq!(by_id.role, Role::Owner);
|
||||
|
||||
let by_email = users::find_by_email(&pool, &user.email).await.unwrap();
|
||||
assert_eq!(by_email.id, user.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_email_is_a_conflict() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
let mut a = user_in(&ws, Role::Member);
|
||||
let mut b = user_in(&ws, Role::Member);
|
||||
b.email = a.email.clone();
|
||||
users::insert(&pool, &a).await.unwrap();
|
||||
let err = users::insert(&pool, &b).await.unwrap_err();
|
||||
assert!(matches!(err, DbError::Conflict(_)));
|
||||
// Silence unused warnings for fields we only compare implicitly.
|
||||
a.display_name.clear();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_members_lists_in_join_order() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
let owner = user_in(&ws, Role::Owner);
|
||||
let member = user_in(&ws, Role::Member);
|
||||
users::insert(&pool, &owner).await.unwrap();
|
||||
users::insert(&pool, &member).await.unwrap();
|
||||
|
||||
let members = users::list_by_workspace(&pool, ws.id).await.unwrap();
|
||||
assert_eq!(members.len(), 2);
|
||||
assert_eq!(members[0].id, owner.id);
|
||||
assert_eq!(members[1].id, member.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_insert_creates_default_access_policy() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
let owner = user_in(&ws, Role::Owner);
|
||||
users::insert(&pool, &owner).await.unwrap();
|
||||
let agent = agent_in(&ws, &owner);
|
||||
|
||||
agents::insert(&pool, &agent, &AccessPolicy::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let policy = agents::access_policy(&pool, agent.id).await.unwrap();
|
||||
assert_eq!(policy.humans, HumanScope::EntireTeam);
|
||||
assert_eq!(policy.agents, AgentScope::Any);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_roster_excludes_deleted_and_round_trips_fields() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
let owner = user_in(&ws, Role::Owner);
|
||||
users::insert(&pool, &owner).await.unwrap();
|
||||
|
||||
let keep = agent_in(&ws, &owner);
|
||||
let remove = agent_in(&ws, &owner);
|
||||
agents::insert(&pool, &keep, &AccessPolicy::default())
|
||||
.await
|
||||
.unwrap();
|
||||
agents::insert(&pool, &remove, &AccessPolicy::default())
|
||||
.await
|
||||
.unwrap();
|
||||
agents::soft_delete(&pool, remove.id).await.unwrap();
|
||||
|
||||
let roster = agents::roster(&pool, ws.id).await.unwrap();
|
||||
assert_eq!(roster.len(), 1);
|
||||
assert_eq!(roster[0], keep);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_status_updates() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
let owner = user_in(&ws, Role::Owner);
|
||||
users::insert(&pool, &owner).await.unwrap();
|
||||
let agent = agent_in(&ws, &owner);
|
||||
agents::insert(&pool, &agent, &AccessPolicy::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
agents::set_status(&pool, agent.id, AgentStatus::Online)
|
||||
.await
|
||||
.unwrap();
|
||||
let roster = agents::roster(&pool, ws.id).await.unwrap();
|
||||
assert_eq!(roster[0].status, AgentStatus::Online);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn specific_access_policy_round_trips() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
let owner = user_in(&ws, Role::Owner);
|
||||
users::insert(&pool, &owner).await.unwrap();
|
||||
let agent = agent_in(&ws, &owner);
|
||||
|
||||
let policy = AccessPolicy {
|
||||
humans: HumanScope::Specific(vec![owner.id]),
|
||||
agents: AgentScope::Specific(vec![AgentId::from_str(&agent.id.to_string()).unwrap()]),
|
||||
};
|
||||
agents::insert(&pool, &agent, &policy).await.unwrap();
|
||||
let stored = agents::access_policy(&pool, agent.id).await.unwrap();
|
||||
assert_eq!(stored, policy);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credit_balance_sums_remaining_lots() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
|
||||
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 0);
|
||||
credits::add_lot(&pool, ws.id, 1000, "purchase")
|
||||
.await
|
||||
.unwrap();
|
||||
credits::add_lot(&pool, ws.id, 250, "promo").await.unwrap();
|
||||
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 1250);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_log_appends_and_rejects_mutation() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let ws = workspace();
|
||||
workspaces::insert(&pool, &ws).await.unwrap();
|
||||
|
||||
let entry_id = audit::append(
|
||||
&pool,
|
||||
ws.id,
|
||||
audit::Actor::System,
|
||||
"workspace.created",
|
||||
"workspace",
|
||||
&ws.id.to_string(),
|
||||
serde_json::json!({"plan": "team"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(entry_id > 0);
|
||||
|
||||
// Append-only is enforced by the database itself, not convention.
|
||||
let update = sqlx::query("UPDATE audit_log SET event_type = 'tampered' WHERE id = $1")
|
||||
.bind(entry_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
assert!(update.is_err());
|
||||
let delete = sqlx::query("DELETE FROM audit_log WHERE id = $1")
|
||||
.bind(entry_id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
assert!(delete.is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user