2639 lines
80 KiB
Rust
2639 lines
80 KiB
Rust
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use async_trait::async_trait;
|
|
use sha2::{Digest, Sha256};
|
|
use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectOptions};
|
|
|
|
use crate::domain::{
|
|
Category, DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory,
|
|
MealIngredient, Passkey, SessionUser, User,
|
|
};
|
|
use crate::ports::{
|
|
CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository,
|
|
MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasskeyRepository,
|
|
SessionRepository, UserRepository,
|
|
};
|
|
|
|
/// The embedded SQL migrations, applied automatically on startup.
|
|
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
|
|
|
|
#[derive(Clone)]
|
|
pub struct SqliteDatabase {
|
|
pool: SqlitePool,
|
|
}
|
|
|
|
impl SqliteDatabase {
|
|
pub async fn open(path: &str) -> DomainResult<Self> {
|
|
let options = SqliteConnectOptions::new()
|
|
.filename(path)
|
|
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
|
.foreign_keys(true)
|
|
.busy_timeout(std::time::Duration::from_secs(5))
|
|
.create_if_missing(true);
|
|
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
|
|
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
|
|
seed_default_categories(&pool).await?;
|
|
seed_default_meal_categories(&pool).await?;
|
|
Ok(Self { pool })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub async fn open_in_memory() -> DomainResult<Self> {
|
|
// A pool of `:memory:` connections would each get a separate database,
|
|
// so use a unique temporary file that shares the schema across the pool.
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
let path = std::env::temp_dir().join(format!(
|
|
"sustenance-test-{}-{}-{}.db",
|
|
std::process::id(),
|
|
now(),
|
|
unique
|
|
));
|
|
let path = path.to_str().unwrap();
|
|
let options = SqliteConnectOptions::new()
|
|
.filename(path)
|
|
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
|
.foreign_keys(true)
|
|
.busy_timeout(std::time::Duration::from_secs(5))
|
|
.create_if_missing(true);
|
|
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
|
|
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
|
|
seed_default_categories(&pool).await?;
|
|
seed_default_meal_categories(&pool).await?;
|
|
Ok(Self { pool })
|
|
}
|
|
}
|
|
|
|
impl SqliteDatabase {
|
|
/// Runs `operation` inside a single transaction, committing on success and
|
|
/// rolling back on error. Multiple repositories can participate in the same
|
|
/// transaction so their writes commit together atomically.
|
|
pub async fn run<T, F>(&self, operation: F) -> DomainResult<T>
|
|
where
|
|
T: Send + 'static,
|
|
F: for<'a> FnOnce(
|
|
&'a mut SqliteConnection,
|
|
)
|
|
-> Pin<Box<dyn Future<Output = DomainResult<T>> + Send + 'a>>
|
|
+ Send
|
|
+ 'static,
|
|
{
|
|
let mut connection = self.pool.acquire().await.map_err(db_error)?;
|
|
let mut transaction = connection.begin().await.map_err(db_error)?;
|
|
let result = operation(&mut transaction).await;
|
|
match result {
|
|
Ok(value) => {
|
|
transaction.commit().await.map_err(db_error)?;
|
|
Ok(value)
|
|
}
|
|
Err(error) => {
|
|
transaction.rollback().await.map_err(db_error)?;
|
|
Err(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inserts the default global categories once, if the categories table is empty.
|
|
async fn seed_default_categories(pool: &SqlitePool) -> DomainResult<()> {
|
|
let count: i64 = sqlx::query("SELECT COUNT(*) FROM categories")
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get(0);
|
|
if count > 0 {
|
|
return Ok(());
|
|
}
|
|
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
|
|
sqlx::query("INSERT INTO categories (name, position, created_at) VALUES (?1, ?2, ?3)")
|
|
.bind(category_name)
|
|
.bind(position as i64)
|
|
.bind(now())
|
|
.execute(pool)
|
|
.await
|
|
.map_err(db_error)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Inserts the default meal categories once, if the meal_categories table is empty.
|
|
async fn seed_default_meal_categories(pool: &SqlitePool) -> DomainResult<()> {
|
|
let count: i64 = sqlx::query("SELECT COUNT(*) FROM meal_categories")
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get(0);
|
|
if count > 0 {
|
|
return Ok(());
|
|
}
|
|
for (position, category_name) in DEFAULT_MEAL_CATEGORIES.iter().enumerate() {
|
|
sqlx::query("INSERT INTO meal_categories (name, position, created_at) VALUES (?1, ?2, ?3)")
|
|
.bind(category_name)
|
|
.bind(position as i64)
|
|
.bind(now())
|
|
.execute(pool)
|
|
.await
|
|
.map_err(db_error)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteUserRepository;
|
|
|
|
#[async_trait]
|
|
impl UserRepository for SqliteUserRepository {
|
|
async fn create_user(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
email: String,
|
|
display_name: String,
|
|
password_hash: String,
|
|
) -> DomainResult<User> {
|
|
// Generate a random, high-entropy user handle per the WebAuthn spec so
|
|
// the value embedded in authenticators is opaque and unguessable.
|
|
let user_handle = new_user_handle();
|
|
let result = sqlx::query(
|
|
"INSERT INTO users (email, display_name, password_hash, user_handle, created_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
)
|
|
.bind(&email)
|
|
.bind(&display_name)
|
|
.bind(&password_hash)
|
|
.bind(&user_handle)
|
|
.bind(now())
|
|
.execute(&mut *txn)
|
|
.await;
|
|
match result {
|
|
Ok(_) => {
|
|
let id = sqlx::query("SELECT last_insert_rowid()")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get::<i64, _>(0);
|
|
Ok(User {
|
|
id,
|
|
email,
|
|
display_name,
|
|
user_handle,
|
|
})
|
|
}
|
|
Err(error) if is_unique_violation(&error) => Err(DomainError::Conflict),
|
|
Err(error) => Err(db_error(error)),
|
|
}
|
|
}
|
|
|
|
async fn find_user_by_email(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
email: String,
|
|
) -> DomainResult<Option<(User, String)>> {
|
|
let row = sqlx::query(
|
|
"SELECT id, email, display_name, user_handle, password_hash
|
|
FROM users WHERE email = ?1 COLLATE NOCASE",
|
|
)
|
|
.bind(&email)
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(row.map(|row| {
|
|
(
|
|
User {
|
|
id: row.get(0),
|
|
email: row.get(1),
|
|
display_name: row.get(2),
|
|
user_handle: row.get(3),
|
|
},
|
|
row.get(4),
|
|
)
|
|
}))
|
|
}
|
|
|
|
async fn find_user_by_handle(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
user_handle: Vec<u8>,
|
|
) -> DomainResult<Option<User>> {
|
|
let row = sqlx::query(
|
|
"SELECT id, email, display_name, user_handle
|
|
FROM users WHERE user_handle = ?1",
|
|
)
|
|
.bind(&user_handle)
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(row.map(|row| User {
|
|
id: row.get(0),
|
|
email: row.get(1),
|
|
display_name: row.get(2),
|
|
user_handle: row.get(3),
|
|
}))
|
|
}
|
|
|
|
async fn update_password_hash(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
user_id: i64,
|
|
password_hash: String,
|
|
) -> DomainResult<()> {
|
|
sqlx::query("UPDATE users SET password_hash = ?1 WHERE id = ?2")
|
|
.bind(&password_hash)
|
|
.bind(user_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn has_users(&self, txn: &mut SqliteConnection) -> DomainResult<bool> {
|
|
let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM users)")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(row.get::<i64, _>(0) != 0)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqlitePasskeyRepository;
|
|
|
|
#[async_trait]
|
|
impl PasskeyRepository for SqlitePasskeyRepository {
|
|
async fn create_passkey(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
user_id: i64,
|
|
credential_id: String,
|
|
credential: String,
|
|
counter: i64,
|
|
) -> DomainResult<Passkey> {
|
|
let result = sqlx::query(
|
|
"INSERT INTO passkeys (user_id, credential_id, credential, counter, created_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
)
|
|
.bind(user_id)
|
|
.bind(&credential_id)
|
|
.bind(&credential)
|
|
.bind(counter)
|
|
.bind(now())
|
|
.execute(&mut *txn)
|
|
.await;
|
|
match result {
|
|
Ok(_) => {
|
|
let id = sqlx::query("SELECT last_insert_rowid()")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get::<i64, _>(0);
|
|
Ok(Passkey {
|
|
id,
|
|
user_id,
|
|
credential_id,
|
|
credential,
|
|
counter,
|
|
})
|
|
}
|
|
Err(error) if is_unique_violation(&error) => Err(DomainError::Conflict),
|
|
Err(error) => Err(db_error(error)),
|
|
}
|
|
}
|
|
|
|
async fn find_by_credential_id(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
credential_id: String,
|
|
) -> DomainResult<Option<Passkey>> {
|
|
let row = sqlx::query(
|
|
"SELECT id, user_id, credential_id, credential, counter
|
|
FROM passkeys WHERE credential_id = ?1",
|
|
)
|
|
.bind(&credential_id)
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(row.map(|row| Passkey {
|
|
id: row.get(0),
|
|
user_id: row.get(1),
|
|
credential_id: row.get(2),
|
|
credential: row.get(3),
|
|
counter: row.get(4),
|
|
}))
|
|
}
|
|
|
|
async fn list_for_user(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
user_id: i64,
|
|
) -> DomainResult<Vec<Passkey>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, user_id, credential_id, credential, counter
|
|
FROM passkeys WHERE user_id = ?1 ORDER BY id ASC",
|
|
)
|
|
.bind(user_id)
|
|
.fetch_all(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|row| Passkey {
|
|
id: row.get(0),
|
|
user_id: row.get(1),
|
|
credential_id: row.get(2),
|
|
credential: row.get(3),
|
|
counter: row.get(4),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn delete_passkey(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
user_id: i64,
|
|
passkey_id: i64,
|
|
) -> DomainResult<()> {
|
|
let changed = sqlx::query("DELETE FROM passkeys WHERE id = ?1 AND user_id = ?2")
|
|
.bind(passkey_id)
|
|
.bind(user_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteSessionRepository;
|
|
|
|
#[async_trait]
|
|
impl SessionRepository for SqliteSessionRepository {
|
|
async fn create_session(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
user_id: i64,
|
|
) -> DomainResult<(String, String)> {
|
|
let session_token = hex::encode(crate::security::new_secret());
|
|
let csrf_token = hex::encode(crate::security::new_secret());
|
|
sqlx::query(
|
|
"INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at)
|
|
VALUES (?1, ?2, ?3, ?4)",
|
|
)
|
|
.bind(hash_secret(&session_token))
|
|
.bind(user_id)
|
|
.bind(hex::decode(&csrf_token).expect("csrf_token is valid hex"))
|
|
.bind(now() + 60 * 60 * 24 * 30)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok((session_token, csrf_token))
|
|
}
|
|
|
|
async fn session_user(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
session_token: String,
|
|
) -> DomainResult<Option<SessionUser>> {
|
|
let row = sqlx::query(
|
|
"SELECT u.id, u.email, u.display_name, u.user_handle, s.csrf_token
|
|
FROM sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.token_hash = ?1 AND s.expires_at > ?2",
|
|
)
|
|
.bind(hash_secret(&session_token))
|
|
.bind(now())
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(row.map(|row| SessionUser {
|
|
user: User {
|
|
id: row.get(0),
|
|
email: row.get(1),
|
|
display_name: row.get(2),
|
|
user_handle: row.get(3),
|
|
},
|
|
csrf_token: hex::encode(row.get::<Vec<u8>, _>(4)),
|
|
}))
|
|
}
|
|
|
|
async fn delete_session(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
session_token: String,
|
|
) -> DomainResult<()> {
|
|
sqlx::query("DELETE FROM sessions WHERE token_hash = ?1")
|
|
.bind(hash_secret(&session_token))
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteListRepository;
|
|
|
|
#[async_trait]
|
|
impl ListRepository for SqliteListRepository {
|
|
async fn list_summaries(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<GroceryList>> {
|
|
let rows = sqlx::query(
|
|
"SELECT l.id, l.name, l.revision
|
|
FROM lists l
|
|
ORDER BY l.created_at DESC",
|
|
)
|
|
.fetch_all(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|row| GroceryList {
|
|
id: row.get(0),
|
|
name: row.get(1),
|
|
revision: row.get(2),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn create_list(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
name: String,
|
|
) -> DomainResult<GroceryList> {
|
|
sqlx::query("INSERT INTO lists (name, revision, created_at) VALUES (?1, 0, ?2)")
|
|
.bind(&name)
|
|
.bind(now())
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
let list_id = sqlx::query("SELECT last_insert_rowid()")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get::<i64, _>(0);
|
|
Ok(GroceryList {
|
|
id: list_id,
|
|
name,
|
|
revision: 0,
|
|
})
|
|
}
|
|
|
|
async fn get_list(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
list_id: i64,
|
|
) -> DomainResult<Option<GroceryList>> {
|
|
let row = sqlx::query(
|
|
"SELECT l.id, l.name, l.revision
|
|
FROM lists l
|
|
WHERE l.id = ?1",
|
|
)
|
|
.bind(list_id)
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(row.map(|row| GroceryList {
|
|
id: row.get(0),
|
|
name: row.get(1),
|
|
revision: row.get(2),
|
|
}))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteCategoryRepository;
|
|
|
|
#[async_trait]
|
|
impl CategoryRepository for SqliteCategoryRepository {
|
|
async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Category>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, name
|
|
FROM categories
|
|
ORDER BY position ASC, name COLLATE NOCASE ASC",
|
|
)
|
|
.fetch_all(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|row| Category {
|
|
id: row.get(0),
|
|
name: row.get(1),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn create_category(&self, txn: &mut SqliteConnection, name: String) -> DomainResult<i64> {
|
|
let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM categories")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get(0);
|
|
let result = sqlx::query(
|
|
"INSERT INTO categories (name, position, created_at)
|
|
VALUES (?1, ?2, ?3)",
|
|
)
|
|
.bind(&name)
|
|
.bind(position)
|
|
.bind(now())
|
|
.execute(&mut *txn)
|
|
.await;
|
|
match result {
|
|
Ok(_) => {}
|
|
Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict),
|
|
Err(error) => return Err(db_error(error)),
|
|
}
|
|
let id = sqlx::query("SELECT last_insert_rowid()")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get::<i64, _>(0);
|
|
Ok(id)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteItemRepository;
|
|
|
|
#[async_trait]
|
|
impl ItemRepository for SqliteItemRepository {
|
|
async fn items(&self, txn: &mut SqliteConnection, list_id: i64) -> DomainResult<Vec<Item>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, list_id, name, quantity, note, category_id, checked, version
|
|
FROM items
|
|
WHERE list_id = ?1
|
|
ORDER BY position ASC, created_at ASC",
|
|
)
|
|
.bind(list_id)
|
|
.fetch_all(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|row| Item {
|
|
id: row.get(0),
|
|
list_id: row.get(1),
|
|
name: row.get(2),
|
|
quantity: row.get(3),
|
|
note: row.get(4),
|
|
category_id: row.get(5),
|
|
checked: row.get::<i64, _>(6) != 0,
|
|
version: row.get(7),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn add_item(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
list_id: i64,
|
|
name: String,
|
|
quantity: String,
|
|
note: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<i64> {
|
|
ensure_category(txn, category_id).await?;
|
|
let position: i64 =
|
|
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1")
|
|
.bind(list_id)
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get(0);
|
|
sqlx::query(
|
|
"INSERT INTO items
|
|
(list_id, name, quantity, note, category_id, checked, version, position, created_at, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?7)",
|
|
)
|
|
.bind(list_id)
|
|
.bind(&name)
|
|
.bind(&quantity)
|
|
.bind(¬e)
|
|
.bind(category_id)
|
|
.bind(position)
|
|
.bind(now())
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
bump_revision(txn, list_id).await
|
|
}
|
|
|
|
async fn add_items_bulk(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
list_id: i64,
|
|
items: Vec<NewItem>,
|
|
) -> DomainResult<i64> {
|
|
let mut position: i64 =
|
|
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1")
|
|
.bind(list_id)
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get(0);
|
|
let now = now();
|
|
for item in items {
|
|
ensure_category(txn, item.category_id).await?;
|
|
sqlx::query(
|
|
"INSERT INTO items
|
|
(list_id, name, quantity, note, category_id, checked, version, position, list_meal_id, created_at, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?8, ?8)",
|
|
)
|
|
.bind(list_id)
|
|
.bind(&item.name)
|
|
.bind(&item.quantity)
|
|
.bind(&item.note)
|
|
.bind(item.category_id)
|
|
.bind(position)
|
|
.bind(item.list_meal_id)
|
|
.bind(now)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
position += 1;
|
|
}
|
|
bump_revision(txn, list_id).await
|
|
}
|
|
|
|
async fn set_item_checked(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
list_id: i64,
|
|
item_id: i64,
|
|
checked: bool,
|
|
) -> DomainResult<i64> {
|
|
let changed = sqlx::query(
|
|
"UPDATE items
|
|
SET checked = ?1, version = version + 1, updated_at = ?2
|
|
WHERE id = ?3 AND list_id = ?4",
|
|
)
|
|
.bind(checked as i64)
|
|
.bind(now())
|
|
.bind(item_id)
|
|
.bind(list_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
bump_revision(txn, list_id).await
|
|
}
|
|
|
|
async fn update_item(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
list_id: i64,
|
|
item_id: i64,
|
|
name: String,
|
|
quantity: String,
|
|
note: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<i64> {
|
|
ensure_category(txn, category_id).await?;
|
|
let changed = sqlx::query(
|
|
"UPDATE items
|
|
SET name = ?1, quantity = ?2, note = ?3, category_id = ?4,
|
|
version = version + 1, updated_at = ?5
|
|
WHERE id = ?6 AND list_id = ?7",
|
|
)
|
|
.bind(&name)
|
|
.bind(&quantity)
|
|
.bind(¬e)
|
|
.bind(category_id)
|
|
.bind(now())
|
|
.bind(item_id)
|
|
.bind(list_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
bump_revision(txn, list_id).await
|
|
}
|
|
|
|
async fn delete_item(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
list_id: i64,
|
|
item_id: i64,
|
|
) -> DomainResult<i64> {
|
|
let changed = sqlx::query("DELETE FROM items WHERE id = ?1 AND list_id = ?2")
|
|
.bind(item_id)
|
|
.bind(list_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
bump_revision(txn, list_id).await
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteListMealRepository;
|
|
|
|
#[async_trait]
|
|
impl ListMealRepository for SqliteListMealRepository {
|
|
async fn list_meals(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
list_id: i64,
|
|
) -> DomainResult<Vec<ListMeal>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, meal_id, name, created_at
|
|
FROM list_meals
|
|
WHERE list_id = ?1
|
|
ORDER BY created_at ASC, id ASC",
|
|
)
|
|
.bind(list_id)
|
|
.fetch_all(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|row| ListMeal {
|
|
id: row.get(0),
|
|
meal_id: row.get(1),
|
|
name: row.get(2),
|
|
created_at: row.get(3),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn add_meal(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
list_id: i64,
|
|
meal_id: i64,
|
|
name: String,
|
|
) -> DomainResult<i64> {
|
|
sqlx::query(
|
|
"INSERT INTO list_meals (list_id, meal_id, name, created_at)
|
|
VALUES (?1, ?2, ?3, ?4)",
|
|
)
|
|
.bind(list_id)
|
|
.bind(meal_id)
|
|
.bind(&name)
|
|
.bind(now())
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
let id = sqlx::query("SELECT last_insert_rowid()")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get::<i64, _>(0);
|
|
Ok(id)
|
|
}
|
|
|
|
async fn remove_meal(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
list_id: i64,
|
|
list_meal_id: i64,
|
|
) -> DomainResult<i64> {
|
|
let changed = sqlx::query("DELETE FROM list_meals WHERE id = ?1 AND list_id = ?2")
|
|
.bind(list_meal_id)
|
|
.bind(list_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
bump_revision(txn, list_id).await
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteInvitationRepository;
|
|
|
|
#[async_trait]
|
|
impl InvitationRepository for SqliteInvitationRepository {
|
|
async fn create_invitation(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
created_by: i64,
|
|
token: String,
|
|
) -> DomainResult<i64> {
|
|
let expires_at = now() + 60 * 60 * 24 * 7;
|
|
sqlx::query(
|
|
"INSERT INTO invitations (token_hash, created_by, expires_at)
|
|
VALUES (?1, ?2, ?3)",
|
|
)
|
|
.bind(hash_secret(&token))
|
|
.bind(created_by)
|
|
.bind(expires_at)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(expires_at)
|
|
}
|
|
|
|
async fn invitation(&self, txn: &mut SqliteConnection, token: String) -> DomainResult<bool> {
|
|
let row = sqlx::query(
|
|
"SELECT 1 FROM invitations
|
|
WHERE token_hash = ?1 AND expires_at > ?2",
|
|
)
|
|
.bind(hash_secret(&token))
|
|
.bind(now())
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(row.is_some())
|
|
}
|
|
|
|
async fn accept_invitation(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
token: String,
|
|
) -> DomainResult<()> {
|
|
let valid = sqlx::query(
|
|
"SELECT 1 FROM invitations
|
|
WHERE token_hash = ?1 AND expires_at > ?2",
|
|
)
|
|
.bind(hash_secret(&token))
|
|
.bind(now())
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.is_some();
|
|
if !valid {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
sqlx::query("DELETE FROM invitations WHERE token_hash = ?1")
|
|
.bind(hash_secret(&token))
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteMealCategoryRepository;
|
|
|
|
#[async_trait]
|
|
impl MealCategoryRepository for SqliteMealCategoryRepository {
|
|
async fn meal_categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<MealCategory>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, name
|
|
FROM meal_categories
|
|
ORDER BY position ASC, name COLLATE NOCASE ASC",
|
|
)
|
|
.fetch_all(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|row| MealCategory {
|
|
id: row.get(0),
|
|
name: row.get(1),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn create_meal_category(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
name: String,
|
|
) -> DomainResult<i64> {
|
|
let position: i64 =
|
|
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM meal_categories")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get(0);
|
|
let result = sqlx::query(
|
|
"INSERT INTO meal_categories (name, position, created_at)
|
|
VALUES (?1, ?2, ?3)",
|
|
)
|
|
.bind(&name)
|
|
.bind(position)
|
|
.bind(now())
|
|
.execute(&mut *txn)
|
|
.await;
|
|
match result {
|
|
Ok(_) => {}
|
|
Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict),
|
|
Err(error) => return Err(db_error(error)),
|
|
}
|
|
Ok(sqlx::query("SELECT last_insert_rowid()")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get::<i64, _>(0))
|
|
}
|
|
|
|
async fn delete_meal_category(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
category_id: i64,
|
|
) -> DomainResult<()> {
|
|
let changed = sqlx::query("DELETE FROM meal_categories WHERE id = ?1")
|
|
.bind(category_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteMealRepository;
|
|
|
|
#[async_trait]
|
|
impl MealRepository for SqliteMealRepository {
|
|
async fn create_meal(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
name: String,
|
|
description: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<Meal> {
|
|
let now = now();
|
|
sqlx::query(
|
|
"INSERT INTO meals (name, description, category_id, created_at, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?4)",
|
|
)
|
|
.bind(&name)
|
|
.bind(&description)
|
|
.bind(category_id)
|
|
.bind(now)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
let id = sqlx::query("SELECT last_insert_rowid()")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get::<i64, _>(0);
|
|
Ok(Meal {
|
|
id,
|
|
name,
|
|
description,
|
|
category_id,
|
|
ingredients: Vec::new(),
|
|
})
|
|
}
|
|
|
|
async fn get_meal(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
meal_id: i64,
|
|
) -> DomainResult<Option<Meal>> {
|
|
let row = sqlx::query(
|
|
"SELECT id, name, description, category_id
|
|
FROM meals
|
|
WHERE id = ?1",
|
|
)
|
|
.bind(meal_id)
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
let Some(row) = row else {
|
|
return Ok(None);
|
|
};
|
|
let meal = Meal {
|
|
id: row.get(0),
|
|
name: row.get(1),
|
|
description: row.get(2),
|
|
category_id: row.get(3),
|
|
ingredients: Vec::new(),
|
|
};
|
|
let ingredients = SqliteMealIngredientRepository
|
|
.ingredients_for_meal(txn, meal.id)
|
|
.await?;
|
|
Ok(Some(Meal {
|
|
ingredients,
|
|
..meal
|
|
}))
|
|
}
|
|
|
|
async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Meal>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, name, description, category_id
|
|
FROM meals
|
|
ORDER BY name COLLATE NOCASE ASC",
|
|
)
|
|
.fetch_all(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
let mut meals = Vec::new();
|
|
for row in rows {
|
|
let meal = Meal {
|
|
id: row.get(0),
|
|
name: row.get(1),
|
|
description: row.get(2),
|
|
category_id: row.get(3),
|
|
ingredients: Vec::new(),
|
|
};
|
|
let ingredients = SqliteMealIngredientRepository
|
|
.ingredients_for_meal(txn, meal.id)
|
|
.await?;
|
|
meals.push(Meal {
|
|
ingredients,
|
|
..meal
|
|
});
|
|
}
|
|
Ok(meals)
|
|
}
|
|
|
|
async fn update_meal(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
meal_id: i64,
|
|
name: String,
|
|
description: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<()> {
|
|
let changed = sqlx::query(
|
|
"UPDATE meals
|
|
SET name = ?1, description = ?2, category_id = ?3, updated_at = ?4
|
|
WHERE id = ?5",
|
|
)
|
|
.bind(&name)
|
|
.bind(&description)
|
|
.bind(category_id)
|
|
.bind(now())
|
|
.bind(meal_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete_meal(&self, txn: &mut SqliteConnection, meal_id: i64) -> DomainResult<()> {
|
|
let changed = sqlx::query("DELETE FROM meals WHERE id = ?1")
|
|
.bind(meal_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SqliteMealIngredientRepository;
|
|
|
|
#[async_trait]
|
|
impl MealIngredientRepository for SqliteMealIngredientRepository {
|
|
async fn ingredients_for_meal(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
meal_id: i64,
|
|
) -> DomainResult<Vec<MealIngredient>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, name, quantity, note, category_id
|
|
FROM meal_ingredients
|
|
WHERE meal_id = ?1
|
|
ORDER BY position ASC, id ASC",
|
|
)
|
|
.bind(meal_id)
|
|
.fetch_all(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|row| MealIngredient {
|
|
id: row.get(0),
|
|
name: row.get(1),
|
|
quantity: row.get(2),
|
|
note: row.get(3),
|
|
category_id: row.get(4),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn add_ingredient(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
meal_id: i64,
|
|
name: String,
|
|
quantity: String,
|
|
note: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<i64> {
|
|
ensure_category(txn, category_id).await?;
|
|
let position: i64 = sqlx::query(
|
|
"SELECT COALESCE(MAX(position), -1) + 1
|
|
FROM meal_ingredients WHERE meal_id = ?1",
|
|
)
|
|
.bind(meal_id)
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get(0);
|
|
sqlx::query(
|
|
"INSERT INTO meal_ingredients (meal_id, name, quantity, note, category_id, position)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
)
|
|
.bind(meal_id)
|
|
.bind(&name)
|
|
.bind(&quantity)
|
|
.bind(¬e)
|
|
.bind(category_id)
|
|
.bind(position)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
let id = sqlx::query("SELECT last_insert_rowid()")
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.get::<i64, _>(0);
|
|
Ok(id)
|
|
}
|
|
|
|
async fn update_ingredient(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
meal_id: i64,
|
|
ingredient_id: i64,
|
|
name: String,
|
|
quantity: String,
|
|
note: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<()> {
|
|
ensure_category(txn, category_id).await?;
|
|
let changed = sqlx::query(
|
|
"UPDATE meal_ingredients
|
|
SET name = ?1, quantity = ?2, note = ?3, category_id = ?4
|
|
WHERE id = ?5 AND meal_id = ?6",
|
|
)
|
|
.bind(&name)
|
|
.bind(&quantity)
|
|
.bind(¬e)
|
|
.bind(category_id)
|
|
.bind(ingredient_id)
|
|
.bind(meal_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete_ingredient(
|
|
&self,
|
|
txn: &mut SqliteConnection,
|
|
meal_id: i64,
|
|
ingredient_id: i64,
|
|
) -> DomainResult<()> {
|
|
let changed = sqlx::query("DELETE FROM meal_ingredients WHERE id = ?1 AND meal_id = ?2")
|
|
.bind(ingredient_id)
|
|
.bind(meal_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?
|
|
.rows_affected();
|
|
if changed == 0 {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
async fn ensure_category(txn: &mut SqliteConnection, category_id: Option<i64>) -> DomainResult<()> {
|
|
let Some(category_id) = category_id else {
|
|
return Ok(());
|
|
};
|
|
let row = sqlx::query("SELECT 1 FROM categories WHERE id = ?1")
|
|
.bind(category_id)
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
if row.is_none() {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn bump_revision(txn: &mut SqliteConnection, list_id: i64) -> DomainResult<i64> {
|
|
sqlx::query("UPDATE lists SET revision = revision + 1 WHERE id = ?1")
|
|
.bind(list_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
let row = sqlx::query("SELECT revision FROM lists WHERE id = ?1")
|
|
.bind(list_id)
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(row.get(0))
|
|
}
|
|
|
|
const DEFAULT_CATEGORIES: &[&str] = &[
|
|
"Produce",
|
|
"Meat & seafood",
|
|
"Dairy & eggs",
|
|
"Pantry",
|
|
"Frozen",
|
|
"Household",
|
|
];
|
|
|
|
const DEFAULT_MEAL_CATEGORIES: &[&str] =
|
|
&["Beef", "Chicken", "Pasta", "Sandwiches", "Salads", "Soups"];
|
|
|
|
fn hash_secret(secret: &str) -> Vec<u8> {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(secret.as_bytes());
|
|
hasher.finalize().to_vec()
|
|
}
|
|
|
|
fn now() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs() as i64
|
|
}
|
|
|
|
/// A random, high-entropy user handle used as the WebAuthn userHandle.
|
|
/// 32 random bytes, which is exactly the 64-byte maximum the WebAuthn spec
|
|
/// allows for a userHandle while still providing 256 bits of entropy. Opaque
|
|
/// and unguessable per the spec. Stored as raw bytes.
|
|
fn new_user_handle() -> Vec<u8> {
|
|
use rand::RngCore;
|
|
let mut bytes = [0_u8; 32];
|
|
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
|
bytes.to_vec()
|
|
}
|
|
|
|
fn is_unique_violation(error: &sqlx::Error) -> bool {
|
|
error
|
|
.as_database_error()
|
|
.map(|database_error| database_error.message().to_uppercase().contains("UNIQUE"))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn db_error(error: sqlx::Error) -> DomainError {
|
|
DomainError::Database(error.to_string())
|
|
}
|
|
|
|
fn migrate_error(error: sqlx::migrate::MigrateError) -> DomainError {
|
|
DomainError::Database(error.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
async fn setup() -> SqliteDatabase {
|
|
SqliteDatabase::open_in_memory().await.unwrap()
|
|
}
|
|
|
|
async fn create_user(db: &SqliteDatabase, email: &str) -> User {
|
|
let users = SqliteUserRepository;
|
|
let email = email.to_owned();
|
|
db.run(move |txn| {
|
|
let users = users.clone();
|
|
Box::pin(async move {
|
|
users
|
|
.create_user(txn, email, "Test User".into(), "hash".into())
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn create_list(db: &SqliteDatabase, name: &str) -> GroceryList {
|
|
let lists = SqliteListRepository;
|
|
let name = name.to_owned();
|
|
db.run(move |txn| {
|
|
let lists = lists.clone();
|
|
Box::pin(async move { lists.create_list(txn, name).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn add_item(db: &SqliteDatabase, list_id: i64, name: &str) -> Item {
|
|
let items = SqliteItemRepository;
|
|
let name_for_insert = name.to_owned();
|
|
db.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move {
|
|
items
|
|
.add_item(
|
|
txn,
|
|
list_id,
|
|
name_for_insert,
|
|
String::new(),
|
|
String::new(),
|
|
None,
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let items = SqliteItemRepository;
|
|
db.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move { items.items(txn, list_id).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.into_iter()
|
|
.find(|item| item.name == name)
|
|
.unwrap()
|
|
}
|
|
|
|
async fn get_items(db: &SqliteDatabase, list_id: i64) -> Vec<Item> {
|
|
let items = SqliteItemRepository;
|
|
db.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move { items.items(txn, list_id).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn get_categories(db: &SqliteDatabase) -> Vec<Category> {
|
|
let categories = SqliteCategoryRepository;
|
|
db.run(move |txn| {
|
|
let categories = categories.clone();
|
|
Box::pin(async move { categories.categories(txn).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
// ---- UserRepository ----
|
|
|
|
#[tokio::test]
|
|
async fn create_user_returns_user_with_id() {
|
|
let db = setup().await;
|
|
let user = create_user(&db, "alice@example.com").await;
|
|
assert!(user.id > 0);
|
|
assert_eq!(user.email, "alice@example.com");
|
|
assert_eq!(user.display_name, "Test User");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_user_with_duplicate_email_conflicts() {
|
|
let db = setup().await;
|
|
create_user(&db, "alice@example.com").await;
|
|
let users = SqliteUserRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let users = users.clone();
|
|
Box::pin(async move {
|
|
users
|
|
.create_user(
|
|
txn,
|
|
"alice@example.com".into(),
|
|
"Other".into(),
|
|
"hash".into(),
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::Conflict)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn find_user_by_email_returns_user_and_hash() {
|
|
let db = setup().await;
|
|
create_user(&db, "alice@example.com").await;
|
|
let users = SqliteUserRepository;
|
|
let found = db
|
|
.run(move |txn| {
|
|
let users = users.clone();
|
|
Box::pin(async move {
|
|
users
|
|
.find_user_by_email(txn, "alice@example.com".into())
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let (user, hash) = found.unwrap();
|
|
assert_eq!(user.email, "alice@example.com");
|
|
assert_eq!(hash, "hash");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn find_user_by_email_is_case_insensitive() {
|
|
let db = setup().await;
|
|
create_user(&db, "alice@example.com").await;
|
|
let users = SqliteUserRepository;
|
|
let found = db
|
|
.run(move |txn| {
|
|
let users = users.clone();
|
|
Box::pin(async move {
|
|
users
|
|
.find_user_by_email(txn, "ALICE@EXAMPLE.COM".into())
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(found.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn find_user_by_email_returns_none_for_unknown() {
|
|
let db = setup().await;
|
|
let users = SqliteUserRepository;
|
|
let found = db
|
|
.run(move |txn| {
|
|
let users = users.clone();
|
|
Box::pin(async move {
|
|
users
|
|
.find_user_by_email(txn, "nobody@example.com".into())
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(found.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn has_users_reflects_user_count() {
|
|
let db = setup().await;
|
|
let users = SqliteUserRepository;
|
|
let empty = db
|
|
.run(move |txn| {
|
|
let users = users.clone();
|
|
Box::pin(async move { users.has_users(txn).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(!empty);
|
|
|
|
create_user(&db, "alice@example.com").await;
|
|
let has = db
|
|
.run(move |txn| {
|
|
let users = users.clone();
|
|
Box::pin(async move { users.has_users(txn).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(has);
|
|
}
|
|
|
|
// ---- SessionRepository ----
|
|
|
|
#[tokio::test]
|
|
async fn create_and_lookup_session() {
|
|
let db = setup().await;
|
|
let user = create_user(&db, "alice@example.com").await;
|
|
let sessions = SqliteSessionRepository;
|
|
let (token, csrf) = db
|
|
.run(move |txn| {
|
|
let sessions = sessions.clone();
|
|
Box::pin(async move { sessions.create_session(txn, user.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(!token.is_empty());
|
|
assert!(!csrf.is_empty());
|
|
|
|
let session = db
|
|
.run(move |txn| {
|
|
let sessions = sessions.clone();
|
|
Box::pin(async move { sessions.session_user(txn, token.clone()).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(session.user.id, user.id);
|
|
assert_eq!(session.csrf_token, csrf);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_user_returns_none_for_unknown_token() {
|
|
let db = setup().await;
|
|
let sessions = SqliteSessionRepository;
|
|
let session = db
|
|
.run(move |txn| {
|
|
let sessions = sessions.clone();
|
|
Box::pin(async move { sessions.session_user(txn, "bogus".into()).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(session.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_session_removes_it() {
|
|
let db = setup().await;
|
|
let user = create_user(&db, "alice@example.com").await;
|
|
let sessions = SqliteSessionRepository;
|
|
let (token, _) = db
|
|
.run(move |txn| {
|
|
let sessions = sessions.clone();
|
|
Box::pin(async move { sessions.create_session(txn, user.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let token_for_delete = token.clone();
|
|
db.run(move |txn| {
|
|
let sessions = sessions.clone();
|
|
Box::pin(async move { sessions.delete_session(txn, token_for_delete).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let session = db
|
|
.run(move |txn| {
|
|
let sessions = sessions.clone();
|
|
Box::pin(async move { sessions.session_user(txn, token).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(session.is_none());
|
|
}
|
|
|
|
// ---- ListRepository ----
|
|
|
|
#[tokio::test]
|
|
async fn create_list_does_not_seed_categories() {
|
|
let db = setup().await;
|
|
// Default categories are seeded globally at startup.
|
|
let before = get_categories(&db).await.len();
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
assert!(list.id > 0);
|
|
assert_eq!(list.revision, 0);
|
|
// Categories are global now; creating a list must not add any.
|
|
assert_eq!(get_categories(&db).await.len(), before);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_summaries_returns_all_lists() {
|
|
let db = setup().await;
|
|
let first = create_list(&db, "First").await;
|
|
let second = create_list(&db, "Second").await;
|
|
let lists = SqliteListRepository;
|
|
let summaries = db
|
|
.run(move |txn| {
|
|
let lists = lists.clone();
|
|
Box::pin(async move { lists.list_summaries(txn).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let mut ids = summaries.iter().map(|l| l.id).collect::<Vec<_>>();
|
|
ids.sort_unstable();
|
|
assert_eq!(ids, vec![first.id, second.id]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_list_returns_list_or_none() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let lists = SqliteListRepository;
|
|
let found = db
|
|
.run(move |txn| {
|
|
let lists = lists.clone();
|
|
Box::pin(async move { lists.get_list(txn, list.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(found.unwrap().name, "Weekly shop");
|
|
|
|
let missing = db
|
|
.run(move |txn| {
|
|
let lists = lists.clone();
|
|
Box::pin(async move { lists.get_list(txn, 9999).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(missing.is_none());
|
|
}
|
|
|
|
// ---- CategoryRepository ----
|
|
|
|
#[tokio::test]
|
|
async fn create_category_is_global_and_returns_id() {
|
|
let db = setup().await;
|
|
let categories = SqliteCategoryRepository;
|
|
let id = db
|
|
.run(move |txn| {
|
|
let categories = categories.clone();
|
|
Box::pin(async move { categories.create_category(txn, "Bakery".into()).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(id > 0);
|
|
|
|
let cats = get_categories(&db).await;
|
|
assert!(cats.iter().any(|c| c.name == "Bakery"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_duplicate_category_conflicts() {
|
|
let db = setup().await;
|
|
let categories = SqliteCategoryRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let categories = categories.clone();
|
|
Box::pin(async move { categories.create_category(txn, "Produce".into()).await })
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::Conflict)));
|
|
}
|
|
|
|
// ---- MealCategoryRepository ----
|
|
|
|
async fn get_meal_categories(db: &SqliteDatabase) -> Vec<MealCategory> {
|
|
let categories = SqliteMealCategoryRepository;
|
|
db.run(move |txn| {
|
|
let categories = categories.clone();
|
|
Box::pin(async move { categories.meal_categories(txn).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn meal_categories_are_seeded_with_defaults() {
|
|
let db = setup().await;
|
|
let categories = get_meal_categories(&db).await;
|
|
let names = categories
|
|
.iter()
|
|
.map(|c| c.name.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert!(names.contains(&"Beef"));
|
|
assert!(names.contains(&"Chicken"));
|
|
assert!(names.contains(&"Pasta"));
|
|
assert!(names.contains(&"Sandwiches"));
|
|
assert!(names.contains(&"Salads"));
|
|
assert!(names.contains(&"Soups"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_meal_category_returns_id_and_lists() {
|
|
let db = setup().await;
|
|
let categories = SqliteMealCategoryRepository;
|
|
let id = db
|
|
.run(move |txn| {
|
|
let categories = categories.clone();
|
|
Box::pin(async move {
|
|
categories
|
|
.create_meal_category(txn, "Breakfast".into())
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(id > 0);
|
|
let cats = get_meal_categories(&db).await;
|
|
assert!(cats.iter().any(|c| c.id == id && c.name == "Breakfast"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_duplicate_meal_category_conflicts() {
|
|
let db = setup().await;
|
|
let categories = SqliteMealCategoryRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let categories = categories.clone();
|
|
Box::pin(async move { categories.create_meal_category(txn, "Beef".into()).await })
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::Conflict)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_meal_category_cascades_to_null_on_meals() {
|
|
let db = setup().await;
|
|
let categories = SqliteMealCategoryRepository;
|
|
let category_id = db
|
|
.run(move |txn| {
|
|
let categories = categories.clone();
|
|
Box::pin(async move {
|
|
categories
|
|
.create_meal_category(txn, "Breakfast".into())
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let meal = create_meal(&db, "Pancakes").await;
|
|
let meals = SqliteMealRepository;
|
|
db.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move {
|
|
meals
|
|
.update_meal(
|
|
txn,
|
|
meal.id,
|
|
"Pancakes".into(),
|
|
String::new(),
|
|
Some(category_id),
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
db.run(move |txn| {
|
|
let categories = categories.clone();
|
|
Box::pin(async move { categories.delete_meal_category(txn, category_id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let fetched = db
|
|
.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move { meals.get_meal(txn, meal.id).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(fetched.category_id, None);
|
|
}
|
|
|
|
// ---- ItemRepository ----
|
|
|
|
#[tokio::test]
|
|
async fn add_item_returns_revision_and_is_listed() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let items = SqliteItemRepository;
|
|
let revision = db
|
|
.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move {
|
|
items
|
|
.add_item(
|
|
txn,
|
|
list.id,
|
|
"Milk".into(),
|
|
"2 litres".into(),
|
|
"note".into(),
|
|
None,
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revision, 1);
|
|
|
|
let items = get_items(&db, list.id).await;
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].name, "Milk");
|
|
assert_eq!(items[0].quantity, "2 litres");
|
|
assert_eq!(items[0].note, "note");
|
|
assert!(!items[0].checked);
|
|
assert_eq!(items[0].version, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_item_with_unknown_category_fails() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let items = SqliteItemRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move {
|
|
items
|
|
.add_item(
|
|
txn,
|
|
list.id,
|
|
"Milk".into(),
|
|
String::new(),
|
|
String::new(),
|
|
Some(9999),
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_items_bulk_inserts_all_and_bumps_revision_once() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let items = SqliteItemRepository;
|
|
let new_items = vec![
|
|
NewItem {
|
|
name: "Penne".into(),
|
|
quantity: "500g".into(),
|
|
note: String::new(),
|
|
category_id: None,
|
|
list_meal_id: None,
|
|
},
|
|
NewItem {
|
|
name: "Tomato".into(),
|
|
quantity: "2".into(),
|
|
note: String::new(),
|
|
category_id: None,
|
|
list_meal_id: None,
|
|
},
|
|
];
|
|
let revision = db
|
|
.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move { items.add_items_bulk(txn, list.id, new_items).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revision, 1);
|
|
|
|
let listed = get_items(&db, list.id).await;
|
|
let mut names = listed.iter().map(|i| i.name.clone()).collect::<Vec<_>>();
|
|
names.sort_unstable();
|
|
assert_eq!(names, vec!["Penne".to_owned(), "Tomato".to_owned()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_item_changes_fields_and_bumps_version() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let item = add_item(&db, list.id, "Milk").await;
|
|
let items = SqliteItemRepository;
|
|
let revision = db
|
|
.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move {
|
|
items
|
|
.update_item(
|
|
txn,
|
|
list.id,
|
|
item.id,
|
|
"Oat milk".into(),
|
|
"1 litre".into(),
|
|
"chilled".into(),
|
|
None,
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revision, 2);
|
|
|
|
let updated = get_items(&db, list.id).await.remove(0);
|
|
assert_eq!(updated.name, "Oat milk");
|
|
assert_eq!(updated.quantity, "1 litre");
|
|
assert_eq!(updated.note, "chilled");
|
|
assert_eq!(updated.version, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_missing_item_fails() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let items = SqliteItemRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move {
|
|
items
|
|
.update_item(
|
|
txn,
|
|
list.id,
|
|
9999,
|
|
"X".into(),
|
|
String::new(),
|
|
String::new(),
|
|
None,
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_item_removes_it_and_bumps_revision() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let item = add_item(&db, list.id, "Milk").await;
|
|
let items = SqliteItemRepository;
|
|
let revision = db
|
|
.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move { items.delete_item(txn, list.id, item.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revision, 2);
|
|
assert!(get_items(&db, list.id).await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_missing_item_fails() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let items = SqliteItemRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move { items.delete_item(txn, list.id, 9999).await })
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_item_checked_on_missing_item_fails() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let items = SqliteItemRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move { items.set_item_checked(txn, list.id, 9999, true).await })
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
// ---- InvitationRepository ----
|
|
|
|
#[tokio::test]
|
|
async fn create_invitation_is_valid() {
|
|
let db = setup().await;
|
|
let user = create_user(&db, "alice@example.com").await;
|
|
let invitations = SqliteInvitationRepository;
|
|
let expires = db
|
|
.run(move |txn| {
|
|
let invitations = invitations.clone();
|
|
Box::pin(async move {
|
|
invitations
|
|
.create_invitation(txn, user.id, "token-1".into())
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(expires > now());
|
|
|
|
let valid = db
|
|
.run(move |txn| {
|
|
let invitations = invitations.clone();
|
|
Box::pin(async move { invitations.invitation(txn, "token-1".into()).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(valid);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invitation_is_false_for_unknown_token() {
|
|
let db = setup().await;
|
|
let invitations = SqliteInvitationRepository;
|
|
let valid = db
|
|
.run(move |txn| {
|
|
let invitations = invitations.clone();
|
|
Box::pin(async move { invitations.invitation(txn, "bogus".into()).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(!valid);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn accept_invitation_consumes_it() {
|
|
let db = setup().await;
|
|
let user = create_user(&db, "alice@example.com").await;
|
|
let invitations = SqliteInvitationRepository;
|
|
db.run(move |txn| {
|
|
let invitations = invitations.clone();
|
|
Box::pin(async move {
|
|
invitations
|
|
.create_invitation(txn, user.id, "token-1".into())
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
db.run(move |txn| {
|
|
let invitations = invitations.clone();
|
|
Box::pin(async move { invitations.accept_invitation(txn, "token-1".into()).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let valid = db
|
|
.run(move |txn| {
|
|
let invitations = invitations.clone();
|
|
Box::pin(async move { invitations.invitation(txn, "token-1".into()).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(!valid);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn accept_invitation_for_unknown_token_fails() {
|
|
let db = setup().await;
|
|
let invitations = SqliteInvitationRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let invitations = invitations.clone();
|
|
Box::pin(async move { invitations.accept_invitation(txn, "bogus".into()).await })
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
// ---- Existing integration-style tests ----
|
|
|
|
#[tokio::test]
|
|
async fn checked_state_is_set_not_toggled() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "List").await;
|
|
let item = add_item(&db, list.id, "Coffee").await;
|
|
let items = SqliteItemRepository;
|
|
db.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move { items.set_item_checked(txn, list.id, item.id, true).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
db.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move { items.set_item_checked(txn, list.id, item.id, true).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let item = get_items(&db, list.id).await.remove(0);
|
|
assert!(item.checked);
|
|
assert_eq!(item.version, 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn checking_an_item_does_not_change_list_order() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "List").await;
|
|
add_item(&db, list.id, "First").await;
|
|
add_item(&db, list.id, "Second").await;
|
|
let items = SqliteItemRepository;
|
|
let first_item = get_items(&db, list.id).await.remove(0);
|
|
|
|
db.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move {
|
|
items
|
|
.set_item_checked(txn, list.id, first_item.id, true)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let items = get_items(&db, list.id).await;
|
|
assert_eq!(items[0].name, "First");
|
|
assert!(items[0].checked);
|
|
assert_eq!(items[1].name, "Second");
|
|
}
|
|
|
|
// ---- MealRepository / MealIngredientRepository ----
|
|
|
|
async fn create_meal(db: &SqliteDatabase, name: &str) -> Meal {
|
|
let meals = SqliteMealRepository;
|
|
let name = name.to_owned();
|
|
db.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move { meals.create_meal(txn, name, String::new(), None).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn add_ingredient(
|
|
db: &SqliteDatabase,
|
|
meal_id: i64,
|
|
name: &str,
|
|
category_id: Option<i64>,
|
|
) -> MealIngredient {
|
|
let ingredients = SqliteMealIngredientRepository;
|
|
let name_for_insert = name.to_owned();
|
|
db.run(move |txn| {
|
|
let ingredients = ingredients.clone();
|
|
Box::pin(async move {
|
|
ingredients
|
|
.add_ingredient(
|
|
txn,
|
|
meal_id,
|
|
name_for_insert,
|
|
String::new(),
|
|
String::new(),
|
|
category_id,
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let ingredients = SqliteMealIngredientRepository;
|
|
db.run(move |txn| {
|
|
let ingredients = ingredients.clone();
|
|
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal_id).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.into_iter()
|
|
.find(|ingredient| ingredient.name == name)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_meal_returns_meal_with_id() {
|
|
let db = setup().await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
assert!(meal.id > 0);
|
|
assert_eq!(meal.name, "Pasta");
|
|
assert!(meal.ingredients.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_meals_returns_all_meals() {
|
|
let db = setup().await;
|
|
create_meal(&db, "Pasta").await;
|
|
create_meal(&db, "Salad").await;
|
|
let meals = SqliteMealRepository;
|
|
let meals = db
|
|
.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move { meals.list_meals(txn).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let mut names = meals.iter().map(|m| m.name.clone()).collect::<Vec<_>>();
|
|
names.sort_unstable();
|
|
assert_eq!(names, vec!["Pasta".to_owned(), "Salad".to_owned()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_meal_returns_meal_with_ingredients() {
|
|
let db = setup().await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
add_ingredient(&db, meal.id, "Penne", None).await;
|
|
add_ingredient(&db, meal.id, "Tomato", None).await;
|
|
let meals = SqliteMealRepository;
|
|
let fetched = db
|
|
.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move { meals.get_meal(txn, meal.id).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(fetched.name, "Pasta");
|
|
let mut names = fetched
|
|
.ingredients
|
|
.iter()
|
|
.map(|i| i.name.clone())
|
|
.collect::<Vec<_>>();
|
|
names.sort_unstable();
|
|
assert_eq!(names, vec!["Penne".to_owned(), "Tomato".to_owned()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_meal_returns_none_for_unknown() {
|
|
let db = setup().await;
|
|
let meals = SqliteMealRepository;
|
|
let found = db
|
|
.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move { meals.get_meal(txn, 9999).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(found.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_meal_changes_fields() {
|
|
let db = setup().await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
let meals = SqliteMealRepository;
|
|
db.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move {
|
|
meals
|
|
.update_meal(
|
|
txn,
|
|
meal.id,
|
|
"Pasta al pomodoro".into(),
|
|
"desc".into(),
|
|
None,
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let fetched = db
|
|
.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move { meals.get_meal(txn, meal.id).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(fetched.name, "Pasta al pomodoro");
|
|
assert_eq!(fetched.description, "desc");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_missing_meal_fails() {
|
|
let db = setup().await;
|
|
let meals = SqliteMealRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move {
|
|
meals
|
|
.update_meal(txn, 9999, "X".into(), String::new(), None)
|
|
.await
|
|
})
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_meal_removes_it_and_ingredients() {
|
|
let db = setup().await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
add_ingredient(&db, meal.id, "Penne", None).await;
|
|
let meals = SqliteMealRepository;
|
|
db.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move { meals.delete_meal(txn, meal.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let found = db
|
|
.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move { meals.get_meal(txn, meal.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(found.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_missing_meal_fails() {
|
|
let db = setup().await;
|
|
let meals = SqliteMealRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let meals = meals.clone();
|
|
Box::pin(async move { meals.delete_meal(txn, 9999).await })
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_ingredient_with_unknown_category_fails() {
|
|
let db = setup().await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
let ingredients = SqliteMealIngredientRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let ingredients = ingredients.clone();
|
|
Box::pin(async move {
|
|
ingredients
|
|
.add_ingredient(
|
|
txn,
|
|
meal.id,
|
|
"X".into(),
|
|
String::new(),
|
|
String::new(),
|
|
Some(9999),
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_ingredient_changes_fields() {
|
|
let db = setup().await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
let ingredient = add_ingredient(&db, meal.id, "Penne", None).await;
|
|
let ingredients = SqliteMealIngredientRepository;
|
|
db.run(move |txn| {
|
|
let ingredients = ingredients.clone();
|
|
Box::pin(async move {
|
|
ingredients
|
|
.update_ingredient(
|
|
txn,
|
|
meal.id,
|
|
ingredient.id,
|
|
"Rigatoni".into(),
|
|
"500g".into(),
|
|
String::new(),
|
|
None,
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let fetched = db
|
|
.run(move |txn| {
|
|
let ingredients = ingredients.clone();
|
|
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(fetched[0].name, "Rigatoni");
|
|
assert_eq!(fetched[0].quantity, "500g");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_ingredient_removes_it() {
|
|
let db = setup().await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
let ingredient = add_ingredient(&db, meal.id, "Penne", None).await;
|
|
let ingredients = SqliteMealIngredientRepository;
|
|
db.run(move |txn| {
|
|
let ingredients = ingredients.clone();
|
|
Box::pin(async move {
|
|
ingredients
|
|
.delete_ingredient(txn, meal.id, ingredient.id)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let remaining = db
|
|
.run(move |txn| {
|
|
let ingredients = ingredients.clone();
|
|
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(remaining.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_missing_ingredient_fails() {
|
|
let db = setup().await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
let ingredients = SqliteMealIngredientRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let ingredients = ingredients.clone();
|
|
Box::pin(async move { ingredients.delete_ingredient(txn, meal.id, 9999).await })
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
// ---- ListMealRepository ----
|
|
|
|
async fn add_meal_to_list(db: &SqliteDatabase, list_id: i64, meal: &Meal) -> ListMeal {
|
|
let list_meals = SqliteListMealRepository;
|
|
let name = meal.name.clone();
|
|
let meal_id = meal.id;
|
|
let id = db
|
|
.run(move |txn| {
|
|
let list_meals = list_meals.clone();
|
|
Box::pin(async move { list_meals.add_meal(txn, list_id, meal_id, name).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
ListMeal {
|
|
id,
|
|
meal_id: Some(meal.id),
|
|
name: meal.name.clone(),
|
|
created_at: 0,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_meals_returns_meals_added_to_a_list() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
add_meal_to_list(&db, list.id, &meal).await;
|
|
|
|
let list_meals = SqliteListMealRepository;
|
|
let meals = db
|
|
.run(move |txn| {
|
|
let list_meals = list_meals.clone();
|
|
Box::pin(async move { list_meals.list_meals(txn, list.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(meals.len(), 1);
|
|
assert_eq!(meals[0].name, "Pasta");
|
|
assert_eq!(meals[0].meal_id, Some(meal.id));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn removing_a_meal_deletes_its_items_and_bumps_revision() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let meal = create_meal(&db, "Pasta").await;
|
|
add_ingredient(&db, meal.id, "Penne", None).await;
|
|
add_ingredient(&db, meal.id, "Tomato", None).await;
|
|
let list_meal = add_meal_to_list(&db, list.id, &meal).await;
|
|
|
|
let items = SqliteItemRepository;
|
|
let ingredients = SqliteMealIngredientRepository;
|
|
let ingredient_rows = db
|
|
.run(move |txn| {
|
|
let ingredients = ingredients.clone();
|
|
Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let new_items = ingredient_rows
|
|
.into_iter()
|
|
.map(|ingredient| NewItem {
|
|
name: ingredient.name,
|
|
quantity: ingredient.quantity,
|
|
note: ingredient.note,
|
|
category_id: ingredient.category_id,
|
|
list_meal_id: Some(list_meal.id),
|
|
})
|
|
.collect();
|
|
db.run(move |txn| {
|
|
let items = items.clone();
|
|
Box::pin(async move { items.add_items_bulk(txn, list.id, new_items).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(get_items(&db, list.id).await.len(), 2);
|
|
|
|
let list_meals = SqliteListMealRepository;
|
|
let revision = db
|
|
.run(move |txn| {
|
|
let list_meals = list_meals.clone();
|
|
Box::pin(async move { list_meals.remove_meal(txn, list.id, list_meal.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revision, 2);
|
|
|
|
let meals = db
|
|
.run(move |txn| {
|
|
let list_meals = list_meals.clone();
|
|
Box::pin(async move { list_meals.list_meals(txn, list.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(meals.is_empty());
|
|
assert!(get_items(&db, list.id).await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn removing_an_unknown_meal_from_a_list_fails() {
|
|
let db = setup().await;
|
|
let list = create_list(&db, "Weekly shop").await;
|
|
let list_meals = SqliteListMealRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let list_meals = list_meals.clone();
|
|
Box::pin(async move { list_meals.remove_meal(txn, list.id, 9999).await })
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
|
|
// ---- PasskeyRepository ----
|
|
|
|
#[tokio::test]
|
|
async fn passkey_crud_roundtrip() {
|
|
let db = setup().await;
|
|
let user = create_user(&db, "alice@example.com").await;
|
|
let passkeys = SqlitePasskeyRepository;
|
|
|
|
let created = db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move {
|
|
passkeys
|
|
.create_passkey(txn, user.id, "cred-1".into(), "{}".into(), 0)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(created.id > 0);
|
|
assert_eq!(created.user_id, user.id);
|
|
assert_eq!(created.credential_id, "cred-1");
|
|
|
|
let found = db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move { passkeys.find_by_credential_id(txn, "cred-1".into()).await })
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(found.id, created.id);
|
|
|
|
let listed = db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move { passkeys.list_for_user(txn, user.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(listed.len(), 1);
|
|
|
|
db.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move { passkeys.delete_passkey(txn, user.id, created.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let after = db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move { passkeys.list_for_user(txn, user.id).await })
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(after.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_passkey_credential_id_conflicts() {
|
|
let db = setup().await;
|
|
let user = create_user(&db, "alice@example.com").await;
|
|
let passkeys = SqlitePasskeyRepository;
|
|
db.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move {
|
|
passkeys
|
|
.create_passkey(txn, user.id, "cred-1".into(), "{}".into(), 0)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move {
|
|
passkeys
|
|
.create_passkey(txn, user.id, "cred-1".into(), "{}".into(), 0)
|
|
.await
|
|
})
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::Conflict)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_missing_passkey_fails() {
|
|
let db = setup().await;
|
|
let user = create_user(&db, "alice@example.com").await;
|
|
let passkeys = SqlitePasskeyRepository;
|
|
let result = db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move { passkeys.delete_passkey(txn, user.id, 9999).await })
|
|
})
|
|
.await;
|
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
|
}
|
|
}
|