Adjusting to make the database better tested.

This commit is contained in:
2025-09-07 17:18:50 -04:00
parent 2895897e2d
commit 6bf4aa4bdb
9 changed files with 330 additions and 147 deletions

View File

@ -26,3 +26,4 @@ tokio = { version = "1", features = ["full"] }
default = [] default = []
database = ["sqlx"] database = ["sqlx"]
publisher = ["database"] publisher = ["database"]
tester = ["publisher"]

View File

@ -1,51 +1,66 @@
use std::path::{Path, PathBuf}; #![cfg(feature = "publisher")]
use chrono::NaiveDate; use chrono::NaiveDate;
use tavern::{Adventurer, Database, Legend, Lore, Tale, Tavern};
use tavern::{Adventurer, Legend, Lore, Story, Tale, Tavern};
use tavern::Database;
#[cfg(feature = "publisher")] /// This will generate a tavern that we can create a Toml file from.
fn generate_tavern() -> Tavern fn generate_tavern() -> Tavern
{ {
let legend: Legend = Legend let legend: Legend = Legend
{ {
profile: profile: String::from("https://cybermages.tech/about/myrddin"),
String::from("https://cybermages.tech/about/myrddin"), image: String::from("https://cybermages.tech/about/myrddin/pic"),
image: blurb: String::from("I love code!")
String::from("https://cybermages.tech/about/myrddin/pic"), };
blurb: String::from("I love code!") };
let author: Adventurer = let author: Adventurer = Adventurer
Adventurer { name: String::from("Jason Smith"), {
name: String::from("Jason Smith"),
handle: String::from("myrddin"), handle: String::from("myrddin"),
legend }; legend: legend
};
let lore: Lore = let lore: Lore = Lore
Lore { title: String::from("Test post"), {
title: String::from("Test post"),
slug: String::from("test_post"), slug: String::from("test_post"),
author: author.handle.clone(), author: author.handle.clone(),
summary: String::from("The Moon is made of cheese!"), summary: String::from("The Moon is made of cheese!"),
tags: vec![String::from("Space"), tags: vec![String::from("Space"), String::from("Cheese")],
String::from("Cheese")],
publish_date: publish_date:
NaiveDate::from_ymd_opt(2025, 12, 25).unwrap() NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()
.and_hms_opt(13, .and_hms_opt(13, 10, 41)
10, 41) .unwrap()
.unwrap() }; };
let tale: Tale = Tale { lore, let tale: Tale = Tale
story: PathBuf::from("posts/test_post.md") };
// Create a dummy posts directory and file for this example to work
if !Path::new("posts").exists()
{ {
std::fs::create_dir("posts").unwrap(); lore: lore,
} story: Story::Html("#Test Post\nThis is a test post.\n\n##Subsection\nMini post.\n* Test 1\n* Test 2\n* Test 3".to_string())
std::fs::write("posts/the-rustacean.md", };
"# Hello, Rust!\n\nThis is a **test** post.").unwrap();
let lore2: Lore = Lore
{
title: String::from("Test Tale"),
slug: String::from("test_tale"),
author: author.handle.clone(),
summary: String::from("The Moon is made of rocks!"),
tags: vec![String::from("Space"), String::from("Rocks")],
publish_date:
NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()
.and_hms_opt(13, 10, 41)
.unwrap()
};
let tale2: Tale = Tale
{
lore: lore2,
story: Story::Html("#Test Tale\nThis is a test tale.\n\n##Subsection\nMini tale.\n* Test 1\n* Test 2\n* Test 3".to_string())
};
Tavern { title: String::from("Runes & Ramblings"), Tavern { title: String::from("Runes & Ramblings"),
description: String::from("Join software engineer Jason Smith \ description: String::from("Join software engineer Jason Smith \
@ -53,14 +68,52 @@ fn generate_tavern() -> Tavern
Explore program design, tech \ Explore program design, tech \
stacks, and more on this blog from \ stacks, and more on this blog from \
CybeMages, LLC."), CybeMages, LLC."),
tales: vec![tale], tales: vec![tale, tale2],
authors: vec![author] } authors: vec![author] }
} }
#[cfg(feature = "publisher")] fn create_temp_file<P>(filename: P) -> std::path::PathBuf
where P: AsRef<std::path::Path>
{
let mut path = std::env::temp_dir();
path.push(filename);
path
}
fn cleanup_temp_file<P>(path: P)
where P: AsRef<std::path::Path>
{
match path.as_ref().try_exists()
{
Ok(exists) =>
{
if exists
{
let _ = std::fs::remove_file(path);
}
}
Err(e) =>
{
eprintln!("{}", e);
}
}
}
fn write_to_file<P>(tavern: Tavern, config_file: P) -> Result<(), Box<dyn std::error::Error>>
where P: AsRef<std::path::Path>
{
let toml_string = toml::to_string_pretty(&tavern)?;
std::fs::write(&config_file, &toml_string)?;
Ok(())
}
fn read_from_file<P>(config_file: P) -> Tavern fn read_from_file<P>(config_file: P) -> Tavern
where P: AsRef<Path> where P: AsRef<std::path::Path>
{ {
// Read the previously written TOML file // Read the previously written TOML file
let toml_data = let toml_data =
@ -71,65 +124,36 @@ fn read_from_file<P>(config_file: P) -> Tavern
} }
#[cfg(feature = "publisher")]
async fn create_database() -> Result<(), Box<dyn std::error::Error>> async fn create_database() -> Result<(), Box<dyn std::error::Error>>
{ {
// First we need to generate a TOML file to work with.
let config_path = create_temp_file("tavern.toml");
cleanup_temp_file(&config_path);
write_to_file(generate_tavern(), &config_path)?;
// This part would be the entry point of your CI/CD script // This part would be the entry point of your CI/CD script
// It would load your data and then save it to the database // It would load your data and then save it to the database
// Create a Tavern object // Create a Tavern object
let _tavern = read_from_file("Tavern.toml"); let tavern = read_from_file(&config_path);
// let tavern = generate_tavern();
// Open the database and save the Tavern content // Open the database and save the Tavern content
let _db = Database::open("/home/myrddin/cybermages/blog/tavern.db").await?; let db_path = create_temp_file("tavern.db");
//db.insert_tavern(&tavern.title, &tavern.description)?; cleanup_temp_file(&db_path);
//println!("Saved site settings: Title='{}', Description='{}'",
//tavern.title, tavern.description);
//for author in &tavern.authors let db = Database::open(db_path).await?;
// { db.insert_tavern(&tavern).await?;
// db.insert_adventurer(author)?;
// println!("Saved adventurer: {}", author.name);
// }
//
// for tale in &tavern.tales
// {
// db.insert_tale(tale)?;
// println!("Saved tale: {}", tale.title);
// }
Ok(()) Ok(())
} }
#[cfg(feature = "publisher")]
#[tokio::main] #[tokio::main]
pub async fn main() pub async fn main()
{ {
match std::env::set_current_dir("/home/myrddin/cybermages/blog/")
{
Ok(_) =>
{
println!("Successfully changed working directory.");
}
Err(e) =>
{
eprintln!("Failed to change directory: {}", e);
}
}
match create_database().await match create_database().await
{ {
Ok(_) => Ok(_) => {}
{} Err(e) => { eprintln!("Error: {}", e); }
Err(e) =>
{
eprintln!("Error: {}", e);
} }
} }
}
#[cfg(not(feature = "publisher"))]
pub fn main()
{
}

