jerrycan 0.3.0

The AI-native Rust backend platform: framework, CLI, and MCP server. https://jerrycan.cc
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
//! Generated acceptance tests: the design contract as runnable assertions.

use jerrycan::platform::design::Design;
use jerrycan::platform::testgen;

const GOLDEN: &str = include_str!("../../../conformance/designs/todo-api.design.json");

fn golden(db: bool) -> Design {
    let mut v: serde_json::Value = serde_json::from_str(GOLDEN).unwrap();
    if db {
        v["dependencies"] = serde_json::json!(["db"]);
    }
    serde_json::from_value(v).unwrap()
}

#[test]
fn memory_mode_tests_cover_success_and_listed_errors() {
    let design = golden(false);
    let module = &design.modules[0]; // todos (with comments subroute)
    let generated = testgen::acceptance_rs(&design, module);

    assert!(
        generated.contains("GENERATED by jerrycan gen-tests"),
        "tool-owned banner"
    );
    assert!(
        generated.contains("use route_todos::module;"),
        "{generated}"
    );
    // success tests:
    for expected in [
        "async fn list_todos_returns_200",
        "async fn create_todo_returns_201",
        "async fn show_todo_returns_200",
        "async fn delete_todo_returns_204",
        "async fn list_comments_returns_200",
        "async fn create_comment_returns_201",
    ] {
        assert!(
            generated.contains(expected),
            "missing {expected}\n{generated}"
        );
    }
    // listed 404s:
    assert!(
        generated.contains("async fn show_todo_missing_id_is_404"),
        "{generated}"
    );
    assert!(generated.contains("/todos/999999"), "{generated}");
    // seed-then-request flow uses the creator:
    assert!(generated.contains("post_json(\"/todos/\""), "{generated}");
    // memory preamble — no db:
    assert!(!generated.contains("jerrycan::db"), "{generated}");
    assert_eq!(
        testgen::test_count(&generated),
        8,
        "6 success + 2 listed 404s"
    );
}

#[test]
fn db_mode_preamble_migrates_an_in_memory_database() {
    let design = golden(true);
    let module = &design.modules[0];
    let generated = testgen::acceptance_rs(&design, module);
    assert!(
        generated.contains("Db::connect(\"sqlite::memory:\")"),
        "{generated}"
    );
    assert!(
        generated.contains("include_str!(\"../migrations/sqlite/0001_create_tables.sql\")"),
        "{generated}"
    );
    assert!(generated.contains(".extend(db)"), "{generated}");
}

/// A module's TestApp migrates the FULL workspace schema (issue #14), not just
/// its own tables — so a handler that legitimately writes ANOTHER module's table
/// no longer 500s with "no such table" under the module TestApp. The `orders`
/// TestApp must include BOTH its own migration (relative) AND the `products`
/// module's migration (cross-crate `../../products/...`).
#[test]
fn module_testapp_migrates_the_full_workspace_schema() {
    let design: Design = serde_json::from_value(serde_json::json!({
        "name": "shop-api",
        "contract_version": 1,
        "dependencies": ["db"],
        "modules": [
            { "name": "products",
              "entities": [{ "name": "Product", "fields": [
                  { "name": "sku", "type": "string" } ]}],
              "endpoints": [{ "operation_id": "list_products", "method": "GET", "path": "/",
                  "success": { "status": 200, "entity": "Product", "list": true } }] },
            { "name": "orders",
              "entities": [{ "name": "Order", "fields": [
                  { "name": "total", "type": "integer" } ]}],
              "endpoints": [{ "operation_id": "list_orders", "method": "GET", "path": "/",
                  "success": { "status": 200, "entity": "Order", "list": true } }] }
        ]
    }))
    .unwrap();
    let orders = design.modules.iter().find(|m| m.name == "orders").unwrap();
    let generated = testgen::acceptance_rs(&design, orders);
    // Its own tables (relative include).
    assert!(
        generated.contains("include_str!(\"../migrations/sqlite/0001_create_tables.sql\")"),
        "orders TestApp migrates its own tables: {generated}"
    );
    // AND the products module's tables (cross-crate include) — the whole point:
    // an orders handler may write the products table.
    assert!(
        generated
            .contains("include_str!(\"../../products/migrations/sqlite/0001_create_tables.sql\")"),
        "orders TestApp must also migrate the products module's tables: {generated}"
    );
}

