aquarium_control/database/
sql_interface_balling.rs

1/* Copyright 2024 Uwe Martin
2
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
5The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
7THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8*/
9
10//! Manages all database interactions for the Balling dosing system.
11//!
12//! This module provides a dedicated interface, `SqlInterfaceBalling`, for handling
13//! all SQL operations related to the Balling feature. It encapsulates logic for
14//! reading pump configurations (`ballingsetvals`), logging dosing events
15//! (`ballingdosinglog`), and retrieving dosing history.
16//!
17//! ## Key Components
18//!
19//! - **`SqlInterfaceBalling` Struct**: The primary struct that holds a database
20//!   connection and provides methods for all Balling-related database operations.
21//!
22//! - **`BallingSetVal` Struct**: A data structure representing the configuration
23//!   parameters for a single Balling pump, such as dosing speed and volume.
24//!
25//! - **`new()` Constructor**: A critical entry point that not only creates an
26//!   `SqlInterfaceBalling` instance but also performs essential "fail-fast"
27//!   validation. At initialization, it checks the database for:
28//!   - The presence of `NULL` values in the configuration table.
29//!   - Whether the number of rows in the `ballingsetvals` and `ballingdosinglog`
30//!     tables exceeds the limits defined in the application configuration.
31//!   This ensures the system doesn't start in an invalid or inconsistent state.
32//!
33//! ## Design and Purpose
34//!
35//! The main goal of this module is to provide a robust, encapsulated, and safe
36//! interface for the Balling subsystem's data.
37//!
38//! - **Encapsulation**: All SQL queries and logic specific to Balling are contained
39//!   within this module, separating concerns and making the overall database
40//!   architecture cleaner.
41//!
42//! - **Data Integrity**: The constructor's validation logic enforces data integrity
43//!   rules at startup, preventing runtime errors caused by misconfigured or
44//!   overgrown database tables.
45
46#[cfg(test)]
47use log::error;
48
49use chrono::NaiveDateTime;
50use mysql::prelude::*;
51use mysql::*;
52use std::time::Duration;
53
54use crate::database::sql_query_strings::{
55    SQL_QUERY_CHECK_BALLING_DOSING_LOG_COUNT, SQL_QUERY_CHECK_BALLING_SETVALS_COUNT,
56    SQL_QUERY_CHECK_BALLING_SETVALS_NULL,
57};
58use crate::database::{
59    sql_interface::SqlInterface, sql_interface_error::SqlInterfaceError, sql_query_strings,
60};
61
62/// Contains the configuration and the implementation of the SQL interface for Balling.
63#[derive(Debug)]
64pub struct SqlInterfaceBalling {
65    /// Connection to the database
66    pub conn: PooledConn,
67}
68
69/// Contains set value data for each pump.
70pub struct BallingSetVal {
71    /// ID of the pump
72    /// Attribute is public because access from within test cases.
73    /// Data is not used in the implementation. Therefore, it is tagged as unused.
74    #[allow(unused)]
75    pub pump_id: i32,
76
77    /// flow rate of the pump in ml/sec
78    pub dosing_speed: f32,
79
80    /// dosing volume in ml which the pump shall deliver each hour
81    pub dosing_volume: f32,
82
83    /// A label which describes the fluid that the pump is delivering
84    /// Attribute is public because access from within test cases.
85    /// Data is not used in the implementation. Therefore, it is tagged as unused.
86    #[allow(unused)]
87    pub label: String,
88}
89
90impl SqlInterfaceBalling {
91    /// Creates a new `SqlInterfaceBalling` instance.
92    ///
93    /// This constructor initializes the Balling SQL interface with an established
94    /// database connection. It performs several pre-flight checks to ensure data
95    /// integrity and adherence to configured limits, such as verifying that the
96    /// Balling set value table contains no NULL values and that table row counts
97    /// are within their specified maximums.
98    ///
99    /// # Arguments
100    /// * `conn` - An active, pooled database connection.
101    /// * `max_rows_balling_set_values` - Maximum allowed number of rows in the Balling set value table.
102    /// * `max_rows_balling_dosing_log` - Maximum allowed number of rows in the Balling dosing log table.
103    ///
104    /// # Returns
105    /// A `Result` containing a new `SqlInterfaceBalling` instance on success.
106    ///
107    /// # Errors
108    /// This function will return an error if:
109    /// - Any of the initial database queries to get table counts fail (`DatabaseCheckBallingSetValsFailure`).
110    /// - Any of the retrieved counts are negative, indicating a database issue (`DatabaseBallingSetValTableNegativeValue`).
111    /// - The `ballingsetvals` table contains entries with `NULL` values (`DatabaseBallingSetValTableContainsNull`).
112    /// - The number of rows in the `ballingsetvals` table exceeds `max_rows_balling_set_values` (`DatabaseBallingSetValTableContainsTooManyRows`).
113    /// - The number of rows in the `ballingdosinglog` table exceeds `max_rows_balling_dosing_log` (`DatabaseBallingDosingLogTableContainsTooManyRows`).
114    pub fn new(
115        mut conn: PooledConn,
116        max_rows_balling_set_values: u64,
117        max_rows_balling_dosing_log: u64,
118    ) -> Result<Self, SqlInterfaceError> {
119        // query the database
120        let count_null_values = SqlInterface::get_single_integer_from_database(
121            &mut conn,
122            SQL_QUERY_CHECK_BALLING_SETVALS_NULL,
123        )
124        .map_err(|e| SqlInterfaceError::DatabaseCheckBallingSetValsFailure {
125            location: module_path!().to_string(),
126            source: Box::new(e),
127        })?;
128        let count_rows_setvals = SqlInterface::get_single_integer_from_database(
129            &mut conn,
130            SQL_QUERY_CHECK_BALLING_SETVALS_COUNT,
131        )
132        .map_err(|e| SqlInterfaceError::DatabaseCheckBallingSetValsFailure {
133            location: module_path!().to_string(),
134            source: Box::new(e),
135        })?;
136        let count_rows_dosing_log = SqlInterface::get_single_integer_from_database(
137            &mut conn,
138            SQL_QUERY_CHECK_BALLING_DOSING_LOG_COUNT,
139        )
140        .map_err(|e| SqlInterfaceError::DatabaseCheckBallingSetValsFailure {
141            location: module_path!().to_string(),
142            source: Box::new(e),
143        })?;
144
145        // check the query results
146        if count_null_values < 0 || count_rows_setvals < 0 || count_rows_dosing_log < 0 {
147            return Err(SqlInterfaceError::DatabaseBallingSetValTableNegativeValue(
148                module_path!().to_string(),
149                count_null_values,
150                count_rows_setvals,
151                count_rows_dosing_log,
152            ));
153        }
154
155        if count_null_values > 0 {
156            return Err(SqlInterfaceError::DatabaseBallingSetValTableContainsNull(
157                module_path!().to_string(),
158                count_null_values,
159            ));
160        }
161
162        if max_rows_balling_set_values > 0 {
163            // execute the check only when the limit is greater than zero
164            if count_rows_setvals as u64 > max_rows_balling_set_values {
165                return Err(
166                    SqlInterfaceError::DatabaseBallingSetValTableContainsTooManyRows(
167                        module_path!().to_string(),
168                        count_rows_setvals.cast_unsigned(),
169                        max_rows_balling_set_values,
170                    ),
171                );
172            }
173        }
174
175        if max_rows_balling_dosing_log > 0 {
176            // execute the check only when the limit is greater than zero
177            if count_rows_dosing_log as u64 > max_rows_balling_dosing_log {
178                return Err(
179                    SqlInterfaceError::DatabaseBallingDosingLogTableContainsTooManyRows(
180                        module_path!().to_string(),
181                        count_rows_dosing_log.cast_unsigned(),
182                        max_rows_balling_dosing_log,
183                    ),
184                );
185            }
186        }
187
188        Ok(SqlInterfaceBalling { conn })
189    }
190
191    /// Retrieves the timestamp of the last Balling dosing event for a specific pump.
192    ///
193    /// This function queries the database for the most recent dosing timestamp associated
194    /// with the given `pump_id`.
195    ///
196    /// # Arguments
197    /// * `pump_id` - The unique identifier of the pump.
198    ///
199    /// # Returns
200    /// A `Result` containing an `Option<NaiveDateTime>`:
201    /// - `Ok(Some(NaiveDateTime))` if a last dosing timestamp is found for the pump.
202    /// - `Ok(None)` if no previous dosing events are recorded for the specified pump.
203    ///
204    /// # Errors
205    /// This function will return an error if the underlying database query fails. This
206    /// could be due to a connection issue, a syntax error, or if the query unexpectedly
207    /// returns more than one row. The specific error is propagated from the underlying
208    /// `get_optional_timestamp` call.
209    fn get_last_balling_dosing_timestamp(
210        &mut self,
211        pump_id: i64,
212    ) -> Result<Option<NaiveDateTime>, SqlInterfaceError> {
213        let sql_query = str::replace(
214            sql_query_strings::SQL_QUERY_READ_LAST_BALLING_DOSING_TIMESTAMP,
215            "#pump_id",
216            &pump_id.to_string(),
217        );
218        SqlInterface::get_optional_timestamp(&mut self.conn, &sql_query)
219    }
220
221    /// Calculates the duration in seconds since the last Balling dosing event for a given pump.
222    ///
223    /// This function retrieves both the current database timestamp and the last dosing
224    /// timestamp for the specified `pump_id`. It then computes the time difference.
225    ///
226    /// # Arguments
227    /// * `pump_id` - The unique identifier of the pump.
228    ///
229    /// # Returns
230    /// A `Result` containing an `Option<i64>`:
231    /// - `Ok(Some(Duration::new))`: The duration that has passed since the last dosing event.
232    /// - `Ok(None)`: If no previous dosing event is recorded for this pump.
233    ///
234    /// # Errors
235    /// This function will return an error if it fails to retrieve either the current
236    /// timestamp or the last dosing timestamp from the database. This could be due to
237    /// a connection loss or other database-side issues. It also will return an error if the
238    /// database contains any entries that are in the future.
239    pub fn get_duration_since_last_balling_dosing(
240        &mut self,
241        pump_id: i64,
242    ) -> Result<Option<Duration>, SqlInterfaceError> {
243        let current_datetime = SqlInterface::get_current_timestamp(&mut self.conn)?;
244        let last_dosing_datetime_opt = self.get_last_balling_dosing_timestamp(pump_id)?;
245
246        match last_dosing_datetime_opt {
247            Some(last_dosing_datetime) => {
248                if last_dosing_datetime > current_datetime {
249                    Err(SqlInterfaceError::BallingDosingLogEntryInFuture(
250                        module_path!().to_string(),
251                        pump_id,
252                        last_dosing_datetime,
253                    ))
254                } else {
255                    let duration =
256                        (current_datetime - last_dosing_datetime)
257                            .to_std()
258                            .map_err(|e| {
259                                SqlInterfaceError::BallingDosingTimestampConversionFailure {
260                                    location: module_path!().to_string(),
261                                    pump_id,
262                                    current_datetime,
263                                    last_dosing_datetime,
264                                    source: e,
265                                }
266                            })?;
267
268                    Ok(Some(duration))
269                }
270            }
271            None => Ok(None),
272        }
273    }
274
275    /// Inserts a new Balling dosing event record into the SQL database.
276    ///
277    /// This function logs details of a Balling dosing operation, including the
278    /// exact time, the pump used, and the volume of fluid dispensed.
279    ///
280    /// # Arguments
281    /// * `timestamp` - The `NaiveDateTime` when the dosing event occurred.
282    /// * `pump_id` - The identifier of the pump that performed the dosing.
283    /// * `dosing_volume` - The volume (in milliliters) of fluid dispensed.
284    ///
285    /// # Returns
286    /// An empty `Result` (`Ok(())`) if the event record was successfully inserted.
287    ///
288    /// # Errors
289    /// Returns `SqlInterfaceError::InsertBallingEventFailure` if the `INSERT`
290    /// query fails. This can happen due to a lost connection, constraint violation
291    /// (e.g., foreign key), or incorrect data types. The original `mysql::Error`
292    /// is included as the source.
293    pub fn insert_balling_event(
294        &mut self,
295        timestamp: NaiveDateTime,
296        pump_id: i64,
297        dosing_volume: f64,
298    ) -> Result<(), SqlInterfaceError> {
299        self.conn
300            .exec_drop(
301                sql_query_strings::SQL_QUERY_WRITE_BALLING_EVENT.to_owned(),
302                params! {
303                    "timestamp" => timestamp,
304                    "pump_id" => pump_id,
305                    "dosing_volume" => dosing_volume,
306                },
307            )
308            .map_err(|e| SqlInterfaceError::InsertBallingEventFailure {
309                location: module_path!().to_string(),
310                source: e,
311            })
312    }
313
314    /// Retrieves a single set of Balling dosing parameters for a specified pump from the database.
315    ///
316    /// This function queries the database for the Balling set values (e.g., dosing speed, volume, label)
317    /// associated with a given `pump_id`. It strictly expects to find exactly one matching dataset.
318    ///
319    /// # Arguments
320    /// * `pump_id` - The unique identifier of the pump for which to retrieve the set values.
321    ///
322    /// # Returns
323    /// A `Result` containing the `BallingSetVal` for the specified pump on success.
324    ///
325    /// # Errors
326    /// This function will return an error if:
327    /// - The database query fails for any reason (`SingleBallingSetValRequestFailure`).
328    /// - The query returns zero rows (`SingleBallingSetValEmptyResponse`).
329    /// - The query returns more than one row (`SingleBallingSetValNoSingleResponse`), which
330    ///   indicates a data consistency issue.
331    pub fn get_single_balling_setval_from_database(
332        &mut self,
333        pump_id: i64,
334    ) -> Result<BallingSetVal, SqlInterfaceError> {
335        let params = params! { "pump_id" => pump_id };
336
337        // Get a single data set from the database
338        let mut single_balling_setval_array = match self.conn.exec_map(
339            sql_query_strings::SQL_QUERY_READ_BALLING_SETVALS,
340            params,
341            |(pump_id, dosing_speed, dosing_volume, label)| BallingSetVal {
342                pump_id,
343                dosing_speed,
344                dosing_volume,
345                label,
346            },
347        ) {
348            Ok(c) => c,
349            Err(e) => {
350                return Err(SqlInterfaceError::SingleBallingSetValRequestFailure {
351                    location: module_path!().to_string(),
352                    query: sql_query_strings::SQL_QUERY_READ_BALLING_SETVALS.to_string(),
353                    pump_id,
354                    source: e,
355                });
356            }
357        };
358
359        // check the error case if there is no response
360        if single_balling_setval_array.is_empty() {
361            return Err(SqlInterfaceError::SingleBallingSetValEmptyResponse(
362                module_path!().to_string(),
363                sql_query_strings::SQL_QUERY_READ_BALLING_SETVALS.to_string(),
364                pump_id,
365            ));
366        }
367
368        // check the error case if there is more than one response
369        if single_balling_setval_array.len() > 1 {
370            return Err(SqlInterfaceError::SingleBallingSetValNoSingleResponse(
371                module_path!().to_string(),
372                sql_query_strings::SQL_QUERY_READ_BALLING_SETVALS.to_string(),
373                pump_id,
374            ));
375        }
376        Ok(single_balling_setval_array.remove(0))
377    }
378
379    #[cfg(test)]
380    // Inserts a pump configuration dataset into the database.
381    //
382    // This helper function is used exclusively in test environments to
383    // pre-populate the database with specific pump configuration data,
384    // allowing for consistent and isolated testing of related functionalities.
385    //
386    // # Arguments
387    // * `pump_id` - The unique identifier for the pump.
388    // * `dosing_speed` - The flow rate of the pump in milliliters per second.
389    // * `dosing_volume` - The volume (in milliliters) intended to be dosed in a single event.
390    // * `label` - A descriptive label for the fluid being dispensed by the pump.
391    //
392    // # Returns
393    // An empty `Result` (`Ok(())`) if the configuration was successfully inserted.
394    //
395    // # Errors
396    // Returns `SqlInterfaceError::InsertBallingPumpConfigurationFailure` if the
397    // `INSERT` query fails. This error should be updated to include the source
398    // `mysql::Error` for better diagnostics in test failures.
399    pub fn insert_pump_configuration(
400        &mut self,
401        pump_id: i64,
402        dosing_speed: f32,
403        dosing_volume: f32,
404        label: String,
405    ) -> Result<(), SqlInterfaceError> {
406        let sql_query_string = sql_query_strings::SQL_QUERY_WRITE_PUMP_CONFIGURATION.to_owned();
407        let sql_query_string_for_error = sql_query_string.clone();
408        match self.conn.exec_drop::<_, _>(
409            sql_query_string,
410            params! {
411            "pump_id" => pump_id,
412            "dosing_speed" => dosing_speed,
413            "dosing_volume" => dosing_volume,
414            "label" => label,
415            },
416        ) {
417            Ok(_) => Ok(()),
418            Err(e) => {
419                error!( // logging for testing purposes only
420                    target: module_path!(),
421                    "could not insert Balling pump configuration into database ({e:?})"
422                );
423                Err(SqlInterfaceError::InsertBallingPumpConfigurationFailure(
424                    module_path!().to_string(),
425                    sql_query_string_for_error,
426                ))
427            }
428        }
429    }
430}
431
432#[cfg(test)]
433pub mod tests {
434    use crate::database::sql_interface_balling::SqlInterfaceBalling;
435    use crate::database::{
436        sql_interface::SqlInterface, sql_interface_error::SqlInterfaceError, sql_query_strings,
437    };
438    use crate::utilities::config::{read_config_file_with_test_database, ConfigData};
439    use all_asserts::assert_le;
440    use chrono::Local;
441    use chrono::NaiveDateTime;
442    use mysql::{params, prelude::Queryable};
443    use spin_sleep::SpinSleeper;
444    use std::time::Duration;
445
446    pub struct SqlBallingEvent {
447        timestamp: String,
448        pump_id: i32,
449        dosing_volume: f32,
450    }
451
452    pub struct BallingEvent {
453        timestamp: NaiveDateTime,
454        pump_id: i32,
455        dosing_volume: f32,
456    }
457
458    #[test]
459    // This test case verifies the validation logic within the `SqlInterfaceBalling::new()` constructor.
460    // It covers the following scenarios:
461    // 1. Happy Path: Initialization succeeds with valid data and row counts.
462    // 2. Failure on Row Limit (SetVals): Fails when `ballingsetvals` exceeds its max row count.
463    // 3. Failure on Row Limit (DosingLog): Fails when `ballingdosinglog` exceeds its max row count.
464    // 4. Deactivated Checks: Succeeds even with excess rows if the limits are set to 0.
465    // Test case uses test database #57.
466    fn test_sql_interface_balling_new() {
467        // --- Common Setup ---
468        let config: ConfigData = read_config_file_with_test_database(
469            "/config/aquarium_control_test_generic.toml".to_string(),
470            57,
471        );
472        println!("Testing with database {}", config.sql_interface.db_name);
473        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
474            .expect("Initialization of SQL interface for test failed.");
475
476        // --- Test Case 1: Happy Path ---
477        println!("* Testing new() with valid data (Happy Path)...");
478        SqlInterface::truncate_table(
479            &mut sql_interface,
480            sql_query_strings::SQL_TABLE_BALLING_SETVALS.to_string(),
481        )
482        .unwrap();
483        SqlInterface::truncate_table(
484            &mut sql_interface,
485            sql_query_strings::SQL_TABLE_BALLING_DOSING_LOG.to_string(),
486        )
487        .unwrap();
488
489        // Insert one valid row into each table
490        let mut conn = sql_interface.get_connection().unwrap();
491        conn.exec_drop(
492            sql_query_strings::SQL_QUERY_WRITE_BALLING_SETVALS,
493            params! { "pump_id" => 1, "dosing_speed" => 1.0, "dosing_volume" => 1.0, "label" => "valid" },
494        ).unwrap();
495        conn.exec_drop(
496            sql_query_strings::SQL_QUERY_WRITE_BALLING_EVENT,
497            params! { "timestamp" => Local::now().naive_local(), "pump_id" => 1, "dosing_volume" => 1.0 },
498        ).unwrap();
499
500        let result = SqlInterfaceBalling::new(conn, 10, 10);
501        assert!(
502            result.is_ok(),
503            "Expected new() to succeed on happy path, but it failed: {:?}",
504            result.err()
505        );
506        println!("* Succeeded: Happy path initialization is successful.");
507
508        // --- Test Case 2: Failure on ballingsetvals row limit exceeded ---
509        println!("* Testing new() with too many rows in ballingsetvals...");
510        SqlInterface::truncate_table(
511            &mut sql_interface,
512            sql_query_strings::SQL_TABLE_BALLING_SETVALS.to_string(),
513        )
514        .unwrap();
515        let mut conn = sql_interface.get_connection().unwrap();
516        conn.exec_drop(sql_query_strings::SQL_QUERY_WRITE_BALLING_SETVALS, params! { "pump_id" => 1, "dosing_speed" => 1.0, "dosing_volume" => 1.0, "label" => "row1" }).unwrap();
517        conn.exec_drop(sql_query_strings::SQL_QUERY_WRITE_BALLING_SETVALS, params! { "pump_id" => 2, "dosing_speed" => 1.0, "dosing_volume" => 1.0, "label" => "row2" }).unwrap();
518
519        let result = SqlInterfaceBalling::new(conn, 1, 10); // Set the limit to 1
520        assert!(
521            matches!(
522                result,
523                Err(SqlInterfaceError::DatabaseBallingSetValTableContainsTooManyRows(_, _, _))
524            ),
525            "Expected row limit error for setvals, but got {:?}",
526            result
527        );
528        println!("* Succeeded: Initialization fails if ballingsetvals exceeds row limit.");
529
530        // --- Test Case 3: Failure on ballingdosinglog row limit exceeded ---
531        println!("* Testing new() with too many rows in ballingdosinglog...");
532        let spin_sleeper = SpinSleeper::default();
533        SqlInterface::truncate_table(
534            &mut sql_interface,
535            sql_query_strings::SQL_TABLE_BALLING_DOSING_LOG.to_string(),
536        )
537        .unwrap();
538        let mut conn = sql_interface.get_connection().unwrap();
539        conn.exec_drop(sql_query_strings::SQL_QUERY_WRITE_BALLING_EVENT, params! { "timestamp" => Local::now().naive_local(), "pump_id" => 1, "dosing_volume" => 1.0 }).unwrap();
540        spin_sleeper.sleep(Duration::from_millis(500));
541        conn.exec_drop(sql_query_strings::SQL_QUERY_WRITE_BALLING_EVENT, params! { "timestamp" => Local::now().naive_local(), "pump_id" => 2, "dosing_volume" => 1.0 }).unwrap();
542
543        let result = SqlInterfaceBalling::new(conn, 10, 1); // Set the limit to 1
544        assert!(
545            matches!(
546                result,
547                Err(SqlInterfaceError::DatabaseBallingDosingLogTableContainsTooManyRows(_, _, _))
548            ),
549            "Expected row limit error for the dosing log, but got {:?}",
550            result
551        );
552        println!("* Succeeded: Initialization fails if ballingdosinglog exceeds row limit.");
553
554        // --- Test Case 4: Deactivated checks (limits are 0) ---
555        println!("* Testing new() with deactivated row limit checks (limits = 0)...");
556        // We reuse the state from the previous test (2 rows in the dosing log)
557        let conn = sql_interface.get_connection().unwrap();
558        let result = SqlInterfaceBalling::new(conn, 0, 0); // Set limits to 0
559        assert!(
560            result.is_ok(),
561            "Expected new() to succeed with deactivated checks, but it failed: {:?}",
562            result.err()
563        );
564        println!("* Succeeded: Initialization passes when row limit checks are deactivated.");
565    }
566
567    #[test]
568    // Test case verifies the behavior of all functions accessing the Balling event log table.
569    // They are all combined in one function to limit the number of additional databases.
570    // Test case uses test database #30.
571    pub fn test_sql_interface_balling_events() {
572        let pump_id = 1;
573        let config: ConfigData = read_config_file_with_test_database(
574            "/config/aquarium_control_test_generic.toml".to_string(),
575            30,
576        );
577        println!("Testing with database {}", config.sql_interface.db_name);
578        let max_rows_balling_set_values = config.sql_interface.max_rows_balling_set_values;
579        let max_rows_balling_dosing_log = config.sql_interface.max_rows_balling_dosing_log;
580        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
581            .expect("Initialization of SQL interface for test failed.");
582        let mut sql_interface_balling = SqlInterfaceBalling::new(
583            sql_interface.get_connection().unwrap(),
584            max_rows_balling_set_values,
585            max_rows_balling_dosing_log,
586        )
587        .unwrap();
588
589        // *** check the timestamp of last Balling dosing ******************************************
590        match SqlInterface::truncate_table(
591            &mut sql_interface,
592            sql_query_strings::SQL_TABLE_BALLING_DOSING_LOG.to_string(),
593        ) {
594            Ok(_) => {}
595            Err(e) => panic!("Could not prepare test case: {e:?}"),
596        }
597        assert!(matches!(
598            sql_interface_balling.get_last_balling_dosing_timestamp(pump_id),
599            Ok(None)
600        ));
601        println!("* checking timestamp of last Balling dosing log succeeded.");
602        // *****************************************************************************************
603
604        // *** check timestamp of last Balling dosing **********************************************
605        let balling_timestamp =
606            NaiveDateTime::parse_from_str("2025-04-12 11:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
607        match sql_interface_balling.insert_balling_event(balling_timestamp, pump_id, 0.5) {
608            Ok(_) => {}
609            Err(e) => panic!("Could not insert Balling dosing event into database: {e:?}"),
610        };
611        assert!(matches!(
612            sql_interface_balling.get_last_balling_dosing_timestamp(pump_id),
613            Ok(_)
614        ));
615        println!("* checking timestamp of last Balling dosing succeeded.");
616        // *****************************************************************************************
617
618        // *** check the content of last Balling dosing ********************************************
619        // Read back Balling dosing event entry from the database
620        let single_balling_event_entry_array = match sql_interface_balling.conn.query_map(
621            sql_query_strings::SQL_QUERY_READ_BALLING_EVENT,
622            |(timestamp, pump_id, dosing_volume)| SqlBallingEvent {
623                timestamp,
624                pump_id,
625                dosing_volume,
626            },
627        ) {
628            Ok(c) => c,
629            Err(e) => {
630                panic!("Could not read Balling dosing event from data base: {e:?}");
631            }
632        };
633        // check if SQL response has the right structure
634        assert_eq!(single_balling_event_entry_array.len(), 1);
635        // check if SQL response contains the correct data
636        let balling_event = BallingEvent {
637            timestamp: NaiveDateTime::parse_from_str(
638                single_balling_event_entry_array[0].timestamp.as_str(),
639                "%Y-%m-%d %H:%M:%S",
640            )
641            .expect("conversion of timestamp to NaiveDate failed."),
642            pump_id: single_balling_event_entry_array[0].pump_id,
643            dosing_volume: single_balling_event_entry_array[0].dosing_volume,
644        };
645        assert_eq!(balling_event.timestamp, balling_timestamp);
646        assert_eq!(balling_event.pump_id, 1);
647        assert_eq!(balling_event.dosing_volume, 0.5);
648        // *****************************************************************************************
649
650        // *** check duration since last Balling dosing ********************************************
651        let duration_since_last_balling_dosing =
652            match sql_interface_balling.get_duration_since_last_balling_dosing(1) {
653                Ok(c) => c,
654                Err(e) => panic!("Could not calculate duration since last Balling dosing: {e:?}"),
655            }
656            .unwrap();
657        let reference_duration_since_last_balling_dosing =
658            SqlInterface::get_duration_from_naive_timestamp_to_local_now(balling_timestamp)
659                .unwrap();
660        let duration_delta_seconds = duration_since_last_balling_dosing
661            .as_secs()
662            .abs_diff(reference_duration_since_last_balling_dosing.num_seconds() as u64);
663        assert_le!(duration_delta_seconds, 1);
664        println!("* checking duration since last Balling dosing succeeded.");
665        // *****************************************************************************************
666    }
667
668    #[test]
669    // Test case verifies the behavior of all functions accessing the Balling set value table.
670    // They are all combined in one function to limit the number of additional databases.
671    // Test case uses test database #31.
672    pub fn test_sql_interface_balling_setvals() {
673        let config: ConfigData = read_config_file_with_test_database(
674            "/config/aquarium_control_test_generic.toml".to_string(),
675            31,
676        );
677        println!("Testing with database {}", config.sql_interface.db_name);
678        let max_rows_balling_set_values = config.sql_interface.max_rows_balling_set_values;
679        let max_rows_balling_dosing_log = config.sql_interface.max_rows_balling_dosing_log;
680        let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
681            .expect("Initialization of SQL interface for test failed.");
682        let mut sql_interface_balling = SqlInterfaceBalling::new(
683            sql_interface.get_connection().unwrap(),
684            max_rows_balling_set_values,
685            max_rows_balling_dosing_log,
686        )
687        .unwrap();
688
689        // *** read balling set vals from empty table **********************************************
690        match SqlInterface::truncate_table(
691            &mut sql_interface,
692            sql_query_strings::SQL_TABLE_BALLING_SETVALS.to_string(),
693        ) {
694            Ok(_) => {}
695            Err(e) => panic!("Could not prepare test case: {e:?}"),
696        }
697        match sql_interface_balling.get_single_balling_setval_from_database(1) {
698            Ok(_) => {
699                panic!("Reading from empty set value table returned value.");
700            }
701            Err(e) => {
702                assert!(matches!(
703                    e,
704                    SqlInterfaceError::SingleBallingSetValEmptyResponse(_, _, _)
705                ));
706            }
707        }
708        println!("* checking reading from empty Balling dosing set value table succeeded.");
709        // *****************************************************************************************
710
711        // *** read balling set vals from pre-filled table *****************************************
712        // prepare database: insert Balling set vals
713        let sql_query_string = sql_query_strings::SQL_QUERY_WRITE_BALLING_SETVALS.to_string();
714        match sql_interface_balling.conn.exec_drop::<_, _>(
715            sql_query_string.clone(),
716            params! {
717            "pump_id" => 1,
718            "dosing_speed" => 2.0,
719            "dosing_volume" => 0.5,
720            "label" => "test_pump".to_string(),
721            },
722        ) {
723            Ok(_) => {}
724            Err(e) => {
725                panic!("Error when inserting Balling set values into database: {e:?}");
726            }
727        };
728        match sql_interface_balling.get_single_balling_setval_from_database(1) {
729            Ok(c) => {
730                assert_eq!(c.pump_id, 1);
731                assert_eq!(c.dosing_speed, 2.0);
732                assert_eq!(c.dosing_volume, 0.5);
733                assert_eq!(c.label, "test_pump");
734            }
735            Err(e) => {
736                panic!("Error when reading from pre-filled Balling set value table: {e:?}");
737            }
738        }
739        println!("* checking reading existing Balling dosing set values succeeded.");
740
741        // *** check if instantiation of SqlInterfaceBalling checks maximum number of rows ******
742        // add one more Balling set value row
743        match sql_interface_balling.conn.exec_drop::<_, _>(
744            sql_query_string,
745            params! {
746            "pump_id" => 2,
747            "dosing_speed" => 3.0,
748            "dosing_volume" => 0.5,
749            "label" => "test_pump2".to_string(),
750            },
751        ) {
752            Ok(_) => {}
753            Err(e) => {
754                panic!("Error when inserting Balling set values into database: {e:?}");
755            }
756        };
757        let test_result = SqlInterfaceBalling::new(sql_interface.get_connection().unwrap(), 1, 0);
758        assert!(matches!(
759            test_result,
760            Err(SqlInterfaceError::DatabaseBallingSetValTableContainsTooManyRows(_, _, _))
761        ));
762
763        // **************************************************************************************
764    }
765}