#![cfg(all(
feature = "ws",
feature = "maud",
feature = "htmx",
feature = "db",
feature = "test-support"
))]
#![allow(
clippy::must_use_candidate,
clippy::missing_const_for_fn,
clippy::too_many_lines
)]
use autumn_web::__private::CURRENT_CHANNELS;
use autumn_web::channels::Channels;
use autumn_web::hooks::Patch;
use autumn_web::live::LiveFragment;
use autumn_web::prelude::*;
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_async::pooled_connection::deadpool::Pool;
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
diesel::table! {
live_pf_posts (id) {
id -> Int8,
title -> Text,
category -> Text,
}
}
#[autumn_web::model(table = "live_pf_posts")]
#[derive(PartialEq, Eq)]
pub struct LivePfPost {
#[id]
pub id: i64,
pub title: String,
pub category: String,
}
impl LiveFragment for LivePfPost {
fn dom_id_for(id: i64) -> String {
format!("live-pf-post-{id}")
}
fn dom_id(&self) -> String {
Self::dom_id_for(self.id)
}
fn render_fragment(&self) -> maud::Markup {
html! {
li id=(self.dom_id()) class="pf-post" {
(self.title) " [" (self.category) "]"
}
}
}
fn insert_swap() -> autumn_web::htmx::OobSwap {
autumn_web::htmx::OobSwap::Target(
autumn_web::htmx::OobMethod::BeforeEnd,
"#live-pf-posts-list".to_string(),
)
}
}
#[autumn_web::repository(
LivePfPost,
table = "live_pf_posts",
broadcasts = true,
topic = "live_pf_cat:{category}",
container = "live-pf-posts-list"
)]
pub trait LivePfPostRepository {}
fn render_custom_pf(post: &LivePfPost) -> maud::Markup {
maud::html! {
div id=(format!("custom-pf-{}", post.id)) class="custom-pf" {
span { (post.title) }
}
}
}
#[autumn_web::repository(
LivePfPost,
table = "live_pf_posts",
broadcasts = true,
render = render_custom_pf,
container = "custom-pf-list"
)]
pub trait CustomPfPostRepository {}
diesel::table! {
live_pf_simple (id) {
id -> Int8,
name -> Text,
}
}
#[autumn_web::model(table = "live_pf_simple")]
pub struct LivePfSimple {
#[id]
pub id: i64,
pub name: String,
}
impl LiveFragment for LivePfSimple {
fn dom_id_for(id: i64) -> String {
format!("pf-simple-{id}")
}
fn dom_id(&self) -> String {
Self::dom_id_for(self.id)
}
fn render_fragment(&self) -> maud::Markup {
html! { li id=(self.dom_id()) { (self.name) } }
}
fn insert_swap() -> autumn_web::htmx::OobSwap {
autumn_web::htmx::OobSwap::Target(
autumn_web::htmx::OobMethod::BeforeEnd,
"#pf-simple-list".to_string(),
)
}
}
#[autumn_web::repository(LivePfSimple, table = "live_pf_simple", broadcasts = true)]
pub trait LivePfSimpleRepository {}
async fn setup_db() -> (
testcontainers::ContainerAsync<Postgres>,
Pool<AsyncPgConnection>,
) {
let container = Postgres::default().start().await.expect("postgres start");
let url = format!(
"postgres://postgres:postgres@{}:{}/postgres",
container.get_host().await.unwrap(),
container.get_host_port_ipv4(5432).await.unwrap(),
);
let manager = AsyncDieselConnectionManager::new(url);
let pool = Pool::builder(manager).build().expect("pool build");
let mut conn = pool.get().await.unwrap();
let _: diesel::QueryResult<usize> = diesel::sql_query(
"CREATE TABLE IF NOT EXISTS live_pf_posts (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
category TEXT NOT NULL
)",
)
.execute(&mut *conn)
.await;
let _: diesel::QueryResult<usize> = diesel::sql_query(
"CREATE TABLE IF NOT EXISTS live_pf_simple (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
)",
)
.execute(&mut *conn)
.await;
drop(conn);
(container, pool)
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn delete_dynamic_topic_publishes_on_interpolated_topic() {
let (_container, pool) = setup_db().await;
let channels = Channels::new(16);
let mut rx = channels.subscribe("live_pf_cat:rust");
let repo = PgLivePfPostRepository::with_pool_untracked(pool);
let saved = CURRENT_CHANNELS
.scope(channels.clone(), async {
repo.save(&NewLivePfPost {
title: "Rust post".to_owned(),
category: "rust".to_owned(),
})
.await
.expect("save")
})
.await;
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), rx.recv()).await;
CURRENT_CHANNELS
.scope(channels, async {
repo.delete_by_id(saved.id).await.expect("delete");
})
.await;
let msg = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
.await
.expect("delete broadcast timed out — wrong topic used (table-name fallback?)")
.expect("channel closed");
let html = msg.as_str();
assert!(
html.contains("delete"),
"must be a delete swap, got: {html}"
);
assert!(
html.contains(&format!("live-pf-post-{}", saved.id)),
"delete must target correct dom-id live-pf-post-{}, got: {html}",
saved.id
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn delete_with_render_uses_real_dom_id() {
let (_container, pool) = setup_db().await;
let channels = Channels::new(16);
let mut rx = channels.subscribe("live_pf_posts");
let repo = PgCustomPfPostRepository::with_pool_untracked(pool);
let saved = CURRENT_CHANNELS
.scope(channels.clone(), async {
repo.save(&NewLivePfPost {
title: "Custom".to_owned(),
category: "any".to_owned(),
})
.await
.expect("save")
})
.await;
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), rx.recv()).await;
CURRENT_CHANNELS
.scope(channels, async {
repo.delete_by_id(saved.id).await.expect("delete");
})
.await;
let msg = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
.await
.expect("delete broadcast timed out")
.expect("channel closed");
let html = msg.as_str();
assert!(
html.contains("delete"),
"must be a delete swap, got: {html}"
);
assert!(
html.contains(&format!("custom-pf-{}", saved.id)),
"delete must use render-fn id custom-pf-{}, not approximated id, got: {html}",
saved.id
);
assert!(
!html.contains(&format!("live_pf_post-{}", saved.id)),
"must not use approximated model_prefix id, got: {html}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn update_topic_change_deletes_old_inserts_new() {
let (_container, pool) = setup_db().await;
let channels = Channels::new(16);
let repo = PgLivePfPostRepository::with_pool_untracked(pool);
let saved = CURRENT_CHANNELS
.scope(channels.clone(), async {
repo.save(&NewLivePfPost {
title: "Topic changer".to_owned(),
category: "rust".to_owned(),
})
.await
.expect("save")
})
.await;
let mut old_rx = channels.subscribe("live_pf_cat:rust");
let mut new_rx = channels.subscribe("live_pf_cat:go");
let _ = tokio::time::timeout(std::time::Duration::from_millis(100), old_rx.recv()).await;
CURRENT_CHANNELS
.scope(channels.clone(), async {
repo.update(
saved.id,
&UpdateLivePfPost {
title: Patch::Unchanged,
category: Patch::Set("go".to_owned()),
},
)
.await
.expect("update");
})
.await;
let del_msg = tokio::time::timeout(std::time::Duration::from_millis(500), old_rx.recv())
.await
.expect("no delete on old topic live_pf_cat:rust — pre-fetch not implemented?")
.expect("channel closed");
let del_html = del_msg.as_str();
assert!(
del_html.contains("delete"),
"old-topic broadcast must be a delete, got: {del_html}"
);
assert!(
del_html.contains(&format!("live-pf-post-{}", saved.id)),
"delete on old topic must target correct id, got: {del_html}"
);
let ins_msg = tokio::time::timeout(std::time::Duration::from_millis(500), new_rx.recv())
.await
.expect("no insert on new topic live_pf_cat:go")
.expect("channel closed");
let ins_html = ins_msg.as_str();
assert!(
ins_html.contains("beforeend"),
"new-topic broadcast must be a beforeend insert, got: {ins_html}"
);
assert!(
ins_html.contains("live-pf-posts-list"),
"insert must target container live-pf-posts-list, got: {ins_html}"
);
assert!(
ins_html.contains(&format!("live-pf-post-{}", saved.id)),
"insert must contain the updated record, got: {ins_html}"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn simple_static_topic_delete_still_works() {
let (_container, pool) = setup_db().await;
let channels = Channels::new(16);
let mut rx = channels.subscribe("live_pf_simple");
let repo = PgLivePfSimpleRepository::with_pool_untracked(pool);
let saved = CURRENT_CHANNELS
.scope(channels.clone(), async {
repo.save(&NewLivePfSimple {
name: "simple item".to_owned(),
})
.await
.expect("save")
})
.await;
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), rx.recv()).await;
CURRENT_CHANNELS
.scope(channels, async {
repo.delete_by_id(saved.id).await.expect("delete");
})
.await;
let msg = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
.await
.expect("delete broadcast timed out on simple static-topic repo")
.expect("channel closed");
let html = msg.as_str();
assert!(
html.contains("delete"),
"must be a delete swap, got: {html}"
);
assert!(
html.contains(&format!("pf-simple-{}", saved.id)),
"simple delete must target dom id pf-simple-{}, got: {html}",
saved.id
);
}