#[test]
fn unsupported_error_cases_become_an_agent_todo_comment() {
    let mut design = golden(false);
    design.modules[0].endpoints[1]
        .errors
        .push(jerrycan::platform::design::ErrorCase {
            status: 409,
            code: Some("JC0409".into()),
            when: "duplicate title".into(),
        });
    let generated = testgen::acceptance_rs(&design, &design.modules[0].clone());
    assert!(
        generated.contains("// AGENT TODO: design lists 409 (duplicate title)"),
        "{generated}"
    );
}

/// A PUBLIC endpoint in an auth design gets a success test but NO
/// `_without_auth_is_401` test, and its request carries no session cookie. WHY
/// (Rule 9): `public: true` marks a credential-issuing route (login/register)
/// that is unauthenticated BY DESIGN — generating a 401 test or threading a
/// cookie would assert the opposite of the contract (fix F1).
#[test]
fn public_endpoints_get_no_401_test_and_no_cookie() {
    // An auth design (so guarded endpoints would normally get cookies + 401
    // tests) with one PUBLIC register POST and one ordinary guarded POST.
    let design: Design = serde_json::from_value(serde_json::json!({
        "name": "auth-api",
        "contract_version": 1,
        "auth": { "model": "jwt", "roles": ["admin"] },
        "dependencies": ["auth"],
        "modules": [{
            "name": "accounts",
            "entities": [{ "name": "User", "fields": [
                { "name": "email", "type": "string" },
                { "name": "password", "type": "string" }
            ]}],
            "endpoints": [
                { "operation_id": "register", "method": "POST", "path": "/register",
                  "public": true,
                  "request_body": { "entity": "User" },
                  "success": { "status": 201, "entity": "User" } },
                { "operation_id": "create_account", "method": "POST", "path": "/",
                  "auth_required": true,
                  "request_body": { "entity": "User" },
                  "success": { "status": 201, "entity": "User" } }
            ]
        }]
    }))
    .unwrap();
    let module = &design.modules[0];
    let generated = testgen::acceptance_rs(&design, module);

    // The public register route still gets its success test...
    assert!(
        generated.contains("async fn register_returns_201"),
        "public route still gets a success test: {generated}"
    );
    // ...but NO 401 test (it is unauthenticated by design)...
    assert!(
        !generated.contains("register_without_auth_is_401"),
        "public route must NOT get a 401 test: {generated}"
    );
    // ...and its request uses the plain (cookie-less) verb.
    assert!(
        generated.contains("t.post_json(\"/accounts/register\""),
        "public route request must carry no cookie: {generated}"
    );

    // The ordinary guarded endpoint still gets BOTH a cookied success request
    // and a 401 test — the carve-out is narrow to public routes.
    assert!(
        generated.contains("create_account_without_auth_is_401"),
        "a guarded route still gets its 401 test: {generated}"
    );
    assert!(
        generated.contains("t.post_json_with(\"/accounts/\""),
        "a guarded route still threads the cookie: {generated}"
    );
}

