readd most of the frontend

This commit is contained in:
Hamcha 2025-01-25 14:30:08 +01:00
parent b8abd92adb
commit 192a952ae8
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
15 changed files with 660 additions and 62 deletions
Cargo.lockCargo.toml
assets
src
domain/entities
inbound/renderer
main.rs
outbound
repository
services

37
Cargo.lock generated
View file

@ -427,6 +427,7 @@ dependencies = [
"dioxus-html", "dioxus-html",
"dioxus-liveview", "dioxus-liveview",
"dioxus-logger", "dioxus-logger",
"dioxus-router",
"dioxus-signals", "dioxus-signals",
"dioxus-ssr", "dioxus-ssr",
"dioxus-web", "dioxus-web",
@ -738,6 +739,34 @@ dependencies = [
"tracing-wasm", "tracing-wasm",
] ]
[[package]]
name = "dioxus-router"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0299dea6f8759eb766ecf79247d50139f4b640056c3e60e8af254ea6921fb60d"
dependencies = [
"dioxus-cli-config",
"dioxus-history",
"dioxus-lib",
"dioxus-router-macro",
"rustversion",
"tracing",
"url",
"urlencoding",
]
[[package]]
name = "dioxus-router-macro"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "149652f16bb41cdcb4477829947bdefd7847660986e7eabaa26fd7bf43acc985"
dependencies = [
"proc-macro2",
"quote",
"slab",
"syn",
]
[[package]] [[package]]
name = "dioxus-rsx" name = "dioxus-rsx"
version = "0.6.1" version = "0.6.1"
@ -1506,10 +1535,12 @@ dependencies = [
name = "mabel-hex" name = "mabel-hex"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"dioxus", "dioxus",
"dioxus-cli-config", "dioxus-cli-config",
"dioxus-logger", "dioxus-logger",
"dotenvy", "dotenvy",
"serde",
"thiserror 2.0.11", "thiserror 2.0.11",
"tokio", "tokio",
] ]
@ -2641,6 +2672,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf-8" name = "utf-8"
version = "0.7.6" version = "0.7.6"

View file

@ -5,11 +5,13 @@ authors = ["Ash Keel <ash@nebula.cafe>"]
edition = "2021" edition = "2021"
[dependencies] [dependencies]
dioxus = { version = "0.6", features = ["fullstack"] } async-trait = "0.1"
dioxus = { version = "0.6", features = ["fullstack", "router"] }
dioxus-cli-config = "0.6" dioxus-cli-config = "0.6"
dioxus-logger = "0.6" dioxus-logger = "0.6"
dotenvy = { version = "0.15", optional = true } dotenvy = { version = "0.15", optional = true }
thiserror = "2.0.11" serde = { version = "1", features = ["derive"] }
thiserror = "2"
tokio = { version = "1", features = ["full"], optional = true } tokio = { version = "1", features = ["full"], optional = true }
[features] [features]

View file

