From 64c871b77e0d3e57afe9cec42e22b50e202be32d Mon Sep 17 00:00:00 2001 From: Pavel Hegler Date: Tue, 1 Sep 2026 19:22:04 +0200 Subject: [PATCH] fix: a declared id:integer is server-assigned, not required client data (#302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ai/00-designing.md promises `omit id` and `declare id:integer` behave identically — both a server-assigned auto-increment pk, out of the create body. Codegen violated its own doc: a declared integer id stayed required in the Model/{Entity}Request, the insert did `id: Set(item.id)`, OpenAPI advertised it as client-writable, and testgen posted it — so `POST` without id got 422. Route the server-assigned decision (synthetic OR declared integer) through one discriminator, `Entity::id_is_server_assigned`, so every surface agrees: - genroute: Model + {Entity}Request drop id (`#[serde(default)]`); insert leaves it `NotSet` (active_sets AND active_sets_pinning) - openapi: a declared integer id is `readOnly` on the component and excluded from the create request schema - testgen: the create probe omits id; the id-echo asserts a server-assigned pk instead of a client value; removed the now-unnecessary tenancy pk-override A declared string/uuid id stays client-supplied (byte-identical, verified). Also fix two stale testgen comments: a same-module FK violation is 422 JC0422 (not 500 JC0510) since #296. --- crates/jerrycan/src/platform/design.rs | 20 ++++ crates/jerrycan/src/platform/genroute.rs | 100 ++++++++++++++++--- crates/jerrycan/src/platform/openapi.rs | 58 +++++++++-- crates/jerrycan/src/platform/testgen.rs | 119 ++++++++--------------- crates/jerrycan/tests/testgen.rs | 51 +++++----- 5 files changed, 221 insertions(+), 127 deletions(-) diff --git a/crates/jerrycan/src/platform/design.rs b/crates/jerrycan/src/platform/design.rs index 103f879..3a1c829 100644 --- a/crates/jerrycan/src/platform/design.rs +++ b/crates/jerrycan/src/platform/design.rs @@ -224,6 +224,26 @@ pub struct Entity { pub fields: Vec, } +impl Entity { + /// Whether this entity's `id` primary key is SERVER-ASSIGNED (issue #302). True + /// when `id` is OMITTED (a synthetic auto-increment pk is added) OR declared as + /// `integer` — in both the DB assigns the pk (`BIGSERIAL`/autoincrement), so it is + /// dropped from the create body (`#[serde(default)]` in the Model/`{Entity}Request`, + /// excluded from the OpenAPI create schema, `readOnly` on the response component), + /// inserted as `ActiveValue::NotSet`, and NOT posted by the generated create probe. + /// False for a declared `string`/`uuid` id — that is CLIENT-SUPPLIED (the client + /// sends it, the handler `Set`s it, the probe posts it). Contract: + /// docs/ai/00-designing.md:155-165 promises `omit id` and `declare id:integer` + /// behave identically; this is the single discriminator all four surfaces route + /// through (mirrors the DDL's `Some(t) if t != FieldType::Integer` pk branch). + pub fn id_is_server_assigned(&self) -> bool { + match self.fields.iter().find(|f| f.name == "id") { + None => true, + Some(f) => f.field_type == FieldType::Integer, + } + } +} + #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/jerrycan/src/platform/genroute.rs b/crates/jerrycan/src/platform/genroute.rs index f65d0ab..93e1462 100644 --- a/crates/jerrycan/src/platform/genroute.rs +++ b/crates/jerrycan/src/platform/genroute.rs @@ -1420,12 +1420,13 @@ pub(crate) fn model_rs_db(m: &ModuleDesign, design: &Design, auth: bool) -> Opti let snake = Design::to_snake(&e.name); let table = design.table_name(&e.name); let key = key_rust_type(e); - // The synthetic pk surfaces as a visible `id` field so POST bodies may - // omit it (`#[serde(default)]`); a declared id has no default. - let id_default = if declared_id(e).is_some() { - "" - } else { + // A server-assigned pk (synthetic OR a declared `integer`, issue #302) + // surfaces as a visible `id` field POST bodies MAY omit (`#[serde(default)]`) + // — the DB assigns it; a client-supplied `string`/`uuid` pk has no default. + let id_default = if e.id_is_server_assigned() { " #[serde(default)]\n" + } else { + "" }; let mut fields = String::new(); @@ -1587,10 +1588,13 @@ fn request_dto_rs(e: &Entity, design: &Design, for_update: bool) -> String { format!("{entity}Request") }; let key = key_rust_type(e); - let id_default = if declared_id(e).is_some() { - "" - } else { + // A server-assigned pk (synthetic OR a declared `integer`, issue #302) is dropped + // from the create body — `#[serde(default)]` so a client MAY omit it and the DB + // assigns it; a client-supplied `string`/`uuid` pk stays required (no default). + let id_default = if e.id_is_server_assigned() { " #[serde(default)]\n" + } else { + "" }; // Identity fk is dropped only under auth (#34 injects the session user's id); // a path-redundant parent fk (#53b) is dropped regardless of auth. @@ -1831,8 +1835,9 @@ fn model_field_names(e: &Entity) -> Vec { } /// ActiveModel field assignments, one per line. `item` is consumed, so values -/// move in (no clones). A synthetic pk (no declared `id`) is `NotSet` so the -/// DB assigns the autoincrement id; a declared pk is `Set` from the item. +/// move in (no clones). A server-assigned pk (synthetic OR a declared `integer`, +/// issue #302) is `NotSet` so the DB assigns the autoincrement id; a client-supplied +/// `string`/`uuid` pk is `Set` from the item. /// `with_id == false` omits the id line (the update path sets it explicitly). fn active_sets(e: &Entity, with_id: bool) -> String { let indent = " "; @@ -1842,10 +1847,10 @@ fn active_sets(e: &Entity, with_id: bool) -> String { if !with_id { continue; } - if declared_id(e).is_some() { - out.push_str(&format!("{indent}id: Set(item.id),\n")); - } else { + if e.id_is_server_assigned() { out.push_str(&format!("{indent}id: sea_orm::ActiveValue::NotSet,\n")); + } else { + out.push_str(&format!("{indent}id: Set(item.id),\n")); } } else { // A keyword field is a raw identifier on both the ActiveModel field @@ -1890,10 +1895,10 @@ fn active_sets_pinning(e: &Entity, with_id: bool, pin_col: &str, pin_expr: &str) if !with_id { continue; } - if declared_id(e).is_some() { - out.push_str(&format!("{indent}id: Set(item.id),\n")); - } else { + if e.id_is_server_assigned() { out.push_str(&format!("{indent}id: sea_orm::ActiveValue::NotSet,\n")); + } else { + out.push_str(&format!("{indent}id: Set(item.id),\n")); } } else { let ident = rust_ident(&name); @@ -8736,6 +8741,69 @@ pub(crate) mod tests { assert_eq!(pg.matches("\"id\"").count(), 1, "{pg}"); } + /// #302: `omit id` and `declare id:integer` must behave IDENTICALLY — both are a + /// SERVER-assigned pk (the DB autoincrements it). So a declared integer id is + /// dropped from the create body (`#[serde(default)]` on the Model AND the + /// `{Entity}Request`, so a client MAY omit it) and the insert leaves it `NotSet`. + /// A declared `string`/`uuid` id is CLIENT-supplied: required in the body, `Set` + /// from the item. Guards the four-surface agreement at its genroute source. + #[test] + fn create_body_drops_a_server_assigned_id_keeps_a_client_supplied_one() { + let id_field = |ty: FieldType| Field { + name: "id".into(), + field_type: ty, + required: true, + unique: false, + index: false, + values: None, + default: None, + min: None, + max: None, + min_len: None, + max_len: None, + write_only: false, + reserve_against: None, + }; + + // Declared INTEGER id → server-assigned, identical to a synthetic pk. + let mut mi = todos(); + mi.entities[0] + .fields + .insert(0, id_field(FieldType::Integer)); + let ei = &mi.entities[0]; + let dto_i = request_dto_rs(ei, &demo(), false); + assert!( + dto_i.contains("#[serde(default)]\n pub id: i64,"), + "integer id is droppable from the {{Entity}}Request body: {dto_i}" + ); + let model_i = model_rs_db(&mi, &demo(), false).unwrap(); + assert!( + model_i.contains("#[serde(default)]\n pub id: i64,"), + "the Model (plain create body) also drops the integer id: {model_i}" + ); + assert!( + active_sets(ei, true).contains("id: sea_orm::ActiveValue::NotSet,"), + "the insert leaves a server-assigned id NotSet: {}", + active_sets(ei, true) + ); + + // Declared UUID id → client-supplied, unchanged (required, Set from the item). + let mut mu = todos(); + mu.entities[0].fields.insert(0, id_field(FieldType::Uuid)); + let eu = &mu.entities[0]; + let dto_u = request_dto_rs(eu, &demo(), false); + assert!( + dto_u.contains(" pub id: String,") + && !dto_u.contains("#[serde(default)]\n pub id:"), + "a uuid id stays required client input (no serde default): {dto_u}" + ); + assert!( + active_sets(eu, true).contains("id: Set(item.id),"), + "the insert Sets a client-supplied id: {}", + active_sets(eu, true) + ); + } + /// Text ids (uuid/string) are the pk with their declared type, and the /// whole generated surface keys on String — repo signatures, the insert /// return (sqlite has no last_insert_id for text pks), and Path extractors. diff --git a/crates/jerrycan/src/platform/openapi.rs b/crates/jerrycan/src/platform/openapi.rs index 1dbc4d4..34f7087 100644 --- a/crates/jerrycan/src/platform/openapi.rs +++ b/crates/jerrycan/src/platform/openapi.rs @@ -265,6 +265,19 @@ fn walk_schemas(design: &Design, m: &ModuleDesign, schemas: &mut serde_json::Map required.push(Value::String("id".into())); } for f in &e.fields { + // #302: a declared `integer` id is SERVER-assigned (DB autoincrement, `NotSet` + // on insert) — mark it `readOnly` + required so the component (which doubles as + // the plain CREATE body when no `{Entity}Request` is minted) documents it as + // response-only, never client-sent, byte-identical to the synthetic pk above. A + // declared `string`/`uuid` id is client-supplied and rides the normal loop. + if f.name == "id" && e.id_is_server_assigned() { + properties.insert( + "id".into(), + json!({ "type": "integer", "format": "int64", "readOnly": true }), + ); + required.push(Value::String("id".into())); + continue; + } // #274: in db mode an optional field is `Option` — it serializes `None` // as an explicit `null` and accepts `null` — so its schema must admit null. // Memory-mode optional fields are bare `T` with `#[serde(default)]` (a @@ -414,11 +427,14 @@ fn request_schema(design: &Design, e: &Entity, for_update: bool) -> Value { // `now`-default timestamp (#110) is server-owned AND set-once, so it is dropped // from BOTH request schemas (immutable on update). The extra clause is inert for // every non-`now` field — designs without the sentinel stay byte-identical. - for f in e - .fields - .iter() - .filter(|f| (for_update || f.default.is_none()) && !Design::field_is_now_default(f)) - { + for f in e.fields.iter().filter(|f| { + // #302: a server-assigned (synthetic/declared-`integer`) id is out of the create + // body — excluded here exactly as `request_dto_rs` drops it. A client-supplied + // `string`/`uuid` id stays (required client input). + !(f.name == "id" && e.id_is_server_assigned()) + && (for_update || f.default.is_none()) + && !Design::field_is_now_default(f) + }) { // #274: the db request DTO types an optional field as `Option` (it accepts // `null`), so its schema must admit null. `request_schema` is only built in db // mode (gated at the call sites in `walk_schemas`), so this needs no db-check. @@ -867,6 +883,9 @@ mod tests { { "name": "Widget", "fields": [{ "name": "label", "type": "string" }] }, { "name": "Gadget", "fields": [ { "name": "id", "type": "integer" }, + { "name": "label", "type": "string" } ]}, + { "name": "Token", "fields": [ + { "name": "id", "type": "string" }, { "name": "label", "type": "string" } ]} ], "endpoints": [ { "operation_id": "get_widget", "method": "GET", "path": "/{id}", @@ -888,13 +907,16 @@ mod tests { .any(|v| v == "id"), "synthetic id is required: {widget}" ); - // Declared-id entity: `id` present exactly once (from its field, not doubled), - // and NOT readOnly — a declared id is client-supplied. + // #302: a declared INTEGER id is SERVER-assigned — identical to the synthetic + // pk above: integer, `readOnly`, required (present in the response, never sent + // on the plain-body create). `id` present exactly once (from its field, not + // doubled with a synthetic pk). let gadget = &d["components"]["schemas"]["Gadget"]; assert_eq!(gadget["properties"]["id"]["type"], "integer"); - assert!( - gadget["properties"]["id"].get("readOnly").is_none(), - "a declared id is client-supplied, never readOnly: {gadget}" + assert_eq!(gadget["properties"]["id"]["format"], "int64"); + assert_eq!( + gadget["properties"]["id"]["readOnly"], true, + "a declared integer id is server-assigned, readOnly like the synthetic pk: {gadget}" ); assert_eq!( gadget["required"] @@ -906,6 +928,22 @@ mod tests { 1, "declared id is not duplicated: {gadget}" ); + // #302: a declared STRING/uuid id is CLIENT-supplied — present, required, and + // NOT readOnly (the client sends the pk on create). + let token = &d["components"]["schemas"]["Token"]; + assert_eq!(token["properties"]["id"]["type"], "string"); + assert!( + token["properties"]["id"].get("readOnly").is_none(), + "a declared string id is client-supplied, never readOnly: {token}" + ); + assert!( + token["required"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "id"), + "client-supplied id is required: {token}" + ); // Memory mode: no synthetic id → the component stays fields-only. const MEM: &str = r#"{ "name": "memsynth", "contract_version": 1, "dependencies": [], diff --git a/crates/jerrycan/src/platform/testgen.rs b/crates/jerrycan/src/platform/testgen.rs index 34bbdfc..7012c2e 100644 --- a/crates/jerrycan/src/platform/testgen.rs +++ b/crates/jerrycan/src/platform/testgen.rs @@ -172,7 +172,7 @@ fn constrained_seed_string_n(f: &Field, n: u32) -> String { /// ONLY to decide quoting — coincidentally correct for an integer pk (whose seed /// id is also `1`), but for a uuid/string-pk parent the seeded id is NOT `1`, so /// the child fk dangled, the same-module DDL FOREIGN KEY rejected the insert with -/// a `500` (`JC0510`), and the create probe — plus every probe seeding a row +/// a `422` (`JC0422`), and the create probe — plus every probe seeding a row /// through it — was un-greenable. Mirrors `target_key_rust_type`'s id-field /// lookup; the pk is unconstrained (JC0552), so `fixture_value` reduces to the /// plain type literal here. A synthetic pk (no declared `id`) or an unknown target @@ -227,7 +227,7 @@ fn fixture_json( // #293: a nullable (`set_null`) belongs_to fk is `Option` in the DTO, // and its parent is frequently an unseedable reference entity (no create // endpoint) — `seed_parents` cannot create a row for it, so a fabricated - // id would dangle and the same-module DDL FOREIGN KEY would 500 (JC0510), + // id would dangle and the same-module DDL FOREIGN KEY would 422 (JC0422), // making the happy-path create probe (and every row it seeds through) // un-greenable. The minimal body sends `null` (deserializes to `None`), // which never violates the fk. A required (cascade/restrict) fk keeps its @@ -248,6 +248,10 @@ fn fixture_json( let cols = e .fields .iter() + // #302: a server-assigned (synthetic/declared-`integer`) id is NOT posted — the + // DB assigns it, exactly as the generated create body omits it. A client-supplied + // `string`/`uuid` id stays (the client sends the pk). + .filter(|f| !(f.name == "id" && e.id_is_server_assigned())) .filter(|f| (keep_defaults || f.default.is_none()) && !Design::field_is_now_default(f)) .map(|f| { // `overrides` replaces named fields' literals: the reject probe corrupts @@ -677,47 +681,6 @@ fn create_probe_parent_seed( seed } -/// The distinct pk the tenancy entity's own create probe must post so it doesn't -/// 409 against the tenant row app() auto-seeds (#249). `app()` seeds the tenant at -/// id 1 (and id 2 for the isolation second tenant) whenever this module needs the -/// tenant seed, so a create body reusing the fixture pk `1` collides. Returns -/// `Some(("id", "3"))` — a pk past BOTH auto-seeded tenants — when: `ep` is a POST -/// whose body IS the tenancy entity, this module seeds the tenant -/// (`module_needs_tenant`, the SAME gate that also emits the second-tenant seed), -/// and the tenancy entity carries an explicit integer `id` the create body posts. -/// None otherwise — a synthetic-pk tenancy entity autoincrements past the seed for -/// free, and a non-tenancy create is untouched — so every existing suite stays -/// byte-identical. -fn tenancy_create_pk_override( - design: &Design, - unit: &ModuleDesign, - ep: &Endpoint, -) -> Option<(&'static str, &'static str)> { - if ep.method != HttpMethod::POST { - return None; - } - let tenancy = design.tenancy.as_ref()?; - let entity_name = ep - .request_body - .as_ref() - .and_then(|rb| rb.entity.as_deref())?; - if entity_name != tenancy.entity { - return None; - } - if !module_needs_tenant(design, unit) { - return None; - } - let entity = unit.entities.iter().find(|e| e.name == entity_name)?; - // Only when the body carries an explicit integer pk: a synthetic pk - // autoincrements past the seed on its own, and the integer-literal tenant seed - // (`tenant_row_cols_vals`) already assumes an integer tenancy pk. - entity - .fields - .iter() - .any(|f| f.name == "id" && matches!(f.field_type, FieldType::Integer)) - .then_some(("id", "3")) -} - /// True when this endpoint's SUCCESS requires a credential/signature the generator /// cannot synthesize, so a minimal-body probe can never reach the designed success /// status. Two shapes: (a) a signature-authenticated webhook (Stripe-style — a bad @@ -1056,33 +1019,22 @@ fn unit_tests( ); } } else if param_count(ep) == 0 { - // #249: the tenancy entity's own create probe must NOT reuse the pk - // app() auto-seeds for the tenant (id 1, and id 2 for the isolation - // second tenant) — that would 409 on the PK. Post a distinct pk so the - // create reaches its 201. None (byte-identical) for every non-tenancy - // create and for a synthetic-pk tenancy entity (whose create - // autoincrements past the seed for free). - let pk_override = tenancy_create_pk_override(design, unit, ep); - let overrides: Vec<(&str, &str)> = pk_override.into_iter().collect(); - let request = request_expr(design, unit, ep, &full_path, guarded, &overrides); + // #302: with a server-assigned pk (synthetic OR declared `integer`) out of + // the create body, the tenancy entity's own create no longer reuses the pk + // app() seeds the tenant at (id 1) — the DB autoincrements past it, so the + // old #249 pk-bump is unnecessary. A client-supplied `string`/`uuid` tenancy + // pk posts its own fixture (no bump was ever emitted for it). + let request = request_expr(design, unit, ep, &full_path, guarded, &[]); // #248: a create whose body carries an enforced same-module belongs_to fk // (e.g. an fk-alias `Transfer belongs_to Account as from/to`) needs its - // parent rows seeded first, or the DDL FK violation 500s the 201 probe — + // parent rows seeded first, or the DDL FK violation 422s the 201 probe — // mirror the /{id} probe (`seed_parents`). Empty (byte-identical) for a // create without such a parent. let seed = create_probe_parent_seed(design, top, unit, &cbase, ep, auth); - // A creator that echoes its entity must echo the id it was given — - // catches inserts that return a backend default (0) instead. When the pk - // was bumped (#249) the echo asserts the bumped value, not the fixture. - // #263: skip the id-echo when `success.list` — a list creator responds with - // a JSON ARRAY (`Json>` / the #259 `(StatusCode, Json>)` tuple), - // so `body["id"]` (string-indexing an array) is always `null` and the probe - // could never green on a correct handler. A list response has no single - // canonical id to echo. - // #266: also skip when the success status returns NO JSON body — a 204 - // (`NoContent`) or a 3xx (`Redirect`) has an EMPTY body, so - // `from_str(&res.text())` would panic on `""`. Only a 2xx that is not 204 - // (200/201/202…) carries the JSON the echo reads. + // A creator that echoes its entity must echo an id. #263: skip when + // `success.list` — a JSON ARRAY has no canonical `body["id"]`. #266: skip a + // 204/3xx (`from_str` would panic on an empty body). Only a 2xx that is not + // 204 (200/201/202…) carries the JSON the echo reads. let body_bearing = (200..300).contains(&status) && status != 204; let id_echo = (ep.method == HttpMethod::POST && !ep.success.list && body_bearing) .then_some(ep.request_body.as_ref()) @@ -1090,15 +1042,24 @@ fn unit_tests( .and_then(|rb| rb.entity.as_deref()) .filter(|entity| ep.success.entity.as_deref() == Some(entity)) .and_then(|entity| unit.entities.iter().find(|e| e.name == entity)) - .and_then(|e| e.fields.iter().find(|f| f.name == "id")) - .map(|f| { - let echoed = pk_override - .map(|(_, v)| v.to_string()) - .unwrap_or_else(|| fixture_value(f)); - format!( - " let body: serde_json::Value = serde_json::from_str(&res.text()).expect(\"json body\");\n assert_eq!(body[\"id\"], serde_json::json!({echoed}), \"design: created {} echoes its id\");\n", - ep.success.entity.as_deref().unwrap_or("entity") - ) + .and_then(|e| e.fields.iter().find(|f| f.name == "id").map(|f| (e, f))) + .map(|(e, f)| { + let entity_name = ep.success.entity.as_deref().unwrap_or("entity"); + if e.id_is_server_assigned() { + // #302: a declared `integer` id is SERVER-assigned — the probe + // never posts it, so the create can't echo a client value. Assert + // the server RETURNED a positive pk, still catching an insert that + // returns null/0 (identical intent to the old id-echo). + format!( + " let body: serde_json::Value = serde_json::from_str(&res.text()).expect(\"json body\");\n assert!(body[\"id\"].as_i64().is_some_and(|id| id > 0), \"design: created {entity_name} is assigned a server id, got {{}}\", body[\"id\"]);\n" + ) + } else { + // A client-supplied `string`/`uuid` id is echoed verbatim. + let echoed = fixture_value(f); + format!( + " let body: serde_json::Value = serde_json::from_str(&res.text()).expect(\"json body\");\n assert_eq!(body[\"id\"], serde_json::json!({echoed}), \"design: created {entity_name} echoes its id\");\n" + ) + } }) .unwrap_or_default(); out.code.push_str(&format!( @@ -3592,22 +3553,24 @@ mod tests { crate::platform::questions::validate(&d) ); let (content, _) = render_acceptance(&d, &d.modules[0]); - // The 201 create keeps its id-echo (a JSON body to read). + // The 201 create keeps its id-echo (a JSON body to read). #302: Order's id is a + // declared INTEGER (server-assigned), so the echo asserts a server-returned pk, + // not a client-echoed value. let ok = section(&content, "create_order_returns_201"); assert!( - ok.contains("echoes its id") && ok.contains("expect(\"json body\")"), + ok.contains("is assigned a server id") && ok.contains("expect(\"json body\")"), "a 201 create keeps the id-echo (body-bearing):\n{ok}" ); // The 303 create has an EMPTY body — NO id-echo (would panic on from_str("")). let redir = section(&content, "create_order_redirect_returns_303"); assert!( - !redir.contains("echoes its id") && !redir.contains("expect(\"json body\")"), + !redir.contains("expect(\"json body\")"), "a 303 create must NOT id-echo (empty Redirect body):\n{redir}" ); // The 204 create likewise has no body — NO id-echo. let quiet = section(&content, "create_order_quiet_returns_204"); assert!( - !quiet.contains("echoes its id") && !quiet.contains("expect(\"json body\")"), + !quiet.contains("expect(\"json body\")"), "a 204 create must NOT id-echo (empty NoContent body):\n{quiet}" ); } diff --git a/crates/jerrycan/tests/testgen.rs b/crates/jerrycan/tests/testgen.rs index 15e1c5d..474cc24 100644 --- a/crates/jerrycan/tests/testgen.rs +++ b/crates/jerrycan/tests/testgen.rs @@ -3636,14 +3636,16 @@ fn create_probe_does_not_seed_cross_module_belongs_to() { ); } -/// #249: the tenancy entity's OWN create probe must not collide with the tenant +/// #249/#302: the tenancy entity's OWN create probe must not collide with the tenant /// row `app()` auto-seeds, and its reserve counter must be seeded at its declared /// `default` (not the generic fixture that could equal capacity). WHY (Rule 9): in /// a module that also owns a tenant-owned child, `app()` seeds tenant id 1 (and id 2 -/// for the isolation second tenant); a `create_workspace` reusing pk `1` 409s on the -/// PK, and a `seats_used default:0` counter seeded at the generic `1` is born AT a -/// `seat_limit` of `1`, so its reserve probe 409s (Ok(false)) instead of the asserted -/// 200. Both were un-greenable happy-path tests — a green-means-safe inverse. +/// for the isolation second tenant). #302: `create_workspace` no longer posts an `id` +/// (a declared integer pk is SERVER-assigned), so the DB autoincrements PAST the +/// seeded tenants instead of 409-ing on a reused pk `1`. Separately, a +/// `seats_used default:0` counter seeded at the generic `1` is born AT a `seat_limit` +/// of `1`, so its reserve probe 409s (Ok(false)) instead of the asserted 200. Both +/// were un-greenable happy-path tests — a green-means-safe inverse. #[test] fn tenancy_create_and_reserve_probes_are_greenable() { const SEATS: &str = r#"{ @@ -3692,19 +3694,17 @@ fn tenancy_create_and_reserve_probes_are_greenable() { "tenant 2's seat counter must ALSO seed at its default 0:\n{generated}" ); - // (b) the create probe posts a pk PAST both seeded tenants (3), and echoes it. + // (b) #302: Workspace's id is a declared INTEGER — SERVER-assigned. The create + // probe posts NO id at all (the DB autoincrements past the seeded tenants 1 and 2 + // for free, so it can never collide), and the id-echo asserts a server-returned pk. let create = test_body(&generated, "create_workspace_returns_201"); assert!( - create.contains("\"id\": 3"), - "the tenancy create probe must post a non-colliding pk (past tenants 1 and 2):\n{create}" + !create.contains("\"id\":"), + "a server-assigned pk is NOT posted in the create body:\n{create}" ); assert!( - create.contains("serde_json::json!(3)"), - "the id-echo must assert the bumped pk, not the fixture:\n{create}" - ); - assert!( - !create.contains("\"id\": 1"), - "the tenancy create probe must NOT reuse the seeded tenant pk 1:\n{create}" + create.contains("is_some_and(|id| id > 0)"), + "the id-echo asserts the server assigned a pk, not a client-sent value:\n{create}" ); // (c) the reserve probe operates on the app-seeded tenant (id 1), whose counter @@ -3720,12 +3720,13 @@ fn tenancy_create_and_reserve_probes_are_greenable() { ); } -/// #249 byte-identity: a non-tenancy create with a defaulted field is UNCHANGED — -/// the pk override fires only for the tenancy entity in a tenant-seeding module, and -/// the create body still drops the defaulted field (server-owned), so the emission -/// stays byte-identical to the pre-#249 generator. +/// #302: a non-tenancy create with a declared INTEGER id drops BOTH the pk (SERVER- +/// assigned — the DB autoincrements it) and a defaulted field (server-owned) from the +/// create body; the id-echo asserts the server RETURNED a pk. Supersedes the #249 +/// pk-override byte-identity guard — that override is gone (a server-assigned integer +/// pk is simply never posted, tenancy or not). #[test] -fn non_tenancy_create_with_default_is_unchanged() { +fn non_tenancy_integer_id_create_drops_id_and_default() { const PLAIN: &str = r#"{ "name": "plain-api", "contract_version": 0, "dependencies": ["db"], "modules": [{ @@ -3743,11 +3744,15 @@ fn non_tenancy_create_with_default_is_unchanged() { let d: Design = serde_json::from_str(PLAIN).unwrap(); let generated = testgen::acceptance_rs(&d, &d.modules[0]); let create = test_body(&generated, "create_item_returns_201"); - // The pk stays the fixture 1 (no tenant to collide with) and the defaulted - // `active` is omitted from the create body (server-owned). + // #302: the server-assigned integer pk is NOT posted, and the defaulted `active` + // is omitted (server-owned). The id-echo asserts a server-returned pk. + assert!( + !create.contains("\"id\":"), + "a server-assigned integer pk is not posted in the create body:\n{create}" + ); assert!( - create.contains("\"id\": 1") && create.contains("serde_json::json!(1)"), - "a non-tenancy create keeps the fixture pk 1:\n{create}" + create.contains("is_some_and(|id| id > 0)"), + "the id-echo asserts a server-assigned pk, not a client value:\n{create}" ); assert!( !create.contains("active"),