/// A tenant-owned module's guarded handlers take `Dep<Tenant>`; the generated
/// test app must register the `tenant` factory and SEED a membership row, or the
/// guard 403s every guarded request (a false stub-test failure). WHY this matters:
/// the seed is what keeps these acceptance tests failing for the RIGHT reason
/// (stub-500), not an unseeded-membership 403. It must (a) migrate the tenant
/// module's tables (the `{tenant}_members` table the guard queries lives there),
/// (b) insert a tenant row whose enum column uses a DECLARED value so the CHECK
/// passes, and (c) insert the membership row, then provide the `tenant` factory.
#[test]
fn tenancy_module_tests_seed_membership_and_provide_the_guard() {
    let s = include_str!("../../../conformance/designs/reference-slice.design.json");
    let design: Design = serde_json::from_str(s).unwrap();
    let leads = design
        .modules
        .iter()
        .find(|m| m.name == "leads")
        .expect("leads module");
    let generated = testgen::acceptance_rs(&design, leads);

    // The Tenant factory is registered app-wide for the guard to resolve.
    assert!(
        generated.contains(".provide_dep(shared::tenant)"),
        "{generated}"
    );
    // The tenant module's migration is pulled in (cross-crate) so the membership
    // table exists in this module's test database.
    assert!(
        generated.contains(
            "include_str!(\"../../workspaces/migrations/sqlite/0001_create_tables.sql\")"
        ),
        "tenant migration cross-included: {generated}"
    );
    // Seeds: a tenant row (enum `plan` uses a declared value, not 'test-value', so
    // the CHECK passes) and the membership row for user 1.
    assert!(
        generated.contains("INSERT INTO \\\"workspaces\\\"") && generated.contains("'trial'"),
        "tenant row seeded with a valid enum value: {generated}"
    );
    assert!(
        generated.contains(
            "INSERT INTO \\\"workspace_members\\\" (user_id, workspace_id, role) VALUES (1, 1, 'owner')"
        ),
        "membership row seeded for user 1: {generated}"
    );
    // The raw-SQL seed needs ConnectionTrait in scope.
    assert!(
        generated.contains("use jerrycan::db::sea_orm::ConnectionTrait;"),
        "{generated}"
    );

    // The tenant module ITSELF (workspaces) owns no tenant-owned entity, so its
    // test neither seeds nor provides the guard (no false coupling).
    let workspaces = design
        .modules
        .iter()
        .find(|m| m.name == "workspaces")
        .expect("workspaces module");
    let ws_gen = testgen::acceptance_rs(&design, workspaces);
    assert!(
        !ws_gen.contains(".provide_dep(shared::tenant)") && !ws_gen.contains("workspace_members"),
        "non-tenant-owned module needs no seed: {ws_gen}"
    );
}

/// The generated JSON request BODY for an enum field must use a DECLARED value,
/// not the generic `"test-value"` placeholder. WHY: the generator's own
/// migration emits `CHECK ("role" IN ('admin','user'))`, so a body with
/// `"role": "test-value"` makes the happy-path acceptance test fail at run time
/// with an opaque `JC0510` even when the handler is correctly implemented — the
/// gen-test would contradict the gen-migration. The body must agree with the
/// SQL seed (`seed_sql_value`), which already uses the first declared value.
#[test]
fn generated_request_body_uses_a_declared_enum_value_not_the_placeholder() {
    let s = include_str!("../../../conformance/designs/reference-slice.design.json");
    let design: Design = serde_json::from_str(s).unwrap();
    let users = design
        .modules
        .iter()
        .find(|m| m.name == "users")
        .expect("users module");
    let generated = testgen::acceptance_rs(&design, users);
    // `register`'s body posts the `User` entity, whose `role` is an enum
    // `["admin","user"]` → the first declared value, NOT `"test-value"`.
    assert!(
        generated.contains("\"role\": \"admin\""),
        "enum field uses its first declared value: {generated}"
    );
    assert!(
        !generated.contains("\"role\": \"test-value\""),
        "enum field must NOT use the placeholder (trips the CHECK): {generated}"
    );
    // A plain (non-enum) string field still uses the placeholder.
    assert!(
        generated.contains("\"email\": \"test-value\""),
        "non-enum string keeps the placeholder: {generated}"
    );
}

