1use puerto_core::RepositoryError;
18use sqlx::migrate::Migrator;
19use sqlx::{PgPool, postgres::PgPoolOptions};
20use std::time::Duration;
21
22#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum DatabaseError {
26 #[error("database.connection_error")]
27 ConnectionError,
28 #[error("database.migration_error")]
29 MigrationError,
30}
31
32pub async fn create_postgres_pool(database_url: &str) -> Result<PgPool, DatabaseError> {
33 PgPoolOptions::new()
34 .max_connections(5)
35 .acquire_timeout(Duration::from_secs(30))
36 .connect(database_url)
37 .await
38 .map_err(|_| DatabaseError::ConnectionError)
39}
40
41pub async fn run_migrations(pool: &PgPool, migrator: &Migrator) -> Result<(), DatabaseError> {
43 migrator
44 .run(pool)
45 .await
46 .map_err(|_| DatabaseError::MigrationError)
47}
48
49pub async fn ping(pool: &PgPool) -> bool {
54 sqlx::query("SELECT 1").execute(pool).await.is_ok()
55}
56
57pub fn map_sqlx_error(err: &sqlx::Error) -> RepositoryError {
60 let unique_violation = err
62 .as_database_error()
63 .and_then(|db_err| db_err.code())
64 .is_some_and(|code| code == "23505");
65 if unique_violation {
66 RepositoryError::Duplicate
67 } else {
68 RepositoryError::Unavailable
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 fn unreachable_pool() -> PgPool {
78 PgPoolOptions::new()
79 .acquire_timeout(Duration::from_millis(200))
80 .connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
81 .unwrap()
82 }
83
84 #[derive(Debug)]
86 struct FakeDbError(&'static str);
87
88 impl std::fmt::Display for FakeDbError {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 write!(f, "SQLSTATE {}", self.0)
91 }
92 }
93
94 impl std::error::Error for FakeDbError {}
95
96 impl sqlx::error::DatabaseError for FakeDbError {
97 fn message(&self) -> &str {
98 self.0
99 }
100 fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
101 Some(self.0.into())
102 }
103 fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
104 self
105 }
106 fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
107 self
108 }
109 fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
110 self
111 }
112 fn kind(&self) -> sqlx::error::ErrorKind {
113 sqlx::error::ErrorKind::Other
114 }
115 }
116
117 fn database_error(code: &'static str) -> sqlx::Error {
118 sqlx::Error::Database(Box::new(FakeDbError(code)))
119 }
120
121 #[test]
122 fn should_map_a_unique_violation_to_duplicate() {
123 assert_eq!(
124 map_sqlx_error(&database_error("23505")),
125 RepositoryError::Duplicate
126 );
127 }
128
129 #[test]
130 fn should_map_another_database_error_to_unavailable() {
131 assert_eq!(
133 map_sqlx_error(&database_error("23503")),
134 RepositoryError::Unavailable
135 );
136 }
137
138 #[test]
139 fn should_map_a_non_database_error_to_unavailable() {
140 assert_eq!(
141 map_sqlx_error(&sqlx::Error::PoolTimedOut),
142 RepositoryError::Unavailable
143 );
144 }
145
146 #[tokio::test]
147 async fn should_report_not_ready_when_the_database_is_unreachable() {
148 assert!(!ping(&unreachable_pool()).await);
149 }
150
151 #[tokio::test]
152 async fn should_reject_a_database_url_that_is_not_one() {
153 let result = create_postgres_pool("not a database url").await;
154
155 assert!(matches!(result, Err(DatabaseError::ConnectionError)));
156 }
157
158 #[test]
159 fn should_keep_the_machine_readable_codes() {
160 assert_eq!(
161 DatabaseError::ConnectionError.to_string(),
162 "database.connection_error"
163 );
164 assert_eq!(
165 DatabaseError::MigrationError.to_string(),
166 "database.migration_error"
167 );
168 }
169
170 async fn test_database() -> Option<PgPool> {
173 let server = std::env::var("PUERTO_TEST_DATABASE_URL").ok()?;
174 Some(
175 create_postgres_pool(&format!("{server}/postgres"))
176 .await
177 .unwrap(),
178 )
179 }
180
181 #[tokio::test]
182 #[ignore = "needs Postgres: make test/db"]
183 async fn postgres_unique_violation_maps_to_duplicate() {
184 let Some(pool) = test_database().await else {
185 eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
186 return;
187 };
188 let mut conn = pool.acquire().await.unwrap();
189 sqlx::query("CREATE TEMPORARY TABLE skus (sku TEXT UNIQUE)")
191 .execute(&mut *conn)
192 .await
193 .unwrap();
194 sqlx::query("INSERT INTO skus VALUES ('A-1')")
195 .execute(&mut *conn)
196 .await
197 .unwrap();
198
199 let err = sqlx::query("INSERT INTO skus VALUES ('A-1')")
200 .execute(&mut *conn)
201 .await
202 .unwrap_err();
203
204 assert_eq!(map_sqlx_error(&err), RepositoryError::Duplicate);
205 }
206
207 #[tokio::test]
208 #[ignore = "needs Postgres: make test/db"]
209 async fn postgres_ping_reports_ready() {
210 let Some(pool) = test_database().await else {
211 eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
212 return;
213 };
214
215 assert!(ping(&pool).await);
216 }
217}