@ -1,22 +0,0 @@
@import url(https://rsms.me/inter/inter.css);
:root {
background-color: #F8F7FF;
color: rgb(23, 3, 21);
font-family: Inter, sans-serif;
/* fix for Chrome */
font-feature-settings: 'liga' 1, 'calt' 1;
}
@supports (font-variation-settings: normal) {
:root {
font-family: InterVariable, sans-serif;
}
}
html,
body {
padding: 0;
margin: 0;
}

62
assets/style/main.css Normal file
View file

@ -0,0 +1,62 @@
@import url(https://rsms.me/inter/inter.css);
:root {
background-color: #F8F7FF;
color: rgb(23, 3, 21);
font-family: Inter, sans-serif;
/* fix for Chrome */
font-feature-settings: 'liga' 1, 'calt' 1;
}
@supports (font-variation-settings: normal) {
:root {
font-family: InterVariable, sans-serif;
}
}
html,
body {
padding: 0;
margin: 0;
}
header.site-header {
background-color: #5720a5;
color: #F8F7FF;
padding: 0.5rem 1rem;
a[href],
a[href]:visited {
color: #fab19a;
&.active {
pointer-events: none;
color: white;
}
}
nav.page-list {
font-size: 16pt;
&>ul {
display: flex;
list-style-type: none;
gap: 1rem;
padding: 0;
&>li a {
font-weight: 300;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
main {
padding: 0.5rem 1rem;
}

View file

@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub struct Paginated<DataType, CursorType> {
pub data: Vec<DataType>,
pub next: Option<CursorType>,
}

View file

@ -1 +1,2 @@
pub mod cursor;
pub mod site; pub mod site;

View file

@ -1,39 +1,55 @@
#[derive(Debug, Clone)] use serde::{Deserialize, Serialize};
pub struct SiteMetadata {
pub domain: String,
pub title: String,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageInfo { pub struct PageInfo {
pub title: String, pub title: String,
pub name: String, pub name: String,
pub order: i32, pub order: i32,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SiteInfo { pub struct SiteInfo {
pub info: SiteMetadata, pub domain: String,
pub title: String,
pub pages: Vec<PageInfo>, pub pages: Vec<PageInfo>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Page { pub struct Page {
pub info: PageInfo, pub info: PageInfo,
pub content: PageContent, pub content: PageContent,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PageContent { pub enum PageContent {
Single { content: Post }, Single {
content: Post,
},
Collection {
kind: CollectionKind,
collection_id: String,
},
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CollectionKind {
Blog,
Gallery,
}
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
pub struct Post { pub struct Post {
pub blocks: Vec<Block>, pub blocks: Vec<Block>,
} }
#[derive(Debug, Clone)] #[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
pub enum Block { pub enum Block {
Text { text: String }, Text { text: String },
Gallery { images: Vec<Image> },
}
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
pub struct Image {
pub src: String,
pub caption: String,
} }

View file

@ -0,0 +1,29 @@
use dioxus::prelude::*;
use crate::domain::entities::site::{Page, SiteInfo};
#[derive(Clone)]
pub struct SiteContext {
pub info: SiteInfo,
}
pub fn set_site(info: &SiteInfo) {
let _state = use_context_provider(|| SiteContext { info: info.clone() });
}
pub fn site() -> SiteContext {
use_context::<SiteContext>()
}
#[derive(Clone)]
pub struct PageContext {
pub data: Page,
}
pub fn set_page(data: Page) {
let _state = use_context_provider(|| PageContext { data });
}
pub fn page() -> PageContext {
use_context::<PageContext>()
}

View file

@ -2,10 +2,185 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use page::{Page, PostElement};
use server::get_site_info;
use crate::domain::entities::site::PageContent;
mod meta;
mod page;
mod server; mod server;
#[derive(Clone, Routable, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[rustfmt::skip]
enum Route {
#[layout(SiteLayout)]
#[route("/")]
Home {},
#[nest("/p")]
#[route("/:page")]
Single { page: String },
#[route("/:page/:id")]
Post { page: String, id: String },
#[end_nest]
#[route("/:..route")]
PageNotFound { route: Vec<String> },
}
pub fn App() -> Element { pub fn App() -> Element {
// Retrieve site info
let site_info = use_server_future(get_site_info)?.suspend()?;
// Inject site info in context
match &*site_info.read() {
Ok(info) => meta::set_site(info),
Err(err) => {
return rsx! {
h1 { "FATAL ERROR: {err}" }
}
}
}
rsx! { rsx! {
h1 { "Hello, world!" } Router::<Route> {}
}
}
#[component]
fn PageNotFound(route: Vec<String>) -> Element {
rsx! {
h1 { "404 - {route:?}" }
}
}
#[component]
fn PageLink(page: String, title: String, current: Route) -> Element {
let to = if page == "/" {
Route::Home {}
} else {
Route::Single { page }
};
let class = if current == to { "active" } else { "" };
rsx! {
Link { to, class, "{title}" }
}
}
#[component]
pub fn SiteLayout() -> Element {
let site = meta::site();
let route = use_route::<Route>();
rsx! {
header { class: "site-header",
nav { class: "page-list",
ul {
for page in site.info.pages {
li {
PageLink {
page: page.name,
title: page.title,
current: route.clone(),
}
}
}
}
}
h1 { "{site.info.title}" }
}
SuspenseBoundary { fallback: |_context: SuspenseContext| rsx! { "..." },
main { Outlet::<Route> {} }
}
}
}
#[component]
pub fn Home() -> Element {
let site = meta::site();
let page_ref =
use_server_future(move || server::get_page(site.info.domain.clone(), "/".to_string()))?
.suspend()?;
match &*page_ref.read() {
Ok(page) => meta::set_page(page.clone()),
Err(err) => {
return rsx! {
h1 { "FATAL ERROR: {err}" }
}
}
}
rsx! {
Page {}
}
}
#[component]
pub fn Single(page: String) -> Element {
let site = meta::site();
let page_ref =
use_server_future(move || server::get_page(site.info.domain.clone(), page.clone()))?
.suspend()?;
match &*page_ref.read() {
Ok(page) => meta::set_page(page.clone()),
Err(err) => {
return rsx! {
h1 { "FATAL ERROR: {err}" }
}
}
}
rsx! {
Page {}
}
}
#[component]
pub fn Post(page: String, id: String) -> Element {
let site = meta::site();
let domain = site.info.domain.clone();
let page_ref =
use_server_future(move || server::get_page(domain.clone(), page.clone()))?.suspend()?;
match &*page_ref.read() {
Ok(page) => {
meta::set_page(page.clone());
}
Err(err) => {
return rsx! {
h1 { "FATAL ERROR: {err}" }
}
}
};
let page = meta::page();
let collection_id = match &page.data.content {
PageContent::Collection {
kind: _,
collection_id,
} => collection_id.clone(),
_ => {
return rsx! {
h1 { "NOT A COLLECTION" }
}
}
};
let post_ref = use_server_future(move || {
server::get_post(site.info.domain.clone(), collection_id.clone(), id.clone())
})?
.suspend()?;
let post = match &*post_ref.read() {
Ok(post) => post.clone(),
Err(err) => {
return rsx! {
h1 { "FATAL ERROR: {err}" }
}
}
};
rsx! {
PostElement { post }
} }
} }

View file

@ -0,0 +1,74 @@
use dioxus::prelude::*;
use crate::domain::entities::site::{Block, PageContent, Post};
use super::meta;
#[component]
pub fn Page() -> Element {
let page = meta::page();
let info = page.data.info;
rsx! {
h2 { "Page {info.title}" }
match page.data.content {
PageContent::Single { content } => rsx! {
PostElement { post: content }
},
PageContent::Collection { kind: _, collection_id } => {
rsx! {
SuspenseBoundary { fallback: |_context: SuspenseContext| rsx! { "..." },
//Collection { collection_id }
}
}
}
}
}
}
/*
#[component]
pub fn Collection(collection_id: String) -> Element {
let site = meta::site();
let posts =
use_server_future(move || get_posts(site.info.domain.clone(), collection_id.clone()))?
.suspend()?;
let result = match &*posts.read() {
Ok(posts) => rsx! {
for post in posts.data.clone() {
PostElement { post }
}
},
Err(err) => {
rsx! {
h1 { "FATAL ERROR: {err}" }
}
}
};
result
}*/
#[component]
pub fn PostElement(post: Post) -> Element {
rsx! {
for block in post.blocks {
BlockElement { block }
}
}
}
#[component]
pub fn BlockElement(block: Block) -> Element {
match block {
Block::Text { text } => rsx! {
p { "{text}" }
},
Block::Gallery { images } => rsx! {
for image in images {
img { src: "{image.src}", alt: "{image.caption}" }
}
},
}
}

View file

@ -1 +1,57 @@
use dioxus::prelude::*;
use crate::domain::entities::{
cursor::Paginated,
site::{Page, Post, SiteInfo},
};
#[server]
pub async fn get_site_info() -> Result<SiteInfo, ServerFnError> {
use crate::outbound::services::site::SiteService;
let FromContext::<SiteService>(service) = extract().await?;
let headers = server_context().request_parts().headers.clone();
let domain = match headers.get("host").and_then(|h| h.to_str().ok()) {
Some(host) => host
.split_once(':')
.map(|(domain, _)| domain)
.unwrap_or(host),
None => "",
}
.to_string();
let site = service.get_site(domain.as_str()).await?;
Ok(site)
}
#[server]
pub async fn get_page(domain: String, page: String) -> Result<Page, ServerFnError> {
use crate::outbound::services::site::SiteService;
let FromContext::<SiteService>(service) = extract().await?;
Ok(service.get_page(&domain, &page).await?)
}
/*
#[server]
pub async fn get_posts(
domain: String,
collection_id: String,
) -> Result<Paginated<Post, String>, ServerFnError> {
use crate::outbound::services::site::SiteService;
let FromContext::<SiteService>(service) = extract().await?;
Ok(service.get_posts(&domain, &collection_id).await?)
}*/
#[server]
pub async fn get_post(
domain: String,
collection_id: String,
id: String,
) -> Result<Post, ServerFnError> {
use crate::outbound::services::site::SiteService;
let FromContext::<SiteService>(service) = extract().await?;
Ok(service.get_post(&domain, &collection_id, &id).await?)
}

View file

@ -21,7 +21,28 @@ fn main() {
builder = builder builder = builder
.with_context_provider( .with_context_provider(
|| { || {
Box::new(SiteService::new(InMemoryStore::new())) let mut store = InMemoryStore::new();
store.add_site(outbound::repository::site::SiteMetadata {
domain: "localhost".to_string(),
title: "Test site".to_string(),
});
store.add_page("localhost", domain::entities::site::Page{
info: domain::entities::site::PageInfo {
title: "Home".to_string(),
name: "/".to_string(),
order: 0,
},
content: domain::entities::site::PageContent::Single {
content: domain::entities::site::Post {
blocks: vec![{
domain::entities::site::Block::Text {
text: "Hello, world!".to_string(),
}
}],
},
}
});
Box::new(SiteService::new(store))
} }
); );
} }

View file

@ -1,16 +1,19 @@
use std::collections::HashMap; use std::collections::HashMap;
use async_trait::async_trait;
use crate::{ use crate::{
domain::entities::site::{Page, PageInfo, SiteMetadata}, domain::entities::site::{Page, PageInfo, Post},
outbound::repository::{ outbound::repository::{
self, self,
site::{Result, SiteRepository}, site::{Result, SiteMetadata, SiteRepository},
}, },
}; };
pub struct InMemoryStore { pub struct InMemoryStore {
sites: HashMap<String, SiteMetadata>, sites: HashMap<String, SiteMetadata>,
pages: HashMap<(String, String), Page>, pages: HashMap<(String, String), Page>,
posts: HashMap<(String, String), HashMap<String, Post>>,
} }
impl InMemoryStore { impl InMemoryStore {
@ -18,6 +21,7 @@ impl InMemoryStore {
InMemoryStore { InMemoryStore {
sites: HashMap::new(), sites: HashMap::new(),
pages: HashMap::new(), pages: HashMap::new(),
posts: HashMap::new(),
} }
} }
@ -30,6 +34,13 @@ impl InMemoryStore {
.insert((site.to_string(), page.info.name.clone()), page); .insert((site.to_string(), page.info.name.clone()), page);
} }
pub fn add_post(&mut self, site: &str, collection_id: &str, post_id: &str, post: Post) {
self.posts
.entry((site.to_string(), collection_id.to_string()))
.or_insert_with(HashMap::new)
.insert(post_id.to_string(), post);
}
#[cfg(test)] #[cfg(test)]
pub fn with_test_data() -> InMemoryStore { pub fn with_test_data() -> InMemoryStore {
use crate::domain::entities::site::{Block, PageContent, Post}; use crate::domain::entities::site::{Block, PageContent, Post};
@ -75,10 +86,22 @@ impl InMemoryStore {
}, },
); );
store.add_post(
"example.com",
"home_posts",
"post_id",
Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
);
store store
} }
} }
#[async_trait]
impl SiteRepository for InMemoryStore { impl SiteRepository for InMemoryStore {
async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata> { async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata> {
self.sites self.sites
@ -106,13 +129,24 @@ impl SiteRepository for InMemoryStore {
.ok_or(repository::site::Error::NotFound) .ok_or(repository::site::Error::NotFound)
.cloned() .cloned()
} }
async fn get_post(&self, domain: &str, collection_id: &str, post_id: &str) -> Result<Post> {
self.posts
.get(&(domain.to_string(), collection_id.to_string()))
.and_then(|posts| posts.get(post_id))
.cloned()
.ok_or(repository::site::Error::NotFound)
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{ use crate::{
domain::entities::site::{Block, Page, PageContent, PageInfo, Post, SiteMetadata}, domain::entities::site::{Block, Page, PageContent, PageInfo, Post},
outbound::repository::{self, site::SiteRepository}, outbound::repository::{
self,
site::{SiteMetadata, SiteRepository},
},
}; };
use super::InMemoryStore; use super::InMemoryStore;
@ -185,10 +219,6 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn get_page_works() { async fn get_page_works() {
let mut store = InMemoryStore::new(); let mut store = InMemoryStore::new();
store.add_site(SiteMetadata {
domain: "example.com".to_string(),
title: "Test site".to_string(),
});
store.add_page( store.add_page(
"example.com", "example.com",
Page { Page {
@ -207,10 +237,15 @@ mod tests {
}, },
); );
let page = store.get_page("example.com", "/").await.unwrap(); let page = store.get_page("example.com", "/").await.unwrap();
let PageContent::Single { content } = page.content; let PageContent::Single { content } = page.content else {
panic!("page content must be a single text block")
};
let Post { blocks } = content; let Post { blocks } = content;
assert_eq!(blocks.len(), 1); assert_eq!(blocks.len(), 1);
let Block::Text { text } = &blocks[0];
let Block::Text { text } = &blocks[0] else {
panic!("page content must be a single text block")
};
assert_eq!(text, "Hello, world!"); assert_eq!(text, "Hello, world!");
} }
@ -224,4 +259,42 @@ mod tests {
repository::site::Error::NotFound repository::site::Error::NotFound
)); ));
} }
#[tokio::test]
async fn get_post_works() {
let mut store = InMemoryStore::new();
store.add_post(
"example.com",
"home_posts",
"test_id",
Post {
blocks: vec![Block::Text {
text: "Hello, world!".to_string(),
}],
},
);
let post = store
.get_post("example.com", "home_posts", "test_id")
.await
.unwrap();
let Post { blocks } = post;
assert_eq!(blocks.len(), 1);
let Block::Text { text } = &blocks[0] else {
panic!("page content must be a single text block")
};
assert_eq!(text, "Hello, world!");
}
#[tokio::test]
async fn get_post_for_nonexistent_fails() {
let store = InMemoryStore::new();
let result = store.get_post("example.com", "home_posts", "test_id").await;
assert!(result.is_err());
assert!(matches!(
result.err().unwrap(),
repository::site::Error::NotFound
));
}
} }

View file

@ -1,11 +1,20 @@
use async_trait::async_trait;
use thiserror::Error; use thiserror::Error;
use crate::domain::entities::site::{Page, PageInfo, SiteMetadata}; use crate::domain::entities::site::{Page, PageInfo, Post};
pub trait SiteRepository { #[derive(Debug, Clone)]
pub struct SiteMetadata {
pub domain: String,
pub title: String,
}
#[async_trait]
pub trait SiteRepository: Send + Sync + 'static {
async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata>; async fn get_site_by_domain(&self, domain: &str) -> Result<SiteMetadata>;
async fn get_pages_for_site(&self, domain: &str) -> Result<Vec<PageInfo>>; async fn get_pages_for_site(&self, domain: &str) -> Result<Vec<PageInfo>>;
async fn get_page(&self, domain: &str, name: &str) -> Result<Page>; async fn get_page(&self, domain: &str, name: &str) -> Result<Page>;
async fn get_post(&self, domain: &str, collection_id: &str, id: &str) -> Result<Post>;
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;

View file

@ -1,17 +1,22 @@
use std::sync::Arc;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
domain::entities::site::{Page, SiteInfo}, domain::entities::site::{Page, Post, SiteInfo},
outbound::repository::{self, site::SiteRepository}, outbound::repository::{self, site::SiteRepository},
}; };
pub struct SiteService<SiteRepo: SiteRepository> { #[derive(Clone)]
site_repository: SiteRepo, pub struct SiteService {
site_repository: Arc<dyn SiteRepository>,
} }
impl<SiteRepo: SiteRepository> SiteService<SiteRepo> { impl SiteService {
pub fn new(site_repository: SiteRepo) -> Self { pub fn new(site_repository: impl SiteRepository) -> Self {
Self { site_repository } Self {
site_repository: Arc::new(site_repository),
}
} }
pub async fn get_site(&self, domain: &str) -> Result<SiteInfo> { pub async fn get_site(&self, domain: &str) -> Result<SiteInfo> {
@ -21,7 +26,11 @@ impl<SiteRepo: SiteRepository> SiteService<SiteRepo> {
.get_pages_for_site(&info.domain) .get_pages_for_site(&info.domain)
.await?; .await?;
Ok(SiteInfo { info, pages }) Ok(SiteInfo {
title: info.title,
domain: info.domain,
pages,
})
} }
pub async fn get_page(&self, domain: &str, name: &str) -> Result<Page> { pub async fn get_page(&self, domain: &str, name: &str) -> Result<Page> {
@ -30,6 +39,16 @@ impl<SiteRepo: SiteRepository> SiteService<SiteRepo> {
Ok(page) Ok(page)
} }
pub async fn get_post(&self, domain: &str, collection_id: &str, id: &str) -> Result<Post> {
let info = self.site_repository.get_site_by_domain(domain).await?;
let post = self
.site_repository
.get_post(&info.domain, collection_id, id)
.await?;
Ok(post)
}
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@ -64,11 +83,19 @@ mod tests {
async fn gets_site_info() { async fn gets_site_info() {
let service = SiteService::new(InMemoryStore::with_test_data()); 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.domain, "example.com");
assert_eq!(info.info.title, "Test site"); assert_eq!(info.title, "Test site");
assert_eq!(info.pages.len(), 2); assert_eq!(info.pages.len(), 2);
} }
#[tokio::test]
async fn gets_nonexistent_site() {
let service = SiteService::new(InMemoryStore::with_test_data());
let result = service.get_site("nonexistent.com").await;
assert!(result.is_err());
assert!(matches!(result.err().unwrap(), Error::NotFound));
}
#[tokio::test] #[tokio::test]
async fn gets_page_data() { async fn gets_page_data() {
let service = SiteService::new(InMemoryStore::with_test_data()); let service = SiteService::new(InMemoryStore::with_test_data());
@ -80,9 +107,15 @@ mod tests {
// page content must be a single text block // page content must be a single text block
let PageContent::Single { let PageContent::Single {
content: page_content, content: page_content,
} = page.content; } = page.content
else {
panic!("page content must be a single text block");
};
assert_eq!(page_content.blocks.len(), 1); assert_eq!(page_content.blocks.len(), 1);
let Block::Text { text } = &page_content.blocks[0];
let Block::Text { text } = &page_content.blocks[0] else {
panic!("page content must be a single text block");
};
assert_eq!(text, "Hello, world!"); assert_eq!(text, "Hello, world!");
} }
@ -93,4 +126,29 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.err().unwrap(), Error::NotFound)); assert!(matches!(result.err().unwrap(), Error::NotFound));
} }
#[tokio::test]
async fn gets_a_single_post() {
let service = SiteService::new(InMemoryStore::with_test_data());
let post = service
.get_post("example.com", "home_posts", "post_id")
.await
.unwrap();
assert_eq!(post.blocks.len(), 1);
let Block::Text { text } = &post.blocks[0] else {
panic!("page content must be a single text block");
};
assert_eq!(text, "Hello, world!");
}
#[tokio::test]
async fn gets_nonexistent_post() {
let service = SiteService::new(InMemoryStore::with_test_data());
let result = service
.get_post("example.com", "home_posts", "nonexistent")
.await;
assert!(result.is_err());
assert!(matches!(result.err().unwrap(), Error::NotFound));
}
} }