/// A tenant-owned entity's create/update bodies must carry the fk column the
/// `belongs_to` derives, valued at the SEEDED tenant (workspace 1). WHY: without
/// it the generated request body is missing a NOT-NULL column, so the handler's
/// `Json<Lead>` deserialization rejects it 422 — the test fails before reaching
/// the stub, masking whether the handler is actually implemented. With the fk
/// present the request reaches the stub (500 on stubs → green when implemented).
#[test]
fn tenant_owned_fixtures_carry_the_foreign_key() {
    let s = include_str!("../../../conformance/designs/reference-slice.design.json");
    let design: Design = serde_json::from_str(s).unwrap();
    let leads = design
        .modules
        .iter()
        .find(|m| m.name == "leads")
        .expect("leads module");
    let generated = testgen::acceptance_rs(&design, leads);
    // create_lead (POST /) and update_lead (PUT /{id}) both send a Lead body;
    // each must include the workspace_id fk valued at the seeded tenant (1).
    assert!(
        generated.contains("\"workspace_id\": 1"),
        "Lead fixture bodies must carry workspace_id: 1: {generated}"
    );

    // A non-tenant-owned entity's body must NOT gain a phantom fk.
    let workspaces = design
        .modules
        .iter()
        .find(|m| m.name == "workspaces")
        .expect("workspaces module");
    let ws_gen = testgen::acceptance_rs(&design, workspaces);
    assert!(
        !ws_gen.contains("\"workspace_id\""),
        "Workspace (the tenant itself) must not carry a self fk: {ws_gen}"
    );
}

/// A tenant-owned module with a creator + GET /{id} gets a cross-tenant
/// isolation test: user 1 creates a row in workspace 1, user 2 (workspace 2)
/// must NOT be able to read it. This is the security contract — it fails on
/// stubs (500) and stays red if the agent uses unscoped repo methods (which
/// would return the foreign row), going green only with scoped get_for.
#[test]
fn tenant_owned_modules_get_isolation_tests() {
    let s = include_str!("../../../conformance/designs/reference-slice.design.json");
    let d: Design = serde_json::from_str(s).unwrap();
    let leads = d
        .modules
        .iter()
        .find(|m| m.name == "leads")
        .expect("leads module");
    let out = testgen::acceptance_rs(&d, leads);
    assert!(
        out.contains("async fn tenant_a_cannot_read_tenant_b_leads()"),
        "{out}"
    );
    assert!(out.contains("404"), "cross-tenant get must 404: {out}");
    // The isolation test seeds a SECOND tenant (workspace 2) + membership for
    // user 2, and mints user 2's cookie via the generalized helper.
    assert!(
        out.contains("fn seed_second_tenant(") && out.contains("test_cookie_for("),
        "isolation test needs a second-tenant seed + per-user cookie helper: {out}"
    );
    // test_cookie() stays back-compat (delegates to test_cookie_for(1)).
    assert!(
        out.contains("test_cookie_for(1)"),
        "test_cookie() must delegate to test_cookie_for(1): {out}"
    );
    // The DELETE leg is role-gated (owner); user 2's membership must seed the
    // owner role so the role check passes and the SCOPED remove_for 404s (proving
    // isolation, not a role rejection).
    assert!(
        out.contains("'owner'"),
        "second tenant membership seeds the owner role: {out}"
    );

    // The non-tenant-owned tenant module (workspaces) gets NO isolation test.
    let workspaces = d
        .modules
        .iter()
        .find(|m| m.name == "workspaces")
        .expect("workspaces module");
    let ws_gen = testgen::acceptance_rs(&d, workspaces);
    assert!(
        !ws_gen.contains("cannot_read_tenant_b"),
        "non-tenant-owned module gets no isolation test: {ws_gen}"
    );
}

