aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-03-19 02:42:02 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-03-19 02:42:02 +0100
commit6df299c87ba8faab75626d04392f874ec642c8dc (patch)
tree64744ae2dea2c726d72589ce290e88679e60c564 /crates
parentchore(rocie-client): Re-generate the client api (diff)
downloadserver-6df299c87ba8faab75626d04392f874ec642c8dc.zip
feat(rocie-server): Provide default units (and other changes)
Diffstat (limited to '')
-rw-r--r--crates/rocie-server/src/api/set/no_auth/user.rs43
-rw-r--r--crates/rocie-server/src/app.rs10
-rw-r--r--crates/rocie-server/src/storage/migrate/defaults.rs99
-rw-r--r--crates/rocie-server/src/storage/migrate/mod.rs72
-rw-r--r--crates/rocie-server/src/storage/migrate/sql/0->1.sql7
-rw-r--r--crates/rocie-server/src/storage/sql/config.rs7
-rw-r--r--crates/rocie-server/src/storage/sql/get/config/mod.rs41
-rw-r--r--crates/rocie-server/src/storage/sql/get/mod.rs3
-rw-r--r--crates/rocie-server/src/storage/sql/get/unit/mod.rs40
-rw-r--r--crates/rocie-server/src/storage/sql/insert/config/mod.rs80
-rw-r--r--crates/rocie-server/src/storage/sql/insert/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/recipe.rs100
-rw-r--r--crates/rocie-server/tests/_testenv/init.rs21
-rw-r--r--crates/rocie-server/tests/defaults/mod.rs48
-rw-r--r--crates/rocie-server/tests/recipies/mod.rs178
-rw-r--r--crates/rocie-server/tests/tests.rs1
-rw-r--r--crates/rocie-server/tests/users/mod.rs13
18 files changed, 682 insertions, 83 deletions
diff --git a/crates/rocie-server/src/api/set/no_auth/user.rs b/crates/rocie-server/src/api/set/no_auth/user.rs
index 7ca865c..69758a7 100644
--- a/crates/rocie-server/src/api/set/no_auth/user.rs
+++ b/crates/rocie-server/src/api/set/no_auth/user.rs
@@ -6,9 +6,13 @@ use utoipa::ToSchema;
use crate::{
api::set::auth::user::UserStub,
app::App,
- storage::sql::{
- insert::Operations,
- user::{PasswordHash, User, UserId},
+ storage::{
+ migrate::migrate_db,
+ sql::{
+ config::Config,
+ insert::Operations,
+ user::{PasswordHash, User, UserId},
+ },
},
};
@@ -84,6 +88,14 @@ async fn logout(user: Identity) -> impl Responder {
HttpResponse::Ok()
}
+#[derive(ToSchema, Deserialize)]
+struct ProvisionInfo {
+ user: UserStub,
+
+ /// Whether we should apply the default configuration.
+ use_defaults: bool,
+}
+
/// Provision this instance.
///
/// This only works, if no users exist yet.
@@ -104,24 +116,39 @@ async fn logout(user: Identity) -> impl Responder {
body = String
)
),
- request_body = UserStub,
+ request_body = ProvisionInfo,
)]
#[post("/provision")]
async fn provision(
request: HttpRequest,
app: web::Data<App>,
- new_user: web::Json<UserStub>,
+ info: web::Json<ProvisionInfo>,
) -> Result<impl Responder> {
if User::get_all(&app).await?.is_empty() {
- let user = new_user.into_inner();
+ let info = info.into_inner();
let mut ops = Operations::new("register user (during provisioning)");
- let password_hash = PasswordHash::from_password(&user.password);
- let user = User::register(user.name, password_hash, user.description, &mut ops);
+ let password_hash = PasswordHash::from_password(&info.user.password);
+ let user = User::register(
+ info.user.name,
+ password_hash,
+ info.user.description,
+ &mut ops,
+ );
ops.apply(&app).await?;
+ if info.use_defaults {
+ let mut ops =
+ Operations::new("Set should use defaults on config (during provisioning)");
+ let mut config = Config::get(&app).await?;
+ config.set_use_default(true, &mut ops);
+ ops.apply(&app).await?;
+
+ migrate_db(&app).await?;
+ }
+
Identity::login(&request.extensions(), user.id.to_string())?;
Ok(HttpResponse::Ok().json(user.id))
diff --git a/crates/rocie-server/src/app.rs b/crates/rocie-server/src/app.rs
index bb27470..59eed28 100644
--- a/crates/rocie-server/src/app.rs
+++ b/crates/rocie-server/src/app.rs
@@ -1,12 +1,13 @@
-use std::path::PathBuf;
+use std::{cell::OnceCell, path::PathBuf, sync::OnceLock};
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
-use crate::storage::migrate::migrate_db;
+use crate::storage::migrate::{DbVersion, migrate_db};
#[derive(Clone)]
pub(crate) struct App {
pub(crate) db: SqlitePool,
+ pub(crate) db_version_at_start: OnceLock<DbVersion>,
}
impl App {
@@ -25,7 +26,10 @@ impl App {
})?
};
- let me = Self { db };
+ let me = Self {
+ db,
+ db_version_at_start: OnceLock::new(),
+ };
migrate_db(&me).await?;
diff --git a/crates/rocie-server/src/storage/migrate/defaults.rs b/crates/rocie-server/src/storage/migrate/defaults.rs
new file mode 100644
index 0000000..3a2019c
--- /dev/null
+++ b/crates/rocie-server/src/storage/migrate/defaults.rs
@@ -0,0 +1,99 @@
+use crate::{
+ app::App,
+ storage::sql::{insert::Operations, unit::Unit, unit_property::UnitProperty},
+};
+
+#[expect(clippy::unnecessary_wraps, reason = "The API expects an Option<_>")]
+fn so(input: &'static str) -> Option<String> {
+ Some(s(input))
+}
+fn s(input: &'static str) -> String {
+ String::from(input)
+}
+
+macro_rules! register {
+ (
+ $app:ident # $unit_prop_description:literal, $unit_prop_name:literal
+ $(
+ -> $unit_full_name_singular:ident, $unit_full_name_plural:ident, $unit_short_name:ident
+ )*
+ ) => {
+ let mut ops = Operations::new(concat!(
+ "create",
+ $unit_prop_name,
+ "unit property (during provisioning)"
+ ));
+ let unit_property = UnitProperty::register(
+ s($unit_prop_name),
+ so($unit_prop_description),
+ &mut ops,
+ )
+ .id;
+ ops.apply(&$app).await?;
+
+ let mut ops = Operations::new(concat!(
+ "create default units for",
+ $unit_prop_name,
+ "property (during provisioning)"
+ ));
+
+ $(
+ Unit::register(
+ s(stringify!($unit_full_name_singular)),
+ s(stringify!($unit_full_name_plural)),
+ s(stringify!($unit_short_name)),
+ None,
+ unit_property,
+ &mut ops,
+ );
+ )*
+
+ ops.apply(&$app).await?;
+ };
+}
+
+pub(super) async fn add_defaults_0_to_1(app: &App) -> Result<(), apply_defaults::Error> {
+ register!(
+ app # "Time mesurement units", "Time"
+ -> second, seconds, s
+ -> minute, minutes, min
+ -> hour, hours, h
+ );
+
+ register!(
+ app # "Mass (weight) mesurement units", "Mass"
+ -> milligram, milligrams, mg
+ -> gram, grams, g
+ -> kilogram, kilograms, kg
+ );
+
+ register!(
+ app # "Volume mesurement units", "Volume"
+ -> milliliter, millilters, ml
+ -> deciliter, deciliters, dl
+ -> liter, liters, l
+
+ // English
+ -> tablespoon, tablespoons, tbsp
+ -> teaspoon, teaspoons, tsp
+
+ // Swedish
+ -> tesked, teskedar, tsk
+ -> matsked, matskedar, msk
+ );
+
+ Ok(())
+}
+
+pub(crate) mod apply_defaults {
+ use crate::storage::sql::insert::{self, apply};
+
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to add new default unit-property the database: {0}")]
+ AddUnitProperty(#[from] apply::Error<insert::unit_property::Operation>),
+
+ #[error("Failed to add new default unit the database: {0}")]
+ AddUnit(#[from] apply::Error<insert::unit::Operation>),
+ }
+}
diff --git a/crates/rocie-server/src/storage/migrate/mod.rs b/crates/rocie-server/src/storage/migrate/mod.rs
index ae0732b..5c81580 100644
--- a/crates/rocie-server/src/storage/migrate/mod.rs
+++ b/crates/rocie-server/src/storage/migrate/mod.rs
@@ -7,10 +7,15 @@ use chrono::TimeDelta;
use log::{debug, info};
use sqlx::{Sqlite, SqlitePool, Transaction, query};
-use crate::app::App;
+use crate::{
+ app::App,
+ storage::sql::{config::Config, get::config},
+};
+
+mod defaults;
macro_rules! make_upgrade {
- ($app:expr, $old_version:expr, $new_version:expr, $sql_name:expr) => {
+ ($app:expr, $old_version:expr, $new_version:expr, $sql_name:expr) => {{
let mut tx = $app
.db
.begin()
@@ -57,8 +62,8 @@ macro_rules! make_upgrade {
new_version: $new_version,
})?;
- Ok(())
- };
+ Ok::<_, update::Error>(())
+ }};
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
@@ -135,11 +140,12 @@ impl DbVersion {
///
/// Each update is atomic, so if this function fails you are still guaranteed to have a
/// database at version `get_version`.
- #[allow(clippy::too_many_lines)]
async fn update(self, app: &App) -> Result<(), update::Error> {
match self {
Self::Empty => {
- make_upgrade! {app, Self::Empty, Self::One, "./sql/0->1.sql"}
+ make_upgrade! {app, Self::Empty, Self::One, "./sql/0->1.sql"}?;
+
+ Ok(())
}
// This is the current_version
@@ -158,7 +164,10 @@ impl Display for DbVersion {
}
}
pub(crate) mod update {
- use crate::storage::migrate::{DbVersion, db_version_set, get_db_version};
+ use crate::storage::{
+ migrate::{DbVersion, db_version_set, defaults, get_db_version},
+ sql::get::config,
+ };
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
@@ -182,6 +191,11 @@ pub(crate) mod update {
#[error("Failed to commit the update transaction: {0}")]
TxnCommit(sqlx::Error),
+ #[error("Failed to access the rocie config: {0}")]
+ ConfigGet(#[from] config::get::Error),
+ #[error("Failed to add defaults to the database: {0}")]
+ AddDefaults(#[from] defaults::apply_defaults::Error),
+
#[error("Failed to perform the next chained update (to ver {new_version}): {err}")]
NextUpdate {
err: Box<Self>,
@@ -197,6 +211,12 @@ pub(crate) mod db_version_parse {
}
}
+async fn should_use_defaults(app: &App) -> Result<bool, config::get::Error> {
+ let config = Config::get(app).await?;
+
+ Ok(config.should_use_defaults)
+}
+
/// Returns the current data as UNIX time stamp.
pub(crate) fn get_current_date() -> i64 {
let start = SystemTime::now();
@@ -238,7 +258,7 @@ pub(crate) async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion, get_d
)
.fetch_optional(pool)
.await
- .map_err(|err| get_db_version::Error::VersionTableExistance(err))?;
+ .map_err(get_db_version::Error::VersionTableExistance)?;
if let Some(output) = query {
assert_eq!(output.result, 1);
@@ -261,7 +281,7 @@ pub(crate) async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion, get_d
)
.fetch_one(pool)
.await
- .map_err(|err| get_db_version::Error::VersionNumberFetch(err))?;
+ .map_err(get_db_version::Error::VersionNumberFetch)?;
Ok(DbVersion::from_db(
current_version.number,
@@ -288,6 +308,26 @@ pub(crate) mod get_db_version {
pub(crate) async fn migrate_db(app: &App) -> Result<(), migrate_db::Error> {
let current_version = get_version(app).await?;
+ if app.db_version_at_start.get().is_none() {
+ app.db_version_at_start
+ .set(current_version)
+ .expect("the cell to be unititialized, we checked");
+ }
+
+ if app.db_version_at_start.get() == Some(&DbVersion::Empty) {
+ // We cannot run this code in the normal update function, because for the empty db, there
+ // is no way to know if we want defaults.
+ //
+ // So we need to add the defaults later, which is achieved by another call to `migrate_db`
+ // in provision.
+ //
+ // That is kinda hacky, but I don't see a way around it.
+ // For defaults added in version Two and onward, the default mechanism should just work.
+ if should_use_defaults(app).await? {
+ defaults::add_defaults_0_to_1(app).await?;
+ }
+ }
+
if current_version == CURRENT_VERSION {
return Ok(());
}
@@ -300,7 +340,12 @@ pub(crate) async fn migrate_db(app: &App) -> Result<(), migrate_db::Error> {
}
pub(crate) mod migrate_db {
- use crate::storage::migrate::{get_db_version, update};
+ use actix_web::ResponseError;
+
+ use crate::storage::{
+ migrate::{defaults, get_db_version, update},
+ sql::get::config,
+ };
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
@@ -309,5 +354,12 @@ pub(crate) mod migrate_db {
#[error("Failed to update the database: {0}")]
Upadate(#[from] update::Error),
+
+ #[error("Failed to access the rocie config: {0}")]
+ ConfigGet(#[from] config::get::Error),
+ #[error("Failed to add defaults to the database: {0}")]
+ AddDefaults(#[from] defaults::apply_defaults::Error),
}
+
+ impl ResponseError for Error {}
}
diff --git a/crates/rocie-server/src/storage/migrate/sql/0->1.sql b/crates/rocie-server/src/storage/migrate/sql/0->1.sql
index ba44c68..8f99322 100644
--- a/crates/rocie-server/src/storage/migrate/sql/0->1.sql
+++ b/crates/rocie-server/src/storage/migrate/sql/0->1.sql
@@ -178,3 +178,10 @@ CREATE TABLE txn_log (
timestamp INTEGER NOT NULL,
operation TEXT NOT NULL
) STRICT;
+
+CREATE TABLE config (
+ -- Make it impossible to insert more than one value here.
+ id INTEGER PRIMARY KEY NOT NULL CHECK (id = 0),
+ use_defaults INTEGER NOT NULL CHECK (use_defaults = 1 OR use_defaults = 0)
+) STRICT;
+INSERT INTO config (id, use_defaults) VALUES (0, 0);
diff --git a/crates/rocie-server/src/storage/sql/config.rs b/crates/rocie-server/src/storage/sql/config.rs
new file mode 100644
index 0000000..d62859c
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/config.rs
@@ -0,0 +1,7 @@
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)]
+pub(crate) struct Config {
+ pub(crate) should_use_defaults: bool,
+}
diff --git a/crates/rocie-server/src/storage/sql/get/config/mod.rs b/crates/rocie-server/src/storage/sql/get/config/mod.rs
new file mode 100644
index 0000000..eb8be86
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/get/config/mod.rs
@@ -0,0 +1,41 @@
+use crate::{app::App, storage::sql::config::Config};
+
+use sqlx::query;
+
+impl Config {
+ pub(crate) async fn get(app: &App) -> Result<Self, get::Error> {
+ let record = query!(
+ "
+ SELECT use_defaults
+ FROM config
+ WHERE id = 0
+"
+ )
+ .fetch_one(&app.db)
+ .await?;
+
+ let should_use_defaults = if record.use_defaults == 1 {
+ true
+ } else if record.use_defaults == 0 {
+ false
+ } else {
+ unreachable!("Should not be possible, sqlite's CHECK prevents it")
+ };
+
+ Ok(Self {
+ should_use_defaults,
+ })
+ }
+}
+
+pub(crate) mod get {
+ use actix_web::ResponseError;
+
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to execute the sql query")]
+ SqlError(#[from] sqlx::Error),
+ }
+
+ impl ResponseError for Error {}
+}
diff --git a/crates/rocie-server/src/storage/sql/get/mod.rs b/crates/rocie-server/src/storage/sql/get/mod.rs
index a6ee0e1..e3520da 100644
--- a/crates/rocie-server/src/storage/sql/get/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/mod.rs
@@ -1,9 +1,10 @@
pub(crate) mod barcode;
+pub(crate) mod config;
pub(crate) mod product;
pub(crate) mod product_amount;
pub(crate) mod product_parent;
-pub(crate) mod recipe_parent;
pub(crate) mod recipe;
+pub(crate) mod recipe_parent;
pub(crate) mod unit;
pub(crate) mod unit_property;
pub(crate) mod user;
diff --git a/crates/rocie-server/src/storage/sql/get/unit/mod.rs b/crates/rocie-server/src/storage/sql/get/unit/mod.rs
index 6f5d297..2c85970 100644
--- a/crates/rocie-server/src/storage/sql/get/unit/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/unit/mod.rs
@@ -57,6 +57,34 @@ impl Unit {
Ok(None)
}
}
+
+ pub(crate) async fn from_name(app: &App, name: &str) -> Result<Option<Self>, from_name::Error> {
+ let record = query!(
+ "
+ SELECT id, full_name_singular, unit_property, full_name_plural, short_name, description
+ FROM units
+ WHERE full_name_singular = ? OR full_name_plural = ? OR short_name = ?
+",
+ name,
+ name,
+ name
+ )
+ .fetch_optional(&app.db)
+ .await?;
+
+ if let Some(record) = record {
+ Ok(Some(Self {
+ id: UnitId::from_db(&record.id),
+ unit_property: UnitPropertyId::from_db(&record.unit_property),
+ full_name_singular: record.full_name_singular,
+ full_name_plural: record.full_name_plural,
+ short_name: record.short_name,
+ description: record.description,
+ }))
+ } else {
+ Ok(None)
+ }
+ }
}
pub(crate) mod get_all {
@@ -82,3 +110,15 @@ pub(crate) mod from_id {
impl ResponseError for Error {}
}
+
+pub(crate) mod from_name {
+ use actix_web::ResponseError;
+
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to execute the sql query")]
+ SqlError(#[from] sqlx::Error),
+ }
+
+ impl ResponseError for Error {}
+}
diff --git a/crates/rocie-server/src/storage/sql/insert/config/mod.rs b/crates/rocie-server/src/storage/sql/insert/config/mod.rs
new file mode 100644
index 0000000..8c81feb
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/insert/config/mod.rs
@@ -0,0 +1,80 @@
+use serde::{Deserialize, Serialize};
+use sqlx::query;
+
+use crate::storage::sql::{
+ config::Config,
+ insert::{Operations, Transactionable},
+};
+
+#[derive(Debug, Deserialize, Serialize)]
+pub(crate) enum Operation {
+ UseDefault { value: bool, old_value: bool },
+}
+
+impl Transactionable for Operation {
+ type ApplyError = apply::Error;
+ type UndoError = undo::Error;
+
+ async fn apply(self, txn: &mut sqlx::SqliteConnection) -> Result<(), apply::Error> {
+ match self {
+ Operation::UseDefault { value, .. } => {
+ query!(
+ "
+ UPDATE config
+ SET use_defaults = ?
+ WHERE id = 0
+",
+ value,
+ )
+ .execute(txn)
+ .await?;
+ }
+ }
+ Ok(())
+ }
+
+ async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> {
+ match self {
+ Operation::UseDefault { old_value, .. } => {
+ query!(
+ "
+ UPDATE config
+ SET use_defaults = ?
+ WHERE id = 0
+",
+ old_value,
+ )
+ .execute(txn)
+ .await?;
+ }
+ }
+ Ok(())
+ }
+}
+
+pub(crate) mod undo {
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to execute undo sql statments: {0}")]
+ SqlError(#[from] sqlx::Error),
+ }
+}
+pub(crate) mod apply {
+ #[derive(thiserror::Error, Debug)]
+ pub(crate) enum Error {
+ #[error("Failed to execute apply sql statments: {0}")]
+ SqlError(#[from] sqlx::Error),
+ }
+}
+
+impl Config {
+ pub(crate) fn set_use_default(&mut self, new_value: bool, ops: &mut Operations<Operation>) {
+ if self.should_use_defaults != new_value {
+ ops.push(Operation::UseDefault {
+ value: new_value,
+ old_value: self.should_use_defaults,
+ });
+ self.should_use_defaults = new_value;
+ }
+ }
+}
diff --git a/crates/rocie-server/src/storage/sql/insert/mod.rs b/crates/rocie-server/src/storage/sql/insert/mod.rs
index b92a88c..c106b2b 100644
--- a/crates/rocie-server/src/storage/sql/insert/mod.rs
+++ b/crates/rocie-server/src/storage/sql/insert/mod.rs
@@ -8,6 +8,7 @@ use serde::{Serialize, de::DeserializeOwned};
use sqlx::{SqliteConnection, query};
pub(crate) mod barcode;
+pub(crate) mod config;
pub(crate) mod product;
pub(crate) mod product_parent;
pub(crate) mod recipe;
diff --git a/crates/rocie-server/src/storage/sql/mod.rs b/crates/rocie-server/src/storage/sql/mod.rs
index c37e68f..f1d7cb1 100644
--- a/crates/rocie-server/src/storage/sql/mod.rs
+++ b/crates/rocie-server/src/storage/sql/mod.rs
@@ -3,6 +3,7 @@ pub(crate) mod insert;
// Types
pub(crate) mod barcode;
+pub(crate) mod config;
pub(crate) mod product;
pub(crate) mod product_amount;
pub(crate) mod product_parent;
diff --git a/crates/rocie-server/src/storage/sql/recipe.rs b/crates/rocie-server/src/storage/sql/recipe.rs
index 1fc3b56..7347b4b 100644
--- a/crates/rocie-server/src/storage/sql/recipe.rs
+++ b/crates/rocie-server/src/storage/sql/recipe.rs
@@ -1,5 +1,6 @@
#![expect(clippy::unused_async)]
+use log::error;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -10,7 +11,7 @@ use crate::{
product::{Product, ProductId},
product_amount::ProductAmount,
recipe_parent::RecipeParentId,
- unit::UnitAmount,
+ unit::{Unit, UnitAmount},
},
};
@@ -97,7 +98,6 @@ pub(crate) struct Section {
/// Each type of content inside a section
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
-#[serde(tag = "type", content = "value", rename_all = "camelCase")]
pub(crate) enum Content {
/// A step
Step(Step),
@@ -124,7 +124,6 @@ pub(crate) struct Step {
/// Except for [`Item::Text`], the value is the index where the item is located
/// in it's corresponding [`Vec`] in the [`Recipe`].
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
-#[serde(tag = "type", rename_all = "camelCase")]
pub(crate) enum Item {
/// Just plain text
Text {
@@ -205,7 +204,8 @@ pub(crate) struct Timer {
pub(crate) name: Option<String>,
/// Time quantity
- pub(crate) quantity: UnitAmount,
+ #[schema(nullable = false)]
+ pub(crate) quantity: Option<UnitAmount>,
}
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
@@ -238,12 +238,18 @@ pub(crate) struct Metadata {
}
pub(crate) mod conversion {
- use crate::storage::sql::get::product::from_id;
+ use crate::storage::sql::get::{product::from_id, unit::from_name};
#[derive(thiserror::Error, Debug)]
pub(crate) enum Error {
#[error("Failed to get a product by id: `{0}`")]
ProductAccess(#[from] from_id::Error),
+
+ #[error("The quantity of a cookware has an unit: `{0}`")]
+ CookwareQtyHasUnit(cooklang::Quantity),
+
+ #[error("Failed to get a unit by name: `{0}`")]
+ UnitLookup(#[from] from_name::Error),
}
}
@@ -258,8 +264,8 @@ pub(crate) struct NameAndUrl {
impl NameAndUrl {
async fn from(value: cooklang::metadata::NameAndUrl) -> Result<Self, conversion::Error> {
Ok(Self {
- name: value.name().map(|v| v.to_owned()),
- url: value.url().map(|v| v.to_owned()),
+ name: value.name().map(ToOwned::to_owned),
+ url: value.url().map(ToOwned::to_owned),
})
}
}
@@ -274,7 +280,7 @@ impl CooklangRecipe {
sections: for_in!(value.sections, |s| Section::from(s).await),
ingredients: for_in!(value.ingredients, |i| Ingredient::from(app, i).await),
cookware: for_in!(value.cookware, |c| Cookware::from(c).await),
- timers: for_in!(value.timers, |t| Timer::from(t).await),
+ timers: for_in!(value.timers, |t| Timer::from(app, t).await),
})
}
}
@@ -353,16 +359,22 @@ impl Ingredient {
async fn from(app: &App, value: cooklang::Ingredient) -> Result<Self, conversion::Error> {
if value.name.starts_with('/') {
Ok(Self::RecipeReference { id: todo!() })
- } else if let Some(product) = Product::from_name(&app, value.name.as_str()).await? {
+ } else if let Some(product) = Product::from_name(app, value.name.as_str()).await? {
Ok(Self::RegisteredProduct {
id: product.id,
alias: value.alias,
- quantity: None,
+ quantity: {
+ let unit_amount = quantity_to_unit_amount(app, value.quantity).await?;
+ unit_amount.map(|ua| ProductAmount {
+ product_id: product.id,
+ amount: ua,
+ })
+ },
})
} else {
Ok(Self::NotRegisteredProduct {
name: value.name,
- quantity: None,
+ quantity: quantity_to_unit_amount(app, value.quantity).await?,
})
}
}
@@ -372,20 +384,72 @@ impl Cookware {
Ok(Self {
name: value.name,
alias: value.alias,
- quantity: value.quantity.map(|q| match q.value() {
- cooklang::Value::Number(number) => number.value() as usize,
- cooklang::Value::Range { start, end } => todo!(),
- cooklang::Value::Text(_) => todo!(),
- }),
+ quantity: if let Some(qty) = value.quantity {
+ if qty.unit().is_some() {
+ return Err(conversion::Error::CookwareQtyHasUnit(qty));
+ }
+
+ let var_name = match qty.value() {
+ cooklang::Value::Number(number) => number.value() as usize,
+ cooklang::Value::Range { .. } | cooklang::Value::Text(_) => todo!(),
+ };
+
+ Some(var_name)
+ } else {
+ None
+ },
note: value.note,
})
}
}
impl Timer {
- async fn from(value: cooklang::Timer) -> Result<Self, conversion::Error> {
+ async fn from(app: &App, value: cooklang::Timer) -> Result<Self, conversion::Error> {
Ok(Self {
name: value.name,
- quantity: todo!(),
+ quantity: quantity_to_unit_amount(app, value.quantity).await?,
})
}
}
+
+async fn quantity_to_unit_amount(
+ app: &App,
+ qty: Option<cooklang::Quantity>,
+) -> Result<Option<UnitAmount>, conversion::Error> {
+ if let Some(qty) = qty {
+ let amount: f64 = match qty.value() {
+ cooklang::Value::Number(number) => number.value(),
+ cooklang::Value::Range { start, end } => {
+ // Let's just assume that more is better than less?
+ // TODO: This should be mapped correctly <2026-03-15>
+
+ end.value()
+ }
+ cooklang::Value::Text(_) => {
+ // TODO: Is there maybe a better way to deal with, non-parsable quantities? <2026-03-15>
+ return Ok(None);
+ }
+ };
+
+ let unit = if let Some(unit_name) = qty.unit() {
+ let unit = Unit::from_name(app, unit_name).await?;
+
+ if unit.is_none() {
+ error!(
+ "Failed to transfer the quantity for a recipe amount, as the unit is not yet known: {unit_name}"
+ );
+ }
+
+ unit
+ } else {
+ return Ok(None);
+ };
+
+ Ok(unit.map(|unit| UnitAmount {
+ // TODO: We need to convert the unit to the one that can fit the full f64 value as u32. <2026-03-15>
+ value: amount as u32,
+ unit: unit.id,
+ }))
+ } else {
+ Ok(None)
+ }
+}
diff --git a/crates/rocie-server/tests/_testenv/init.rs b/crates/rocie-server/tests/_testenv/init.rs
index 4a169fe..758ca4e 100644
--- a/crates/rocie-server/tests/_testenv/init.rs
+++ b/crates/rocie-server/tests/_testenv/init.rs
@@ -21,7 +21,7 @@ use std::{
use rocie_client::{
apis::{api_set_no_auth_user_api::provision, configuration::Configuration},
- models::UserStub,
+ models::{ProvisionInfo, UserStub},
};
use crate::{
@@ -116,10 +116,15 @@ impl TestEnv {
request!(
env,
- provision(UserStub {
- description: Some("Test user, used during test runs".to_string()),
- name: "rocie".to_string(),
- password: "server".to_string()
+ provision(ProvisionInfo {
+ user: UserStub {
+ description: Some("Test user, used during test runs".to_string()),
+ name: "rocie".to_string(),
+ password: "server".to_string()
+ },
+ // Don't use any default units.
+ // Otherwise we would need to update the tests every time we add new ones.
+ use_defaults: false,
})
);
@@ -150,7 +155,11 @@ impl TestEnv {
let mut stdout = BufReader::new(child.stdout.as_mut().expect("Was captured"));
let mut port = String::new();
- assert_ne!(stdout.read_line(&mut port).expect("Works"), 0);
+ assert_ne!(
+ stdout.read_line(&mut port).expect("Works"),
+ 0,
+ "We should have been able to read the one line, the server printed"
+ );
port.trim_end()
.parse()
diff --git a/crates/rocie-server/tests/defaults/mod.rs b/crates/rocie-server/tests/defaults/mod.rs
new file mode 100644
index 0000000..6a334b0
--- /dev/null
+++ b/crates/rocie-server/tests/defaults/mod.rs
@@ -0,0 +1,48 @@
+use rocie_client::{
+ apis::{api_get_auth_unit_api::units, api_set_no_auth_user_api::provision},
+ models::{ProvisionInfo, UserStub},
+};
+
+use crate::testenv::{TestEnv, init::function_name, log::request};
+
+#[tokio::test]
+async fn test_defaults_disabled() {
+ let env = TestEnv::new_no_login(function_name!());
+
+ request!(
+ env,
+ provision(ProvisionInfo {
+ user: UserStub {
+ description: None,
+ name: "James Richard Haynes".to_string(),
+ password: "hunter14".to_string()
+ },
+ use_defaults: false,
+ })
+ );
+
+ let default_units = request!(env, units());
+
+ assert_eq!(default_units, vec![]);
+}
+
+#[tokio::test]
+async fn test_defaults_all() {
+ let env = TestEnv::new_no_login(function_name!());
+
+ request!(
+ env,
+ provision(ProvisionInfo {
+ user: UserStub {
+ description: None,
+ name: "James Richard Haynes".to_string(),
+ password: "hunter14".to_string()
+ },
+ use_defaults: true,
+ })
+ );
+
+ let default_units = request!(env, units());
+
+ assert!(!default_units.is_empty());
+}
diff --git a/crates/rocie-server/tests/recipies/mod.rs b/crates/rocie-server/tests/recipies/mod.rs
index e59c3f6..15680f1 100644
--- a/crates/rocie-server/tests/recipies/mod.rs
+++ b/crates/rocie-server/tests/recipies/mod.rs
@@ -1,13 +1,14 @@
use rocie_client::{
apis::{
- api_get_auth_recipe_api::recipe_by_id, api_set_auth_product_api::register_product,
+ api_get_auth_recipe_api::{recipe_by_id, recipe_by_name},
+ api_set_auth_product_api::register_product,
api_set_auth_recipe_api::add_recipe,
api_set_auth_unit_property_api::register_unit_property,
},
models::{
Content, ContentOneOf, CooklangRecipe, Ingredient, IngredientOneOf1NotRegisteredProduct,
- Item, ItemOneOf, ItemOneOf1, Metadata, NameAndUrl, ProductStub, RecipeStub, Section, Step,
- UnitPropertyStub,
+ Item, ItemOneOf, ItemOneOf1, ItemOneOf1Ingredient, ItemOneOf3, ItemOneOfText, Metadata,
+ NameAndUrl, ProductStub, RecipeStub, Section, Step, UnitPropertyStub,
},
};
@@ -74,7 +75,28 @@ title: Curry
}
#[tokio::test]
-async fn test_recipe_ingredients() {
+async fn test_recipe_whitespace() {
+ let env = TestEnv::new(function_name!()).await;
+
+ let name = " Curry ".to_owned();
+
+ request!(
+ env,
+ add_recipe(RecipeStub {
+ content: " nothing really ".to_owned(),
+ name: name.clone(),
+ parent: None,
+ })
+ );
+
+ let output = request!(env, recipe_by_name(&name));
+
+ assert_eq!(output.name, name);
+}
+
+#[tokio::test]
+#[expect(clippy::too_many_lines)]
+async fn test_recipe_contents() {
let env = TestEnv::new(function_name!()).await;
let up = request!(
@@ -104,6 +126,8 @@ author: James Connor
title: Curry
---
Add @rice{} and @water{200%ml} to a pot.
+
+Now add @curry-spice{200%g} let rest for ~{20%min}.
"
.to_owned(),
name: "Curry".to_owned(),
@@ -130,41 +154,131 @@ Add @rice{} and @water{200%ml} to a pot.
quantity: None,
}
}),
+ Ingredient::IngredientOneOf1(rocie_client::models::IngredientOneOf1 {
+ not_registered_product: IngredientOneOf1NotRegisteredProduct {
+ name: "curry-spice".to_owned(),
+ quantity: None,
+ }
+ }),
]
);
assert_eq!(
output.content.sections,
vec![Section {
- content: vec![Content::ContentOneOf(ContentOneOf {
- value: Step {
- items: vec![
- Item::ItemOneOf(ItemOneOf {
- r#type: rocie_client::models::item_one_of::Type::Text,
- value: "Add ".to_owned()
- }),
- Item::ItemOneOf1(ItemOneOf1 {
- r#type: rocie_client::models::item_one_of_1::Type::Ingredient,
- index: 0
- }),
- Item::ItemOneOf(ItemOneOf {
- r#type: rocie_client::models::item_one_of::Type::Text,
- value: " and ".to_owned()
- }),
- Item::ItemOneOf1(ItemOneOf1 {
- r#type: rocie_client::models::item_one_of_1::Type::Ingredient,
- index: 1
- }),
- Item::ItemOneOf(ItemOneOf {
- r#type: rocie_client::models::item_one_of::Type::Text,
- value: " to a pot.".to_owned()
- })
- ],
- number: 1
- },
- r#type: rocie_client::models::content_one_of::Type::Step
- })],
+ content: vec![
+ Content::ContentOneOf(ContentOneOf {
+ step: Step {
+ items: vec![
+ Item::ItemOneOf(ItemOneOf {
+ text: ItemOneOfText {
+ value: "Add ".to_owned()
+ }
+ }),
+ Item::ItemOneOf1(ItemOneOf1 {
+ ingredient: ItemOneOf1Ingredient { index: 0 }
+ }),
+ Item::ItemOneOf(ItemOneOf {
+ text: ItemOneOfText {
+ value: " and ".to_owned()
+ }
+ }),
+ Item::ItemOneOf1(ItemOneOf1 {
+ ingredient: ItemOneOf1Ingredient { index: 1 }
+ }),
+ Item::ItemOneOf(ItemOneOf {
+ text: ItemOneOfText {
+ value: " to a pot.".to_owned()
+ }
+ })
+ ],
+ number: 1
+ }
+ }),
+ Content::ContentOneOf(ContentOneOf {
+ step: Step {
+ items: vec![
+ Item::ItemOneOf(ItemOneOf {
+ text: ItemOneOfText {
+ value: "Now add ".to_owned()
+ }
+ }),
+ Item::ItemOneOf1(ItemOneOf1 {
+ ingredient: ItemOneOf1Ingredient { index: 2 }
+ }),
+ Item::ItemOneOf(ItemOneOf {
+ text: ItemOneOfText {
+ value: " let rest for ".to_owned()
+ }
+ }),
+ Item::ItemOneOf3(ItemOneOf3 {
+ timer: ItemOneOf1Ingredient { index: 0 }
+ }),
+ Item::ItemOneOf(ItemOneOf {
+ text: ItemOneOfText {
+ value: ".".to_owned()
+ }
+ })
+ ],
+ number: 2
+ }
+ })
+ ],
name: None
}]
);
}
+
+#[tokio::test]
+async fn test_recipe_full_parse() {
+ let env = TestEnv::new(function_name!()).await;
+
+ // Recipe source: https://cook.md/https://bbcgoodfood.com/recipes/easy-pancakes
+ let recipe_id = request!(
+ env,
+ add_recipe(RecipeStub {
+ content: "
+---
+title: Easy pancakes
+description: Learn how to make the perfect pancakes every time with our foolproof easy crêpe recipe – elaborate flip optional
+image: https://images.immediate.co.uk/production/volatile/sites/30/2020/08/recipe-image-legacy-id-1273477_8-ad36e3b.jpg?resize=440,400
+nutrition:
+ calories: 61 calories
+ fat: 2 grams fat
+ saturated fat: 1 grams saturated fat
+ carbohydrates: 7 grams carbohydrates
+ sugar: 1 grams sugar
+ protein: 3 grams protein
+ sodium: 0.1 milligram of sodium
+tags: Cassie Best, Cook school, Crepe, Crêpes, easy pancakes, Flip, Flipping, Good for you, healthy pancakes, How to make pancakes, Make ahead, Pancake day, Pancake filling, Shrove Tuesday, Skills, thin pancakes
+source: https://bbcgoodfood.com/recipes/easy-pancakes
+author: Cassie Best
+prep time: 10 minutes
+course: Breakfast, Brunch, Main course
+time required: 30 minutes
+cook time: 20 minutes
+servings: Makes 12
+cuisine: British
+diet: Vegetarian
+---
+
+Put @plain flour{100%g}, @eggs{2}(large), @milk{300%ml}, @sunflower oil{1%tbsp} and a pinch of @salt{} into a #bowl{} or large jug, then whisk to a smooth batter. This should be similar in consistency to single cream.
+
+Set aside for ~{30%minutes} to rest if you have time, or start cooking straight away.
+
+Set a #medium frying pan{} or #crêpe pan{} over a medium heat and carefully wipe it with some oiled kitchen paper.
+
+When hot, cook your pancakes for ~{1%minute} on each side until golden, using around half a ladleful of batter per pancake. Keep them warm in a low oven as you make the rest.
+
+Serve with @?lemon wedges{} (optional) and @?caster sugar{} (optional), or your favourite filling. Once cold, you can layer the pancakes between baking parchment, then wrap in cling film and freeze for up to two months.
+"
+ .to_owned(),
+ name: "Easy pancakes".to_owned(),
+ parent: None,
+ })
+ );
+
+ let output = request!(env, recipe_by_id(recipe_id));
+
+ assert_eq!(output.name, "Easy pancakes".to_owned());
+}
diff --git a/crates/rocie-server/tests/tests.rs b/crates/rocie-server/tests/tests.rs
index e788712..6222017 100644
--- a/crates/rocie-server/tests/tests.rs
+++ b/crates/rocie-server/tests/tests.rs
@@ -9,3 +9,4 @@ mod recipe_parents;
mod recipies;
mod units;
mod users;
+mod defaults;
diff --git a/crates/rocie-server/tests/users/mod.rs b/crates/rocie-server/tests/users/mod.rs
index d381e8f..c7ba6f8 100644
--- a/crates/rocie-server/tests/users/mod.rs
+++ b/crates/rocie-server/tests/users/mod.rs
@@ -4,7 +4,7 @@ use rocie_client::{
api_set_auth_user_api::register_user,
api_set_no_auth_user_api::{login, logout, provision},
},
- models::{LoginInfo, UserStub},
+ models::{LoginInfo, ProvisionInfo, UserStub},
};
use crate::testenv::{TestEnv, init::function_name, log::request};
@@ -15,10 +15,13 @@ async fn test_provisioning() {
let user_id = request!(
env,
- provision(UserStub {
- description: None,
- name: "James Richard Haynes".to_string(),
- password: "hunter14".to_string()
+ provision(ProvisionInfo {
+ user: UserStub {
+ description: None,
+ name: "James Richard Haynes".to_string(),
+ password: "hunter14".to_string()
+ },
+ use_defaults: false,
})
);