replace static with in-mem store and test the hell out of it

This commit is contained in:
Hamcha 2025-01-25 13:23:03 +01:00
parent 9249ac7717
commit b8abd92adb
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
6 changed files with 263 additions and 84 deletions
src
domain/entities
main.rs
outbound
repository/adapters
services

View file

@ -1,30 +1,39 @@
#[derive(Debug, Clone)]
pub struct SiteMetadata { pub struct SiteMetadata {
pub domain: String, pub domain: String,
pub title: String, pub title: String,
} }
#[derive(Debug, Clone)]
pub struct PageInfo { pub struct PageInfo {
pub title: String, pub title: String,
pub name: String, pub name: String,
pub order: i32,
} }
#[derive(Debug, Clone)]
pub struct SiteInfo { pub struct SiteInfo {
pub info: SiteMetadata, pub info: SiteMetadata,
pub pages: Vec<PageInfo>, pub pages: Vec<PageInfo>,
} }
#[derive(Debug, Clone)]
pub struct Page { pub struct Page {
pub info: PageInfo, pub info: PageInfo,
pub content: PageContent, pub content: PageContent,
} }
#[derive(Debug, Clone)]
pub enum PageContent { pub enum PageContent {
Single { content: Post }, Single { content: Post },
} }
#[derive(Debug, Clone)]
pub struct Post { pub struct Post {
pub blocks: Vec<Block>, pub blocks: Vec<Block>,
} }
#[derive(Debug, Clone)]
pub enum Block { pub enum Block {
Text { text: String }, Text { text: String },
} }

View file