/// A credential/signature-gated endpoint's SUCCESS test would be UN-GREENABLE: the
/// generator can't supply the credential, so a minimal-body probe can never reach
/// the designed success status (a `public` login 401s bad creds; a signed webhook
/// 400/401s a bad signature). WHY (Rule 9): emitting a hard `_returns_<status>`
/// assertion for these would leave a generated test that NO correct implementation
/// can pass — `jerrycan check` could never go green. So the generator must emit an
/// `// AGENT TODO` instead, and the agent writes the credentialed test by hand.
/// This locks BOTH gated shapes: (a) a public POST declaring 401 and (b) a
/// signature-authenticated webhook (declares a 4xx whose `when` names "signature").
#[test]
fn credential_gated_endpoints_get_an_agent_todo_not_a_success_test() {
    let design: Design = serde_json::from_value(serde_json::json!({
        "name": "gated-api",
        "contract_version": 1,
        "auth": { "model": "jwt", "roles": ["admin"] },
        "dependencies": ["auth"],
        "modules": [{
            "name": "accounts",
            "endpoints": [
                // (a) a public login: declares 401 on bad creds, no body.
                { "operation_id": "login", "method": "POST", "path": "/login",
                  "public": true,
                  "success": { "status": 200 },
                  "errors": [{ "status": 401, "when": "invalid email or password" }] },
                // (b) a signature-authenticated webhook: declares a 400 whose
                // `when` names a signature check (Stripe-style).
                { "operation_id": "stripe_webhook", "method": "POST", "path": "/webhook",
                  "success": { "status": 200 },
                  "errors": [{ "status": 400, "when": "Stripe signature is missing or invalid" }] }
            ]
        }]
    }))
    .unwrap();
    let generated = testgen::acceptance_rs(&design, &design.modules[0]);

    // Neither gated endpoint gets an un-greenable `_returns_` success assertion...
    assert!(
        !generated.contains("async fn login_returns_"),
        "credential-gated login must NOT get a success test: {generated}"
    );
    assert!(
        !generated.contains("async fn stripe_webhook_returns_"),
        "signature webhook must NOT get a success test: {generated}"
    );
    // ...each instead carries an AGENT TODO naming the credential it needs.
    assert!(
        generated.contains(
            "// AGENT TODO: login (POST /accounts/login) authenticates via a credential/signature"
        ),
        "login must get a credential AGENT TODO: {generated}"
    );
    assert!(
        generated.contains(
            "// AGENT TODO: stripe_webhook (POST /accounts/webhook) authenticates via a credential/signature"
        ),
        "webhook must get a credential AGENT TODO: {generated}"
    );
}

/// An explicit `probe: "skip"` hint (issue #11) makes the generator drop the
/// un-greenable 2xx probe even when the heuristic MISSES the endpoint — here a
/// `public` webhook that declares NO 401/403 error, so `endpoint_is_credential_gated`
/// wouldn't flag it. WHY (Rule 9): without the hint the generator would emit a
/// `_returns_200` probe that a correct signature-checking handler MUST reject,
/// so `jerrycan check` could never reach ok:true. With the hint it emits a TODO,
/// and an ordinary (auto) endpoint in the same module still gets its success probe.
#[test]
fn probe_skip_hint_drops_the_ungreenable_success_probe() {
    let design: Design = serde_json::from_value(serde_json::json!({
        "name": "ingest-api",
        "contract_version": 1,
        "dependencies": [],
        "modules": [{
            "name": "ingest",
            "endpoints": [
                // A public webhook the heuristic misses (no declared 401/403, no
                // "signature" in a `when`), marked probe: skip explicitly.
                { "operation_id": "receive_hook", "method": "POST", "path": "/hook",
                  "public": true, "probe": "skip",
                  "success": { "status": 202 } },
                // An ordinary endpoint (auto) still gets its happy-path probe.
                { "operation_id": "health_ping", "method": "GET", "path": "/ping",
                  "success": { "status": 200 } }
            ]
        }]
    }))
    .unwrap();
    let generated = testgen::acceptance_rs(&design, &design.modules[0]);
    assert!(
        !generated.contains("async fn receive_hook_returns_"),
        "probe: skip must drop the un-greenable success probe: {generated}"
    );
    assert!(
        generated
            .contains("// AGENT TODO: receive_hook (POST /ingest/hook) is marked `probe: skip`"),
        "probe: skip must emit an explanatory TODO: {generated}"
    );
    assert!(
        generated.contains("async fn health_ping_returns_200()"),
        "an ordinary (auto) endpoint still gets its success probe: {generated}"
    );
}

