Line data Source code
1 : //! Account repository for database operations
2 :
3 : use async_trait::async_trait;
4 : use sqlx::PgPool;
5 : use uuid::Uuid;
6 :
7 : /// Account creation result
8 : #[derive(Debug, Clone)]
9 : pub struct CreatedAccount {
10 : pub id: Uuid,
11 : pub root_kid: String,
12 : }
13 :
14 : /// Error types for account operations
15 : #[derive(Debug, thiserror::Error)]
16 : pub 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]
30 : pub 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`]
46 : pub struct PgAccountRepo {
47 : pool: PgPool,
48 : }
49 :
50 : impl PgAccountRepo {
51 : #[must_use]
52 0 : pub const fn new(pool: PgPool) -> Self {
53 0 : Self { pool }
54 0 : }
55 : }
56 :
57 : #[async_trait]
58 : impl AccountRepo for PgAccountRepo {
59 : async fn create(
60 : &self,
61 : username: &str,
62 : root_pubkey: &str,
63 : root_kid: &str,
64 0 : ) -> Result<CreatedAccount, AccountRepoError> {
65 : create_account(&self.pool, username, root_pubkey, root_kid).await
66 0 : }
67 : }
68 :
69 : /// Shared implementation for account creation that works with any executor.
70 : /// This allows tests to use transactions for isolation.
71 9 : async fn create_account<'e, E>(
72 9 : executor: E,
73 9 : username: &str,
74 9 : root_pubkey: &str,
75 9 : root_kid: &str,
76 9 : ) -> Result<CreatedAccount, AccountRepoError>
77 9 : where
78 9 : E: sqlx::Executor<'e, Database = sqlx::Postgres>,
79 9 : {
80 9 : let id = Uuid::new_v4();
81 :
82 9 : let result = sqlx::query(
83 9 : r"
84 9 : INSERT INTO accounts (id, username, root_pubkey, root_kid)
85 9 : VALUES ($1, $2, $3, $4)
86 9 : ",
87 9 : )
88 9 : .bind(id)
89 9 : .bind(username)
90 9 : .bind(root_pubkey)
91 9 : .bind(root_kid)
92 9 : .execute(executor)
93 9 : .await;
94 :
95 9 : match result {
96 7 : Ok(_) => Ok(CreatedAccount {
97 7 : id,
98 7 : root_kid: root_kid.to_string(),
99 7 : }),
100 2 : Err(e) => {
101 2 : if let sqlx::Error::Database(db_err) = &e {
102 2 : if let Some(constraint) = db_err.constraint() {
103 2 : match constraint {
104 2 : "accounts_username_key" => return Err(AccountRepoError::DuplicateUsername),
105 1 : "accounts_root_kid_key" => return Err(AccountRepoError::DuplicateKey),
106 0 : _ => {}
107 : }
108 0 : }
109 0 : }
110 0 : Err(AccountRepoError::Database(e))
111 : }
112 : }
113 9 : }
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.
122 9 : pub async fn create_account_with_executor<'e, E>(
123 9 : executor: E,
124 9 : username: &str,
125 9 : root_pubkey: &str,
126 9 : root_kid: &str,
127 9 : ) -> Result<CreatedAccount, AccountRepoError>
128 9 : where
129 9 : E: sqlx::Executor<'e, Database = sqlx::Postgres>,
130 9 : {
131 9 : create_account(executor, username, root_pubkey, root_kid).await
132 9 : }
133 :
134 : #[cfg(any(test, feature = "test-utils"))]
135 : #[allow(clippy::expect_used)]
136 : pub 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 22 : pub const fn new() -> Self {
154 22 : Self {
155 22 : create_result: Mutex::new(None),
156 22 : calls: Mutex::new(Vec::new()),
157 22 : }
158 22 : }
159 :
160 : /// Set the result that `create()` will return.
161 : ///
162 : /// # Panics
163 : ///
164 : /// Panics if the internal mutex is poisoned.
165 4 : pub fn set_create_result(&self, result: Result<CreatedAccount, AccountRepoError>) {
166 4 : *self.create_result.lock().expect("lock poisoned") = Some(result);
167 4 : }
168 :
169 : /// Retrieve all recorded calls
170 : ///
171 : /// # Panics
172 : ///
173 : /// Panics if the internal mutex is poisoned.
174 1 : pub fn calls(&self) -> Vec<(String, String, String)> {
175 1 : self.calls.lock().expect("lock poisoned").clone()
176 1 : }
177 : }
178 :
179 : impl Default for MockAccountRepo {
180 0 : fn default() -> Self {
181 0 : Self::new()
182 0 : }
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 8 : ) -> 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 4 : .unwrap_or_else(|| {
203 4 : Ok(CreatedAccount {
204 4 : id: Uuid::new_v4(),
205 4 : root_kid: root_kid.to_string(),
206 4 : })
207 4 : })
208 8 : }
209 : }
210 : }
|