tinycongress_api/identity/repo/
accounts.rs

1//! Account repository for database operations
2
3use async_trait::async_trait;
4use sqlx::PgPool;
5use uuid::Uuid;
6
7/// Account creation result
8#[derive(Debug, Clone)]
9pub struct CreatedAccount {
10    pub id: Uuid,
11    pub root_kid: String,
12}
13
14/// Error types for account operations
15#[derive(Debug, thiserror::Error)]
16pub enum AccountRepoError {
17    #[error("username already taken")]
18    DuplicateUsername,
19    #[error("public key already registered")]
20    DuplicateKey,
21    #[error("database error: {0}")]
22    Database(#[from] sqlx::Error),
23}
24
25/// Repository trait for account operations
26///
27/// This trait abstracts database operations to enable unit testing
28/// handlers with mock implementations.
29#[async_trait]
30pub trait AccountRepo: Send + Sync {
31    /// Create a new account with the given credentials
32    ///
33    /// # Errors
34    ///
35    /// Returns `AccountRepoError::DuplicateUsername` if username is taken.
36    /// Returns `AccountRepoError::DuplicateKey` if public key is already registered.
37    async fn create(
38        &self,
39        username: &str,
40        root_pubkey: &str,
41        root_kid: &str,
42    ) -> Result<CreatedAccount, AccountRepoError>;
43}
44
45/// `PostgreSQL` implementation of [`AccountRepo`]
46pub struct PgAccountRepo {
47    pool: PgPool,
48}
49
50impl PgAccountRepo {
51    #[must_use]
52    pub const fn new(pool: PgPool) -> Self {
53        Self { pool }
54    }
55}
56
57#[async_trait]
58impl AccountRepo for PgAccountRepo {
59    async fn create(
60        &self,
61        username: &str,
62        root_pubkey: &str,
63        root_kid: &str,
64    ) -> Result<CreatedAccount, AccountRepoError> {
65        create_account(&self.pool, username, root_pubkey, root_kid).await
66    }
67}
68
69/// Shared implementation for account creation that works with any executor.
70/// This allows tests to use transactions for isolation.
71async fn create_account<'e, E>(
72    executor: E,
73    username: &str,
74    root_pubkey: &str,
75    root_kid: &str,
76) -> Result<CreatedAccount, AccountRepoError>
77where
78    E: sqlx::Executor<'e, Database = sqlx::Postgres>,
79{
80    let id = Uuid::new_v4();
81
82    let result = sqlx::query(
83        r"
84        INSERT INTO accounts (id, username, root_pubkey, root_kid)
85        VALUES ($1, $2, $3, $4)
86        ",
87    )
88    .bind(id)
89    .bind(username)
90    .bind(root_pubkey)
91    .bind(root_kid)
92    .execute(executor)
93    .await;
94
95    match result {
96        Ok(_) => Ok(CreatedAccount {
97            id,
98            root_kid: root_kid.to_string(),
99        }),
100        Err(e) => {
101            if let sqlx::Error::Database(db_err) = &e {
102                if let Some(constraint) = db_err.constraint() {
103                    match constraint {
104                        "accounts_username_key" => return Err(AccountRepoError::DuplicateUsername),
105                        "accounts_root_kid_key" => return Err(AccountRepoError::DuplicateKey),
106                        _ => {}
107                    }
108                }
109            }
110            Err(AccountRepoError::Database(e))
111        }
112    }
113}
114
115/// Create an account using any executor (pool, connection, or transaction).
116/// Useful for tests that need transaction isolation.
117///
118/// # Errors
119///
120/// Returns `AccountRepoError::DuplicateUsername` if username is taken.
121/// Returns `AccountRepoError::DuplicateKey` if public key is already registered.
122pub async fn create_account_with_executor<'e, E>(
123    executor: E,
124    username: &str,
125    root_pubkey: &str,
126    root_kid: &str,
127) -> Result<CreatedAccount, AccountRepoError>
128where
129    E: sqlx::Executor<'e, Database = sqlx::Postgres>,
130{
131    create_account(executor, username, root_pubkey, root_kid).await
132}
133
134#[cfg(any(test, feature = "test-utils"))]
135#[allow(clippy::expect_used)]
136pub mod mock {
137    //! Mock implementation for testing
138
139    use super::{async_trait, AccountRepo, AccountRepoError, CreatedAccount, Uuid};
140    use std::sync::Mutex;
141
142    /// Mock account repository for unit tests.
143    pub struct MockAccountRepo {
144        /// Preset result to return from `create()`.
145        pub create_result: Mutex<Option<Result<CreatedAccount, AccountRepoError>>>,
146        /// Captured calls for verification
147        pub calls: Mutex<Vec<(String, String, String)>>,
148    }
149
150    impl MockAccountRepo {
151        /// Create a new mock repository.
152        #[must_use]
153        pub const fn new() -> Self {
154            Self {
155                create_result: Mutex::new(None),
156                calls: Mutex::new(Vec::new()),
157            }
158        }
159
160        /// Set the result that `create()` will return.
161        ///
162        /// # Panics
163        ///
164        /// Panics if the internal mutex is poisoned.
165        pub fn set_create_result(&self, result: Result<CreatedAccount, AccountRepoError>) {
166            *self.create_result.lock().expect("lock poisoned") = Some(result);
167        }
168
169        /// Retrieve all recorded calls
170        ///
171        /// # Panics
172        ///
173        /// Panics if the internal mutex is poisoned.
174        pub fn calls(&self) -> Vec<(String, String, String)> {
175            self.calls.lock().expect("lock poisoned").clone()
176        }
177    }
178
179    impl Default for MockAccountRepo {
180        fn default() -> Self {
181            Self::new()
182        }
183    }
184
185    #[async_trait]
186    impl AccountRepo for MockAccountRepo {
187        async fn create(
188            &self,
189            username: &str,
190            root_pubkey: &str,
191            root_kid: &str,
192        ) -> Result<CreatedAccount, AccountRepoError> {
193            self.calls.lock().expect("lock poisoned").push((
194                username.to_string(),
195                root_pubkey.to_string(),
196                root_kid.to_string(),
197            ));
198            self.create_result
199                .lock()
200                .expect("lock poisoned")
201                .take()
202                .unwrap_or_else(|| {
203                    Ok(CreatedAccount {
204                        id: Uuid::new_v4(),
205                        root_kid: root_kid.to_string(),
206                    })
207                })
208        }
209    }
210}