/// A db-mode module whose EVERY endpoint is a TODO (e.g. a billing module whose
/// only route is a signature-gated webhook) emits ZERO `#[tokio::test]` functions.
/// The generated file must then carry NO `app()` helper and NO `use` imports —
/// they would be dead code and trip the generated workspace's `-D warnings`,
/// blocking `jerrycan check` from ever going green. WHY (Rule 9): this is the
/// exact regression Fix 1 first introduced (billing's webhook became a TODO,
/// leaving `app()` unused); the file must degrade to banner + TODOs only.
#[test]
fn module_with_only_todos_emits_no_dead_app_helper() {
    let design: Design = serde_json::from_value(serde_json::json!({
        "name": "webhook-only-api",
        "contract_version": 1,
        "dependencies": ["db"],
        "modules": [{
            "name": "billing",
            "endpoints": [
                { "operation_id": "stripe_webhook", "method": "POST", "path": "/webhook",
                  "success": { "status": 200 },
                  "errors": [{ "status": 400, "when": "Stripe signature is missing or invalid" }] }
            ]
        }]
    }))
    .unwrap();
    let generated = testgen::acceptance_rs(&design, &design.modules[0]);
    // The webhook becomes a TODO, so there are no tests at all.
    assert_eq!(
        testgen::test_count(&generated),
        0,
        "a signature-only billing module emits no tests: {generated}"
    );
    // The TODO is still present (the agent must hand-write the webhook test)...
    assert!(
        generated.contains("// AGENT TODO: stripe_webhook"),
        "the webhook TODO must be emitted: {generated}"
    );
    // ...but the dead `app()` helper and the `use` imports must be GONE, or the
    // generated crate fails to build under `-D warnings`.
    assert!(
        !generated.contains("async fn app()"),
        "a tests-less module must NOT emit a dead app() helper: {generated}"
    );
    assert!(
        !generated.contains("use jerrycan::prelude::*;") && !generated.contains("::module;"),
        "a tests-less module must NOT emit dead imports: {generated}"
    );
    // The banner stays (it identifies the tool-owned file).
    assert!(
        generated.contains("GENERATED by jerrycan gen-tests"),
        "the tool banner must remain: {generated}"
    );
}

/// A public POST that declares NO 401/403 (e.g. register: 409/422) is NOT
/// credential-gated — a minimal body CAN reach success — so it keeps its
/// `_returns_` test. WHY: the gate is narrow; widening it to every public route
/// would drop greenable success coverage for register/create-style endpoints.
#[test]
fn public_post_without_401_keeps_its_success_test() {
    let design: Design = serde_json::from_value(serde_json::json!({
        "name": "reg-api",
        "contract_version": 1,
        "auth": { "model": "jwt", "roles": ["admin"] },
        "dependencies": ["auth"],
        "modules": [{
            "name": "accounts",
            "entities": [{ "name": "User", "fields": [
                { "name": "email", "type": "string" }
            ]}],
            "endpoints": [
                { "operation_id": "register", "method": "POST", "path": "/register",
                  "public": true,
                  "request_body": { "entity": "User" },
                  "success": { "status": 201, "entity": "User" },
                  "errors": [
                    { "status": 409, "when": "email already registered" },
                    { "status": 422, "when": "request body fails validation" }
                  ] }
            ]
        }]
    }))
    .unwrap();
    let generated = testgen::acceptance_rs(&design, &design.modules[0]);
    assert!(
        generated.contains("async fn register_returns_201"),
        "a public POST with no 401/403 keeps its success test: {generated}"
    );
}

/// A POST-only `/{id}` action that declares a 404 must get a 404 probe built with
/// the endpoint's REAL method (POST), not a hardcoded GET. WHY (Rule 9): the router
/// returns 405 for a GET against a POST-only route, so a GET probe would assert 404
/// against an observed 405 and fail forever — an un-greenable generated test. The
/// fix routes the missing-id probe through `request_expr` (the success builder), so
/// it POSTs and the handler's real 404-on-missing path is exercised.
#[test]
fn post_only_id_action_404_probe_uses_post_not_get() {
    let design: Design = serde_json::from_value(serde_json::json!({
        "name": "tickets-api",
        "contract_version": 1,
        "modules": [{
            "name": "tickets",
            "endpoints": [
                { "operation_id": "close_ticket", "method": "POST", "path": "/{id}/close",
                  "success": { "status": 200 },
                  "errors": [{ "status": 404, "when": "unknown id" }] }
            ]
        }]
    }))
    .unwrap();
    let generated = testgen::acceptance_rs(&design, &design.modules[0]);
    assert!(
        generated.contains("async fn close_ticket_missing_id_is_404"),
        "POST-only /{{id}} action with a 404 gets a 404 test: {generated}"
    );
    // The probe must POST to the missing id (use the endpoint's method), NOT GET —
    // a GET would 405 and the 404 assertion could never pass.
    assert!(
        generated.contains("t.post_json(\"/tickets/999999/close\""),
        "404 probe must POST (the endpoint's method), not GET: {generated}"
    );
    assert!(
        !generated.contains("t.get(\"/tickets/999999/close\")"),
        "404 probe must NOT be a hardcoded GET: {generated}"
    );
}

