diff --git a/kipina-codebench/golden-examples/todo-rs/Cargo.toml b/kipina-codebench/golden-examples/todo-rs/Cargo.toml new file mode 100644 index 0000000..619b916 --- /dev/null +++ b/kipina-codebench/golden-examples/todo-rs/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "todo-rs" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] } +tower-http = { version = "0.6", features = ["cors"] } + +[dev-dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tokio = { version = "1", features = ["full", "test-util"] } diff --git a/kipina-codebench/golden-examples/todo-rs/src/handlers.rs b/kipina-codebench/golden-examples/todo-rs/src/handlers.rs new file mode 100644 index 0000000..a2f50d8 --- /dev/null +++ b/kipina-codebench/golden-examples/todo-rs/src/handlers.rs @@ -0,0 +1,122 @@ +//! Käsittelijät — CRUD-operaatiot todo-entiteetille. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use sqlx::SqlitePool; + +use crate::models::{CreateTodo, Todo, UpdateTodo}; + +/// Luo uusi tehtävä. +pub async fn create_todo( + State(pool): State, + Json(input): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let priority = input.priority.unwrap_or(1); + let status = input.status.unwrap_or_else(|| "pending".to_string()); + + let result = sqlx::query_as::<_, Todo>( + "INSERT INTO todos (title, description, due_date, priority, status) + VALUES (?, ?, ?, ?, ?) + RETURNING id, title, description, due_date, priority, status", + ) + .bind(&input.title) + .bind(&input.description) + .bind(&input.due_date) + .bind(priority) + .bind(&status) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok((StatusCode::CREATED, Json(result))) +} + +/// Listaa kaikki tehtävät. +pub async fn list_todos( + State(pool): State, +) -> Result>, StatusCode> { + let todos = sqlx::query_as::<_, Todo>("SELECT id, title, description, due_date, priority, status FROM todos") + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(todos)) +} + +/// Hae tehtävä id:llä. +pub async fn get_todo( + State(pool): State, + Path(id): Path, +) -> Result, StatusCode> { + let todo = sqlx::query_as::<_, Todo>( + "SELECT id, title, description, due_date, priority, status FROM todos WHERE id = ?", + ) + .bind(id) + .fetch_optional(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match todo { + Some(t) => Ok(Json(t)), + None => Err(StatusCode::NOT_FOUND), + } +} + +/// Päivitä tehtävä id:llä. +pub async fn update_todo( + State(pool): State, + Path(id): Path, + Json(input): Json, +) -> Result, StatusCode> { + let existing = sqlx::query_as::<_, Todo>( + "SELECT id, title, description, due_date, priority, status FROM todos WHERE id = ?", + ) + .bind(id) + .fetch_optional(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let existing = existing.ok_or(StatusCode::NOT_FOUND)?; + + let title = input.title.unwrap_or(existing.title); + let description = input.description.or(existing.description); + let due_date = input.due_date.or(existing.due_date); + let priority = input.priority.unwrap_or(existing.priority); + let status = input.status.unwrap_or(existing.status); + + let updated = sqlx::query_as::<_, Todo>( + "UPDATE todos SET title = ?, description = ?, due_date = ?, priority = ?, status = ? + WHERE id = ? + RETURNING id, title, description, due_date, priority, status", + ) + .bind(&title) + .bind(&description) + .bind(&due_date) + .bind(priority) + .bind(&status) + .bind(id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(updated)) +} + +/// Poista tehtävä id:llä. +pub async fn delete_todo( + State(pool): State, + Path(id): Path, +) -> Result { + let result = sqlx::query("DELETE FROM todos WHERE id = ?") + .bind(id) + .execute(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if result.rows_affected() == 0 { + return Err(StatusCode::NOT_FOUND); + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/kipina-codebench/golden-examples/todo-rs/src/lib.rs b/kipina-codebench/golden-examples/todo-rs/src/lib.rs new file mode 100644 index 0000000..8f80f47 --- /dev/null +++ b/kipina-codebench/golden-examples/todo-rs/src/lib.rs @@ -0,0 +1,38 @@ +//! Kirjastomoduuli — julkinen API integraatiotesteille. + +pub mod handlers; +pub mod models; + +use axum::routing::{delete, get, post, put}; +use axum::Router; +use sqlx::SqlitePool; +use tower_http::cors::CorsLayer; + +/// Luo reititin annetulla tietokantapoolilla. +pub fn app(pool: SqlitePool) -> Router { + Router::new() + .route("/todos", post(handlers::create_todo)) + .route("/todos", get(handlers::list_todos)) + .route("/todos/{id}", get(handlers::get_todo)) + .route("/todos/{id}", put(handlers::update_todo)) + .route("/todos/{id}", delete(handlers::delete_todo)) + .layer(CorsLayer::permissive()) + .with_state(pool) +} + +/// Alusta tietokantataulu. +pub async fn init_db(pool: &SqlitePool) { + sqlx::query( + "CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + due_date TEXT, + priority INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'pending' + )", + ) + .execute(pool) + .await + .expect("Taulun luonti epäonnistui"); +} diff --git a/kipina-codebench/golden-examples/todo-rs/src/main.rs b/kipina-codebench/golden-examples/todo-rs/src/main.rs new file mode 100644 index 0000000..f35cdc1 --- /dev/null +++ b/kipina-codebench/golden-examples/todo-rs/src/main.rs @@ -0,0 +1,22 @@ +//! Axum CRUD — yksi endpoint-setti per entiteetti, SQLite-tietokanta. + +use sqlx::sqlite::SqlitePoolOptions; +use todo_rs::{app, init_db}; + +#[tokio::main] +async fn main() { + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect("sqlite:./app.db?mode=rwc") + .await + .expect("Tietokantayhteys epäonnistui"); + + init_db(&pool).await; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") + .await + .expect("Portin kuuntelu epäonnistui"); + + println!("Palvelin käynnissä: http://127.0.0.1:3000"); + axum::serve(listener, app(pool)).await.unwrap(); +} diff --git a/kipina-codebench/golden-examples/todo-rs/src/models.rs b/kipina-codebench/golden-examples/todo-rs/src/models.rs new file mode 100644 index 0000000..9b4e5fd --- /dev/null +++ b/kipina-codebench/golden-examples/todo-rs/src/models.rs @@ -0,0 +1,34 @@ +//! Tietomallit — Todo, CreateTodo, UpdateTodo serde-rakenteina. + +use serde::{Deserialize, Serialize}; + +/// Tehtävä — otsikko, kuvaus, deadline, prioriteetti ja status. +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct Todo { + pub id: i64, + pub title: String, + pub description: Option, + pub due_date: Option, + pub priority: i64, + pub status: String, +} + +/// Uuden tehtävän luonti. Pakolliset: title. +#[derive(Debug, Deserialize)] +pub struct CreateTodo { + pub title: String, + pub description: Option, + pub due_date: Option, + pub priority: Option, + pub status: Option, +} + +/// Tehtävän päivitys — kaikki kentät valinnaisia. +#[derive(Debug, Deserialize)] +pub struct UpdateTodo { + pub title: Option, + pub description: Option, + pub due_date: Option, + pub priority: Option, + pub status: Option, +} diff --git a/kipina-codebench/golden-examples/todo-rs/tests/api_test.rs b/kipina-codebench/golden-examples/todo-rs/tests/api_test.rs new file mode 100644 index 0000000..9e96339 --- /dev/null +++ b/kipina-codebench/golden-examples/todo-rs/tests/api_test.rs @@ -0,0 +1,262 @@ +//! Integraatiotestit — muistinvarainen SQLite, uniikki data per testi. + +use axum::http::StatusCode; +use reqwest::Client; +use sqlx::sqlite::SqlitePoolOptions; +use todo_rs::{app, init_db}; + +/// Käynnistä testipalvelin satunnaisessa portissa. +async fn spawn_server() -> (Client, String) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("Testitietokanta epäonnistui"); + + init_db(&pool).await; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("Testiportin kuuntelu epäonnistui"); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{addr}"); + + let router = app(pool); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + (Client::new(), base_url) +} + +#[tokio::test] +async fn test_create_todo() { + let (client, url) = spawn_server().await; + + let res = client + .post(format!("{url}/todos")) + .json(&serde_json::json!({"title": "Osta maitoa", "priority": 2})) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::CREATED); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(body["title"], "Osta maitoa"); + assert_eq!(body["priority"], 2); + assert!(body["id"].is_number()); +} + +#[tokio::test] +async fn test_create_todo_defaults() { + let (client, url) = spawn_server().await; + + let res = client + .post(format!("{url}/todos")) + .json(&serde_json::json!({"title": "Oletusarvotesti"})) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::CREATED); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(body["priority"], 1); + assert_eq!(body["status"], "pending"); + assert!(body["description"].is_null()); +} + +#[tokio::test] +async fn test_list_todos() { + let (client, url) = spawn_server().await; + + client + .post(format!("{url}/todos")) + .json(&serde_json::json!({"title": "Listattava tehtävä"})) + .send() + .await + .unwrap(); + + let res = client.get(format!("{url}/todos")).send().await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + let body: Vec = res.json().await.unwrap(); + assert!(body.len() >= 1); +} + +#[tokio::test] +async fn test_get_todo_by_id() { + let (client, url) = spawn_server().await; + + let created: serde_json::Value = client + .post(format!("{url}/todos")) + .json(&serde_json::json!({"title": "Haettava tehtävä"})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + let id = created["id"].as_i64().unwrap(); + let res = client + .get(format!("{url}/todos/{id}")) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(body["id"], id); + assert_eq!(body["title"], "Haettava tehtävä"); +} + +#[tokio::test] +async fn test_get_todo_not_found() { + let (client, url) = spawn_server().await; + + let res = client + .get(format!("{url}/todos/99999")) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_update_todo() { + let (client, url) = spawn_server().await; + + let created: serde_json::Value = client + .post(format!("{url}/todos")) + .json(&serde_json::json!({"title": "Vanha otsikko"})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + let id = created["id"].as_i64().unwrap(); + let res = client + .put(format!("{url}/todos/{id}")) + .json(&serde_json::json!({"title": "Uusi otsikko"})) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(body["title"], "Uusi otsikko"); +} + +#[tokio::test] +async fn test_update_todo_not_found() { + let (client, url) = spawn_server().await; + + let res = client + .put(format!("{url}/todos/99999")) + .json(&serde_json::json!({"title": "Ei löydy"})) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_delete_todo() { + let (client, url) = spawn_server().await; + + let created: serde_json::Value = client + .post(format!("{url}/todos")) + .json(&serde_json::json!({"title": "Poistettava"})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + let id = created["id"].as_i64().unwrap(); + let res = client + .delete(format!("{url}/todos/{id}")) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::NO_CONTENT); + + let res = client + .get(format!("{url}/todos/{id}")) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_delete_todo_not_found() { + let (client, url) = spawn_server().await; + + let res = client + .delete(format!("{url}/todos/99999")) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_full_lifecycle() { + let (client, url) = spawn_server().await; + + // Luo + let created: serde_json::Value = client + .post(format!("{url}/todos")) + .json(&serde_json::json!({ + "title": "Elinkaaritesti", + "description": "Testataan koko CRUD-kierto", + "due_date": "2026-12-31", + "priority": 3, + "status": "in_progress" + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + let id = created["id"].as_i64().unwrap(); + assert_eq!(created["title"], "Elinkaaritesti"); + assert_eq!(created["description"], "Testataan koko CRUD-kierto"); + assert_eq!(created["due_date"], "2026-12-31"); + assert_eq!(created["priority"], 3); + assert_eq!(created["status"], "in_progress"); + + // Päivitä + let updated: serde_json::Value = client + .put(format!("{url}/todos/{id}")) + .json(&serde_json::json!({"status": "done"})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert_eq!(updated["status"], "done"); + assert_eq!(updated["title"], "Elinkaaritesti"); + + // Poista + let res = client + .delete(format!("{url}/todos/{id}")) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::NO_CONTENT); +}