use renox::chrono::NaiveDate;
use renox::db::Migration;
use renox::prelude::*;
use renox::testing::TestApp;
const SCHEMA: Migration = Migration::new("20300101000000_tasks", "", None).sqlite(
"CREATE TABLE tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0, due_on TEXT, owner_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT, updated_at TEXT);",
None,
).postgres(
"CREATE TABLE tasks (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE, due_on DATE, owner_id BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ);",
None,
);
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "tasks", form)]
struct Task {
id: i64,
#[form(validate(required, max = 20))]
title: String,
done: bool,
due_on: Option<NaiveDate>,
#[form(skip)]
owner_id: i64,
created_at: Option<DateTime>,
updated_at: Option<DateTime>,
}
struct Tasks;
impl Module for Tasks {
fn name(&self) -> &'static str {
"tasks"
}
fn routes(&self) -> Routes {
Routes::new().post(
"/tasks",
|State(state): State<AppState>, Valid(form): Valid<TaskForm>| async move {
let mut task = Task {
owner_id: 7,
..Default::default()
};
form.fill(&mut task);
let task = Task::create(&state.db, task).await?;
Ok::<_, Error>(task.id.to_string())
},
)
}
}
async fn app() -> TestApp {
TestApp::new(App::new().migrations(&[SCHEMA]).module(Tasks)).await
}
#[renox::test]
async fn a_valid_post_creates_a_row() {
let app = app().await;
app.post(
"/tasks",
&[("title", "Write docs"), ("due_on", "2030-05-01")],
)
.await
.assert_ok();
let tasks = Task::all(app.db()).await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].title, "Write docs");
assert!(!tasks[0].done, "an unchecked box is false");
assert_eq!(tasks[0].due_on, NaiveDate::from_ymd_opt(2030, 5, 1));
assert_eq!(tasks[0].owner_id, 7);
}
#[renox::test]
async fn rules_apply() {
let app = app().await;
app.htmx()
.post("/tasks", &[("title", "a title that is far too long")])
.await
.assert_invalid("title");
assert!(Task::all(app.db()).await.unwrap().is_empty());
}
#[renox::test]
async fn skipped_fields_are_not_form_fields() {
let app = app().await;
app.post(
"/tasks",
&[("title", "Mine"), ("owner_id", "99"), ("done", "on")],
)
.await
.assert_ok();
let task = &Task::all(app.db()).await.unwrap()[0];
assert_eq!(task.owner_id, 7);
assert!(task.done);
}
#[test]
fn the_form_serializes() {
let form: TaskForm = serde_json::from_value(serde_json::json!({
"title": "x", "due_on": null
}))
.unwrap();
let value = serde_json::to_value(&form).unwrap();
assert_eq!(value["title"], "x");
assert_eq!(value["done"], false);
assert!(value.get("owner_id").is_none());
assert!(value.get("id").is_none());
}
const PHOTOS: Migration = Migration::new("20300101000100_photos", "", None)
.sqlite(
"CREATE TABLE photos (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT,
created_at TEXT, updated_at TEXT);",
None,
)
.postgres(
"CREATE TABLE photos (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, path TEXT,
created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ);",
None,
);
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "photos", form)]
struct Photo {
id: i64,
#[form(upload, validate(image))]
path: Option<String>,
created_at: Option<DateTime>,
updated_at: Option<DateTime>,
}
struct Photos;
impl Module for Photos {
fn name(&self) -> &'static str {
"photos"
}
fn routes(&self) -> Routes {
Routes::new().post(
"/photos",
|State(state): State<AppState>, Valid(mut form): Valid<PhotoForm>| async move {
let mut photo = Photo::default();
let stored = match form.path.take() {
Some(upload) => Some(upload.store(&state.storage, "photos").await?),
None => None,
};
form.fill(&mut photo);
photo.path = stored;
let photo = Photo::create(&state.db, photo).await?;
Ok::<_, Error>(photo.id.to_string())
},
)
}
}
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDRsmall-but-valid-enough-for-sniffing";
#[renox::test]
async fn an_upload_field_is_stored_by_the_handler() {
let app = TestApp::new(App::new().migrations(&[PHOTOS]).module(Photos)).await;
app.post_multipart("/photos", &[], &[("path", "cat.png", PNG)])
.await
.assert_ok();
let photos = Photo::all(app.db()).await.unwrap();
let path = photos[0].path.clone().expect("the handler set the path");
assert!(path.starts_with("photos/"), "{path}");
}
#[renox::test]
async fn an_upload_field_is_checked() {
let app = TestApp::new(App::new().migrations(&[PHOTOS]).module(Photos)).await;
app.htmx()
.post_multipart("/photos", &[], &[("path", "notes.png", b"just text")])
.await
.assert_invalid("path");
assert!(Photo::all(app.db()).await.unwrap().is_empty());
}
#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone, PartialEq)]
struct Line {
qty: i64,
}
const ORDERS: Migration = Migration::new("20300101000200_orders", "", None).sqlite(
"CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, lines TEXT NOT NULL DEFAULT '[]',
created_at TEXT, updated_at TEXT);",
None,
).postgres(
"CREATE TABLE orders (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, lines JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ);",
None,
);
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "orders", form)]
struct Order {
id: i64,
lines: renox::db::Json<Vec<Line>>,
created_at: Option<DateTime>,
updated_at: Option<DateTime>,
}
struct Orders;
impl Module for Orders {
fn name(&self) -> &'static str {
"orders"
}
fn routes(&self) -> Routes {
Routes::new().post("/orders", |Valid(form): Valid<OrderForm>| async move {
let mut order = Order::default();
form.fill(&mut order);
Ok::<_, Error>(format!("{:?}", order.lines.0))
})
}
}
#[renox::test]
async fn nested_names_fill_a_json_column() {
let app = TestApp::new(App::new().migrations(&[ORDERS]).module(Orders)).await;
let res = app
.post("/orders", &[("lines[0][qty]", "2"), ("lines[1][qty]", "3")])
.await;
res.assert_ok();
assert_eq!(res.text(), "[Line { qty: 2 }, Line { qty: 3 }]");
}
#[renox::test]
async fn a_missing_nested_value_names_its_key() {
let app = TestApp::new(App::new().migrations(&[ORDERS]).module(Orders)).await;
app.htmx()
.post(
"/orders",
&[("lines[0][qty]", "2"), ("lines[1][other]", "3")],
)
.await
.assert_invalid("lines.1.qty");
}