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