use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use autumn_web::config::DatabaseBackend;
use autumn_web::notifications::NOTIFICATIONS_TABLE;
use super::dsl::{Field, FieldConstraints, FieldKind, IdType};
use super::emit::Plan;
use super::model::ensure_cargo_dependencies;
use super::schema_edit::{
create_table_sql_with_metadata_and_id_for, drop_table_sql,
ensure_dev_dependency_tokio_test_features, update_main_rs,
};
use super::{GenerateError, detect_backend, ensure_project_root, read_or_empty, timestamp_now};
const NOTIFICATIONS_DEPS: &[(&str, &str)] = &[
("serde", "{ version = \"1\", features = [\"derive\"] }"),
("serde_json", "\"1\""),
];
pub fn plan_notifications(project_root: &Path) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
let backend = detect_backend(project_root);
let mut plan = Plan::new(project_root);
plan.create(
project_root.join("src").join("notifications.rs"),
render_app_module(),
);
let main_path = project_root.join("src").join("main.rs");
let main_existing = std::fs::read_to_string(&main_path).map_err(|e| {
GenerateError::Io(std::io::Error::new(
e.kind(),
format!("{}: {e}", main_path.display()),
))
})?;
let route_entries = route_entries();
plan.modify(
main_path.clone(),
update_main_rs(&main_existing, &["notifications"], &route_entries),
);
plan.push_revert(crate::generate::emit::Revert::RoutesEntries {
path: main_path,
entries: route_entries,
});
let migration_dir = existing_notifications_migration_dir(project_root).unwrap_or_else(|| {
project_root
.join("migrations")
.join(format!("{}_create_{NOTIFICATIONS_TABLE}", timestamp_now()))
});
plan.create(migration_dir.join("up.sql"), migration_up_sql(backend));
plan.create(
migration_dir.join("down.sql"),
drop_table_sql(NOTIFICATIONS_TABLE),
);
plan.create(
project_root.join("tests").join("notifications_feed.rs"),
render_smoke_test(),
);
let cargo_path = project_root.join("Cargo.toml");
let cargo_existing = read_or_empty(&cargo_path);
let mut updated_cargo = ensure_cargo_dependencies(&cargo_existing, NOTIFICATIONS_DEPS);
updated_cargo = ensure_dev_dependency_tokio_test_features(&updated_cargo);
if updated_cargo != cargo_existing {
plan.modify(cargo_path.clone(), updated_cargo);
}
plan.push_revert(crate::generate::emit::Revert::CargoDeps {
path: cargo_path,
names: NOTIFICATIONS_DEPS
.iter()
.map(|(name, _)| (*name).to_owned())
.collect(),
owner_dir: project_root.join("src").join("notifications.rs"),
});
Ok(plan)
}
fn route_entries() -> Vec<String> {
["feed", "unread_count", "mark_read", "mark_all_read"]
.iter()
.map(|handler| format!("notifications::{handler}"))
.collect()
}
fn existing_notifications_migration_dir(project_root: &Path) -> Option<std::path::PathBuf> {
let suffix = format!("_create_{NOTIFICATIONS_TABLE}");
let mut matches: Vec<std::path::PathBuf> = std::fs::read_dir(project_root.join("migrations"))
.ok()?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.is_dir()
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(&suffix))
})
.collect();
matches.sort();
matches.into_iter().next()
}
fn notification_fields() -> Vec<Field> {
let field = |name: &str, kind: FieldKind, nullable: bool| Field {
name: name.to_owned(),
kind,
nullable,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
};
vec![
field("tenant_id", FieldKind::String, false),
field("recipient_id", FieldKind::I64, false),
field("kind", FieldKind::String, false),
field("payload", FieldKind::String, false),
field("read_at", FieldKind::DateTime, true),
]
}
fn migration_up_sql(backend: DatabaseBackend) -> String {
let sql = create_table_sql_with_metadata_and_id_for(
backend,
NOTIFICATIONS_TABLE,
¬ification_fields(),
&BTreeSet::new(),
&BTreeMap::new(),
IdType::BigSerial,
);
let sql = match backend {
DatabaseBackend::Postgres => sql.replace(
"created_at TIMESTAMP NOT NULL DEFAULT NOW()",
"created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()",
),
DatabaseBackend::Sqlite => sql,
};
let sql = sql.replace(
"tenant_id TEXT NOT NULL",
"tenant_id TEXT NOT NULL DEFAULT ''",
);
format!(
"{sql}CREATE INDEX idx_notifications_tenant_recipient ON notifications \
(tenant_id, recipient_id);\n"
)
}
const fn render_handlers() -> &'static str {
r#"/// The session key `current_recipient_id` reads. Populate it from your
/// auth (see the TODO on `current_recipient_id`); until then the feed is
/// dormant and every request 401s.
const RECIPIENT_SESSION_KEY: &str = "notifications.recipient_id";
/// Resolve the signed-in recipient for this request.
///
/// The recipient is derived server-side from the signed session — never
/// from a path or body parameter — so a caller can only ever see or mark
/// their own feed. An unauthenticated request gets a 401, so these routes
/// stay dormant until real auth populates the session key.
///
/// TODO: replace this session lookup with your real auth (e.g. an
/// `Auth<CurrentUser>` extractor returning `user.id`), which is what should
/// populate `RECIPIENT_SESSION_KEY` on sign-in.
async fn current_recipient_id(session: &Session) -> AutumnResult<i64> {
session
.get(RECIPIENT_SESSION_KEY)
.await
.and_then(|value| value.parse().ok())
.ok_or_else(|| AutumnError::unauthorized_msg("not signed in"))
}
/// `POST /notifications` body: who to notify, an application-defined kind
/// discriminator (e.g. `"comment.created"`), and a free-form JSON payload.
#[derive(Deserialize)]
pub struct NotifyBody {
pub recipient_id: i64,
pub kind: String,
pub payload: serde_json::Value,
}
/// `POST /notifications` — DEMO write handler: create a notification from a
/// JSON body and return the stored record.
///
/// This is a worked example, **not wired into the app by default**: it is
/// deliberately absent from `main.rs`'s `routes![...]` (see `route_entries`
/// in the generator) because the recipient comes from the request body.
/// Server-side notifies are legitimately cross-recipient (one user's action
/// notifies another), which is exactly why the recipient can't be derived
/// from the caller's session — and why an unguarded `POST /notifications`
/// would let anyone spam anyone.
///
/// SECURITY / TODO: to expose a write endpoint, register this behind your own
/// authorization (restrict it to an admin/service caller). Preferred: delete
/// this handler and call `notifications.notify(recipient_id, kind, payload)`
/// directly from the domain action that triggers the notification (a new
/// comment, a mention, …). The smoke test registers this handler in its own
/// test router to exercise the write path.
#[post("/notifications")]
pub async fn notify(
notifications: Notifications,
Json(body): Json<NotifyBody>,
) -> AutumnResult<Json<Notification>> {
let notification = notifications
.notify(body.recipient_id, body.kind, body.payload)
.await?;
Ok(Json(notification))
}
/// `GET /notifications` — one page of the signed-in recipient's feed.
///
/// Supports `?page=`/`?size=` (the `PageRequest` extractor) plus
/// `?filter[unread]=true`, `?filter[kind]=…`, and `?sort=id|created_at`
/// (the `ListQuery` extractor); defaults to newest-first.
#[get("/notifications")]
pub async fn feed(
session: Session,
query: ListQuery,
page: PageRequest,
notifications: Notifications,
) -> AutumnResult<Json<Page<Notification>>> {
let recipient_id = current_recipient_id(&session).await?;
Ok(Json(notifications.list(recipient_id, &query, &page).await?))
}
/// `GET /notifications/unread_count` — the signed-in recipient's bell-badge
/// number.
#[get("/notifications/unread_count")]
pub async fn unread_count(
session: Session,
notifications: Notifications,
) -> AutumnResult<Json<u64>> {
let recipient_id = current_recipient_id(&session).await?;
Ok(Json(notifications.unread_count(recipient_id).await?))
}
/// `POST /notifications/{id}/read` — mark one of the signed-in recipient's
/// notifications read.
///
/// Uses the recipient-scoped `mark_read_for` with the session-derived
/// recipient, rather than the unscoped `mark_read`: a notification owned by
/// a different recipient is left untouched (a no-op, not an error), so even
/// a guessed `id` can never mark another user's notification read
/// (IDOR-safe). Idempotent on re-marking.
#[post("/notifications/{id}/read")]
pub async fn mark_read(
Path(id): Path<i64>,
session: Session,
notifications: Notifications,
) -> AutumnResult<&'static str> {
let recipient_id = current_recipient_id(&session).await?;
notifications.mark_read_for(recipient_id, id).await?;
Ok("ok")
}
/// `POST /notifications/read_all` — mark the signed-in recipient's whole
/// feed read; returns how many notifications transitioned (0 on a repeat
/// call).
#[post("/notifications/read_all")]
pub async fn mark_all_read(
session: Session,
notifications: Notifications,
) -> AutumnResult<Json<u64>> {
let recipient_id = current_recipient_id(&session).await?;
Ok(Json(notifications.mark_all_read(recipient_id).await?))
}
"#
}
const fn render_test_only_demo_login() -> &'static str {
r#"/// TEST-ONLY: seed the session with a recipient id so the session-scoped
/// routes above are reachable from this smoke test. This handler is NOT part
/// of the generated `src/notifications.rs` and is NOT registered in the real
/// router — shipping it would be an impersonation endpoint (anyone could POST
/// `/notifications/demo_login/<victim>`). A real app populates
/// `RECIPIENT_SESSION_KEY` from its own authentication instead.
#[post("/notifications/demo_login/{recipient_id}")]
pub async fn demo_login(Path(recipient_id): Path<i64>, session: Session) -> &'static str {
session
.insert(RECIPIENT_SESSION_KEY, recipient_id.to_string())
.await;
"ok"
}
"#
}
fn render_app_module() -> String {
format!(
r#"//! Generated by `autumn generate notifications`. Edit freely.
//!
//! A minimal in-app notification feed over the framework's
//! [`Notifications`] extractor (`autumn_web::notifications`). Storage
//! resolves automatically: the `notifications` table scaffolded by the
//! accompanying migration when a database is configured, an in-process
//! memory store otherwise — so these routes work before `autumn migrate`
//! has ever run.
//!
//! The feed/mark routes are session-scoped: the recipient is derived
//! server-side (`current_recipient_id`), never from the request, so one
//! user can never read or mark another user's feed. These routes stay
//! dormant (every request 401s) until your real auth populates the session
//! key — wire that in `current_recipient_id`; see the TODO there. There is
//! deliberately no demo-login route in this module: seeding the session is
//! done only by a test-only route in the generated smoke test, so a
//! deployed app never exposes a "become any user" endpoint.
//!
//! Optional realtime push: with the `ws` feature enabled, swap `notify` for
//! `notify_with_push` to also broadcast each stored notification on the
//! conventional `notifications:{{recipient_id}}` channel topic
//! (`Notifications::topic`).
use autumn_web::notifications::{{Notification, Notifications}};
use autumn_web::prelude::*;
use serde::Deserialize;
{handlers}"#,
handlers = render_handlers(),
)
}
#[allow(
clippy::too_many_lines,
reason = "one literal template for the generated smoke test; the length is the \
emitted file's, and splitting the flow across helpers would only \
obscure what the generated test contains"
)]
fn render_smoke_test() -> String {
format!(
r#"//! Smoke test generated by `autumn generate notifications`.
//!
//! Exercises the full demo-login → notify → list → mark-read →
//! unread-count flow over HTTP through the in-process `TestApp` — no
//! database needed: with no DB configured the `Notifications` extractor
//! falls back to its in-process memory store. `TestClient` carries a
//! cookie jar, so the session set by the demo login is replayed on every
//! later request, exactly like a browser. The handlers below are
//! re-declarations of `src/notifications.rs`'s handlers of the same names,
//! kept byte-for-byte in sync — `tests/` integration binaries cannot
//! import the app's own binary crate (there is no `src/lib.rs`).
use autumn_web::notifications::{{Notification, Notifications}};
use autumn_web::prelude::*;
use autumn_web::test::TestApp;
use serde::Deserialize;
{handlers}
{demo_login}
#[tokio::test]
async fn notifications_feed_round_trips_over_http() {{
let client = TestApp::new()
.routes(routes![
demo_login,
notify,
feed,
unread_count,
mark_read,
mark_all_read
])
.build();
// Anonymous requests are rejected: the feed is session-scoped.
client.get("/notifications").send().await.assert_status(401);
// Demo sign-in as recipient 7 — the cookie jar keeps the session for
// the rest of the flow.
client
.post("/notifications/demo_login/7")
.send()
.await
.assert_ok();
// Notify: POST a notification and get the stored record back.
let created: Notification = client
.post("/notifications")
.json(&serde_json::json!({{
"recipient_id": 7,
"kind": "comment.created",
"payload": {{"post": 42}}
}}))
.send()
.await
.assert_ok()
.json();
assert_eq!(created.recipient_id, 7);
assert_eq!(created.kind, "comment.created");
// Feed (session-scoped): the new notification shows up.
let feed_page: Page<Notification> = client
.get("/notifications")
.send()
.await
.assert_ok()
.json();
assert_eq!(feed_page.total_elements, 1);
assert_eq!(feed_page.content[0].id, created.id);
// The unread badge counts it...
let unread: u64 = client
.get("/notifications/unread_count")
.send()
.await
.assert_ok()
.json();
assert_eq!(unread, 1);
// ...until it is marked read (scoped to the session's recipient).
client
.post(&format!("/notifications/{{}}/read", created.id))
.send()
.await
.assert_ok();
let unread: u64 = client
.get("/notifications/unread_count")
.send()
.await
.assert_ok()
.json();
assert_eq!(unread, 0);
// The unread-only feed is now empty.
let unread_feed: Page<Notification> = client
.get("/notifications?filter[unread]=true")
.send()
.await
.assert_ok()
.json();
assert_eq!(unread_feed.total_elements, 0);
// mark-all-read sweeps the rest of the feed in one call.
client
.post("/notifications")
.json(&serde_json::json!({{
"recipient_id": 7,
"kind": "like",
"payload": {{}}
}}))
.send()
.await
.assert_ok();
let swept: u64 = client
.post("/notifications/read_all")
.send()
.await
.assert_ok()
.json();
assert_eq!(swept, 1);
// Cross-recipient isolation: signing in as a different recipient shows
// an empty feed — the recipient comes from the session, never a URL,
// so recipient 7's notifications are unreachable from this session.
client
.post("/notifications/demo_login/8")
.send()
.await
.assert_ok();
let other_feed: Page<Notification> = client
.get("/notifications")
.send()
.await
.assert_ok()
.json();
assert_eq!(other_feed.total_elements, 0);
}}
"#,
handlers = render_handlers(),
demo_login = render_test_only_demo_login(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::Flags;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn project_with_main(main_content: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/main.rs"), main_content).unwrap();
tmp
}
fn default_main() -> &'static str {
r#"use autumn_web::prelude::*;
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#
}
fn with_no_db_env<R>(f: impl FnOnce() -> R) -> R {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
f,
)
}
fn migration_dir(root: &Path) -> PathBuf {
let entries: Vec<PathBuf> = fs::read_dir(root.join("migrations"))
.expect("migrations dir must exist")
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with("_create_notifications"))
})
.collect();
assert_eq!(
entries.len(),
1,
"exactly one notifications migration: {entries:?}"
);
entries.into_iter().next().unwrap()
}
#[test]
fn plan_creates_migration_up_and_down() {
let tmp = project_with_main(default_main());
let plan = plan_notifications(tmp.path()).unwrap();
for suffix in [
"_create_notifications/up.sql",
"_create_notifications/down.sql",
] {
assert!(
plan.actions.iter().any(|a| {
a.path()
.to_string_lossy()
.replace('\\', "/")
.ends_with(suffix)
}),
"plan must include migrations/<ts>{suffix}"
);
}
}
#[test]
fn plan_creates_app_module_and_smoke_test() {
let tmp = project_with_main(default_main());
let plan = plan_notifications(tmp.path()).unwrap();
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("src/notifications.rs")),
"plan must include src/notifications.rs"
);
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("tests/notifications_feed.rs")),
"plan must include tests/notifications_feed.rs"
);
}
#[test]
fn plan_modifies_main_rs() {
let tmp = project_with_main(default_main());
let plan = plan_notifications(tmp.path()).unwrap();
assert!(
plan.actions
.iter()
.any(|a| a.path().ends_with("src/main.rs")),
"plan must modify src/main.rs"
);
}
#[test]
fn plan_errors_when_not_in_project() {
let tmp = TempDir::new().unwrap();
let err = plan_notifications(tmp.path()).unwrap_err();
assert!(matches!(err, GenerateError::NotInProject));
}
#[test]
fn plan_errors_when_main_rs_missing() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
let err = plan_notifications(tmp.path()).unwrap_err();
assert!(matches!(err, GenerateError::Io(_)));
}
#[test]
fn postgres_migration_matches_framework_store_ddl() {
with_no_db_env(|| {
let tmp = project_with_main(default_main());
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let dir = migration_dir(tmp.path());
let up = fs::read_to_string(dir.join("up.sql")).unwrap();
assert_eq!(
up,
"CREATE TABLE notifications (\n\
\x20 id BIGSERIAL PRIMARY KEY,\n\
\x20 tenant_id TEXT NOT NULL DEFAULT '',\n\
\x20 recipient_id BIGINT NOT NULL,\n\
\x20 kind TEXT NOT NULL,\n\
\x20 payload TEXT NOT NULL,\n\
\x20 read_at TIMESTAMPTZ NULL,\n\
\x20 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n\
);\n\
CREATE INDEX idx_notifications_tenant_recipient ON notifications (tenant_id, recipient_id);\n"
);
let down = fs::read_to_string(dir.join("down.sql")).unwrap();
assert_eq!(down, "DROP TABLE notifications;\n");
});
}
#[test]
fn sqlite_project_emits_sqlite_ddl() {
with_no_db_env(|| {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"sqlite://app.db\"\n",
)
.unwrap();
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let up = fs::read_to_string(migration_dir(tmp.path()).join("up.sql")).unwrap();
assert!(up.contains("id INTEGER PRIMARY KEY AUTOINCREMENT"), "{up}");
assert!(up.contains("tenant_id TEXT NOT NULL DEFAULT ''"), "{up}");
assert!(up.contains("recipient_id INTEGER NOT NULL"), "{up}");
assert!(up.contains("kind TEXT NOT NULL"), "{up}");
assert!(up.contains("payload TEXT NOT NULL"), "{up}");
assert!(up.contains("read_at TEXT NULL"), "{up}");
assert!(
up.contains("created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"),
"{up}"
);
assert!(
up.contains("CREATE INDEX idx_notifications_tenant_recipient"),
"{up}"
);
for leak in ["BIGSERIAL", "TIMESTAMPTZ", "NOW()", "BIGINT"] {
assert!(!up.contains(leak), "SQLite up.sql leaked `{leak}`: {up}");
}
});
}
fn count_notification_migration_dirs(root: &Path) -> usize {
fs::read_dir(root.join("migrations")).map_or(0, |entries| {
entries
.filter_map(Result::ok)
.filter(|e| {
e.path().is_dir()
&& e.file_name()
.to_str()
.is_some_and(|n| n.ends_with("_create_notifications"))
})
.count()
})
}
#[test]
fn first_run_mints_exactly_one_migration_dir() {
let tmp = project_with_main(default_main());
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
assert_eq!(count_notification_migration_dirs(tmp.path()), 1);
}
#[test]
fn rerun_reuses_existing_migration_dir_instead_of_minting_a_second() {
let tmp = project_with_main(default_main());
let existing = tmp
.path()
.join("migrations")
.join("20200101000000_create_notifications");
fs::create_dir_all(&existing).unwrap();
fs::write(existing.join("up.sql"), "-- stale placeholder\n").unwrap();
fs::write(existing.join("down.sql"), "-- stale placeholder\n").unwrap();
let plan = plan_notifications(tmp.path()).unwrap();
assert!(
plan.actions.iter().any(|a| {
a.path()
.to_string_lossy()
.replace('\\', "/")
.ends_with("20200101000000_create_notifications/up.sql")
}),
"re-run must write into the existing migration dir, not a new one: {:?}",
plan.actions
.iter()
.map(|a| a.path().to_owned())
.collect::<Vec<_>>()
);
plan.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert_eq!(
count_notification_migration_dirs(tmp.path()),
1,
"a re-run must not mint a second migration directory"
);
let up = fs::read_to_string(existing.join("up.sql")).unwrap();
assert!(
up.contains("CREATE TABLE notifications"),
"the reused dir's up.sql must be refreshed with the real DDL: {up}"
);
}
#[test]
fn execute_writes_app_module_with_session_scoped_handlers() {
let tmp = project_with_main(default_main());
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/notifications.rs")).unwrap();
assert!(
src.contains("use autumn_web::notifications::{Notification, Notifications};"),
"{src}"
);
assert!(
!src.contains("demo_login"),
"the demo login must be test-only — it must not exist in the \
production module: {src}"
);
assert!(src.contains(r#"#[post("/notifications")]"#), "{src}");
assert!(src.contains(r#"#[get("/notifications")]"#), "{src}");
assert!(
src.contains(r#"#[get("/notifications/unread_count")]"#),
"{src}"
);
assert!(
src.contains(r#"#[post("/notifications/{id}/read")]"#),
"{src}"
);
assert!(
src.contains(r#"#[post("/notifications/read_all")]"#),
"{src}"
);
assert!(
src.contains("async fn current_recipient_id(session: &Session)"),
"{src}"
);
assert_eq!(
src.matches("current_recipient_id(&session).await?").count(),
4,
"feed, unread_count, mark_read, and mark_all_read must all resolve \
the recipient from the session: {src}"
);
assert!(src.contains("unauthorized"), "{src}");
assert!(src.contains(".mark_read_for(recipient_id, id)"), "{src}");
assert!(src.contains("IDOR"), "{src}");
assert!(src.contains("SECURITY"), "{src}");
assert!(src.contains("TODO"), "{src}");
assert!(src.contains("query: ListQuery"), "{src}");
assert!(src.contains("page: PageRequest"), "{src}");
assert!(src.contains("Json<Page<Notification>>"), "{src}");
}
#[test]
fn demo_login_is_test_only_never_in_production_router() {
assert!(
!route_entries().iter().any(|e| e.contains("demo_login")),
"route_entries must not register demo_login: {:?}",
route_entries()
);
let tmp = project_with_main(default_main());
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/notifications.rs")).unwrap();
assert!(
!src.contains("fn demo_login"),
"production module must not define a demo_login handler: {src}"
);
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(
!main.contains("demo_login"),
"main.rs must not register a demo_login route: {main}"
);
let test_src = fs::read_to_string(tmp.path().join("tests/notifications_feed.rs")).unwrap();
assert!(
test_src.contains("fn demo_login"),
"smoke test must declare its own test-only session-seeding \
handler: {test_src}"
);
assert!(
test_src.contains("RECIPIENT_SESSION_KEY"),
"smoke test's seeding route must write the session key: {test_src}"
);
}
#[test]
fn production_routes_never_take_the_recipient_from_the_url() {
let tmp = project_with_main(default_main());
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/notifications.rs")).unwrap();
let route_attrs: Vec<&str> = src
.lines()
.map(str::trim)
.filter(|l| l.starts_with("#[get(") || l.starts_with("#[post("))
.collect();
assert_eq!(
route_attrs.len(),
5,
"five routes expected: {route_attrs:?}"
);
assert!(
!route_attrs.iter().any(|l| l.contains("{recipient_id}")),
"no production route may take a recipient id from the URL: \
{route_attrs:?}"
);
assert!(src.contains("pub recipient_id: i64"), "{src}");
}
#[test]
fn execute_updates_main_rs_with_mod_and_route_entries() {
let tmp = project_with_main(default_main());
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main.contains("mod notifications;"), "{main}");
for entry in [
"notifications::feed",
"notifications::unread_count",
"notifications::mark_read",
"notifications::mark_all_read",
] {
assert!(
main.contains(entry),
"main.rs must register {entry}: {main}"
);
}
assert!(
!main.contains("demo_login"),
"main.rs must not register the test-only demo login: {main}"
);
assert!(
!main.contains("notifications::notify"),
"main.rs must not register the unguarded demo notify route: {main}"
);
assert!(
fs::read_to_string(tmp.path().join("src/notifications.rs"))
.unwrap()
.contains("pub async fn notify("),
"the notify example handler still lives in the module, just unwired"
);
}
#[test]
fn execute_writes_smoke_test_with_testapp_flow() {
let tmp = project_with_main(default_main());
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let test_src = fs::read_to_string(tmp.path().join("tests/notifications_feed.rs")).unwrap();
assert!(test_src.contains("TestApp"), "{test_src}");
assert!(test_src.contains("#[tokio::test]"), "{test_src}");
assert!(test_src.contains("assert_status(401)"), "{test_src}");
assert!(
test_src.contains(r#".post("/notifications/demo_login/7")"#),
"{test_src}"
);
assert!(
test_src.contains(r#".post("/notifications")"#),
"{test_src}"
);
assert!(test_src.contains(r#".get("/notifications")"#), "{test_src}");
assert!(
test_src.contains("/notifications/unread_count"),
"{test_src}"
);
assert!(test_src.contains("/read"), "{test_src}");
assert!(test_src.contains("filter[unread]=true"), "{test_src}");
assert!(
test_src.contains(r#".post("/notifications/demo_login/8")"#),
"{test_src}"
);
assert!(
!test_src.contains("/notifications/7/"),
"feed/mark URLs must not carry a recipient id: {test_src}"
);
assert!(
!test_src.contains("todo!"),
"must not be a stub: {test_src}"
);
assert!(
!test_src.contains("#[ignore"),
"smoke test must actually run (no DB/Docker needed): {test_src}"
);
}
#[test]
fn smoke_test_handlers_match_production_handlers_byte_for_byte() {
let tmp = project_with_main(default_main());
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let production_src = fs::read_to_string(tmp.path().join("src/notifications.rs")).unwrap();
let test_src = fs::read_to_string(tmp.path().join("tests/notifications_feed.rs")).unwrap();
assert!(
production_src.contains("pub struct NotifyBody"),
"{production_src}"
);
assert!(test_src.contains("pub struct NotifyBody"), "{test_src}");
let extract_handler = |src: &str, name: &str| -> String {
let anchor = format!("async fn {name}(");
let start = src
.find(&anchor)
.unwrap_or_else(|| panic!("`{anchor}` not found in:\n{src}"));
let brace_start = src[start..].find('{').unwrap() + start;
let bytes = src.as_bytes();
let mut depth = 0usize;
let mut end = brace_start;
for (offset, &b) in bytes[brace_start..].iter().enumerate() {
match b {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
end = brace_start + offset;
break;
}
}
_ => {}
}
}
src[start..=end].to_owned()
};
for name in [
"current_recipient_id",
"notify",
"feed",
"unread_count",
"mark_read",
"mark_all_read",
] {
assert_eq!(
extract_handler(&production_src, name),
extract_handler(&test_src, name),
"the smoke test's re-declared `{name}` handler must be \
byte-identical to the production handler"
);
}
assert!(
production_src
.contains(r#"const RECIPIENT_SESSION_KEY: &str = "notifications.recipient_id";"#),
"{production_src}"
);
assert!(
test_src
.contains(r#"const RECIPIENT_SESSION_KEY: &str = "notifications.recipient_id";"#),
"{test_src}"
);
}
#[test]
fn execute_adds_serde_serde_json_and_tokio_dev_dependency() {
let tmp = project_with_main(default_main());
plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("serde"), "{cargo}");
assert!(cargo.contains("serde_json"), "{cargo}");
assert!(cargo.contains("[dev-dependencies]"), "{cargo}");
assert!(cargo.contains("tokio"), "{cargo}");
}
#[test]
fn dry_run_writes_no_new_files() {
let tmp = project_with_main(default_main());
let original_main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
let original_cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
plan_notifications(tmp.path())
.unwrap()
.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(!tmp.path().join("src/notifications.rs").exists());
assert!(!tmp.path().join("tests/notifications_feed.rs").exists());
assert!(!tmp.path().join("migrations").exists());
let main_after = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert_eq!(original_main, main_after, "dry run must not modify main.rs");
let cargo_after = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert_eq!(
original_cargo, cargo_after,
"dry run must not modify Cargo.toml"
);
}
#[test]
fn collision_without_force_returns_error() {
let tmp = project_with_main(default_main());
fs::write(tmp.path().join("src/notifications.rs"), "// existing").unwrap();
let err = plan_notifications(tmp.path())
.unwrap()
.execute(Flags::default())
.unwrap_err();
assert!(matches!(err, GenerateError::Collisions(_)));
}
#[test]
fn force_overwrites_existing_module() {
let tmp = project_with_main(default_main());
fs::write(tmp.path().join("src/notifications.rs"), "// old").unwrap();
plan_notifications(tmp.path())
.unwrap()
.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/notifications.rs")).unwrap();
assert!(src.contains("Notifications"), "{src}");
}
#[test]
fn generate_then_destroy_round_trips_to_original_project_state() {
with_no_db_env(|| {
let tmp = project_with_main(default_main());
let cargo_path = tmp.path().join("Cargo.toml");
fs::write(
&cargo_path,
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n\n\
[dev-dependencies]\ntokio = { version = \"1\", features = [\"rt\", \"macros\"] }\n",
)
.unwrap();
let main_path = tmp.path().join("src/main.rs");
let original_cargo = fs::read_to_string(&cargo_path).unwrap();
let original_main = fs::read_to_string(&main_path).unwrap();
let plan = plan_notifications(tmp.path()).unwrap();
plan.execute(Flags::default()).unwrap();
assert!(tmp.path().join("src/notifications.rs").exists());
assert!(
fs::read_to_string(&main_path)
.unwrap()
.contains("notifications::feed")
);
let destroy_plan = plan_notifications(tmp.path()).unwrap();
destroy_plan.revert(Flags::default()).unwrap();
assert!(!tmp.path().join("src/notifications.rs").exists());
assert!(!tmp.path().join("tests/notifications_feed.rs").exists());
assert!(!tmp.path().join("migrations").exists());
assert_eq!(fs::read_to_string(&main_path).unwrap(), original_main);
assert_eq!(fs::read_to_string(&cargo_path).unwrap(), original_cargo);
});
}
}