View File

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// ///
#[derive(Deserialize, Serialize)] #[derive(Clone, PartialEq, Deserialize, Serialize)]
pub struct Legend pub struct Legend
{ {
/// A link to the adventurer's profile (e.g., personal website, GitHub, /// A link to the adventurer's profile (e.g., personal website, GitHub,
@ -20,7 +20,7 @@ pub struct Legend
/// ///
/// An `Adventurer` contains identifying and descriptive information /// An `Adventurer` contains identifying and descriptive information
/// such as their name, handle, profile URL, image, and a short blurb. /// such as their name, handle, profile URL, image, and a short blurb.
#[derive(Deserialize, Serialize)] #[derive(Clone, PartialEq, Deserialize, Serialize)]
pub struct Adventurer pub struct Adventurer
{ {
/// The full name of the adventurer. /// The full name of the adventurer.
@ -34,7 +34,3 @@ pub struct Adventurer
#[serde(flatten)] #[serde(flatten)]
pub legend: Legend pub legend: Legend
} }
impl Adventurer {}

View File

@ -4,20 +4,20 @@ use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use sqlx::{Error, Result}; use sqlx::{Error, Result};
#[cfg(not(feature = "publisher"))] #[cfg(any(not(feature = "publisher"), feature = "tester"))]
use sqlx::Row; use sqlx::Row;
use crate::adventurer::Adventurer; use crate::adventurer::Adventurer;
use crate::tale::Tale; use crate::tale::{Story, Tale};
#[cfg(feature = "publisher")] #[cfg(feature = "publisher")]
use crate::converter::Converter; use crate::converter::Converter;
#[cfg(feature = "publisher")] #[cfg(feature = "publisher")]
use crate::tavern::Tavern; use crate::tavern::Tavern;
#[cfg(not(feature = "publisher"))] #[cfg(any(not(feature = "publisher"), feature = "tester"))]
use crate::tale::Lore; use crate::tale::Lore;
#[cfg(not(feature = "publisher"))] #[cfg(any(not(feature = "publisher"), feature = "tester"))]
use crate::adventurer::Legend; use crate::adventurer::Legend;
@ -140,13 +140,24 @@ impl Database
-> Result<()> -> Result<()>
{ {
// Convert the tales content from Markdown to HTML. // Convert the tales content from Markdown to HTML.
let markdown_content = std::fs::read_to_string(&tale.story)?; let html_content: std::borrow::Cow<'_, str> = match &tale.story
let html_content: String = Converter::markdown_to_html(&markdown_content); {
Story::Html(story) => { std::borrow::Cow::Borrowed(story) }
Story::File(path) =>
{
let markdown = std::fs::read_to_string(path)?;
std::borrow::Cow::Owned(Converter::markdown_to_html(&markdown))
}
};
// Start a transaction. // Start a transaction.
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
// Store the tale. // Store the tale.
// Pull the HTML content out first so that the str will last long enough.
// This get's around the macro lifetime issue.
let html_str = html_content.as_ref();
sqlx::query!( sqlx::query!(
"INSERT OR REPLACE INTO tales ( "INSERT OR REPLACE INTO tales (
slug, title, author, summary, publish_date, content slug, title, author, summary, publish_date, content
@ -156,7 +167,7 @@ impl Database
tale.lore.author, tale.lore.author,
tale.lore.summary, tale.lore.summary,
tale.lore.publish_date, tale.lore.publish_date,
html_content html_str
).execute(&mut *tx) // Pass mutable reference to the transaction ).execute(&mut *tx) // Pass mutable reference to the transaction
.await?; .await?;
@ -263,7 +274,7 @@ impl Database
Ok(()) Ok(())
} }
#[cfg(not(feature = "publisher"))] #[cfg(any(not(feature = "publisher"), feature = "tester"))]
pub async fn get_tales_summary(&self, categories: &[String]) pub async fn get_tales_summary(&self, categories: &[String])
-> Result<Vec<Lore>> -> Result<Vec<Lore>>
{ {
@ -312,7 +323,7 @@ impl Database
.unwrap_or_default(); .unwrap_or_default();
let date_str: String = row.try_get("publish_date")?; let date_str: String = row.try_get("publish_date")?;
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S") let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S")
.map_err(|e| sqlx::Error::Decode(e.into()))?; .map_err(|e| sqlx::Error::Decode(e.into()))?;
tales.push(Lore { title: row.try_get("title")?, tales.push(Lore { title: row.try_get("title")?,
@ -328,7 +339,7 @@ impl Database
Ok(tales) Ok(tales)
} }
#[cfg(not(feature = "publisher"))] #[cfg(any(not(feature = "publisher"), feature = "tester"))]
pub async fn get_tale_by_slug(&self, slug: &str) -> Result<Option<Tale>> pub async fn get_tale_by_slug(&self, slug: &str) -> Result<Option<Tale>>
{ {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
@ -356,7 +367,7 @@ impl Database
.unwrap_or_default(); .unwrap_or_default();
let date_str: String = row.try_get("publish_date")?; let date_str: String = row.try_get("publish_date")?;
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S") let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S")
.map_err(|e| Error::Decode(e.into()))?; .map_err(|e| Error::Decode(e.into()))?;
let lore = Lore { title: row.try_get("title")?, let lore = Lore { title: row.try_get("title")?,
@ -367,7 +378,7 @@ impl Database
tags }; tags };
Ok(Some(Tale { lore, Ok(Some(Tale { lore,
story: row.try_get("content")? })) story: Story::Html(row.try_get("content")?) }))
} }
else else
{ {
@ -375,7 +386,7 @@ impl Database
} }
} }
#[cfg(not(feature = "publisher"))] #[cfg(any(not(feature = "publisher"), feature = "tester"))]
pub async fn get_adventurer(&self, handle: &str) pub async fn get_adventurer(&self, handle: &str)
-> Result<Option<Adventurer>> -> Result<Option<Adventurer>>
{ {

View File

@ -17,5 +17,5 @@ pub use crate::adventurer::{Adventurer, Legend};
#[cfg(feature = "database")] #[cfg(feature = "database")]
pub use crate::database::Database; pub use crate::database::Database;
pub use crate::info::{get_name, get_version}; pub use crate::info::{get_name, get_version};
pub use crate::tale::{Lore, Tale}; pub use crate::tale::{Story, Lore, Tale};
pub use crate::tavern::Tavern; pub use crate::tavern::Tavern;

View File

@ -1,17 +1,15 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A type alias representing the path to a Markdown file. #[derive(Clone, PartialEq, Deserialize, Serialize)]
/// This type is used to point to the location of the content of a `Tale`. pub enum Story
#[cfg(feature = "publisher")] {
pub type Markdown = std::path::PathBuf; File(std::path::PathBuf),
Html(String)
}
/// A type alias representing the HTML content of the tale.
#[cfg(not(feature = "publisher"))]
pub type Markdown = String;
@ -19,7 +17,7 @@ pub type Markdown = String;
/// ///
/// This includes details such as the title, author, summary, and /// This includes details such as the title, author, summary, and
/// associated tags. /// associated tags.
#[derive(Deserialize, Serialize)] #[derive(Clone, PartialEq, Deserialize, Serialize)]
pub struct Lore pub struct Lore
{ {
/// The title of the tale. /// The title of the tale.
@ -44,13 +42,14 @@ pub struct Lore
/// Represents a post or story in the application. /// Represents a post or story in the application.
#[derive(Deserialize, Serialize)] #[derive(Clone, PartialEq, Deserialize, Serialize)]
pub struct Tale pub struct Tale
{ {
/// Metadata of the post. /// Metadata of the post.
#[serde(flatten)] #[serde(flatten)]
pub lore: Lore, pub lore: Lore,
/// The file path to the Markdown content of the tale. /// The story content for this tale..
pub story: Markdown #[serde(flatten)]
pub story: Story
} }

View File

@ -10,7 +10,7 @@ use crate::tale::Tale;
/// A `Tavern` contains a list of all tales (posts) and their respective /// A `Tavern` contains a list of all tales (posts) and their respective
/// authors. It serves as the central structure for organizing and serializing /// authors. It serves as the central structure for organizing and serializing
/// the site's content. /// the site's content.
#[derive(Deserialize, Serialize)] #[derive(Clone, PartialEq, Deserialize, Serialize)]
pub struct Tavern pub struct Tavern
{ {
/// ///

139
tavern/tests/database.rs Normal file
View File

@ -0,0 +1,139 @@
#![cfg(feature = "tester")]
use chrono::NaiveDate;
use tavern::{Adventurer, Database, Legend, Lore, Story, Tale, Tavern};
fn generate_tavern() -> Tavern
{
let legend: Legend = Legend
{
profile: String::from("https://cybermages.tech/about/myrddin"),
image: String::from("https://cybermages.tech/about/myrddin/pic"),
blurb: String::from("I love code!")
};
let author: Adventurer = Adventurer
{
name: String::from("Jason Smith"),
handle: String::from("myrddin"),
legend: legend
};
let lore: Lore = Lore
{
title: String::from("Test post"),
slug: String::from("test_post"),
author: author.handle.clone(),
summary: String::from("The Moon is made of cheese!"),
tags: vec![String::from("Space"), String::from("Cheese")],
publish_date:
NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()
.and_hms_opt(13, 10, 41)
.unwrap()
};
let tale: Tale = Tale
{
lore: lore,
story: Story::Html("#Test Post\nThis is a test post.\n\n##Subsection\nMini post.\n* Test 1\n* Test 2\n* Test 3".to_string())
};
let lore2: Lore = Lore
{
title: String::from("Test Tale"),
slug: String::from("test_tale"),
author: author.handle.clone(),
summary: String::from("The Moon is made of rocks!"),
tags: vec![String::from("Space"), String::from("Rocks")],
publish_date:
NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()
.and_hms_opt(13, 10, 41)
.unwrap()
};
let tale2: Tale = Tale
{
lore: lore2,
story: Story::Html("#Test Tale\nThis is a test tale.\n\n##Subsection\nMini tale.\n* Test 1\n* Test 2\n* Test 3".to_string())
};
Tavern { title: String::from("Runes & Ramblings"),
description: String::from("Join software engineer Jason Smith \
on his Rust programming journey. \
Explore program design, tech \
stacks, and more on this blog from \
CybeMages, LLC."),
tales: vec![tale, tale2],
authors: vec![author] }
}
fn create_temp_file<S>(filename: S) -> std::path::PathBuf
where S: AsRef<std::path::Path>
{
let mut path = std::env::temp_dir();
path.push(filename);
path
}
fn cleanup_temp_file(path: &std::path::PathBuf)
{
if path.exists()
{
let _ = std::fs::remove_file(path);
}
}
#[tokio::test]
async fn tavern_tale() -> Result<(), Box<dyn std::error::Error>>
{
let tavern = generate_tavern();
// Save the TOML to a temporary file.
let path = create_temp_file("tavern_test_out.db");
cleanup_temp_file(&path);
let db = Database::open(&path).await?;
db.insert_tavern(&tavern).await?;
// Write a Tale to the Tavern.
let lore: Lore = Lore
{
title: String::from("About Tavern"),
slug: String::from("about_tavern"),
author: String::from("myrddin"),
summary: String::from("Myrddin is awesome and wrote a blog system!"),
tags: vec![String::from("Blog"), String::from("Silly")],
publish_date:
NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()
.and_hms_opt(13, 10, 41)
.unwrap()
};
let tale: Tale = Tale
{
lore: lore,
story: Story::Html("#Tavern Blog System\nThis is a great blogging system.\n\n##Subsection\nWritten by Myrddin himself!.\n* Test 1\n* Test 2\n* Test 3".to_string())
};
db.insert_tale(&tale).await?;
// Read the Tale from the Tavern.
let tale = db.get_tale_by_slug(&tale.lore.slug).await?;
match tale
{
None => { panic!("Do something to say this test failed.") }
Some(tale) =>
{
assert_eq!("Myrddin is awesome and wrote a blog system!", tale.lore.summary);
}
}
cleanup_temp_file(&path);
Ok(())
}

View File

@ -1,23 +1,28 @@
#![cfg(feature = "publisher")] #![cfg(feature = "publisher")]
use chrono::NaiveDate; use chrono::NaiveDate;
use tavern::{Adventurer, FrontMatter, Tale, Tavern}; use tavern::{Adventurer, Legend, Lore, Story, Tale, Tavern};
fn generate_tavern() -> Tavern fn generate_tavern() -> Tavern
{ {
let author: Adventurer = let legend: Legend = Legend
Adventurer { name: String::from("Jason Smith"), {
profile: String::from("https://cybermages.tech/about/myrddin"),
image: String::from("https://cybermages.tech/about/myrddin/pic"),
blurb: String::from("I love code!")
};
let author: Adventurer = Adventurer
{
name: String::from("Jason Smith"),
handle: String::from("myrddin"), handle: String::from("myrddin"),
profile: legend: legend
String::from("https://cybermages.tech/about/myrddin"), };
image:
String::from("https://cybermages.tech/about/myrddin/pic"),
blurb: String::from("I love code!") };
let lore: Lore = Lore
let fm: FrontMatter = FrontMatter { {
title: String::from("Test post"), title: String::from("Test post"),
slug: String::from("test_post"), slug: String::from("test_post"),
author: author.handle.clone(), author: author.handle.clone(),
@ -29,9 +34,11 @@ fn generate_tavern() -> Tavern
.unwrap() .unwrap()
}; };
let tale: Tale = Tale { let tale: Tale = Tale
front_matter: fm, {
content: std::path::PathBuf::from("posts/test_post.md") }; lore: lore,
story: Story::File(std::path::PathBuf::from("posts/test_post.md"))
};
Tavern { title: String::from("Runes & Ramblings"), Tavern { title: String::from("Runes & Ramblings"),
description: String::from("Join software engineer Jason Smith \ description: String::from("Join software engineer Jason Smith \
@ -43,6 +50,14 @@ fn generate_tavern() -> Tavern
authors: vec![author] } authors: vec![author] }
} }
fn create_temp_file<S>(filename: S) -> std::path::PathBuf
where S: AsRef<std::path::Path>
{
let mut path = std::env::temp_dir();
path.push(filename);
path
}
fn cleanup_temp_file(path: &std::path::PathBuf) fn cleanup_temp_file(path: &std::path::PathBuf)
{ {
@ -63,8 +78,7 @@ fn to_file()
succeed"); succeed");
// Save the TOML to a temporary file. // Save the TOML to a temporary file.
let mut path = std::env::temp_dir(); let path = create_temp_file("tavern_test_to.toml");
path.push("tavern_test_out.toml");
std::fs::write(&path, &toml_string).expect("Failed to write TOML to file"); std::fs::write(&path, &toml_string).expect("Failed to write TOML to file");
cleanup_temp_file(&path); cleanup_temp_file(&path);
@ -80,8 +94,7 @@ fn from_file()
succeed"); succeed");
// Save the TOML to a temporary file. // Save the TOML to a temporary file.
let mut path = std::env::temp_dir(); let path = create_temp_file("tavern_test_from.toml");
path.push("tavern_test_in.toml");
std::fs::write(&path, &toml_string).expect("Failed to write TOML to file"); std::fs::write(&path, &toml_string).expect("Failed to write TOML to file");
// Read the previously written TOML file // Read the previously written TOML file
@ -94,9 +107,9 @@ fn from_file()
// Assert some known values to make this a real test // Assert some known values to make this a real test
let tale = &tavern.tales[0]; let tale = &tavern.tales[0];
assert_eq!(tale.front_matter.title, "Test post"); assert_eq!(tale.lore.title, "Test post");
assert_eq!(tale.front_matter.slug, "test_post"); assert_eq!(tale.lore.slug, "test_post");
assert_eq!(tale.front_matter.author, "myrddin"); assert_eq!(tale.lore.author, "myrddin");
cleanup_temp_file(&path); cleanup_temp_file(&path);
} }