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