/// A tenant entity with a `unique` non-PK column must seed tenant 1 and tenant 2
/// with DISTINCT values for that column, or the second-tenant seed the isolation
/// test depends on crashes every test at setup with a UNIQUE-constraint violation.
/// WHY (Rule 9): tenant 1 and tenant 2 previously shared `'test-value'` for every
/// string column; a `unique` column then collides. Tenant 1 must stay byte-identical
/// (`'test-value'`) and tenant 2 must differ (`'test-value-2'`).
#[test]
fn two_tenant_seed_uses_distinct_values_for_a_unique_field() {
    let design: Design = serde_json::from_value(serde_json::json!({
        "name": "slug-api",
        "contract_version": 1,
        "auth": { "model": "jwt", "roles": ["owner", "member"] },
        "dependencies": ["db", "auth"],
        "tenancy": { "entity": "Org", "member_roles": ["owner", "member"] },
        "modules": [
            {
                "name": "orgs",
                "entities": [{ "name": "Org", "fields": [
                    { "name": "id", "type": "integer" },
                    { "name": "slug", "type": "string", "unique": true }
                ]}],
                "endpoints": []
            },
            {
                "name": "projects",
                "entities": [{ "name": "Project",
                    "belongs_to": [{ "entity": "Org" }],
                    "fields": [
                        { "name": "id", "type": "integer" },
                        { "name": "title", "type": "string" }
                    ]}],
                "endpoints": [
                    { "operation_id": "list_projects", "method": "GET", "path": "/",
                      "auth_required": true,
                      "success": { "status": 200, "entity": "Project", "list": true } },
                    { "operation_id": "create_project", "method": "POST", "path": "/",
                      "auth_required": true,
                      "request_body": { "entity": "Project" },
                      "success": { "status": 201, "entity": "Project" } },
                    { "operation_id": "show_project", "method": "GET", "path": "/{id}",
                      "auth_required": true,
                      "success": { "status": 200, "entity": "Project" },
                      "errors": [{ "status": 404, "when": "unknown id" }] }
                ]
            }
        ]
    }))
    .unwrap();
    let projects = design
        .modules
        .iter()
        .find(|m| m.name == "projects")
        .expect("projects module");
    let generated = testgen::acceptance_rs(&design, projects);

    // Tenant 1's seed keeps the byte-identical placeholder for the unique slug.
    assert!(
        generated.contains("VALUES (1, 'test-value')"),
        "tenant 1 seeds the unchanged placeholder slug: {generated}"
    );
    // Tenant 2's seed (in seed_second_tenant) must NOT reuse the SAME slug literal —
    // it carries a distinct value so the UNIQUE constraint holds.
    assert!(
        generated.contains("VALUES (2, 'test-value-2')"),
        "tenant 2 seeds a DISTINCT slug so the unique column doesn't collide: {generated}"
    );
    // Belt-and-suspenders: the two org INSERTs must not share the same slug literal.
    let org_inserts: Vec<&str> = generated
        .lines()
        .filter(|l| l.contains("INSERT INTO \\\"orgs\\\""))
        .collect();
    assert_eq!(org_inserts.len(), 2, "two org rows seeded: {generated}");
    assert_ne!(
        org_inserts[0], org_inserts[1],
        "the two tenant org INSERTs must differ on the unique slug: {generated}"
    );
}