diff --git a/examples/showcase/e2e/tests/shop/crud.spec.ts b/examples/showcase/e2e/tests/shop/crud.spec.ts new file mode 100644 index 0000000..d83c7c8 --- /dev/null +++ b/examples/showcase/e2e/tests/shop/crud.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test'; + +/** + * Phase 3 — shop CRUD + query-param filtering. Exercises: + * + * - i64 money round-trip (`price_cents`) — until #524 lands, prices + * are integer cents + * - `unique` constraint on `sku` + * - `Option` nullable `stock` — null vs. 0 distinction + * - `default = "true"` rendered through Tera DDL → DB default + * - Query-param `?active=true|false` driving `.filter_op()` + * + * Tests build unique SKUs from `Date.now()` so the shared-server + * suite doesn't trip the UNIQUE constraint on reruns. + */ + +const sku = (prefix: string) => `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +test.describe('shop API', () => { + test('POST creates a product + retrieve by id', async ({ request }) => { + const draft = { + name: 'Widget', + sku: sku('WID'), + price_cents: 1999, + stock: 100, + }; + const createResp = await request.post('/shop/products', { data: draft }); + expect(createResp.status()).toBe(201); + const created = await createResp.json(); + expect(created.id).toBeGreaterThan(0); + expect(created.name).toBe(draft.name); + expect(created.sku).toBe(draft.sku); + expect(created.price_cents).toBe(1999); + expect(created.stock).toBe(100); + expect(created.active).toBe(true); // default + + const fetchResp = await request.get(`/shop/products/${created.id}`); + expect(fetchResp.status()).toBe(200); + expect(await fetchResp.json()).toEqual(created); + }); + + test('null stock is preserved (vs. 0)', async ({ request }) => { + const draft = { + name: 'No-stock-tracking', + sku: sku('NST'), + price_cents: 500, + // stock omitted → null + }; + const resp = await request.post('/shop/products', { data: draft }); + const created = await resp.json(); + expect(created.stock).toBeNull(); + }); + + test('?active=true filter excludes inactive products', async ({ request }) => { + // Seed two: one active, one not. + const a = await request.post('/shop/products', { + data: { name: 'OnSale', sku: sku('ON'), price_cents: 100, active: true }, + }); + const b = await request.post('/shop/products', { + data: { name: 'Discontinued', sku: sku('OFF'), price_cents: 100, active: false }, + }); + const activeId = (await a.json()).id; + const inactiveId = (await b.json()).id; + + const resp = await request.get('/shop/products?active=true'); + const list = await resp.json(); + const ids = list.map((p: { id: number }) => p.id); + expect(ids).toContain(activeId); + expect(ids).not.toContain(inactiveId); + }); + + test('?active=false includes inactive products', async ({ request }) => { + const b = await request.post('/shop/products', { + data: { name: 'Discontinued-2', sku: sku('OFF2'), price_cents: 100, active: false }, + }); + const inactiveId = (await b.json()).id; + + const resp = await request.get('/shop/products?active=false'); + const list = await resp.json(); + const ids = list.map((p: { id: number }) => p.id); + expect(ids).toContain(inactiveId); + }); + + test('duplicate sku rejected by unique constraint', async ({ request }) => { + const dupSku = sku('DUP'); + const first = await request.post('/shop/products', { + data: { name: 'First', sku: dupSku, price_cents: 100 }, + }); + expect(first.status()).toBe(201); + + const second = await request.post('/shop/products', { + data: { name: 'Second', sku: dupSku, price_cents: 200 }, + }); + expect(second.status()).toBe(500); // surfaces as a driver error today + }); + + test('GET unknown product is 404', async ({ request }) => { + const resp = await request.get('/shop/products/99999999'); + expect(resp.status()).toBe(404); + }); +}); diff --git a/examples/showcase/migrations/0003_shop.json b/examples/showcase/migrations/0003_shop.json new file mode 100644 index 0000000..39b9bd6 --- /dev/null +++ b/examples/showcase/migrations/0003_shop.json @@ -0,0 +1,208 @@ +{ + "name": "0003_shop", + "created_at": "2026-05-23T12:18:26.904272+00:00", + "prev": "0002_blog", + "atomic": true, + "snapshot": { + "tables": [ + { + "name": "rustango_admin_users", + "model": "AdminUser", + "fields": [ + { + "name": "active", + "column": "active", + "ty": "bool", + "nullable": false, + "primary_key": false, + "default": "true" + }, + { + "name": "created_at", + "column": "created_at", + "ty": "datetime", + "nullable": false, + "primary_key": false + }, + { + "name": "id", + "column": "id", + "ty": "i64", + "nullable": false, + "primary_key": true, + "auto": true + }, + { + "name": "is_superuser", + "column": "is_superuser", + "ty": "bool", + "nullable": false, + "primary_key": false, + "default": "false" + }, + { + "name": "password_hash", + "column": "password_hash", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 200 + }, + { + "name": "username", + "column": "username", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 150, + "unique": true + } + ] + }, + { + "name": "rustango_content_types", + "model": "ContentType", + "fields": [ + { + "name": "app_label", + "column": "app_label", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 100 + }, + { + "name": "id", + "column": "id", + "ty": "i64", + "nullable": false, + "primary_key": true, + "auto": true + }, + { + "name": "model_name", + "column": "model_name", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 100 + }, + { + "name": "table", + "column": "table", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 100 + } + ] + }, + { + "name": "showcase_blog_post", + "model": "Post", + "fields": [ + { + "name": "body", + "column": "body", + "ty": "string", + "nullable": true, + "primary_key": false + }, + { + "name": "created_at", + "column": "created_at", + "ty": "datetime", + "nullable": false, + "primary_key": false, + "default": "now()", + "auto": true + }, + { + "name": "id", + "column": "id", + "ty": "i64", + "nullable": false, + "primary_key": true, + "auto": true + }, + { + "name": "published", + "column": "published", + "ty": "bool", + "nullable": false, + "primary_key": false, + "default": "false" + }, + { + "name": "title", + "column": "title", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 200 + } + ] + }, + { + "name": "showcase_shop_product", + "model": "Product", + "fields": [ + { + "name": "active", + "column": "active", + "ty": "bool", + "nullable": false, + "primary_key": false, + "default": "true" + }, + { + "name": "id", + "column": "id", + "ty": "i64", + "nullable": false, + "primary_key": true, + "auto": true + }, + { + "name": "name", + "column": "name", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 120 + }, + { + "name": "price_cents", + "column": "price_cents", + "ty": "i64", + "nullable": false, + "primary_key": false + }, + { + "name": "sku", + "column": "sku", + "ty": "string", + "nullable": false, + "primary_key": false, + "max_length": 32, + "unique": true + }, + { + "name": "stock", + "column": "stock", + "ty": "i64", + "nullable": true, + "primary_key": false + } + ] + } + ] + }, + "forward": [ + { + "schema": { + "CreateTable": "showcase_shop_product" + } + } + ] +} \ No newline at end of file diff --git a/examples/showcase/src/apps/mod.rs b/examples/showcase/src/apps/mod.rs index 4b8bb1f..fc39724 100644 --- a/examples/showcase/src/apps/mod.rs +++ b/examples/showcase/src/apps/mod.rs @@ -3,6 +3,7 @@ //! `e2e/tests//` folder. pub mod blog; +pub mod shop; use axum::response::Json; use axum::routing::get; @@ -16,6 +17,7 @@ pub fn api() -> Router { Router::new() .route("/__showcase__/info", get(info)) .merge(blog::urls::api()) + .merge(shop::urls::api()) } /// Smoke endpoint — used by the E2E playwright suite's readiness @@ -34,6 +36,6 @@ async fn info() -> Json { "framework": "rustango", "version": env!("CARGO_PKG_VERSION"), "backend": backend, - "apps": ["blog"], + "apps": ["blog", "shop"], })) } diff --git a/examples/showcase/src/apps/shop/mod.rs b/examples/showcase/src/apps/shop/mod.rs new file mode 100644 index 0000000..a32fe27 --- /dev/null +++ b/examples/showcase/src/apps/shop/mod.rs @@ -0,0 +1,6 @@ +//! `shop` sub-app — exercises `FieldType::Decimal` (money fields) +//! round-tripping through PG NUMERIC / MySQL DECIMAL / SQLite TEXT, +//! plus per-query filtering. Phase 3 of the E2E plan. + +pub mod models; +pub mod urls; diff --git a/examples/showcase/src/apps/shop/models.rs b/examples/showcase/src/apps/shop/models.rs new file mode 100644 index 0000000..34388ce --- /dev/null +++ b/examples/showcase/src/apps/shop/models.rs @@ -0,0 +1,36 @@ +//! Shop domain. Until #524 lands (macro `Decimal` support), prices +//! are stored as integer cents — common e-commerce pattern, avoids +//! float drift. + +use rustango::sql::Auto; +use rustango::Model; + +/// One product. `price_cents` exercises `i64` round-trip; `sku` +/// exercises `unique`; `stock` exercises `Option`; `active` +/// exercises bool + `default = "true"`. +#[derive(Model, Debug, Clone)] +#[rustango(table = "showcase_shop_product", display = "name")] +pub struct Product { + #[rustango(primary_key)] + pub id: Auto, + + #[rustango(max_length = 120)] + pub name: String, + + /// SKU — useful as a filter target. Unique so a typo doesn't + /// silently shadow an existing row. + #[rustango(max_length = 32, unique)] + pub sku: String, + + /// Price in cents (integer to avoid float drift). Track #524 for + /// macro `Decimal` support that would let this be `Decimal`. + pub price_cents: i64, + + /// Stock quantity. `Option` → nullable column so backorder + /// shapes ("not tracked") differ from "0 in stock". + pub stock: Option, + + /// Active flag — drives the `/shop/products?active=true` filter. + #[rustango(default = "true")] + pub active: bool, +} diff --git a/examples/showcase/src/apps/shop/urls.rs b/examples/showcase/src/apps/shop/urls.rs new file mode 100644 index 0000000..fdb47cc --- /dev/null +++ b/examples/showcase/src/apps/shop/urls.rs @@ -0,0 +1,162 @@ +//! Shop HTTP routes. Showcases: +//! +//! - `i64` round-trip for money fields (prices in cents — track #524 +//! for the eventual macro `Decimal` support that would replace this) +//! - Query-param filtering: `/shop/products?active=true` +//! - `Option` nullable round-trip +//! - Re-fetch-after-insert (MySQL parity, same as the blog app) + +use axum::extract::{Extension, Path, Query}; +use axum::http::StatusCode; +use axum::response::Json; +use axum::routing::get; +use axum::Router; +use rustango::core::Op; +use rustango::sql::{Auto, FetcherPool as _, Pool}; + +use super::models::Product; + +#[cfg(feature = "postgres")] +type AttachedPool = sqlx::PgPool; +#[cfg(not(feature = "postgres"))] +type AttachedPool = Pool; + +fn into_pool(p: &AttachedPool) -> Pool { + #[cfg(feature = "postgres")] + { + Pool::from(p.clone()) + } + #[cfg(not(feature = "postgres"))] + { + p.clone() + } +} + +#[must_use] +pub fn api() -> Router { + Router::new() + .route( + "/shop/products", + get(list_products).post(create_product), + ) + .route("/shop/products/{id}", get(retrieve_product)) +} + +#[derive(serde::Serialize)] +struct ProductOut { + id: i64, + name: String, + sku: String, + price_cents: i64, + stock: Option, + active: bool, +} + +impl From for ProductOut { + fn from(p: Product) -> Self { + Self { + id: match p.id { + Auto::Set(n) => n, + Auto::Unset => 0, + }, + name: p.name, + sku: p.sku, + price_cents: p.price_cents, + stock: p.stock, + active: p.active, + } + } +} + +#[derive(serde::Deserialize)] +struct ProductIn { + name: String, + sku: String, + price_cents: i64, + #[serde(default)] + stock: Option, + #[serde(default = "default_true")] + active: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(serde::Deserialize)] +struct ListFilters { + /// `?active=true` / `?active=false`. Omit to list everything. + active: Option, +} + +async fn list_products( + Extension(pool): Extension, + Query(filters): Query, +) -> Result>, (StatusCode, String)> { + let pool = into_pool(&pool); + let mut qs = Product::objects().order_by(&[("id", false)]); + if let Some(want_active) = filters.active { + qs = qs.filter_op("active", Op::Eq, want_active); + } + let rows: Vec = qs + .fetch_pool(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json(rows.into_iter().map(ProductOut::from).collect())) +} + +async fn retrieve_product( + Extension(pool): Extension, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let pool = into_pool(&pool); + let mut rows: Vec = Product::objects() + .filter_op("id", Op::Eq, id) + .fetch_pool(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + if let Some(p) = rows.pop() { + Ok(Json(ProductOut::from(p))) + } else { + Err((StatusCode::NOT_FOUND, format!("product {id} not found"))) + } +} + +async fn create_product( + Extension(pool): Extension, + Json(input): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let pool = into_pool(&pool); + let mut p = Product { + id: Auto::Unset, + name: input.name, + sku: input.sku, + price_cents: input.price_cents, + stock: input.stock, + active: input.active, + }; + p.insert_pool(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Re-fetch by PK — same MySQL parity reason as blog. + let id = match p.id { + Auto::Set(n) => n, + Auto::Unset => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "insert_pool didn't populate PK".into(), + )); + } + }; + let mut rows: Vec = Product::objects() + .filter_op("id", Op::Eq, id) + .fetch_pool(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let stored = rows.pop().ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "could not re-fetch inserted row".into(), + ))?; + Ok((StatusCode::CREATED, Json(ProductOut::from(stored)))) +}