Skip to main content

shaperail_codegen/
validator.rs

1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition, WASM_HOOK_PREFIX};
2
3/// A semantic validation error for a resource definition.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct ValidationError {
6    pub message: String,
7}
8
9impl std::fmt::Display for ValidationError {
10    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11        write!(f, "{}", self.message)
12    }
13}
14
15/// Validate a parsed `ResourceDefinition` for semantic correctness.
16///
17/// Returns a list of all validation errors found. An empty list means the
18/// resource is valid.
19pub fn validate_resource(rd: &ResourceDefinition) -> Vec<ValidationError> {
20    let mut errors = Vec::new();
21    let res = &rd.resource;
22
23    // Resource name must not be empty
24    if res.is_empty() {
25        errors.push(err("resource name must not be empty"));
26    }
27
28    // Version must be >= 1
29    if rd.version == 0 {
30        errors.push(err(&format!("resource '{res}': version must be >= 1")));
31    }
32
33    // Schema must have at least one field
34    if rd.schema.is_empty() {
35        errors.push(err(&format!(
36            "resource '{res}': schema must have at least one field"
37        )));
38    }
39
40    // Must have exactly one primary key
41    let primary_count = rd.schema.values().filter(|f| f.primary).count();
42    if primary_count == 0 {
43        errors.push(err(&format!(
44            "resource '{res}': schema must have a primary key field"
45        )));
46    } else if primary_count > 1 {
47        errors.push(err(&format!(
48            "resource '{res}': schema must have exactly one primary key, found {primary_count}"
49        )));
50    }
51
52    // Per-field validation
53    for (name, field) in &rd.schema {
54        // Enum type requires values
55        if field.field_type == FieldType::Enum && field.values.is_none() {
56            errors.push(err(&format!(
57                "resource '{res}': field '{name}' is type enum but has no values"
58            )));
59        }
60
61        // Non-enum type should not have values
62        if field.field_type != FieldType::Enum && field.values.is_some() {
63            errors.push(err(&format!(
64                "resource '{res}': field '{name}' has values but is not type enum"
65            )));
66        }
67
68        // Ref field must be uuid type
69        if field.reference.is_some() && field.field_type != FieldType::Uuid {
70            errors.push(err(&format!(
71                "resource '{res}': field '{name}' has ref but is not type uuid"
72            )));
73        }
74
75        // Ref format must be "resource.field"
76        if let Some(ref reference) = field.reference {
77            if !reference.contains('.') {
78                errors.push(err(&format!(
79                    "resource '{res}': field '{name}' ref must be in 'resource.field' format, got '{reference}'"
80                )));
81            }
82        }
83
84        // Array type requires items
85        if field.field_type == FieldType::Array && field.items.is_none() {
86            errors.push(err(&format!(
87                "resource '{res}': field '{name}' is type array but has no items"
88            )));
89        }
90
91        // Element-level rules for items
92        if let Some(items_spec) = &field.items {
93            // No nested arrays — clearer error than the generic deny_unknown_fields.
94            if items_spec.field_type == FieldType::Array {
95                errors.push(err(&format!(
96                    "resource '{res}': field '{name}' has nested array items; use type: json for nested arrays"
97                )));
98            }
99
100            // Enum items require values: [...]
101            if items_spec.field_type == FieldType::Enum && items_spec.values.is_none() {
102                errors.push(err(&format!(
103                    "resource '{res}': field '{name}' has enum items but no values; add `values: [...]` to items"
104                )));
105            }
106
107            // format only valid for string element type
108            if items_spec.format.is_some() && items_spec.field_type != FieldType::String {
109                errors.push(err(&format!(
110                    "resource '{res}': field '{name}' has items.format but items.type is not string"
111                )));
112            }
113
114            // ref only valid on uuid element type
115            if items_spec.reference.is_some() && items_spec.field_type != FieldType::Uuid {
116                errors.push(err(&format!(
117                    "resource '{res}': field '{name}' has items.ref but items.type is not uuid"
118                )));
119            }
120
121            // ref must be in resource.field shape
122            if let Some(reference) = &items_spec.reference {
123                if !reference.contains('.') {
124                    errors.push(err(&format!(
125                        "resource '{res}': field '{name}' items.ref must be in 'resource.field' format, got '{reference}'"
126                    )));
127                }
128            }
129        }
130
131        // Format only valid for string type
132        if field.format.is_some() && field.field_type != FieldType::String {
133            errors.push(err(&format!(
134                "resource '{res}': field '{name}' has format but is not type string"
135            )));
136        }
137
138        // Primary key should be generated or required
139        if field.primary && !field.generated && !field.required {
140            errors.push(err(&format!(
141                "resource '{res}': primary key field '{name}' must be generated or required"
142            )));
143        }
144
145        // Transient field constraints — `transient: true` means input-only, never persisted.
146        // Combinations that imply persistence are nonsensical and rejected loudly.
147        if field.transient {
148            if field.primary {
149                errors.push(err(&format!(
150                    "resource '{res}': field '{name}' cannot be both transient and primary"
151                )));
152            }
153            if field.generated {
154                errors.push(err(&format!(
155                    "resource '{res}': field '{name}' cannot be both transient and generated"
156                )));
157            }
158            if field.reference.is_some() {
159                errors.push(err(&format!(
160                    "resource '{res}': field '{name}' cannot be both transient and have a ref (foreign keys imply persistence)"
161                )));
162            }
163            if field.unique {
164                errors.push(err(&format!(
165                    "resource '{res}': field '{name}' cannot be both transient and unique (unique constraints require persistence)"
166                )));
167            }
168            if field.default.is_some() {
169                errors.push(err(&format!(
170                    "resource '{res}': field '{name}' cannot be both transient and have a default (defaults apply to stored columns)"
171                )));
172            }
173        }
174    }
175
176    // Transient fields must appear in at least one endpoint's `input:` list — otherwise
177    // they're unreachable: never populated, never validated, never seen anywhere.
178    let transient_fields: Vec<&String> = rd
179        .schema
180        .iter()
181        .filter(|(_, f)| f.transient)
182        .map(|(name, _)| name)
183        .collect();
184    if !transient_fields.is_empty() {
185        let referenced: std::collections::HashSet<&str> = rd
186            .endpoints
187            .as_ref()
188            .map(|eps| {
189                eps.values()
190                    .filter_map(|ep| ep.input.as_ref())
191                    .flat_map(|inputs| inputs.iter().map(|s| s.as_str()))
192                    .collect()
193            })
194            .unwrap_or_default();
195        for name in transient_fields {
196            if !referenced.contains(name.as_str()) {
197                errors.push(err(&format!(
198                    "resource '{res}': transient field '{name}' is not declared in any endpoint's input: list (the field would be unreachable)"
199                )));
200            }
201        }
202    }
203
204    // Tenant key validation (M18)
205    if let Some(ref tenant_key) = rd.tenant_key {
206        match rd.schema.get(tenant_key) {
207            Some(field) => {
208                if field.field_type != FieldType::Uuid {
209                    errors.push(err(&format!(
210                        "resource '{res}': tenant_key '{tenant_key}' must reference a uuid field, found {}",
211                        field.field_type
212                    )));
213                }
214            }
215            None => {
216                errors.push(err(&format!(
217                    "resource '{res}': tenant_key '{tenant_key}' not found in schema"
218                )));
219            }
220        }
221    }
222
223    // Endpoint validation
224    if let Some(endpoints) = &rd.endpoints {
225        for (action, ep) in endpoints {
226            // method and path must be set (either explicitly or via convention defaults)
227            if ep.method.is_none() {
228                errors.push(err(&format!(
229                    "resource '{res}': endpoint '{action}' has no method. Use a known action name (list, get, create, update, delete) or set method explicitly"
230                )));
231            }
232            if ep.path.is_none() {
233                errors.push(err(&format!(
234                    "resource '{res}': endpoint '{action}' has no path. Use a known action name (list, get, create, update, delete) or set path explicitly"
235                )));
236            }
237
238            // handler: is only valid on custom (non-convention) endpoints.
239            // The runtime dispatches list/get/create/update/delete via the
240            // standard CRUD path, so a handler: declaration on those keys
241            // would be silently ignored at codegen time.
242            if let Some(e) = validate_handler_only_on_custom(res, action, ep) {
243                errors.push(e);
244            }
245
246            if let Some(controller) = &ep.controller {
247                // controller: is only valid on conventional CRUD endpoints.
248                if let Some(e) = validate_controller_only_on_crud(res, action, ep) {
249                    errors.push(e);
250                }
251
252                if let Some(before) = &controller.before {
253                    if before.is_empty() {
254                        errors.push(err(&format!(
255                            "resource '{res}': endpoint '{action}' has an empty controller.before name"
256                        )));
257                    }
258                    validate_controller_name(res, action, "before", before, &mut errors);
259                }
260                if let Some(after) = &controller.after {
261                    if after.is_empty() {
262                        errors.push(err(&format!(
263                            "resource '{res}': endpoint '{action}' has an empty controller.after name"
264                        )));
265                    }
266                    validate_controller_name(res, action, "after", after, &mut errors);
267                }
268            }
269
270            if let Some(events) = &ep.events {
271                for event in events {
272                    if event.is_empty() {
273                        errors.push(err(&format!(
274                            "resource '{res}': endpoint '{action}' has an empty event name"
275                        )));
276                    }
277                }
278            }
279
280            if let Some(jobs) = &ep.jobs {
281                for job in jobs {
282                    if job.is_empty() {
283                        errors.push(err(&format!(
284                            "resource '{res}': endpoint '{action}' has an empty job name"
285                        )));
286                    }
287                }
288            }
289
290            // Input fields must exist in schema
291            if let Some(input) = &ep.input {
292                for field_name in input {
293                    if !rd.schema.contains_key(field_name) {
294                        errors.push(err(&format!(
295                            "resource '{res}': endpoint '{action}' input field '{field_name}' not found in schema"
296                        )));
297                    }
298                }
299            }
300
301            // Filter fields must exist in schema
302            if let Some(filters) = &ep.filters {
303                for field_name in filters {
304                    if !rd.schema.contains_key(field_name) {
305                        errors.push(err(&format!(
306                            "resource '{res}': endpoint '{action}' filter field '{field_name}' not found in schema"
307                        )));
308                    }
309                }
310            }
311
312            // Search fields must exist in schema
313            if let Some(search) = &ep.search {
314                for field_name in search {
315                    if !rd.schema.contains_key(field_name) {
316                        errors.push(err(&format!(
317                            "resource '{res}': endpoint '{action}' search field '{field_name}' not found in schema"
318                        )));
319                    }
320                }
321            }
322
323            // Sort fields must exist in schema
324            if let Some(sort) = &ep.sort {
325                for field_name in sort {
326                    if !rd.schema.contains_key(field_name) {
327                        errors.push(err(&format!(
328                            "resource '{res}': endpoint '{action}' sort field '{field_name}' not found in schema"
329                        )));
330                    }
331                }
332            }
333
334            // soft_delete requires deleted_at field in schema
335            if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
336                errors.push(err(&format!(
337                    "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
338                )));
339            }
340
341            if let Some(upload) = &ep.upload {
342                match ep.method.as_ref() {
343                    Some(HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put) => {}
344                    Some(_) => errors.push(err(&format!(
345                        "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
346                    ))),
347                    None => {} // already reported above
348                }
349
350                match rd.schema.get(&upload.field) {
351                    Some(field) if field.field_type == FieldType::File => {}
352                    Some(_) => errors.push(err(&format!(
353                        "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
354                        upload.field
355                    ))),
356                    None => errors.push(err(&format!(
357                        "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
358                        upload.field
359                    ))),
360                }
361
362                if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
363                    errors.push(err(&format!(
364                        "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
365                        upload.storage
366                    )));
367                }
368
369                if !ep
370                    .input
371                    .as_ref()
372                    .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
373                {
374                    errors.push(err(&format!(
375                        "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
376                        upload.field
377                    )));
378                }
379
380                for (suffix, expected_types) in [
381                    ("filename", &[FieldType::String][..]),
382                    ("mime_type", &[FieldType::String][..]),
383                    ("size", &[FieldType::Integer][..]),
384                ] {
385                    let companion = format!("{}_{}", upload.field, suffix);
386                    if let Some(field) = rd.schema.get(&companion) {
387                        if !expected_types.contains(&field.field_type) {
388                            let expected = expected_types
389                                .iter()
390                                .map(ToString::to_string)
391                                .collect::<Vec<_>>()
392                                .join(" or ");
393                            errors.push(err(&format!(
394                                "resource '{res}': companion upload field '{companion}' must be type {expected}"
395                            )));
396                        }
397                    }
398                }
399            }
400        }
401    }
402
403    // Relation validation
404    if let Some(relations) = &rd.relations {
405        for (name, rel) in relations {
406            use shaperail_core::RelationType;
407
408            // belongs_to should have key
409            if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
410                errors.push(err(&format!(
411                    "resource '{res}': relation '{name}' is belongs_to but has no key"
412                )));
413            }
414
415            // has_many/has_one should have foreign_key
416            if matches!(
417                rel.relation_type,
418                RelationType::HasMany | RelationType::HasOne
419            ) && rel.foreign_key.is_none()
420            {
421                errors.push(err(&format!(
422                    "resource '{res}': relation '{name}' is {} but has no foreign_key",
423                    rel.relation_type
424                )));
425            }
426
427            // belongs_to key must exist in schema
428            if let Some(key) = &rel.key {
429                if !rd.schema.contains_key(key) {
430                    errors.push(err(&format!(
431                        "resource '{res}': relation '{name}' key '{key}' not found in schema"
432                    )));
433                }
434            }
435        }
436    }
437
438    // Index validation
439    if let Some(indexes) = &rd.indexes {
440        for (i, idx) in indexes.iter().enumerate() {
441            if idx.fields.is_empty() {
442                errors.push(err(&format!("resource '{res}': index {i} has no fields")));
443            }
444            for field_name in &idx.fields {
445                if !rd.schema.contains_key(field_name) {
446                    errors.push(err(&format!(
447                        "resource '{res}': index {i} references field '{field_name}' not in schema"
448                    )));
449                }
450            }
451            if let Some(order) = &idx.order {
452                if order != "asc" && order != "desc" {
453                    errors.push(err(&format!(
454                        "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
455                    )));
456                }
457            }
458        }
459    }
460
461    errors
462}
463
464/// Rejects `controller: { after: ... }` declarations on non-CRUD (custom) endpoints.
465///
466/// `before:` controllers are now permitted on custom endpoints — the runtime builds
467/// a `Context` with auto-populated `tenant_id`, dispatches the before-hook, and
468/// stashes the resulting `Context` into `req.extensions_mut()` so the custom handler
469/// can read it.
470///
471/// `after:` controllers remain rejected on custom endpoints because the custom handler
472/// owns the response shape — there is no `data:` envelope for the runtime to merge
473/// `ctx.response_extras` into, and no consistent hook point after the handler returns.
474fn validate_controller_only_on_crud(
475    resource: &str,
476    action: &str,
477    endpoint: &shaperail_core::EndpointSpec,
478) -> Option<ValidationError> {
479    const CRUD_ACTIONS: &[&str] = &[
480        "list",
481        "get",
482        "create",
483        "update",
484        "delete",
485        "bulk_create",
486        "bulk_delete",
487    ];
488    if CRUD_ACTIONS.contains(&action) {
489        return None;
490    }
491    // Custom endpoint: allow before-only controller, reject after-controller.
492    let has_after = endpoint
493        .controller
494        .as_ref()
495        .and_then(|c| c.after.as_deref())
496        .is_some();
497    if has_after {
498        return Some(err(&format!(
499            "resource '{resource}': endpoint '{action}' declares `controller: {{ after: ... }}`, \
500             but `after:` controllers are only valid on conventional CRUD endpoints \
501             (list / get / create / update / delete / bulk_create / bulk_delete).\n\
502             \n\
503             Custom endpoints generate their own response via `handler:`, so the runtime \
504             has no place to merge `ctx.response_extras` or pass through after-hook \
505             mutations. Use a `before:` controller for shared setup (auth augmentation, \
506             tenant scoping, request validation), and put response-shaping logic inside \
507             the handler itself."
508        )));
509    }
510    None
511}
512
513/// Rejects `handler:` declarations on convention endpoint action keys.
514///
515/// The runtime dispatches the convention actions (`list`, `get`, `create`,
516/// `update`, `delete`) via the standard CRUD path. The codegen helper
517/// `collect_custom_handlers` filters these out, so a `handler:` declaration
518/// on a convention key is silently dropped — the user's function is never
519/// registered, never compiled, and never called. Surface this as a hard
520/// validation error with an actionable workaround.
521fn validate_handler_only_on_custom(
522    resource: &str,
523    action: &str,
524    endpoint: &shaperail_core::EndpointSpec,
525) -> Option<ValidationError> {
526    let handler = endpoint.handler.as_deref()?;
527    if !crate::rust::HANDLER_CONVENTIONS.contains(&action) {
528        return None;
529    }
530    Some(err(&format!(
531        "resource '{resource}': endpoint '{action}' declares `handler: {handler}`, \
532         but `handler:` is only valid on custom (non-convention) endpoints. \
533         The convention actions list / get / create / update / delete are \
534         dispatched by the runtime CRUD path; a `handler:` declaration on \
535         them would be silently dropped at codegen time.\n\
536         \n\
537         To attach a custom handler at the same HTTP method+path, rename the \
538         endpoint key to a non-convention action (e.g. `post_{resource}`) and \
539         set `method:` and `path:` explicitly:\n\
540         \n\
541         endpoints:\n  \
542           post_{resource}:\n    \
543             method: POST\n    \
544             path: /{resource}\n    \
545             handler: {handler}\n\
546         \n\
547         To customize standard CRUD behavior without replacing the runtime \
548         path, use `controller: {{ before: ... }}` for setup logic and \
549         `controller: {{ after: ... }}` for response shaping."
550    )))
551}
552
553/// Validates a controller name — either a Rust function name or a `wasm:` prefixed path.
554fn validate_controller_name(
555    res: &str,
556    action: &str,
557    phase: &str,
558    name: &str,
559    errors: &mut Vec<ValidationError>,
560) {
561    if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
562        if wasm_path.is_empty() {
563            errors.push(err(&format!(
564                "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
565            )));
566        } else if !wasm_path.ends_with(".wasm") {
567            errors.push(err(&format!(
568                "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
569            )));
570        }
571    }
572}
573
574fn err(message: &str) -> ValidationError {
575    ValidationError {
576        message: message.to_string(),
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::parser::parse_resource;
584
585    #[test]
586    fn valid_resource_passes() {
587        let yaml = include_str!("../../resources/users.yaml");
588        let rd = parse_resource(yaml).unwrap();
589        let errors = validate_resource(&rd);
590        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
591    }
592
593    #[test]
594    fn enum_without_values() {
595        let yaml = r#"
596resource: items
597version: 1
598schema:
599  id: { type: uuid, primary: true, generated: true }
600  status: { type: enum, required: true }
601"#;
602        let rd = parse_resource(yaml).unwrap();
603        let errors = validate_resource(&rd);
604        assert!(errors
605            .iter()
606            .any(|e| e.message.contains("type enum but has no values")));
607    }
608
609    #[test]
610    fn ref_field_not_uuid() {
611        let yaml = r#"
612resource: items
613version: 1
614schema:
615  id: { type: uuid, primary: true, generated: true }
616  org_id: { type: string, ref: organizations.id }
617"#;
618        let rd = parse_resource(yaml).unwrap();
619        let errors = validate_resource(&rd);
620        assert!(errors
621            .iter()
622            .any(|e| e.message.contains("has ref but is not type uuid")));
623    }
624
625    #[test]
626    fn missing_primary_key() {
627        let yaml = r#"
628resource: items
629version: 1
630schema:
631  name: { type: string, required: true }
632"#;
633        let rd = parse_resource(yaml).unwrap();
634        let errors = validate_resource(&rd);
635        assert!(errors
636            .iter()
637            .any(|e| e.message.contains("must have a primary key")));
638    }
639
640    #[test]
641    fn soft_delete_without_deleted_at() {
642        let yaml = r#"
643resource: items
644version: 1
645schema:
646  id: { type: uuid, primary: true, generated: true }
647  name: { type: string, required: true }
648endpoints:
649  delete:
650    method: DELETE
651    path: /items/:id
652    auth: [admin]
653    soft_delete: true
654"#;
655        let rd = parse_resource(yaml).unwrap();
656        let errors = validate_resource(&rd);
657        assert!(errors.iter().any(|e| e
658            .message
659            .contains("soft_delete but schema has no 'deleted_at'")));
660    }
661
662    #[test]
663    fn input_field_not_in_schema() {
664        let yaml = r#"
665resource: items
666version: 1
667schema:
668  id: { type: uuid, primary: true, generated: true }
669  name: { type: string, required: true }
670endpoints:
671  create:
672    method: POST
673    path: /items
674    auth: [admin]
675    input: [name, nonexistent]
676"#;
677        let rd = parse_resource(yaml).unwrap();
678        let errors = validate_resource(&rd);
679        assert!(errors.iter().any(|e| e
680            .message
681            .contains("input field 'nonexistent' not found in schema")));
682    }
683
684    #[test]
685    fn belongs_to_without_key() {
686        let yaml = r#"
687resource: items
688version: 1
689schema:
690  id: { type: uuid, primary: true, generated: true }
691relations:
692  org: { resource: organizations, type: belongs_to }
693"#;
694        let rd = parse_resource(yaml).unwrap();
695        let errors = validate_resource(&rd);
696        assert!(errors
697            .iter()
698            .any(|e| e.message.contains("belongs_to but has no key")));
699    }
700
701    #[test]
702    fn has_many_without_foreign_key() {
703        let yaml = r#"
704resource: items
705version: 1
706schema:
707  id: { type: uuid, primary: true, generated: true }
708relations:
709  orders: { resource: orders, type: has_many }
710"#;
711        let rd = parse_resource(yaml).unwrap();
712        let errors = validate_resource(&rd);
713        assert!(errors
714            .iter()
715            .any(|e| e.message.contains("has_many but has no foreign_key")));
716    }
717
718    #[test]
719    fn index_references_missing_field() {
720        let yaml = r#"
721resource: items
722version: 1
723schema:
724  id: { type: uuid, primary: true, generated: true }
725indexes:
726  - fields: [missing_field]
727"#;
728        let rd = parse_resource(yaml).unwrap();
729        let errors = validate_resource(&rd);
730        assert!(errors.iter().any(|e| e
731            .message
732            .contains("references field 'missing_field' not in schema")));
733    }
734
735    #[test]
736    fn error_message_format() {
737        let yaml = r#"
738resource: users
739version: 1
740schema:
741  id: { type: uuid, primary: true, generated: true }
742  role: { type: enum }
743"#;
744        let rd = parse_resource(yaml).unwrap();
745        let errors = validate_resource(&rd);
746        assert_eq!(
747            errors[0].message,
748            "resource 'users': field 'role' is type enum but has no values"
749        );
750    }
751
752    #[test]
753    fn wasm_controller_valid_path() {
754        let yaml = r#"
755resource: items
756version: 1
757schema:
758  id: { type: uuid, primary: true, generated: true }
759  name: { type: string, required: true }
760endpoints:
761  create:
762    method: POST
763    path: /items
764    input: [name]
765    controller: { before: "wasm:./plugins/my_validator.wasm" }
766"#;
767        let rd = parse_resource(yaml).unwrap();
768        let errors = validate_resource(&rd);
769        assert!(
770            errors.is_empty(),
771            "Expected no errors for valid WASM controller, got: {errors:?}"
772        );
773    }
774
775    #[test]
776    fn wasm_controller_missing_extension() {
777        let yaml = r#"
778resource: items
779version: 1
780schema:
781  id: { type: uuid, primary: true, generated: true }
782  name: { type: string, required: true }
783endpoints:
784  create:
785    method: POST
786    path: /items
787    input: [name]
788    controller: { before: "wasm:./plugins/my_validator" }
789"#;
790        let rd = parse_resource(yaml).unwrap();
791        let errors = validate_resource(&rd);
792        assert!(errors
793            .iter()
794            .any(|e| e.message.contains("WASM path must end with '.wasm'")));
795    }
796
797    #[test]
798    fn wasm_controller_empty_path() {
799        let yaml = r#"
800resource: items
801version: 1
802schema:
803  id: { type: uuid, primary: true, generated: true }
804  name: { type: string, required: true }
805endpoints:
806  create:
807    method: POST
808    path: /items
809    input: [name]
810    controller: { before: "wasm:" }
811"#;
812        let rd = parse_resource(yaml).unwrap();
813        let errors = validate_resource(&rd);
814        assert!(errors
815            .iter()
816            .any(|e| e.message.contains("'wasm:' prefix but no path")));
817    }
818
819    #[test]
820    fn upload_endpoint_valid_when_file_field_declared() {
821        let yaml = r#"
822resource: assets
823version: 1
824schema:
825  id: { type: uuid, primary: true, generated: true }
826  file: { type: file, required: true }
827  file_filename: { type: string }
828  file_mime_type: { type: string }
829  file_size: { type: integer }
830  updated_at: { type: timestamp, generated: true }
831endpoints:
832  upload:
833    method: POST
834    path: /assets/upload
835    input: [file]
836    upload:
837      field: file
838      storage: local
839      max_size: 5mb
840"#;
841        let rd = parse_resource(yaml).unwrap();
842        let errors = validate_resource(&rd);
843        assert!(
844            errors.is_empty(),
845            "Expected valid upload resource, got {errors:?}"
846        );
847    }
848
849    #[test]
850    fn upload_endpoint_requires_file_field() {
851        let yaml = r#"
852resource: assets
853version: 1
854schema:
855  id: { type: uuid, primary: true, generated: true }
856  file_path: { type: string, required: true }
857endpoints:
858  upload:
859    method: POST
860    path: /assets/upload
861    input: [file_path]
862    upload:
863      field: file_path
864      storage: local
865      max_size: 5mb
866"#;
867        let rd = parse_resource(yaml).unwrap();
868        let errors = validate_resource(&rd);
869        assert!(errors.iter().any(|e| e
870            .message
871            .contains("upload field 'file_path' must be type file")));
872    }
873
874    #[test]
875    fn tenant_key_valid_uuid_field() {
876        let yaml = r#"
877resource: projects
878version: 1
879tenant_key: org_id
880schema:
881  id: { type: uuid, primary: true, generated: true }
882  org_id: { type: uuid, ref: organizations.id, required: true }
883  name: { type: string, required: true }
884"#;
885        let rd = parse_resource(yaml).unwrap();
886        let errors = validate_resource(&rd);
887        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
888    }
889
890    #[test]
891    fn tenant_key_missing_field() {
892        let yaml = r#"
893resource: projects
894version: 1
895tenant_key: org_id
896schema:
897  id: { type: uuid, primary: true, generated: true }
898  name: { type: string, required: true }
899"#;
900        let rd = parse_resource(yaml).unwrap();
901        let errors = validate_resource(&rd);
902        assert!(errors.iter().any(|e| e
903            .message
904            .contains("tenant_key 'org_id' not found in schema")));
905    }
906
907    #[test]
908    fn transient_field_valid() {
909        let yaml = r#"
910resource: users
911version: 1
912schema:
913  id:            { type: uuid, primary: true, generated: true }
914  password:      { type: string, transient: true, min: 12, required: true }
915  password_hash: { type: string, required: true }
916endpoints:
917  create:
918    method: POST
919    path: /users
920    input: [password]
921    controller: { before: hash_password }
922"#;
923        let rd = parse_resource(yaml).unwrap();
924        let errors = validate_resource(&rd);
925        assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
926    }
927
928    #[test]
929    fn transient_field_dead_when_not_in_input() {
930        let yaml = r#"
931resource: users
932version: 1
933schema:
934  id:       { type: uuid, primary: true, generated: true }
935  password: { type: string, transient: true, min: 12 }
936endpoints:
937  create:
938    method: POST
939    path: /users
940    input: []
941"#;
942        let rd = parse_resource(yaml).unwrap();
943        let errors = validate_resource(&rd);
944        assert!(errors.iter().any(|e| e
945            .message
946            .contains("transient field 'password' is not declared in any endpoint's input")));
947    }
948
949    #[test]
950    fn transient_field_rejects_primary() {
951        let yaml = r#"
952resource: users
953version: 1
954schema:
955  bad: { type: uuid, transient: true, primary: true }
956"#;
957        let rd = parse_resource(yaml).unwrap();
958        let errors = validate_resource(&rd);
959        assert!(errors
960            .iter()
961            .any(|e| e.message.contains("cannot be both transient and primary")));
962    }
963
964    #[test]
965    fn transient_field_rejects_generated() {
966        let yaml = r#"
967resource: users
968version: 1
969schema:
970  id:  { type: uuid, primary: true, generated: true }
971  bad: { type: timestamp, transient: true, generated: true }
972endpoints:
973  create:
974    method: POST
975    path: /users
976    input: [bad]
977"#;
978        let rd = parse_resource(yaml).unwrap();
979        let errors = validate_resource(&rd);
980        assert!(errors
981            .iter()
982            .any(|e| e.message.contains("cannot be both transient and generated")));
983    }
984
985    #[test]
986    fn transient_field_rejects_ref() {
987        let yaml = r#"
988resource: users
989version: 1
990schema:
991  id:  { type: uuid, primary: true, generated: true }
992  bad: { type: uuid, transient: true, ref: orgs.id }
993endpoints:
994  create:
995    method: POST
996    path: /users
997    input: [bad]
998"#;
999        let rd = parse_resource(yaml).unwrap();
1000        let errors = validate_resource(&rd);
1001        assert!(errors.iter().any(|e| e
1002            .message
1003            .contains("cannot be both transient and have a ref")));
1004    }
1005
1006    #[test]
1007    fn transient_field_rejects_unique() {
1008        let yaml = r#"
1009resource: users
1010version: 1
1011schema:
1012  id:  { type: uuid, primary: true, generated: true }
1013  bad: { type: string, transient: true, unique: true }
1014endpoints:
1015  create:
1016    method: POST
1017    path: /users
1018    input: [bad]
1019"#;
1020        let rd = parse_resource(yaml).unwrap();
1021        let errors = validate_resource(&rd);
1022        assert!(errors
1023            .iter()
1024            .any(|e| e.message.contains("cannot be both transient and unique")));
1025    }
1026
1027    #[test]
1028    fn transient_field_rejects_default() {
1029        let yaml = r#"
1030resource: users
1031version: 1
1032schema:
1033  id:  { type: uuid, primary: true, generated: true }
1034  bad: { type: string, transient: true, default: "x" }
1035endpoints:
1036  create:
1037    method: POST
1038    path: /users
1039    input: [bad]
1040"#;
1041        let rd = parse_resource(yaml).unwrap();
1042        let errors = validate_resource(&rd);
1043        assert!(errors.iter().any(|e| e
1044            .message
1045            .contains("cannot be both transient and have a default")));
1046    }
1047
1048    #[test]
1049    fn tenant_key_wrong_type() {
1050        let yaml = r#"
1051resource: projects
1052version: 1
1053tenant_key: org_name
1054schema:
1055  id: { type: uuid, primary: true, generated: true }
1056  org_name: { type: string, required: true }
1057"#;
1058        let rd = parse_resource(yaml).unwrap();
1059        let errors = validate_resource(&rd);
1060        assert!(errors.iter().any(|e| e
1061            .message
1062            .contains("tenant_key 'org_name' must reference a uuid field")));
1063    }
1064
1065    #[test]
1066    fn reject_after_controller_on_custom_endpoint() {
1067        let yaml = r#"
1068resource: agents
1069version: 1
1070schema:
1071  id: { type: uuid, primary: true, generated: true }
1072endpoints:
1073  regenerate_secret:
1074    method: POST
1075    path: /agents/:id/regenerate_secret
1076    auth: [admin]
1077    controller: { after: my_after }
1078"#;
1079        let rd = parse_resource(yaml).unwrap();
1080        let errors = validate_resource(&rd);
1081        assert!(
1082            errors
1083                .iter()
1084                .any(|e| e.message.contains("declares `controller: { after: ... }`")),
1085            "expected a CustomEndpointWithAfterController error, got: {errors:?}"
1086        );
1087    }
1088
1089    #[test]
1090    fn allow_before_controller_on_custom_endpoint() {
1091        let yaml = r#"
1092resource: agents
1093version: 1
1094schema:
1095  id: { type: uuid, primary: true, generated: true }
1096endpoints:
1097  regenerate_secret:
1098    method: POST
1099    path: /agents/:id/regenerate_secret
1100    auth: [admin]
1101    controller: { before: my_before }
1102"#;
1103        let rd = parse_resource(yaml).unwrap();
1104        let errors = validate_resource(&rd);
1105        assert!(
1106            errors.is_empty(),
1107            "before:-only controller on a custom endpoint should validate clean, got: {errors:?}"
1108        );
1109    }
1110
1111    #[test]
1112    fn allow_controller_on_crud_endpoints() {
1113        let yaml = r#"
1114resource: agents
1115version: 1
1116schema:
1117  id: { type: uuid, primary: true, generated: true }
1118  name: { type: string, required: true }
1119endpoints:
1120  create:
1121    method: POST
1122    path: /agents
1123    input: [name]
1124    controller: { before: my_before }
1125"#;
1126        let rd = parse_resource(yaml).unwrap();
1127        let errors = validate_resource(&rd);
1128        assert!(
1129            !errors.iter().any(|e| e
1130                .message
1131                .contains("declares `controller:` but is a custom endpoint")),
1132            "create endpoint with controller should NOT trip the rule, got: {errors:?}"
1133        );
1134    }
1135
1136    #[test]
1137    fn handler_on_convention_endpoint_rejected() {
1138        for action in &["list", "get", "create", "update", "delete"] {
1139            let yaml = format!(
1140                r#"
1141resource: items
1142version: 1
1143schema:
1144  id: {{ type: uuid, primary: true, generated: true }}
1145  name: {{ type: string, required: true }}
1146endpoints:
1147  {action}:
1148    handler: my_handler
1149"#
1150            );
1151            let rd = parse_resource(&yaml).unwrap();
1152            let errors = validate_resource(&rd);
1153            assert!(
1154                errors
1155                    .iter()
1156                    .any(|e| e.message.contains("`handler: my_handler`")
1157                        && e.message
1158                            .contains("only valid on custom (non-convention) endpoints")),
1159                "expected handler-on-convention error for action '{action}', got: {errors:?}"
1160            );
1161        }
1162    }
1163
1164    #[test]
1165    fn handler_on_custom_endpoint_allowed() {
1166        let yaml = r#"
1167resource: items
1168version: 1
1169schema:
1170  id: { type: uuid, primary: true, generated: true }
1171  name: { type: string, required: true }
1172endpoints:
1173  post_item:
1174    method: POST
1175    path: /items
1176    handler: create_item_custom
1177"#;
1178        let rd = parse_resource(yaml).unwrap();
1179        let errors = validate_resource(&rd);
1180        assert!(
1181            !errors
1182                .iter()
1183                .any(|e| e.message.contains("only valid on custom")),
1184            "custom endpoint with handler must not trip the rule, got: {errors:?}"
1185        );
1186    }
1187
1188    #[test]
1189    fn convention_endpoint_without_handler_allowed() {
1190        let yaml = r#"
1191resource: items
1192version: 1
1193schema:
1194  id: { type: uuid, primary: true, generated: true }
1195  name: { type: string, required: true }
1196endpoints:
1197  create:
1198    input: [name]
1199"#;
1200        let rd = parse_resource(yaml).unwrap();
1201        let errors = validate_resource(&rd);
1202        assert!(
1203            !errors
1204                .iter()
1205                .any(|e| e.message.contains("only valid on custom")),
1206            "convention endpoint without handler must not trip the rule, got: {errors:?}"
1207        );
1208    }
1209
1210    fn array_field(items: shaperail_core::ItemsSpec) -> shaperail_core::FieldSchema {
1211        shaperail_core::FieldSchema {
1212            field_type: shaperail_core::FieldType::Array,
1213            primary: false,
1214            generated: false,
1215            required: false,
1216            unique: false,
1217            nullable: false,
1218            reference: None,
1219            min: None,
1220            max: None,
1221            format: None,
1222            values: None,
1223            default: None,
1224            sensitive: false,
1225            search: false,
1226            items: Some(items),
1227            transient: false,
1228        }
1229    }
1230
1231    fn resource_with_array(
1232        name: &str,
1233        field: shaperail_core::FieldSchema,
1234    ) -> shaperail_core::ResourceDefinition {
1235        let mut schema = indexmap::IndexMap::new();
1236        schema.insert(name.to_string(), field);
1237        shaperail_core::ResourceDefinition {
1238            resource: "test".to_string(),
1239            version: 1,
1240            db: None,
1241            tenant_key: None,
1242            schema,
1243            endpoints: None,
1244            relations: None,
1245            indexes: None,
1246        }
1247    }
1248
1249    // ── Additional tests from coverage-improvement pass ───────────────────
1250
1251    #[test]
1252    fn empty_resource_name_is_rejected() {
1253        use indexmap::IndexMap;
1254        use shaperail_core::{FieldSchema, FieldType, ResourceDefinition};
1255
1256        let mut schema = IndexMap::new();
1257        schema.insert(
1258            "id".to_string(),
1259            FieldSchema {
1260                field_type: FieldType::Uuid,
1261                primary: true,
1262                generated: true,
1263                required: false,
1264                unique: false,
1265                nullable: false,
1266                reference: None,
1267                min: None,
1268                max: None,
1269                format: None,
1270                values: None,
1271                default: None,
1272                sensitive: false,
1273                search: false,
1274                items: None,
1275                transient: false,
1276            },
1277        );
1278        let rd = ResourceDefinition {
1279            resource: String::new(),
1280            version: 1,
1281            db: None,
1282            tenant_key: None,
1283            schema,
1284            endpoints: None,
1285            relations: None,
1286            indexes: None,
1287        };
1288        let errors = validate_resource(&rd);
1289        assert!(
1290            errors
1291                .iter()
1292                .any(|e| e.message.contains("resource name must not be empty")),
1293            "Expected empty name error, got: {errors:?}"
1294        );
1295    }
1296
1297    #[test]
1298    fn version_zero_is_rejected() {
1299        let yaml = r#"
1300resource: items
1301version: 0
1302schema:
1303  id: { type: uuid, primary: true, generated: true }
1304"#;
1305        let rd = parse_resource(yaml).unwrap();
1306        let errors = validate_resource(&rd);
1307        assert!(
1308            errors
1309                .iter()
1310                .any(|e| e.message.contains("version must be >= 1")),
1311            "Expected version error, got: {errors:?}"
1312        );
1313    }
1314
1315    #[test]
1316    fn multiple_primary_keys_rejected() {
1317        let yaml = r#"
1318resource: items
1319version: 1
1320schema:
1321  id:  { type: uuid, primary: true, generated: true }
1322  alt: { type: uuid, primary: true, generated: true }
1323  name: { type: string, required: true }
1324"#;
1325        let rd = parse_resource(yaml).unwrap();
1326        let errors = validate_resource(&rd);
1327        assert!(
1328            errors
1329                .iter()
1330                .any(|e| e.message.contains("exactly one primary key")),
1331            "Expected multiple-PK error, got: {errors:?}"
1332        );
1333    }
1334
1335    #[test]
1336    fn non_enum_field_with_values_rejected() {
1337        let yaml = r#"
1338resource: items
1339version: 1
1340schema:
1341  id:   { type: uuid, primary: true, generated: true }
1342  name: { type: string, required: true, values: ["a", "b"] }
1343"#;
1344        let rd = parse_resource(yaml).unwrap();
1345        let errors = validate_resource(&rd);
1346        assert!(
1347            errors
1348                .iter()
1349                .any(|e| e.message.contains("has values but is not type enum")),
1350            "Expected non-enum-with-values error, got: {errors:?}"
1351        );
1352    }
1353
1354    #[test]
1355    fn array_field_without_items_rejected() {
1356        let yaml = r#"
1357resource: items
1358version: 1
1359schema:
1360  id:   { type: uuid, primary: true, generated: true }
1361  tags: { type: array }
1362"#;
1363        let rd = parse_resource(yaml).unwrap();
1364        let errors = validate_resource(&rd);
1365        assert!(
1366            errors
1367                .iter()
1368                .any(|e| e.message.contains("type array but has no items")),
1369            "Expected array-no-items error, got: {errors:?}"
1370        );
1371    }
1372
1373    #[test]
1374    fn format_on_non_string_rejected() {
1375        let yaml = r#"
1376resource: items
1377version: 1
1378schema:
1379  id:  { type: uuid, primary: true, generated: true }
1380  age: { type: integer, required: true, format: email }
1381"#;
1382        let rd = parse_resource(yaml).unwrap();
1383        let errors = validate_resource(&rd);
1384        assert!(
1385            errors
1386                .iter()
1387                .any(|e| e.message.contains("has format but is not type string")),
1388            "Expected format-on-non-string error, got: {errors:?}"
1389        );
1390    }
1391
1392    #[test]
1393    fn primary_key_not_generated_or_required_rejected() {
1394        let yaml = r#"
1395resource: items
1396version: 1
1397schema:
1398  id: { type: uuid, primary: true }
1399"#;
1400        let rd = parse_resource(yaml).unwrap();
1401        let errors = validate_resource(&rd);
1402        assert!(
1403            errors
1404                .iter()
1405                .any(|e| e.message.contains("must be generated or required")),
1406            "Expected primary-not-generated-or-required error, got: {errors:?}"
1407        );
1408    }
1409
1410    #[test]
1411    fn filter_field_not_in_schema_rejected() {
1412        let yaml = r#"
1413resource: items
1414version: 1
1415schema:
1416  id:   { type: uuid, primary: true, generated: true }
1417  name: { type: string, required: true }
1418endpoints:
1419  list:
1420    auth: public
1421    filters: [name, nonexistent_filter]
1422"#;
1423        let rd = parse_resource(yaml).unwrap();
1424        let errors = validate_resource(&rd);
1425        assert!(
1426            errors.iter().any(|e| e
1427                .message
1428                .contains("filter field 'nonexistent_filter' not found")),
1429            "Expected filter-field error, got: {errors:?}"
1430        );
1431    }
1432
1433    #[test]
1434    fn search_field_not_in_schema_rejected() {
1435        let yaml = r#"
1436resource: items
1437version: 1
1438schema:
1439  id:   { type: uuid, primary: true, generated: true }
1440  name: { type: string, required: true }
1441endpoints:
1442  list:
1443    auth: public
1444    search: [name, missing_search_field]
1445"#;
1446        let rd = parse_resource(yaml).unwrap();
1447        let errors = validate_resource(&rd);
1448        assert!(
1449            errors.iter().any(|e| e
1450                .message
1451                .contains("search field 'missing_search_field' not found")),
1452            "Expected search-field error, got: {errors:?}"
1453        );
1454    }
1455
1456    #[test]
1457    fn sort_field_not_in_schema_rejected() {
1458        let yaml = r#"
1459resource: items
1460version: 1
1461schema:
1462  id:   { type: uuid, primary: true, generated: true }
1463  name: { type: string, required: true }
1464endpoints:
1465  list:
1466    auth: public
1467    sort: [name, unknown_sort]
1468"#;
1469        let rd = parse_resource(yaml).unwrap();
1470        let errors = validate_resource(&rd);
1471        assert!(
1472            errors
1473                .iter()
1474                .any(|e| e.message.contains("sort field 'unknown_sort' not found")),
1475            "Expected sort-field error, got: {errors:?}"
1476        );
1477    }
1478
1479    #[test]
1480    fn relation_key_not_in_schema_rejected() {
1481        let yaml = r#"
1482resource: items
1483version: 1
1484schema:
1485  id: { type: uuid, primary: true, generated: true }
1486relations:
1487  org: { resource: organizations, type: belongs_to, key: missing_key }
1488"#;
1489        let rd = parse_resource(yaml).unwrap();
1490        let errors = validate_resource(&rd);
1491        assert!(
1492            errors
1493                .iter()
1494                .any(|e| e.message.contains("key 'missing_key' not found in schema")),
1495            "Expected relation-key error, got: {errors:?}"
1496        );
1497    }
1498
1499    #[test]
1500    fn index_empty_fields_rejected() {
1501        use indexmap::IndexMap;
1502        use shaperail_core::{FieldSchema, FieldType, IndexSpec, ResourceDefinition};
1503
1504        let mut schema = IndexMap::new();
1505        schema.insert(
1506            "id".to_string(),
1507            FieldSchema {
1508                field_type: FieldType::Uuid,
1509                primary: true,
1510                generated: true,
1511                required: false,
1512                unique: false,
1513                nullable: false,
1514                reference: None,
1515                min: None,
1516                max: None,
1517                format: None,
1518                values: None,
1519                default: None,
1520                sensitive: false,
1521                search: false,
1522                items: None,
1523                transient: false,
1524            },
1525        );
1526        let rd = ResourceDefinition {
1527            resource: "items".to_string(),
1528            version: 1,
1529            db: None,
1530            tenant_key: None,
1531            schema,
1532            endpoints: None,
1533            relations: None,
1534            indexes: Some(vec![IndexSpec {
1535                fields: vec![],
1536                unique: false,
1537                order: None,
1538            }]),
1539        };
1540        let errors = validate_resource(&rd);
1541        assert!(
1542            errors
1543                .iter()
1544                .any(|e| e.message.contains("index 0 has no fields")),
1545            "Expected empty-index error, got: {errors:?}"
1546        );
1547    }
1548
1549    #[test]
1550    fn index_invalid_order_rejected() {
1551        let yaml = r#"
1552resource: items
1553version: 1
1554schema:
1555  id:         { type: uuid, primary: true, generated: true }
1556  created_at: { type: timestamp, generated: true }
1557indexes:
1558  - fields: [created_at]
1559    order: INVALID
1560"#;
1561        let rd = parse_resource(yaml).unwrap();
1562        let errors = validate_resource(&rd);
1563        assert!(
1564            errors
1565                .iter()
1566                .any(|e| e.message.contains("invalid order 'INVALID'")),
1567            "Expected invalid-order error, got: {errors:?}"
1568        );
1569    }
1570
1571    #[test]
1572    fn valid_index_asc_and_desc_accepted() {
1573        let yaml = r#"
1574resource: items
1575version: 1
1576schema:
1577  id:         { type: uuid, primary: true, generated: true }
1578  created_at: { type: timestamp, generated: true }
1579  name:       { type: string, required: true }
1580indexes:
1581  - fields: [created_at]
1582    order: desc
1583  - fields: [name]
1584    order: asc
1585"#;
1586        let rd = parse_resource(yaml).unwrap();
1587        let errors = validate_resource(&rd);
1588        assert!(
1589            errors.is_empty(),
1590            "Expected no errors for valid indexes, got: {errors:?}"
1591        );
1592    }
1593
1594    #[test]
1595    fn ref_missing_dot_separator_rejected() {
1596        let yaml = r#"
1597resource: items
1598version: 1
1599schema:
1600  id:     { type: uuid, primary: true, generated: true }
1601  org_id: { type: uuid, ref: organizations }
1602"#;
1603        let rd = parse_resource(yaml).unwrap();
1604        let errors = validate_resource(&rd);
1605        assert!(
1606            errors
1607                .iter()
1608                .any(|e| e.message.contains("'resource.field' format")),
1609            "Expected bad-ref-format error, got: {errors:?}"
1610        );
1611    }
1612
1613    #[test]
1614    fn has_one_without_foreign_key_rejected() {
1615        let yaml = r#"
1616resource: users
1617version: 1
1618schema:
1619  id: { type: uuid, primary: true, generated: true }
1620relations:
1621  profile: { resource: profiles, type: has_one }
1622"#;
1623        let rd = parse_resource(yaml).unwrap();
1624        let errors = validate_resource(&rd);
1625        assert!(
1626            errors
1627                .iter()
1628                .any(|e| e.message.contains("has_one but has no foreign_key")),
1629            "Expected has_one error, got: {errors:?}"
1630        );
1631    }
1632
1633    #[test]
1634    fn empty_event_name_rejected() {
1635        let yaml = r#"
1636resource: items
1637version: 1
1638schema:
1639  id: { type: uuid, primary: true, generated: true }
1640endpoints:
1641  create:
1642    method: POST
1643    path: /items
1644    events: [""]
1645"#;
1646        let rd = parse_resource(yaml).unwrap();
1647        let errors = validate_resource(&rd);
1648        assert!(
1649            errors
1650                .iter()
1651                .any(|e| e.message.contains("empty event name")),
1652            "Expected empty-event error, got: {errors:?}"
1653        );
1654    }
1655
1656    #[test]
1657    fn empty_job_name_rejected() {
1658        let yaml = r#"
1659resource: items
1660version: 1
1661schema:
1662  id: { type: uuid, primary: true, generated: true }
1663endpoints:
1664  create:
1665    method: POST
1666    path: /items
1667    jobs: [""]
1668"#;
1669        let rd = parse_resource(yaml).unwrap();
1670        let errors = validate_resource(&rd);
1671        assert!(
1672            errors.iter().any(|e| e.message.contains("empty job name")),
1673            "Expected empty-job error, got: {errors:?}"
1674        );
1675    }
1676
1677    #[test]
1678    fn upload_missing_from_input_rejected() {
1679        let yaml = r#"
1680resource: assets
1681version: 1
1682schema:
1683  id:   { type: uuid, primary: true, generated: true }
1684  file: { type: file, required: true }
1685endpoints:
1686  upload:
1687    method: POST
1688    path: /assets/upload
1689    input: []
1690    upload:
1691      field: file
1692      storage: s3
1693      max_size: 5mb
1694"#;
1695        let rd = parse_resource(yaml).unwrap();
1696        let errors = validate_resource(&rd);
1697        assert!(
1698            errors.iter().any(|e| e
1699                .message
1700                .contains("upload field 'file' must appear in input")),
1701            "Expected upload-not-in-input error, got: {errors:?}"
1702        );
1703    }
1704
1705    #[test]
1706    fn upload_invalid_storage_rejected() {
1707        let yaml = r#"
1708resource: assets
1709version: 1
1710schema:
1711  id:   { type: uuid, primary: true, generated: true }
1712  file: { type: file, required: true }
1713endpoints:
1714  upload:
1715    method: POST
1716    path: /assets/upload
1717    input: [file]
1718    upload:
1719      field: file
1720      storage: dropbox
1721      max_size: 5mb
1722"#;
1723        let rd = parse_resource(yaml).unwrap();
1724        let errors = validate_resource(&rd);
1725        assert!(
1726            errors
1727                .iter()
1728                .any(|e| e.message.contains("upload storage 'dropbox' is invalid")),
1729            "Expected invalid-storage error, got: {errors:?}"
1730        );
1731    }
1732
1733    #[test]
1734    fn items_nested_array_rejected() {
1735        let field = array_field(shaperail_core::ItemsSpec::of(
1736            shaperail_core::FieldType::Array,
1737        ));
1738        let rd = resource_with_array("nested", field);
1739        let errors = validate_resource(&rd);
1740        assert!(errors
1741            .iter()
1742            .any(|e| e.message.contains("nested array") || e.message.contains("type: json")));
1743    }
1744
1745    #[test]
1746    fn items_enum_requires_values() {
1747        let field = array_field(shaperail_core::ItemsSpec::of(
1748            shaperail_core::FieldType::Enum,
1749        ));
1750        let rd = resource_with_array("flags", field);
1751        let errors = validate_resource(&rd);
1752        assert!(errors.iter().any(|e| e.message.contains("values")));
1753    }
1754
1755    #[test]
1756    fn items_format_only_on_string() {
1757        let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::Integer);
1758        items.format = Some("email".to_string());
1759        let field = array_field(items);
1760        let rd = resource_with_array("nums", field);
1761        let errors = validate_resource(&rd);
1762        assert!(errors.iter().any(|e| e.message.contains("format")));
1763    }
1764
1765    #[test]
1766    fn items_ref_requires_uuid() {
1767        let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::String);
1768        items.reference = Some("organizations.id".to_string());
1769        let field = array_field(items);
1770        let rd = resource_with_array("badrefs", field);
1771        let errors = validate_resource(&rd);
1772        assert!(errors
1773            .iter()
1774            .any(|e| e.message.contains("ref") && e.message.contains("uuid")));
1775    }
1776
1777    #[test]
1778    fn items_ref_format_must_be_resource_dot_field() {
1779        let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::Uuid);
1780        items.reference = Some("organizations".to_string()); // missing .id
1781        let field = array_field(items);
1782        let rd = resource_with_array("orgs", field);
1783        let errors = validate_resource(&rd);
1784        assert!(errors.iter().any(|e| e.message.contains("resource.field")));
1785    }
1786
1787    #[test]
1788    fn items_uuid_ref_valid() {
1789        let mut items = shaperail_core::ItemsSpec::of(shaperail_core::FieldType::Uuid);
1790        items.reference = Some("organizations.id".to_string());
1791        let field = array_field(items);
1792        let rd = resource_with_array("tags", field);
1793        let errors = validate_resource(&rd);
1794        // No errors related to this field.
1795        assert!(errors.iter().all(|e| !e.message.contains("tags")));
1796    }
1797
1798    #[test]
1799    fn bigint_type_produces_friendly_error() {
1800        let yaml = r#"
1801resource: invoices
1802version: 1
1803schema:
1804  id: { type: uuid, primary: true, generated: true }
1805  amount_cents: { type: bigint, required: true }
1806"#;
1807        let err = parse_resource(yaml).unwrap_err();
1808        let msg = err.to_string();
1809        assert!(
1810            msg.contains("removed in v0.13.0"),
1811            "Expected removal message, got: {msg}"
1812        );
1813        assert!(
1814            msg.contains("use 'integer'"),
1815            "Expected migration hint, got: {msg}"
1816        );
1817        assert!(
1818            msg.contains("E_BIGINT_REMOVED"),
1819            "Expected error code, got: {msg}"
1820        );
1821    }
1822}