@ -15,13 +15,13 @@ fn main() {
let mut builder = dioxus::LaunchBuilder::new(); let mut builder = dioxus::LaunchBuilder::new();
server_only! { server_only! {
use outbound::repository::adapters::static_data::StaticData; use outbound::repository::adapters::memory::InMemoryStore;
use outbound::services::site::SiteService; use outbound::services::site::SiteService;
builder = builder builder = builder
.with_context_provider( .with_context_provider(
|| { || {
Box::new(SiteService::new(StaticData{})) Box::new(SiteService::new(InMemoryStore::new()))
} }
); );
} }

View file

@ -0,0 +1,227 @@
use std::collections::HashMap;
use crate::{
domain::entities::site::{Page, PageInfo, SiteMetadata},
outbound::repository::{
self,
site::{Result, SiteRepository},
},
};
pub struct InMemoryStore {
sites: HashMap<String, SiteMetadata>,
pages: HashMap<(String, String), Page>,
}
impl InMemoryStore {
pub fn new() -> InMemoryStore {
InMemoryStore {
sites: HashMap::new(),
pages: HashMap::new(),
}
}
pub fn add_site(&mut self, site: SiteMetadata) {
self.sites.insert(site.domain.clone(), site);
}
pub fn add_page(&mut self, site: &str, page: Page) {
self.pages
.insert((site.to_string(), page.info.name.clone()), page);
}
#[cfg(test)]
pub fn with_test_data() -> InMemoryStore {
use crate::domain::entities::site::{Block, PageContent, Post};
let mut store = InMemoryStore::new();
store.add_site(SiteMetadata {
domain: "example.com".to_string(),
title: "Test site".to_string(),
});
store.add_page(
"example.com",
Page {
info: PageInfo {
title: "Home".to_string(),
name: "/".to_string(),
order: 0,
},
content: PageContent::Single {
content: Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
},
},
);
store.add_page(
"example.com",
Page {
info: PageInfo {
title: "About".to_string(),
name: "/about".to_string(),
order: 10,
},
content: PageContent::Single {
content: Post {
blocks: vec![Block::Text {
text: "This is the about page.".to_string(),
}],
},
},
},
);
store
}
}
impl SiteRepository for InMemoryStore {
async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata> {
self.sites
.get(domain)
.ok_or(repository::site::Error::NotFound)
.cloned()
}
async fn get_pages_for_site(&self, domain: &str) -> Result<Vec<PageInfo>> {
let mut pages = self
.pages
.iter()
.filter(|((site, _), _)| site == domain)
.map(|(_, page)| page.info.clone())
.collect::<Vec<PageInfo>>();
pages.sort_by(|a, b| a.order.cmp(&b.order));
Ok(pages)
}
async fn get_page(&self, domain: &str, page: &str) -> Result<Page> {
self.pages
.get(&(domain.to_string(), page.to_string()))
.ok_or(repository::site::Error::NotFound)
.cloned()
}
}
#[cfg(test)]
mod tests {
use crate::{
domain::entities::site::{Block, Page, PageContent, PageInfo, Post, SiteMetadata},
outbound::repository::{self, site::SiteRepository},
};
use super::InMemoryStore;
#[tokio::test]
async fn get_site_by_domain_works() {
let mut store = InMemoryStore::new();
store.add_site(SiteMetadata {
domain: "test.com".to_string(),
title: "Some test site".to_string(),
});
let info = store.get_site_by_domain("test.com").await.unwrap();
assert_eq!(info.domain, "test.com");
assert_eq!(info.title, "Some test site");
}
#[tokio::test]
async fn get_site_by_domain_for_nonexistent_fails() {
let store = InMemoryStore::new();
let result = store.get_site_by_domain("test.com").await;
assert!(result.is_err());
assert!(matches!(
result.err().unwrap(),
repository::site::Error::NotFound
));
}
#[tokio::test]
async fn get_pages_for_site_works() {
let mut store = InMemoryStore::new();
store.add_site(SiteMetadata {
domain: "example.com".to_string(),
title: "Test site".to_string(),
});
store.add_page(
"example.com",
Page {
info: PageInfo {
title: "Home".to_string(),
name: "/".to_string(),
order: 0,
},
content: PageContent::Single {
content: Post { blocks: vec![] },
},
},
);
store.add_page(
"example.com",
Page {
info: PageInfo {
title: "Cool page".to_string(),
name: "cool".to_string(),
order: 10,
},
content: PageContent::Single {
content: Post { blocks: vec![] },
},
},
);
let pages = store.get_pages_for_site("example.com").await.unwrap();
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].title, "Home");
assert_eq!(pages[0].name, "/");
assert_eq!(pages[1].title, "Cool page");
assert_eq!(pages[1].name, "cool");
assert!(pages[0].order < pages[1].order);
}
#[tokio::test]
async fn get_page_works() {
let mut store = InMemoryStore::new();
store.add_site(SiteMetadata {
domain: "example.com".to_string(),
title: "Test site".to_string(),
});
store.add_page(
"example.com",
Page {
info: PageInfo {
title: "Home".to_string(),
name: "/".to_string(),
order: 0,
},
content: PageContent::Single {
content: Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
},
},
);
let page = store.get_page("example.com", "/").await.unwrap();
let PageContent::Single { content } = page.content;
let Post { blocks } = content;
assert_eq!(blocks.len(), 1);
let Block::Text { text } = &blocks[0];
assert_eq!(text, "Hello, world!");
}
#[tokio::test]
async fn get_page_for_nonexistent_fails() {
let store = InMemoryStore::new();
let result = store.get_page("example.com", "/").await;
assert!(result.is_err());
assert!(matches!(
result.err().unwrap(),
repository::site::Error::NotFound
));
}
}

View file

@ -1 +1 @@
pub mod static_data; pub mod memory;

View file

@ -1,60 +0,0 @@
use crate::{
domain::entities::site::{Block, Page, PageContent, PageInfo, Post, SiteMetadata},
outbound::repository::site::{Error, Result, SiteRepository},
};
pub struct StaticData {}
impl SiteRepository for StaticData {
async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata> {
Ok(SiteMetadata {
domain: domain.to_string(),
title: "Test site".to_string(),
})
}
async fn get_pages_for_site(&self, _: &str) -> Result<Vec<PageInfo>> {
Ok(vec![
PageInfo {
title: "Home".to_string(),
name: "/".to_string(),
},
PageInfo {
title: "Cool page".to_string(),
name: "cool".to_string(),
},
])
}
async fn get_page(&self, _: &str, name: &str) -> Result<Page> {
match name {
"/" => Ok(Page {
info: PageInfo {
title: "Home".to_string(),
name: name.to_string(),
},
content: PageContent::Single {
content: Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
},
}),
"cool" => Ok(Page {
info: PageInfo {
title: "Cool page".to_string(),
name: name.to_string(),
},
content: PageContent::Single {
content: Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
},
}),
_ => Err(Error::NotFound),
}
}
}

