Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions examples/showcase/e2e/tests/shop/crud.spec.ts
Original file line number Diff line number Diff line change
@@ -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<i64>` 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);
});
});
208 changes: 208 additions & 0 deletions examples/showcase/migrations/0003_shop.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
4 changes: 3 additions & 1 deletion examples/showcase/src/apps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! `e2e/tests/<app>/` folder.

pub mod blog;
pub mod shop;

use axum::response::Json;
use axum::routing::get;
Expand All @@ -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
Expand All @@ -34,6 +36,6 @@ async fn info() -> Json<serde_json::Value> {
"framework": "rustango",
"version": env!("CARGO_PKG_VERSION"),
"backend": backend,
"apps": ["blog"],
"apps": ["blog", "shop"],
}))
}
6 changes: 6 additions & 0 deletions examples/showcase/src/apps/shop/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
36 changes: 36 additions & 0 deletions examples/showcase/src/apps/shop/models.rs
Original file line number Diff line number Diff line change
@@ -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<i64>`; `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<i64>,

#[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<i64>` → nullable column so backorder
/// shapes ("not tracked") differ from "0 in stock".
pub stock: Option<i64>,

/// Active flag — drives the `/shop/products?active=true` filter.
#[rustango(default = "true")]
pub active: bool,
}
Loading
Loading