WP-00: project skeleton with three-level test harness
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Cargo workspace (domain, application, infrastructure, api), axum health endpoint
with SPA fallback, Vue 3 + Tailwind frontend, Vitest, Playwright, Makefile,
Gitea Actions CI and README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 21:28:21 +02:00
parent 741c5e35ad
commit 780c84098b
44 changed files with 8419 additions and 14 deletions

View File

@ -0,0 +1,35 @@
//! Runtime configuration read from environment variables (see `.env.example`).
use std::net::SocketAddr;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub bind: SocketAddr,
pub bootstrap_admin: Option<(String, String)>,
pub cookie_secure: bool,
pub frontend_dir: String,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let jwt_secret =
env("JWT_SECRET").ok_or_else(|| anyhow::anyhow!("JWT_SECRET is required"))?;
anyhow::ensure!(
jwt_secret.len() >= 32,
"JWT_SECRET must be at least 32 characters"
);
Ok(Self {
database_url: env("DATABASE_URL")
.unwrap_or_else(|| "sqlite://data/monitoring.db?mode=rwc".into()),
jwt_secret,
bind: env("BIND")
.unwrap_or_else(|| "127.0.0.1:8080".into())
.parse()?,
bootstrap_admin: env("BOOTSTRAP_ADMIN_EMAIL").zip(env("BOOTSTRAP_ADMIN_PASSWORD")),
cookie_secure: env("COOKIE_SECURE").is_some_and(|v| v == "true" || v == "1"),
frontend_dir: env("FRONTEND_DIR").unwrap_or_else(|| "../frontend/dist".into()),
})
}
}

View File

@ -0,0 +1,28 @@
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
pub mod config;
pub mod test_support;
use axum::{routing::get, Json, Router};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
pub use config::Config;
#[derive(Clone)]
pub struct AppState {
pub cfg: Config,
}
pub fn build_app(state: AppState) -> Router {
let index = format!("{}/index.html", state.cfg.frontend_dir);
let spa = ServeDir::new(&state.cfg.frontend_dir).not_found_service(ServeFile::new(index));
Router::new()
.route("/healthz", get(healthz))
.fallback_service(spa)
.layer(TraceLayer::new_for_http())
.with_state(state)
}
async fn healthz() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}

View File

@ -0,0 +1,15 @@
use api::{build_app, AppState, Config};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
let cfg = Config::from_env()?;
let listener = tokio::net::TcpListener::bind(cfg.bind).await?;
tracing::info!("listening on http://{}", cfg.bind);
axum::serve(listener, build_app(AppState { cfg })).await?;
Ok(())
}

View File

@ -0,0 +1,18 @@
//! Helpers to build a fully wired app on an in-memory database for integration tests.
use crate::{build_app, AppState, Config};
use axum::Router;
pub fn test_config() -> Config {
Config {
database_url: "sqlite::memory:".into(),
jwt_secret: "test-secret-test-secret-test-secret-1234".into(),
bind: "127.0.0.1:0".parse().unwrap(),
bootstrap_admin: None,
cookie_secure: false,
frontend_dir: "/nonexistent".into(),
}
}
pub async fn build_test_app() -> Router {
build_app(AppState { cfg: test_config() })
}