View file

@ -15,14 +15,7 @@ impl<SiteRepo: SiteRepository> SiteService<SiteRepo> {
} }
pub async fn get_site(&self, domain: &str) -> Result<SiteInfo> { pub async fn get_site(&self, domain: &str) -> Result<SiteInfo> {
let info = self let info = self.site_repository.get_site_by_domain(domain).await?;
.site_repository
.get_site_by_domain(domain)
.await
.map_err(|e| match e {
repository::site::Error::NotFound => Error::NotFound,
_ => Error::RepositoryError(e),
})?;
let pages = self let pages = self
.site_repository .site_repository
.get_pages_for_site(&info.domain) .get_pages_for_site(&info.domain)
@ -32,14 +25,7 @@ impl<SiteRepo: SiteRepository> SiteService<SiteRepo> {
} }
pub async fn get_page(&self, domain: &str, name: &str) -> Result<Page> { pub async fn get_page(&self, domain: &str, name: &str) -> Result<Page> {
let info = self let info = self.site_repository.get_site_by_domain(domain).await?;
.site_repository
.get_site_by_domain(domain)
.await
.map_err(|e| match e {
repository::site::Error::NotFound => Error::NotFound,
_ => Error::RepositoryError(e),
})?;
let page = self.site_repository.get_page(&info.domain, name).await?; let page = self.site_repository.get_page(&info.domain, name).await?;
Ok(page) Ok(page)
@ -54,20 +40,29 @@ pub enum Error {
NotFound, NotFound,
#[error("the server encountered an error: {0}")] #[error("the server encountered an error: {0}")]
RepositoryError(#[from] repository::site::Error), RepositoryError(repository::site::Error),
}
impl From<repository::site::Error> for Error {
fn from(e: repository::site::Error) -> Self {
match e {
repository::site::Error::NotFound => Error::NotFound,
_ => Error::RepositoryError(e),
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::SiteService; use super::{Error, SiteService};
use crate::{ use crate::{
domain::entities::site::{Block, PageContent}, domain::entities::site::{Block, PageContent},
outbound::repository::adapters::static_data::StaticData, outbound::repository::adapters::memory::InMemoryStore,
}; };
#[tokio::test] #[tokio::test]
async fn gets_site_info() { async fn gets_site_info() {
let service = SiteService::new(StaticData {}); let service = SiteService::new(InMemoryStore::with_test_data());
let info = service.get_site("example.com").await.unwrap(); let info = service.get_site("example.com").await.unwrap();
assert_eq!(info.info.domain, "example.com"); assert_eq!(info.info.domain, "example.com");
assert_eq!(info.info.title, "Test site"); assert_eq!(info.info.title, "Test site");
@ -76,7 +71,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn gets_page_data() { async fn gets_page_data() {
let service = SiteService::new(StaticData {}); let service = SiteService::new(InMemoryStore::with_test_data());
let page = service.get_page("example.com", "/").await.unwrap(); let page = service.get_page("example.com", "/").await.unwrap();
assert_eq!(page.info.title, "Home"); assert_eq!(page.info.title, "Home");
@ -90,4 +85,12 @@ mod tests {
let Block::Text { text } = &page_content.blocks[0]; let Block::Text { text } = &page_content.blocks[0];
assert_eq!(text, "Hello, world!"); assert_eq!(text, "Hello, world!");
} }
#[tokio::test]
async fn gets_nonexistent_page() {
let service = SiteService::new(InMemoryStore::with_test_data());
let result = service.get_page("example.com", "nonexistent").await;
assert!(result.is_err());
assert!(matches!(result.err().unwrap(), Error::NotFound));
}
} }