feat: add test for util functions and start working on database providers tests procedures

This commit is contained in:
charlesgauthereau
2026-03-12 18:56:07 +01:00
parent 1496208db4
commit 824a5d52a3
17 changed files with 1304 additions and 676 deletions
Generated
+878 -668
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -42,13 +42,21 @@ async-compression = { version = "0.4.37", features = ["tokio", "gzip"] }
tokio-tar = "0.3.1"
oauth2 = "5.0.0"
hyper = "1.8.1"
async-http-client = "0.2.0"
aes-gcm = "0.11.0-rc.3"
generic-array = "0.14.7"
futures-util = "0.3.31"
tokio-stream = "0.1.18"
aes = "0.9.0-rc.4"
typenum = "1.19.0"
testcontainers = "0.27.1"
testcontainers-modules = { version = "0.15.0", features = ["postgres"] }
postgres = "0.19.12"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
mockall = "0.13"
testcontainers = "0.27.1"
wiremock = "0.6"
[[bin]]
+4
View File
@@ -1,8 +1,12 @@
include .env
export $(shell sed 's/=.*//' .env)
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
.PHONY: seed-mongo seed-mysql seed-postgres
up:
@bash $(CLUSTER_SCRIPT)
seed-mongo:
@echo "Seeding MongoDB..."
bash ./scripts/mongo/seed-mongo.sh
-1
View File
@@ -120,4 +120,3 @@ networks:
name: portabase_network
external: true
# docker network create portabase_network
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
check_docker() {
if ! docker info > /dev/null 2>&1; then
echo "Docker is not running. Attempting to start Docker..."
if [[ "$OSTYPE" == "darwin"* ]]; then
open -a Docker
echo "Waiting for Docker to start..."
until docker info > /dev/null 2>&1; do
sleep 2
done
elif command -v systemctl >/dev/null 2>&1; then
sudo systemctl start docker
else
echo "Cannot start Docker automatically. Please start Docker manually."
exit 1
fi
else
echo "Docker is running."
fi
}
check_network() {
local network_name="portabase_network"
if ! docker network ls --format '{{.Name}}' | grep -q "^${network_name}$"; then
echo "Docker network '${network_name}' not found. Creating..."
docker network create "${network_name}"
else
echo "Docker network '${network_name}' already exists."
fi
}
check_docker
check_network
echo "Starting docker-compose..."
docker-compose -f ./docker-compose.yml up
echo "Docker-compose started successfully."
+2
View File
@@ -4,6 +4,8 @@ mod services;
mod settings;
mod tasks;
mod utils;
#[cfg(test)]
mod tests;
use crate::tasks::ping::ping_server;
use crate::utils::locks::FileLock;
+1
View File
@@ -0,0 +1 @@
mod postgres;
+35
View File
@@ -0,0 +1,35 @@
use oauth2::url;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
use crate::services::config::{DatabaseConfig, DbType};
use url::Host;
use crate::domain::factory::DatabaseFactory;
#[tokio::test]
async fn postgres_ping_test() {
let container = Postgres::default()
.start()
.await
.unwrap();
let host = container.get_host().await.unwrap_or(Host::parse("127.0.0.1").unwrap());
let port = container.get_host_port_ipv4(5432).await.unwrap_or(5432) ;
let config = DatabaseConfig {
name: "My test Postgres Database".to_string(),
database: "postgres".to_string(),
db_type: DbType::Postgresql,
username: "postgres".to_string(),
password: "postgres".to_string(),
port,
host: host.to_string(),
generated_id: "40875631-e3d2-4dfe-a26b-2a347ecc64fd".to_string(),
path: "".to_string(),
};
let db = DatabaseFactory::create_for_backup(config.clone()).await;
let reachable = db.ping().await.unwrap_or_else(|_| false);
assert_eq!(reachable, true);
}
+2
View File
@@ -0,0 +1,2 @@
mod utils;
mod domain;
+46
View File
@@ -0,0 +1,46 @@
use serde_json::json;
use crate::utils::common::{vec_to_option_json, BackupMethod};
#[test]
fn backup_method_to_string_automatic() {
let method = BackupMethod::Automatic;
assert_eq!(method.to_string(), "automatic");
}
#[test]
fn backup_method_to_string_manual() {
let method = BackupMethod::Manual;
assert_eq!(method.to_string(), "manual");
}
#[test]
fn vec_to_option_json_returns_none_when_empty() {
let v: Vec<i32> = vec![];
let result = vec_to_option_json(v);
assert!(result.is_none());
}
#[test]
fn vec_to_option_json_serializes_vector() {
let v = vec![1, 2, 3];
let result = vec_to_option_json(v);
assert_eq!(result, Some(json!([1, 2, 3])));
}
#[test]
fn vec_to_option_json_serializes_struct_vector() {
#[derive(serde::Serialize)]
struct Item {
id: u32,
}
let v = vec![Item { id: 1 }, Item { id: 2 }];
let result = vec_to_option_json(v);
assert_eq!(result, Some(json!([
{ "id": 1 },
{ "id": 2 }
])));
}
+73
View File
@@ -0,0 +1,73 @@
use tempfile::tempdir;
use tokio::fs::{write, read};
use anyhow::Result;
use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz};
#[tokio::test]
async fn compress_creates_tar_gz() -> Result<()> {
let tmp = tempdir()?;
let file_path = tmp.path().join("test.txt");
write(&file_path, b"hello world").await?;
let result = compress_to_tar_gz_large(&file_path).await?;
assert!(result.compressed_path.exists());
assert_eq!(result.compressed_path.extension().unwrap(), "gz");
Ok(())
}
#[tokio::test]
async fn compress_skips_existing_tar_gz() -> Result<()> {
let tmp = tempdir()?;
let file_path = tmp.path().join("already.tar.gz");
write(&file_path, b"compressed").await?;
let result = compress_to_tar_gz_large(&file_path).await?;
// Should return same path without creating a new file
assert_eq!(result.compressed_path, file_path);
Ok(())
}
#[tokio::test]
async fn decompress_restores_file() -> Result<()> {
let tmp = tempdir()?;
let file_path = tmp.path().join("file.txt");
write(&file_path, b"data for decompress").await?;
let compress_result = compress_to_tar_gz_large(&file_path).await?;
let output_dir = tmp.path().join("out");
tokio::fs::create_dir_all(&output_dir).await?;
let extracted_files = decompress_large_tar_gz(&compress_result.compressed_path, &output_dir).await?;
assert_eq!(extracted_files.len(), 1);
let extracted_content = read(&extracted_files[0]).await?;
assert_eq!(extracted_content, b"data for decompress");
Ok(())
}
#[tokio::test]
async fn decompress_multiple_files() -> Result<()> {
let tmp = tempdir()?;
let file1 = tmp.path().join("file1.txt");
let file2 = tmp.path().join("file2.txt");
write(&file1, b"file1").await?;
write(&file2, b"file2").await?;
// Compress both files individually (for simplicity in this test)
let compress1 = compress_to_tar_gz_large(&file1).await?;
let compress2 = compress_to_tar_gz_large(&file2).await?;
let output_dir = tmp.path().join("out_multi");
tokio::fs::create_dir_all(&output_dir).await?;
let extracted1 = decompress_large_tar_gz(&compress1.compressed_path, &output_dir).await?;
let extracted2 = decompress_large_tar_gz(&compress2.compressed_path, &output_dir).await?;
assert_eq!(extracted1.len(), 1);
assert_eq!(extracted2.len(), 1);
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
#[cfg(test)]
mod tests {
use serde::Deserialize;
use toml::map::Map;
use toml::Value;
use crate::utils::deserializer::{camel_to_snake, deserialize_snake_case, to_snake_case};
#[test]
fn camel_to_snake_simple() {
assert_eq!(camel_to_snake("CamelCase"), "camel_case");
assert_eq!(camel_to_snake("simpleTest"), "simple_test");
assert_eq!(camel_to_snake("already_snake"), "already_snake");
assert_eq!(camel_to_snake("X"), "x");
assert_eq!(camel_to_snake("ABTest"), "a_b_test");
}
#[test]
fn to_snake_case_nested_table() {
let mut inner_table = Map::new();
inner_table.insert("InnerKey".into(), Value::String("value".into()));
let mut outer_table = Map::new();
outer_table.insert("OuterKey".into(), Value::Table(inner_table));
let value = Value::Table(outer_table);
// Expected snake_case
let mut expected_inner = Map::new();
expected_inner.insert("inner_key".into(), Value::String("value".into()));
let mut expected_outer = Map::new();
expected_outer.insert("outer_key".into(), Value::Table(expected_inner));
let expected = Value::Table(expected_outer);
let result = to_snake_case(value);
assert_eq!(result, expected);
}
#[test]
fn to_snake_case_array_of_tables() {
let mut table1 = Map::new();
table1.insert("CamelKey".into(), Value::Integer(1));
let mut table2 = Map::new();
table2.insert("AnotherKey".into(), Value::Integer(2));
let value = Value::Array(vec![
Value::Table(table1),
Value::Table(table2),
]);
let mut expected_table1 = Map::new();
expected_table1.insert("camel_key".into(), Value::Integer(1));
let mut expected_table2 = Map::new();
expected_table2.insert("another_key".into(), Value::Integer(2));
let expected = Value::Array(vec![
Value::Table(expected_table1),
Value::Table(expected_table2),
]);
let result = to_snake_case(value);
assert_eq!(result, expected);
}
#[test]
fn deserialize_snake_case_works_with_struct() {
#[derive(Deserialize, Debug, PartialEq)]
struct Config {
some_value: i32,
nested_table: Nested,
}
#[derive(Deserialize, Debug, PartialEq)]
struct Nested {
inner_value: String,
}
let toml_str = r#"
SomeValue = 42
[NestedTable]
InnerValue = "hello"
"#;
let value: Value = toml::from_str(toml_str).unwrap();
let snake_value = deserialize_snake_case(value).unwrap();
// Deserialize to struct
let config: Config = snake_value.try_into().unwrap();
assert_eq!(config.some_value, 42);
assert_eq!(config.nested_table.inner_value, "hello");
}
#[test]
fn to_snake_case_non_table_value() {
let value = Value::String("unchanged".into());
let result = to_snake_case(value.clone());
assert_eq!(result, value);
}
}
+53
View File
@@ -0,0 +1,53 @@
use base64::{engine::general_purpose, Engine as _};
use serde_json::json;
use crate::utils::edge_key::{decode_edge_key, EdgeKeyError};
#[test]
fn decode_valid_edge_key() {
let edge_key_b64 = "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiNjI1MDQzY2YtN2MwMC00M2M4LWJjYzktZDM1MTk5ODk2ZGNkIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ==";
let decoded = decode_edge_key(edge_key_b64).unwrap();
assert_eq!(decoded.server_url, "http://localhost:8887");
assert_eq!(decoded.agent_id, "625043cf-7c00-43c8-bcc9-d35199896dcd");
assert_eq!(decoded.master_key_b64, "BXV3XolC656SV7dNgcWPGQlk++rpLI6lGDi7CPB5ieo=");
}
#[test]
fn decode_edge_key_missing_field() {
let incomplete_json = json!({
"serverUrl": "http://localhost:8887",
"agentId": "123"
// masterKeyB64 is missing
})
.to_string();
let b64 = general_purpose::URL_SAFE.encode(incomplete_json);
let result = decode_edge_key(&b64);
match result {
Err(EdgeKeyError::InvalidKey) => {}
_ => panic!("Expected InvalidKey error"),
}
}
#[test]
fn decode_edge_key_invalid_base64() {
let invalid_b64 = "!!!notbase64!!!";
let result = decode_edge_key(invalid_b64);
match result {
Err(EdgeKeyError::Base64Error(_)) => {}
_ => panic!("Expected Base64Error"),
}
}
#[test]
fn decode_edge_key_invalid_json() {
let invalid_json_b64 = general_purpose::URL_SAFE.encode("not a json string");
let result = decode_edge_key(&invalid_json_b64);
match result {
Err(EdgeKeyError::JsonError(_)) => {}
_ => panic!("Expected JsonError"),
}
}
+5
View File
@@ -0,0 +1,5 @@
mod normalize_cron_tests;
mod common_tests;
mod compress_tests;
mod deserializer;
mod edge_key_tests;
+49
View File
@@ -0,0 +1,49 @@
use crate::utils::text::normalize_cron;
use cron::Schedule;
use std::str::FromStr;
use crate::utils::task_manager::cron::next_run_timestamp;
#[test]
fn normalize_adds_seconds_to_five_field_cron() {
let input = "*/5 * * * *";
let normalized = normalize_cron(input);
assert_eq!(normalized, "0 */5 * * * *");
}
#[test]
fn normalize_keeps_six_field_cron() {
let input = "0 */5 * * * *";
let normalized = normalize_cron(input);
assert_eq!(normalized, "0 */5 * * * *");
}
#[test]
fn normalized_expression_is_valid_for_cron_schedule() {
let input = "*/5 * * * *";
let normalized = normalize_cron(input);
let schedule = Schedule::from_str(&normalized);
assert!(schedule.is_ok());
}
#[test]
fn next_run_timestamp_returns_future_timestamp() {
let expr = normalize_cron("*/1 * * * *");
let ts = next_run_timestamp(&expr);
let now = chrono::Local::now().timestamp();
assert!(ts > now);
}
#[test]
fn normalization_does_not_break_schedule_parsing() {
let input = "0 */10 * * * *";
let normalized = normalize_cron(input);
let schedule = Schedule::from_str(&normalized).unwrap();
let next = schedule.upcoming(chrono::Local).next();
assert!(next.is_some());
}
-1
View File
@@ -90,7 +90,6 @@ pub async fn decompress_large_tar_gz(
extracted_files.push(full_path);
}
// remove_file(tar_gz_path).await?;
info!("Decompressed {:?} into {:?}", tar_gz_path, output_dir);
Ok(extracted_files)
+4 -5
View File
@@ -8,7 +8,8 @@ where
let value = Value::deserialize(deserializer)?;
Ok(to_snake_case(value))
}
fn to_snake_case(value: Value) -> Value {
pub fn to_snake_case(value: Value) -> Value {
match value {
Value::Table(table) => Value::Table(
table
@@ -16,14 +17,12 @@ fn to_snake_case(value: Value) -> Value {
.map(|(k, v)| (camel_to_snake(&k), to_snake_case(v)))
.collect(),
),
Value::Array(arr) => {
Value::Array(arr.into_iter().map(to_snake_case).collect())
}
Value::Array(arr) => Value::Array(arr.into_iter().map(to_snake_case).collect()),
other => other,
}
}
fn camel_to_snake(s: &str) -> String {
pub fn camel_to_snake(s: &str) -> String {
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {