Skip to main content

difflore_core/team/
mod.rs

1mod api;
2mod cloud_id;
3mod types;
4
5pub use api::{
6    invite, members, publish_rule, remove_member, resolve_known_cloud_rule_id, review_inbox,
7    skills, unpublish_rule, update_role,
8};
9pub use types::{
10    ReviewInboxItem, TeamContextInput, TeamInviteInput, TeamInviteResult, TeamMemberIdInput,
11    TeamMemberRecord, TeamMembersResult, TeamRulePublishInput, TeamRuleUnpublishInput,
12    TeamSkillsResult, TeamUpdateRoleInput,
13};
14
15#[cfg(test)]
16mod tests {
17    use super::cloud_id::{
18        build_rule_create_body, resolve_cloud_rule_id_for_unpublish, rule_cloud_mapping_key,
19    };
20    use super::types::{LocalRuleUploadRow, TeamRulePublishInput};
21    use sqlx::SqlitePool;
22    use uuid::Uuid;
23
24    async fn setup_migrated_pool() -> SqlitePool {
25        let pool = sqlx::sqlite::SqlitePoolOptions::new()
26            .max_connections(1)
27            .connect("sqlite::memory:")
28            .await
29            .expect("open in-memory pool");
30        sqlx::migrate!("./migrations")
31            .run(&pool)
32            .await
33            .expect("apply migrations");
34        pool
35    }
36
37    #[test]
38    fn rule_create_body_preserves_local_origin() {
39        let row = LocalRuleUploadRow {
40            name: "Prefer structured logs".into(),
41            rule_type: "review_standard".into(),
42            description: "Use logger.info instead of println.".into(),
43            version: "1.0.0".into(),
44            engines_json: r#"["claude"]"#.into(),
45            tags_json: r#"["conversation"]"#.into(),
46            trigger: None,
47            check_prompt: Some("Check logging calls".into()),
48            file_patterns_json: Some(r#"["**/*.rs"]"#.into()),
49            origin: "conversation".into(),
50            source_repo: Some("acme/widgets".into()),
51        };
52
53        let body = build_rule_create_body(&row);
54        assert_eq!(body["origin"].as_str(), Some("conversation"));
55        assert_eq!(body["content"].as_str(), Some(row.description.as_str()));
56        assert_eq!(body["visibility"].as_str(), Some("team"));
57        assert_eq!(body["filePatterns"][0].as_str(), Some("**/*.rs"));
58        assert_eq!(body["sourceRepo"].as_str(), Some("acme/widgets"));
59    }
60
61    #[test]
62    fn rule_create_body_falls_back_to_name_for_empty_content() {
63        let row = LocalRuleUploadRow {
64            name: "Name only".into(),
65            rule_type: "skill".into(),
66            description: "  ".into(),
67            version: "1.0.0".into(),
68            engines_json: "[]".into(),
69            tags_json: "[]".into(),
70            trigger: None,
71            check_prompt: None,
72            file_patterns_json: None,
73            origin: "manual".into(),
74            source_repo: None,
75        };
76
77        let body = build_rule_create_body(&row);
78        assert_eq!(body["content"].as_str(), Some("Name only"));
79        assert_eq!(body["origin"].as_str(), Some("manual"));
80    }
81
82    #[tokio::test]
83    async fn unpublish_resolves_slug_from_auth_mapping() {
84        let pool = setup_migrated_pool().await;
85        let cloud_id = Uuid::new_v4().to_string();
86        let key = rule_cloud_mapping_key("conv-example-12345678");
87        sqlx::query!(
88            "INSERT INTO auth (key, value) VALUES (?1, ?2)",
89            key,
90            cloud_id
91        )
92        .execute(&pool)
93        .await
94        .expect("seed auth mapping");
95
96        let resolved = resolve_cloud_rule_id_for_unpublish(&pool, "conv-example-12345678")
97            .await
98            .expect("resolve cloud rule id");
99        assert_eq!(resolved, cloud_id);
100    }
101
102    #[tokio::test]
103    async fn unpublish_resolves_slug_from_cloud_id_column_when_present() {
104        let pool = setup_migrated_pool().await;
105        let cloud_id = Uuid::new_v4().to_string();
106        sqlx::query!(
107            "INSERT INTO skills (id, name, source, directory, version, cloud_id) \
108             VALUES (?1, 'n', 's', 'd', '1.0.0', ?2)",
109            "local-example",
110            cloud_id
111        )
112        .execute(&pool)
113        .await
114        .expect("seed skill row");
115
116        let resolved = resolve_cloud_rule_id_for_unpublish(&pool, "local-example")
117            .await
118            .expect("resolve via cloud_id column");
119        assert_eq!(resolved, cloud_id);
120    }
121
122    #[tokio::test]
123    async fn unpublish_missing_slug_mapping_is_not_found() {
124        let pool = setup_migrated_pool().await;
125
126        let err = resolve_cloud_rule_id_for_unpublish(&pool, "conv-missing-12345678")
127            .await
128            .expect_err("expected NotFound");
129        assert!(
130            err.to_string().contains("publish"),
131            "unexpected error: {err}"
132        );
133    }
134
135    /// 2026-04-20: origin must travel up so cloud Dashboard sees the
136    /// input-channel provenance of published rules. (Pinning the
137    /// `origin` field is the only non-trivial part of this serde shape.)
138    #[test]
139    fn team_rule_publish_input_includes_origin_on_wire() {
140        let input = TeamRulePublishInput {
141            rule_id: "rule-1".into(),
142            enforcement: Some("required".into()),
143            team_id: Some("t1".into()),
144            origin: Some("conversation".into()),
145        };
146        let json = serde_json::to_value(&input).unwrap();
147        assert_eq!(json.get("ruleId").and_then(|v| v.as_str()), Some("rule-1"));
148        assert_eq!(
149            json.get("origin").and_then(|v| v.as_str()),
150            Some("conversation")
151        );
152    }
153}