Skip to main content

shaperail_codegen/
diagnostics.rs

1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition, WASM_HOOK_PREFIX};
2
3/// A structured diagnostic with error code, human message, fix suggestion, and example.
4#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
5pub struct Diagnostic {
6    /// Stable error code (e.g., "SR001").
7    pub code: &'static str,
8    /// Human-readable error message.
9    pub error: String,
10    /// Suggested fix action.
11    pub fix: String,
12    /// Inline YAML example showing the correct pattern.
13    pub example: String,
14}
15
16impl std::fmt::Display for Diagnostic {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        write!(f, "[{}] {}", self.code, self.error)
19    }
20}
21
22/// Validate a parsed `ResourceDefinition` and return structured diagnostics
23/// with fix suggestions. This is the "AI-friendly" counterpart to `validator::validate_resource`.
24pub fn diagnose_resource(rd: &ResourceDefinition) -> Vec<Diagnostic> {
25    let mut diags = Vec::new();
26    let res = &rd.resource;
27
28    if res.is_empty() {
29        diags.push(Diagnostic {
30            code: "SR001",
31            error: "resource name must not be empty".into(),
32            fix: "add a snake_case plural name to the 'resource' key".into(),
33            example: "resource: users".into(),
34        });
35    }
36
37    if rd.version == 0 {
38        diags.push(Diagnostic {
39            code: "SR002",
40            error: format!("resource '{res}': version must be >= 1"),
41            fix: "set version to 1 or higher".into(),
42            example: "version: 1".into(),
43        });
44    }
45
46    if rd.schema.is_empty() {
47        diags.push(Diagnostic {
48            code: "SR003",
49            error: format!("resource '{res}': schema must have at least one field"),
50            fix: "add at least an id field to the schema section".into(),
51            example: "schema:\n  id: { type: uuid, primary: true, generated: true }".into(),
52        });
53    }
54
55    let primary_count = rd.schema.values().filter(|f| f.primary).count();
56    if primary_count == 0 && !rd.schema.is_empty() {
57        diags.push(Diagnostic {
58            code: "SR004",
59            error: format!("resource '{res}': schema must have a primary key field"),
60            fix: "add 'primary: true' to one field (typically 'id')".into(),
61            example: "id: { type: uuid, primary: true, generated: true }".into(),
62        });
63    } else if primary_count > 1 {
64        diags.push(Diagnostic {
65            code: "SR005",
66            error: format!(
67                "resource '{res}': schema must have exactly one primary key, found {primary_count}"
68            ),
69            fix: "remove 'primary: true' from all fields except one".into(),
70            example: "id: { type: uuid, primary: true, generated: true }".into(),
71        });
72    }
73
74    for (name, field) in &rd.schema {
75        if field.field_type == FieldType::Enum && field.values.is_none() {
76            diags.push(Diagnostic {
77                code: "SR010",
78                error: format!("resource '{res}': field '{name}' is type enum but has no values"),
79                fix: format!("add 'values: [value1, value2]' to the '{name}' field"),
80                example: format!("{name}: {{ type: enum, values: [option_a, option_b] }}"),
81            });
82        }
83
84        if field.field_type != FieldType::Enum && field.values.is_some() {
85            diags.push(Diagnostic {
86                code: "SR011",
87                error: format!("resource '{res}': field '{name}' has values but is not type enum"),
88                fix: format!("change the type to 'enum' or remove 'values' from '{name}'"),
89                example: format!("{name}: {{ type: enum, values: [...] }}"),
90            });
91        }
92
93        if field.reference.is_some() && field.field_type != FieldType::Uuid {
94            diags.push(Diagnostic {
95                code: "SR012",
96                error: format!("resource '{res}': field '{name}' has ref but is not type uuid"),
97                fix: format!("change the type of '{name}' to uuid"),
98                example: format!(
99                    "{name}: {{ type: uuid, ref: {}, required: true }}",
100                    field.reference.as_deref().unwrap_or("resource.id")
101                ),
102            });
103        }
104
105        if let Some(ref reference) = field.reference {
106            if !reference.contains('.') {
107                diags.push(Diagnostic {
108                    code: "SR013",
109                    error: format!(
110                        "resource '{res}': field '{name}' ref must be in 'resource.field' format, got '{reference}'"
111                    ),
112                    fix: "use 'resource_name.field_name' format for the ref value".into(),
113                    example: format!("{name}: {{ type: uuid, ref: organizations.id }}"),
114                });
115            }
116        }
117
118        if field.field_type == FieldType::Array && field.items.is_none() {
119            diags.push(Diagnostic {
120                code: "SR014",
121                error: format!("resource '{res}': field '{name}' is type array but has no items"),
122                fix: format!("add 'items: <element_type>' to the '{name}' field"),
123                example: format!("{name}: {{ type: array, items: string }}"),
124            });
125        }
126
127        if let Some(items_spec) = &field.items {
128            if items_spec.field_type == FieldType::Array {
129                diags.push(Diagnostic {
130                    code: "SR076",
131                    error: format!("resource '{res}': field '{name}' has nested array items"),
132                    fix: "change items to type: json (nested arrays are not supported)".to_string(),
133                    example: format!("{name}: {{ type: json }}"),
134                });
135            }
136            if items_spec.field_type == FieldType::Enum && items_spec.values.is_none() {
137                diags.push(Diagnostic {
138                    code: "SR077",
139                    error: format!("resource '{res}': field '{name}' enum items missing values"),
140                    fix: "add `values: [...]` to items".to_string(),
141                    example: format!(
142                        "{name}: {{ type: array, items: {{ type: enum, values: [a, b] }} }}"
143                    ),
144                });
145            }
146            if items_spec.format.is_some() && items_spec.field_type != FieldType::String {
147                diags.push(Diagnostic {
148                    code: "SR078",
149                    error: format!(
150                        "resource '{res}': field '{name}' items.format only valid on string"
151                    ),
152                    fix: "remove items.format or change items.type to string".to_string(),
153                    example: format!(
154                        "{name}: {{ type: array, items: {{ type: string, format: email }} }}"
155                    ),
156                });
157            }
158            if items_spec.reference.is_some() && items_spec.field_type != FieldType::Uuid {
159                diags.push(Diagnostic {
160                    code: "SR079",
161                    error: format!(
162                        "resource '{res}': field '{name}' items.ref requires items.type uuid"
163                    ),
164                    fix: "change items.type to uuid, or remove items.ref".to_string(),
165                    example: format!(
166                        "{name}: {{ type: array, items: {{ type: uuid, ref: organizations.id }} }}"
167                    ),
168                });
169            }
170            if let Some(reference) = &items_spec.reference {
171                if !reference.contains('.') {
172                    diags.push(Diagnostic {
173                        code: "SR080",
174                        error: format!(
175                            "resource '{res}': field '{name}' items.ref must be 'resource.field'"
176                        ),
177                        fix: "write items.ref as 'resource_name.column_name'".to_string(),
178                        example: "items: { type: uuid, ref: organizations.id }".to_string(),
179                    });
180                }
181            }
182        }
183
184        if field.format.is_some() && field.field_type != FieldType::String {
185            diags.push(Diagnostic {
186                code: "SR015",
187                error: format!(
188                    "resource '{res}': field '{name}' has format but is not type string"
189                ),
190                fix: format!("change the type of '{name}' to string, or remove 'format'"),
191                example: format!(
192                    "{name}: {{ type: string, format: {} }}",
193                    field.format.as_deref().unwrap_or("email")
194                ),
195            });
196        }
197
198        if field.primary && !field.generated && !field.required {
199            diags.push(Diagnostic {
200                code: "SR016",
201                error: format!(
202                    "resource '{res}': primary key field '{name}' must be generated or required"
203                ),
204                fix: format!("add 'generated: true' or 'required: true' to '{name}'"),
205                example: format!("{name}: {{ type: uuid, primary: true, generated: true }}"),
206            });
207        }
208    }
209
210    // Tenant key validation
211    if let Some(ref tenant_key) = rd.tenant_key {
212        match rd.schema.get(tenant_key) {
213            Some(field) => {
214                if field.field_type != FieldType::Uuid {
215                    diags.push(Diagnostic {
216                        code: "SR020",
217                        error: format!(
218                            "resource '{res}': tenant_key '{tenant_key}' must reference a uuid field, found {}",
219                            field.field_type
220                        ),
221                        fix: format!("change the type of '{tenant_key}' to uuid"),
222                        example: format!(
223                            "{tenant_key}: {{ type: uuid, ref: organizations.id, required: true }}"
224                        ),
225                    });
226                }
227            }
228            None => {
229                diags.push(Diagnostic {
230                    code: "SR021",
231                    error: format!(
232                        "resource '{res}': tenant_key '{tenant_key}' not found in schema"
233                    ),
234                    fix: format!("add a '{tenant_key}' uuid field to the schema"),
235                    example: format!(
236                        "{tenant_key}: {{ type: uuid, ref: organizations.id, required: true }}"
237                    ),
238                });
239            }
240        }
241    }
242
243    // Endpoint validation
244    if let Some(endpoints) = &rd.endpoints {
245        for (action, ep) in endpoints {
246            if let Some(controller) = &ep.controller {
247                if let Some(before) = &controller.before {
248                    if before.is_empty() {
249                        diags.push(Diagnostic {
250                            code: "SR030",
251                            error: format!(
252                                "resource '{res}': endpoint '{action}' has an empty controller.before name"
253                            ),
254                            fix: "provide a function name for controller.before".into(),
255                            example: "controller: { before: validate_input }".into(),
256                        });
257                    }
258                    diagnose_controller_name(res, action, "before", before, &mut diags);
259                }
260                if let Some(after) = &controller.after {
261                    if after.is_empty() {
262                        diags.push(Diagnostic {
263                            code: "SR031",
264                            error: format!(
265                                "resource '{res}': endpoint '{action}' has an empty controller.after name"
266                            ),
267                            fix: "provide a function name for controller.after".into(),
268                            example: "controller: { after: enrich_response }".into(),
269                        });
270                    }
271                    diagnose_controller_name(res, action, "after", after, &mut diags);
272                }
273            }
274
275            if let Some(events) = &ep.events {
276                for event in events {
277                    if event.is_empty() {
278                        diags.push(Diagnostic {
279                            code: "SR032",
280                            error: format!(
281                                "resource '{res}': endpoint '{action}' has an empty event name"
282                            ),
283                            fix: "use 'resource.action' format for event names".into(),
284                            example: format!("events: [{res}.created]"),
285                        });
286                    }
287                }
288            }
289
290            if let Some(jobs) = &ep.jobs {
291                for job in jobs {
292                    if job.is_empty() {
293                        diags.push(Diagnostic {
294                            code: "SR033",
295                            error: format!(
296                                "resource '{res}': endpoint '{action}' has an empty job name"
297                            ),
298                            fix: "provide a snake_case job name".into(),
299                            example: "jobs: [send_notification]".into(),
300                        });
301                    }
302                }
303            }
304
305            // Input/filter/search/sort fields must exist
306            for (field_kind, fields) in [
307                ("input", &ep.input),
308                ("filter", &ep.filters),
309                ("search", &ep.search),
310                ("sort", &ep.sort),
311            ] {
312                if let Some(fields) = fields {
313                    for field_name in fields {
314                        if !rd.schema.contains_key(field_name) {
315                            diags.push(Diagnostic {
316                                code: "SR040",
317                                error: format!(
318                                    "resource '{res}': endpoint '{action}' {field_kind} field '{field_name}' not found in schema"
319                                ),
320                                fix: format!(
321                                    "add '{field_name}' to the schema, or remove it from {field_kind}"
322                                ),
323                                example: format!("{field_name}: {{ type: string, required: true }}"),
324                            });
325                        }
326                    }
327                }
328            }
329
330            if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
331                diags.push(Diagnostic {
332                    code: "SR041",
333                    error: format!(
334                        "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
335                    ),
336                    fix: "add 'deleted_at: { type: timestamp, nullable: true }' to the schema".into(),
337                    example: "deleted_at: { type: timestamp, nullable: true }".into(),
338                });
339            }
340
341            if let Some(upload) = &ep.upload {
342                if !matches!(
343                    *ep.method(),
344                    HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put
345                ) {
346                    diags.push(Diagnostic {
347                        code: "SR050",
348                        error: format!(
349                            "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
350                        ),
351                        fix: "change the method to POST, PATCH, or PUT".into(),
352                        example: "method: POST".into(),
353                    });
354                }
355
356                match rd.schema.get(&upload.field) {
357                    Some(field) if field.field_type == FieldType::File => {}
358                    Some(_) => diags.push(Diagnostic {
359                        code: "SR051",
360                        error: format!(
361                            "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
362                            upload.field
363                        ),
364                        fix: format!("change '{}' to type file in the schema", upload.field),
365                        example: format!("{}: {{ type: file, required: true }}", upload.field),
366                    }),
367                    None => diags.push(Diagnostic {
368                        code: "SR052",
369                        error: format!(
370                            "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
371                            upload.field
372                        ),
373                        fix: format!("add '{}' as a file field in the schema", upload.field),
374                        example: format!("{}: {{ type: file, required: true }}", upload.field),
375                    }),
376                }
377
378                if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
379                    diags.push(Diagnostic {
380                        code: "SR053",
381                        error: format!(
382                            "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
383                            upload.storage
384                        ),
385                        fix: "use one of: local, s3, gcs, azure".into(),
386                        example: "upload: { field: file, storage: s3, max_size: 5mb }".into(),
387                    });
388                }
389
390                if !ep
391                    .input
392                    .as_ref()
393                    .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
394                {
395                    diags.push(Diagnostic {
396                        code: "SR054",
397                        error: format!(
398                            "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
399                            upload.field
400                        ),
401                        fix: format!("add '{}' to the input array", upload.field),
402                        example: format!("input: [{}]", upload.field),
403                    });
404                }
405            }
406
407            // SR073 / SR074: subscriber event and handler must not be empty
408            if let Some(subs) = &ep.subscribers {
409                for (i, sub) in subs.iter().enumerate() {
410                    if sub.event.is_empty() {
411                        diags.push(Diagnostic {
412                            code: "SR073",
413                            error: format!(
414                                "resource '{res}': endpoint '{action}' subscriber[{i}] has an empty event pattern"
415                            ),
416                            fix: "provide a non-empty event pattern (e.g., \"user.created\" or \"*.deleted\")".into(),
417                            example: format!(
418                                "subscribers:\n  - event: {res}.created\n    handler: my_handler"
419                            ),
420                        });
421                    }
422                    if sub.handler.is_empty() {
423                        diags.push(Diagnostic {
424                            code: "SR074",
425                            error: format!(
426                                "resource '{res}': endpoint '{action}' subscriber[{i}] has an empty handler name"
427                            ),
428                            fix: "provide a non-empty handler name (e.g., \"send_welcome_email\")".into(),
429                            example: "subscribers:\n  - event: user.created\n    handler: send_welcome_email".into(),
430                        });
431                    }
432                }
433            }
434
435            // SR075: non-convention endpoints must declare a handler
436            const CONVENTIONS: &[&str] = &["list", "get", "create", "update", "delete"];
437            if !CONVENTIONS.contains(&action.as_str()) && ep.handler.is_none() {
438                diags.push(Diagnostic {
439                    code: "SR075",
440                    error: format!(
441                        "resource '{res}': endpoint '{action}' is not a standard action (list/get/create/update/delete) and has no 'handler:' declared",
442                    ),
443                    fix: "add a 'handler: <function_name>' field pointing to a function in resources/<resource>.controller.rs".into(),
444                    example: format!(
445                        "{action}:\n  method: POST\n  path: /{name}/{action}\n  auth: [admin]\n  handler: {action}_{name}",
446                        action = action,
447                        name = rd.resource
448                    ),
449                });
450            }
451        }
452    }
453
454    // Relation validation
455    if let Some(relations) = &rd.relations {
456        for (name, rel) in relations {
457            use shaperail_core::RelationType;
458
459            if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
460                diags.push(Diagnostic {
461                    code: "SR060",
462                    error: format!(
463                        "resource '{res}': relation '{name}' is belongs_to but has no key"
464                    ),
465                    fix: format!("add 'key: {res}_id' to the relation (the local FK field)"),
466                    example: format!(
467                        "{name}: {{ resource: {}, type: belongs_to, key: {}_id }}",
468                        rel.resource, rel.resource
469                    ),
470                });
471            }
472
473            if matches!(
474                rel.relation_type,
475                RelationType::HasMany | RelationType::HasOne
476            ) && rel.foreign_key.is_none()
477            {
478                diags.push(Diagnostic {
479                    code: "SR061",
480                    error: format!(
481                        "resource '{res}': relation '{name}' is {} but has no foreign_key",
482                        rel.relation_type
483                    ),
484                    fix: format!(
485                        "add 'foreign_key: {res}_id' to the relation (the FK on the related table)"
486                    ),
487                    example: format!(
488                        "{name}: {{ resource: {}, type: {}, foreign_key: {res}_id }}",
489                        rel.resource, rel.relation_type
490                    ),
491                });
492            }
493
494            if let Some(key) = &rel.key {
495                if !rd.schema.contains_key(key) {
496                    diags.push(Diagnostic {
497                        code: "SR062",
498                        error: format!(
499                            "resource '{res}': relation '{name}' key '{key}' not found in schema"
500                        ),
501                        fix: format!("add '{key}' as a uuid field in the schema"),
502                        example: format!(
503                            "{key}: {{ type: uuid, ref: {}.id, required: true }}",
504                            rel.resource
505                        ),
506                    });
507                }
508            }
509        }
510    }
511
512    // Index validation
513    if let Some(indexes) = &rd.indexes {
514        for (i, idx) in indexes.iter().enumerate() {
515            if idx.fields.is_empty() {
516                diags.push(Diagnostic {
517                    code: "SR070",
518                    error: format!("resource '{res}': index {i} has no fields"),
519                    fix: "add at least one field to the index".into(),
520                    example: "- { fields: [field_name] }".into(),
521                });
522            }
523            for field_name in &idx.fields {
524                if !rd.schema.contains_key(field_name) {
525                    diags.push(Diagnostic {
526                        code: "SR071",
527                        error: format!(
528                            "resource '{res}': index {i} references field '{field_name}' not in schema"
529                        ),
530                        fix: format!("add '{field_name}' to the schema, or remove it from the index"),
531                        example: format!("{field_name}: {{ type: string, required: true }}"),
532                    });
533                }
534            }
535            if let Some(order) = &idx.order {
536                if order != "asc" && order != "desc" {
537                    diags.push(Diagnostic {
538                        code: "SR072",
539                        error: format!(
540                            "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
541                        ),
542                        fix: "use 'asc' or 'desc' for the index order".into(),
543                        example: "- { fields: [created_at], order: desc }".into(),
544                    });
545                }
546            }
547        }
548    }
549
550    diags
551}
552
553fn diagnose_controller_name(
554    res: &str,
555    action: &str,
556    phase: &str,
557    name: &str,
558    diags: &mut Vec<Diagnostic>,
559) {
560    if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
561        if wasm_path.is_empty() {
562            diags.push(Diagnostic {
563                code: "SR035",
564                error: format!(
565                    "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
566                ),
567                fix: "provide a .wasm file path after the 'wasm:' prefix".into(),
568                example: format!("controller: {{ {phase}: \"wasm:./plugins/validator.wasm\" }}"),
569            });
570        } else if !wasm_path.ends_with(".wasm") {
571            diags.push(Diagnostic {
572                code: "SR036",
573                error: format!(
574                    "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
575                ),
576                fix: "ensure the WASM plugin path ends with '.wasm'".into(),
577                example: format!("controller: {{ {phase}: \"wasm:./plugins/validator.wasm\" }}"),
578            });
579        }
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::parser::parse_resource;
587
588    #[test]
589    fn valid_resource_produces_no_diagnostics() {
590        let yaml = include_str!("../../resources/users.yaml");
591        let rd = parse_resource(yaml).unwrap();
592        let diags = diagnose_resource(&rd);
593        assert!(diags.is_empty(), "Expected no diagnostics, got: {diags:?}");
594    }
595
596    #[test]
597    fn enum_without_values_has_fix_suggestion() {
598        let yaml = r#"
599resource: items
600version: 1
601schema:
602  id: { type: uuid, primary: true, generated: true }
603  status: { type: enum, required: true }
604"#;
605        let rd = parse_resource(yaml).unwrap();
606        let diags = diagnose_resource(&rd);
607        let d = diags.iter().find(|d| d.code == "SR010").unwrap();
608        assert!(d.fix.contains("values"));
609        assert!(d.example.contains("values:"));
610    }
611
612    #[test]
613    fn missing_primary_key_has_fix_suggestion() {
614        let yaml = r#"
615resource: items
616version: 1
617schema:
618  name: { type: string, required: true }
619"#;
620        let rd = parse_resource(yaml).unwrap();
621        let diags = diagnose_resource(&rd);
622        let d = diags.iter().find(|d| d.code == "SR004").unwrap();
623        assert!(d.fix.contains("primary: true"));
624    }
625
626    #[test]
627    fn diagnostics_serialize_to_json() {
628        let d = Diagnostic {
629            code: "SR010",
630            error: "field 'role' is type enum but has no values".into(),
631            fix: "add values".into(),
632            example: "role: { type: enum, values: [a, b] }".into(),
633        };
634        let json = serde_json::to_string(&d).unwrap();
635        assert!(json.contains("SR010"));
636        assert!(json.contains("fix"));
637    }
638
639    #[test]
640    fn subscriber_with_empty_event_has_fix_suggestion() {
641        let yaml = r#"
642resource: items
643version: 1
644schema:
645  id: { type: uuid, primary: true, generated: true }
646endpoints:
647  create:
648    auth: [admin]
649    subscribers:
650      - event: ""
651        handler: my_handler
652"#;
653        let rd = parse_resource(yaml).unwrap();
654        let diags = diagnose_resource(&rd);
655        let d = diags.iter().find(|d| d.code == "SR073");
656        assert!(
657            d.is_some(),
658            "Expected SR073 diagnostic for empty subscriber event"
659        );
660        assert!(d.unwrap().fix.contains("event"));
661    }
662
663    #[test]
664    fn subscriber_with_empty_handler_has_fix_suggestion() {
665        let yaml = r#"
666resource: items
667version: 1
668schema:
669  id: { type: uuid, primary: true, generated: true }
670endpoints:
671  create:
672    auth: [admin]
673    subscribers:
674      - event: items.created
675        handler: ""
676"#;
677        let rd = parse_resource(yaml).unwrap();
678        let diags = diagnose_resource(&rd);
679        let d = diags.iter().find(|d| d.code == "SR074");
680        assert!(
681            d.is_some(),
682            "Expected SR074 diagnostic for empty subscriber handler"
683        );
684        assert!(d.unwrap().fix.contains("handler"));
685    }
686
687    #[test]
688    fn non_convention_endpoint_without_handler_produces_sr075() {
689        let yaml = r#"
690resource: items
691version: 1
692schema:
693  id: { type: uuid, primary: true, generated: true }
694endpoints:
695  archive:
696    method: POST
697    path: /items/:id/archive
698    auth: [admin]
699"#;
700        let rd = parse_resource(yaml).unwrap();
701        let diags = diagnose_resource(&rd);
702        let d = diags.iter().find(|d| d.code == "SR075");
703        assert!(
704            d.is_some(),
705            "Expected SR075 for non-convention endpoint missing handler"
706        );
707        assert!(d.unwrap().fix.contains("handler"));
708    }
709
710    #[test]
711    fn non_convention_endpoint_with_handler_no_sr075() {
712        let yaml = r#"
713resource: items
714version: 1
715schema:
716  id: { type: uuid, primary: true, generated: true }
717endpoints:
718  archive:
719    method: POST
720    path: /items/:id/archive
721    auth: [admin]
722    handler: archive_item
723"#;
724        let rd = parse_resource(yaml).unwrap();
725        let diags = diagnose_resource(&rd);
726        let has_sr075 = diags.iter().any(|d| d.code == "SR075");
727        assert!(!has_sr075, "SR075 should not fire when handler is present");
728    }
729
730    // -- Diagnostic::Display --
731
732    #[test]
733    fn diagnostic_display_format() {
734        let d = Diagnostic {
735            code: "SR010",
736            error: "field 'role' is type enum but has no values".into(),
737            fix: "add values".into(),
738            example: "role: { type: enum, values: [a, b] }".into(),
739        };
740        let s = d.to_string();
741        assert!(s.contains("SR010"), "Expected code in display");
742        assert!(s.contains("enum"), "Expected error text in display");
743    }
744
745    // -- format_feature_warnings --
746
747    #[test]
748    fn format_feature_warnings_empty_returns_empty_string() {
749        use crate::feature_check::format_feature_warnings;
750        let s = format_feature_warnings(&[]);
751        assert!(s.is_empty());
752    }
753
754    #[test]
755    fn format_feature_warnings_nonempty_includes_feature_name() {
756        use crate::feature_check::{format_feature_warnings, RequiredFeature};
757        let feats = vec![RequiredFeature {
758            feature: "wasm-plugins",
759            reason: "resource 'items' uses WASM".into(),
760            enable_hint: "Add to Cargo.toml: shaperail-runtime = { features = [\"wasm-plugins\"] }"
761                .into(),
762        }];
763        let s = format_feature_warnings(&feats);
764        assert!(s.contains("wasm-plugins"));
765        assert!(s.contains("WASM"));
766    }
767
768    // -- SR002: version 0 --
769
770    #[test]
771    fn sr002_version_zero() {
772        let yaml = r#"
773resource: items
774version: 0
775schema:
776  id: { type: uuid, primary: true, generated: true }
777"#;
778        let rd = parse_resource(yaml).unwrap();
779        let diags = diagnose_resource(&rd);
780        assert!(
781            diags.iter().any(|d| d.code == "SR002"),
782            "Expected SR002, got: {diags:?}"
783        );
784        let d = diags.iter().find(|d| d.code == "SR002").unwrap();
785        assert!(d.fix.contains("version"));
786    }
787
788    // -- SR003: empty schema --
789
790    #[test]
791    fn sr003_empty_schema() {
792        use indexmap::IndexMap;
793        use shaperail_core::ResourceDefinition;
794        let rd = ResourceDefinition {
795            resource: "items".to_string(),
796            version: 1,
797            db: None,
798            tenant_key: None,
799            schema: IndexMap::new(),
800            endpoints: None,
801            relations: None,
802            indexes: None,
803        };
804        let diags = diagnose_resource(&rd);
805        assert!(
806            diags.iter().any(|d| d.code == "SR003"),
807            "Expected SR003, got: {diags:?}"
808        );
809    }
810
811    // -- SR005: multiple primary keys --
812
813    #[test]
814    fn sr005_multiple_primary_keys() {
815        let yaml = r#"
816resource: items
817version: 1
818schema:
819  id:  { type: uuid, primary: true, generated: true }
820  alt: { type: uuid, primary: true, generated: true }
821  name: { type: string, required: true }
822"#;
823        let rd = parse_resource(yaml).unwrap();
824        let diags = diagnose_resource(&rd);
825        assert!(
826            diags.iter().any(|d| d.code == "SR005"),
827            "Expected SR005, got: {diags:?}"
828        );
829    }
830
831    // -- SR011: non-enum with values --
832
833    #[test]
834    fn sr011_non_enum_with_values() {
835        let yaml = r#"
836resource: items
837version: 1
838schema:
839  id:   { type: uuid, primary: true, generated: true }
840  name: { type: string, required: true, values: ["a", "b"] }
841"#;
842        let rd = parse_resource(yaml).unwrap();
843        let diags = diagnose_resource(&rd);
844        assert!(
845            diags.iter().any(|d| d.code == "SR011"),
846            "Expected SR011, got: {diags:?}"
847        );
848        let d = diags.iter().find(|d| d.code == "SR011").unwrap();
849        assert!(d.fix.contains("enum") || d.fix.contains("values"));
850    }
851
852    // -- SR012: ref on non-uuid field --
853
854    #[test]
855    fn sr012_ref_on_non_uuid() {
856        let yaml = r#"
857resource: items
858version: 1
859schema:
860  id:     { type: uuid, primary: true, generated: true }
861  org_id: { type: string, ref: organizations.id }
862"#;
863        let rd = parse_resource(yaml).unwrap();
864        let diags = diagnose_resource(&rd);
865        assert!(
866            diags.iter().any(|d| d.code == "SR012"),
867            "Expected SR012, got: {diags:?}"
868        );
869    }
870
871    // -- SR013: ref missing dot --
872
873    #[test]
874    fn sr013_ref_bad_format() {
875        let yaml = r#"
876resource: items
877version: 1
878schema:
879  id:     { type: uuid, primary: true, generated: true }
880  org_id: { type: uuid, ref: organizations }
881"#;
882        let rd = parse_resource(yaml).unwrap();
883        let diags = diagnose_resource(&rd);
884        assert!(
885            diags.iter().any(|d| d.code == "SR013"),
886            "Expected SR013, got: {diags:?}"
887        );
888        let d = diags.iter().find(|d| d.code == "SR013").unwrap();
889        assert!(d.fix.contains("resource_name.field_name") || d.fix.contains("format"));
890    }
891
892    // -- SR014: array without items --
893
894    #[test]
895    fn sr014_array_without_items() {
896        let yaml = r#"
897resource: items
898version: 1
899schema:
900  id:   { type: uuid, primary: true, generated: true }
901  tags: { type: array }
902"#;
903        let rd = parse_resource(yaml).unwrap();
904        let diags = diagnose_resource(&rd);
905        assert!(
906            diags.iter().any(|d| d.code == "SR014"),
907            "Expected SR014, got: {diags:?}"
908        );
909    }
910
911    // -- SR015: format on non-string --
912
913    #[test]
914    fn sr015_format_on_non_string() {
915        let yaml = r#"
916resource: items
917version: 1
918schema:
919  id:  { type: uuid, primary: true, generated: true }
920  age: { type: integer, required: true, format: email }
921"#;
922        let rd = parse_resource(yaml).unwrap();
923        let diags = diagnose_resource(&rd);
924        assert!(
925            diags.iter().any(|d| d.code == "SR015"),
926            "Expected SR015, got: {diags:?}"
927        );
928    }
929
930    // -- SR016: primary not generated/required --
931
932    #[test]
933    fn sr016_primary_not_generated_or_required() {
934        let yaml = r#"
935resource: items
936version: 1
937schema:
938  id: { type: uuid, primary: true }
939"#;
940        let rd = parse_resource(yaml).unwrap();
941        let diags = diagnose_resource(&rd);
942        assert!(
943            diags.iter().any(|d| d.code == "SR016"),
944            "Expected SR016, got: {diags:?}"
945        );
946        let d = diags.iter().find(|d| d.code == "SR016").unwrap();
947        assert!(d.fix.contains("generated: true") || d.fix.contains("required: true"));
948    }
949
950    // -- SR020: tenant_key not uuid --
951
952    #[test]
953    fn sr020_tenant_key_not_uuid() {
954        let yaml = r#"
955resource: items
956version: 1
957tenant_key: org_name
958schema:
959  id:       { type: uuid, primary: true, generated: true }
960  org_name: { type: string, required: true }
961"#;
962        let rd = parse_resource(yaml).unwrap();
963        let diags = diagnose_resource(&rd);
964        assert!(
965            diags.iter().any(|d| d.code == "SR020"),
966            "Expected SR020, got: {diags:?}"
967        );
968    }
969
970    // -- SR021: tenant_key not in schema --
971
972    #[test]
973    fn sr021_tenant_key_not_in_schema() {
974        let yaml = r#"
975resource: items
976version: 1
977tenant_key: missing_field
978schema:
979  id: { type: uuid, primary: true, generated: true }
980"#;
981        let rd = parse_resource(yaml).unwrap();
982        let diags = diagnose_resource(&rd);
983        assert!(
984            diags.iter().any(|d| d.code == "SR021"),
985            "Expected SR021, got: {diags:?}"
986        );
987        let d = diags.iter().find(|d| d.code == "SR021").unwrap();
988        assert!(d.fix.contains("missing_field") || d.fix.contains("add"));
989    }
990
991    // -- SR035: wasm: prefix but empty path --
992
993    #[test]
994    fn sr035_wasm_empty_path() {
995        let yaml = r#"
996resource: items
997version: 1
998schema:
999  id:   { type: uuid, primary: true, generated: true }
1000  name: { type: string, required: true }
1001endpoints:
1002  create:
1003    input: [name]
1004    controller: { before: "wasm:" }
1005"#;
1006        let rd = parse_resource(yaml).unwrap();
1007        let diags = diagnose_resource(&rd);
1008        assert!(
1009            diags.iter().any(|d| d.code == "SR035"),
1010            "Expected SR035, got: {diags:?}"
1011        );
1012    }
1013
1014    // -- SR036: wasm path does not end with .wasm --
1015
1016    #[test]
1017    fn sr036_wasm_path_no_extension() {
1018        let yaml = r#"
1019resource: items
1020version: 1
1021schema:
1022  id:   { type: uuid, primary: true, generated: true }
1023  name: { type: string, required: true }
1024endpoints:
1025  create:
1026    input: [name]
1027    controller: { after: "wasm:./plugins/my_plugin" }
1028"#;
1029        let rd = parse_resource(yaml).unwrap();
1030        let diags = diagnose_resource(&rd);
1031        assert!(
1032            diags.iter().any(|d| d.code == "SR036"),
1033            "Expected SR036, got: {diags:?}"
1034        );
1035    }
1036
1037    // -- SR040: input/filter/search/sort field not in schema --
1038
1039    #[test]
1040    fn sr040_input_field_not_in_schema() {
1041        let yaml = r#"
1042resource: items
1043version: 1
1044schema:
1045  id:   { type: uuid, primary: true, generated: true }
1046  name: { type: string, required: true }
1047endpoints:
1048  create:
1049    input: [name, ghost_field]
1050"#;
1051        let rd = parse_resource(yaml).unwrap();
1052        let diags = diagnose_resource(&rd);
1053        assert!(
1054            diags.iter().any(|d| d.code == "SR040"),
1055            "Expected SR040, got: {diags:?}"
1056        );
1057    }
1058
1059    #[test]
1060    fn sr040_filter_field_not_in_schema() {
1061        let yaml = r#"
1062resource: items
1063version: 1
1064schema:
1065  id:   { type: uuid, primary: true, generated: true }
1066  name: { type: string, required: true }
1067endpoints:
1068  list:
1069    auth: public
1070    filters: [name, missing_filter]
1071"#;
1072        let rd = parse_resource(yaml).unwrap();
1073        let diags = diagnose_resource(&rd);
1074        assert!(
1075            diags.iter().any(|d| d.code == "SR040"),
1076            "Expected SR040, got: {diags:?}"
1077        );
1078    }
1079
1080    // -- SR041: soft_delete without deleted_at --
1081
1082    #[test]
1083    fn sr041_soft_delete_without_deleted_at() {
1084        let yaml = r#"
1085resource: items
1086version: 1
1087schema:
1088  id:   { type: uuid, primary: true, generated: true }
1089  name: { type: string, required: true }
1090endpoints:
1091  delete:
1092    auth: [admin]
1093    soft_delete: true
1094"#;
1095        let rd = parse_resource(yaml).unwrap();
1096        let diags = diagnose_resource(&rd);
1097        assert!(
1098            diags.iter().any(|d| d.code == "SR041"),
1099            "Expected SR041, got: {diags:?}"
1100        );
1101        let d = diags.iter().find(|d| d.code == "SR041").unwrap();
1102        assert!(d.fix.contains("deleted_at"));
1103    }
1104
1105    // -- SR060: belongs_to without key --
1106
1107    #[test]
1108    fn sr060_belongs_to_without_key() {
1109        let yaml = r#"
1110resource: items
1111version: 1
1112schema:
1113  id: { type: uuid, primary: true, generated: true }
1114relations:
1115  org: { resource: organizations, type: belongs_to }
1116"#;
1117        let rd = parse_resource(yaml).unwrap();
1118        let diags = diagnose_resource(&rd);
1119        assert!(
1120            diags.iter().any(|d| d.code == "SR060"),
1121            "Expected SR060, got: {diags:?}"
1122        );
1123        let d = diags.iter().find(|d| d.code == "SR060").unwrap();
1124        assert!(d.fix.contains("key"));
1125    }
1126
1127    // -- SR061: has_many without foreign_key --
1128
1129    #[test]
1130    fn sr061_has_many_without_foreign_key() {
1131        let yaml = r#"
1132resource: users
1133version: 1
1134schema:
1135  id: { type: uuid, primary: true, generated: true }
1136relations:
1137  orders: { resource: orders, type: has_many }
1138"#;
1139        let rd = parse_resource(yaml).unwrap();
1140        let diags = diagnose_resource(&rd);
1141        assert!(
1142            diags.iter().any(|d| d.code == "SR061"),
1143            "Expected SR061, got: {diags:?}"
1144        );
1145        let d = diags.iter().find(|d| d.code == "SR061").unwrap();
1146        assert!(d.fix.contains("foreign_key"));
1147    }
1148
1149    #[test]
1150    fn sr061_has_one_without_foreign_key() {
1151        let yaml = r#"
1152resource: users
1153version: 1
1154schema:
1155  id: { type: uuid, primary: true, generated: true }
1156relations:
1157  profile: { resource: profiles, type: has_one }
1158"#;
1159        let rd = parse_resource(yaml).unwrap();
1160        let diags = diagnose_resource(&rd);
1161        assert!(
1162            diags.iter().any(|d| d.code == "SR061"),
1163            "Expected SR061 for has_one, got: {diags:?}"
1164        );
1165    }
1166
1167    // -- SR062: relation key not in schema --
1168
1169    #[test]
1170    fn sr062_relation_key_not_in_schema() {
1171        let yaml = r#"
1172resource: items
1173version: 1
1174schema:
1175  id: { type: uuid, primary: true, generated: true }
1176relations:
1177  org: { resource: organizations, type: belongs_to, key: missing_fk }
1178"#;
1179        let rd = parse_resource(yaml).unwrap();
1180        let diags = diagnose_resource(&rd);
1181        assert!(
1182            diags.iter().any(|d| d.code == "SR062"),
1183            "Expected SR062, got: {diags:?}"
1184        );
1185    }
1186
1187    // -- SR070: index with no fields --
1188
1189    #[test]
1190    fn sr070_index_no_fields() {
1191        use indexmap::IndexMap;
1192        use shaperail_core::{FieldSchema, FieldType, IndexSpec, ResourceDefinition};
1193
1194        let mut schema = IndexMap::new();
1195        schema.insert(
1196            "id".to_string(),
1197            FieldSchema {
1198                field_type: FieldType::Uuid,
1199                primary: true,
1200                generated: true,
1201                required: false,
1202                unique: false,
1203                nullable: false,
1204                reference: None,
1205                min: None,
1206                max: None,
1207                format: None,
1208                values: None,
1209                default: None,
1210                sensitive: false,
1211                search: false,
1212                items: None,
1213                transient: false,
1214            },
1215        );
1216        let rd = ResourceDefinition {
1217            resource: "items".to_string(),
1218            version: 1,
1219            db: None,
1220            tenant_key: None,
1221            schema,
1222            endpoints: None,
1223            relations: None,
1224            indexes: Some(vec![IndexSpec {
1225                fields: vec![],
1226                unique: false,
1227                order: None,
1228            }]),
1229        };
1230        let diags = diagnose_resource(&rd);
1231        assert!(
1232            diags.iter().any(|d| d.code == "SR070"),
1233            "Expected SR070, got: {diags:?}"
1234        );
1235    }
1236
1237    // -- SR071: index field not in schema --
1238
1239    #[test]
1240    fn sr071_index_field_not_in_schema() {
1241        let yaml = r#"
1242resource: items
1243version: 1
1244schema:
1245  id: { type: uuid, primary: true, generated: true }
1246indexes:
1247  - fields: [missing_field]
1248"#;
1249        let rd = parse_resource(yaml).unwrap();
1250        let diags = diagnose_resource(&rd);
1251        assert!(
1252            diags.iter().any(|d| d.code == "SR071"),
1253            "Expected SR071, got: {diags:?}"
1254        );
1255    }
1256
1257    // -- SR072: index with invalid order --
1258
1259    #[test]
1260    fn sr072_index_invalid_order() {
1261        let yaml = r#"
1262resource: items
1263version: 1
1264schema:
1265  id:         { type: uuid, primary: true, generated: true }
1266  created_at: { type: timestamp, generated: true }
1267indexes:
1268  - fields: [created_at]
1269    order: INVALID
1270"#;
1271        let rd = parse_resource(yaml).unwrap();
1272        let diags = diagnose_resource(&rd);
1273        assert!(
1274            diags.iter().any(|d| d.code == "SR072"),
1275            "Expected SR072, got: {diags:?}"
1276        );
1277        let d = diags.iter().find(|d| d.code == "SR072").unwrap();
1278        assert!(d.fix.contains("asc") || d.fix.contains("desc"));
1279    }
1280
1281    // ── New SR codes added in v0.11.x ─────────────────────────────────────
1282
1283    #[test]
1284    fn sr030_empty_controller_before_name() {
1285        let yaml = r#"
1286resource: items
1287version: 1
1288schema:
1289  id: { type: uuid, primary: true, generated: true }
1290endpoints:
1291  create:
1292    input: [id]
1293    controller: { before: "" }
1294"#;
1295        let rd = parse_resource(yaml).unwrap();
1296        let diags = diagnose_resource(&rd);
1297        assert!(
1298            diags.iter().any(|d| d.code == "SR030"),
1299            "Expected SR030, got: {diags:?}"
1300        );
1301    }
1302
1303    #[test]
1304    fn sr031_empty_controller_after_name() {
1305        let yaml = r#"
1306resource: items
1307version: 1
1308schema:
1309  id: { type: uuid, primary: true, generated: true }
1310endpoints:
1311  create:
1312    input: [id]
1313    controller: { after: "" }
1314"#;
1315        let rd = parse_resource(yaml).unwrap();
1316        let diags = diagnose_resource(&rd);
1317        assert!(
1318            diags.iter().any(|d| d.code == "SR031"),
1319            "Expected SR031, got: {diags:?}"
1320        );
1321    }
1322
1323    #[test]
1324    fn sr032_empty_event_name_in_diagnostics() {
1325        let yaml = r#"
1326resource: items
1327version: 1
1328schema:
1329  id: { type: uuid, primary: true, generated: true }
1330endpoints:
1331  create:
1332    input: [id]
1333    events: [""]
1334"#;
1335        let rd = parse_resource(yaml).unwrap();
1336        let diags = diagnose_resource(&rd);
1337        assert!(
1338            diags.iter().any(|d| d.code == "SR032"),
1339            "Expected SR032, got: {diags:?}"
1340        );
1341    }
1342
1343    #[test]
1344    fn sr033_empty_job_name_in_diagnostics() {
1345        let yaml = r#"
1346resource: items
1347version: 1
1348schema:
1349  id: { type: uuid, primary: true, generated: true }
1350endpoints:
1351  create:
1352    input: [id]
1353    jobs: [""]
1354"#;
1355        let rd = parse_resource(yaml).unwrap();
1356        let diags = diagnose_resource(&rd);
1357        assert!(
1358            diags.iter().any(|d| d.code == "SR033"),
1359            "Expected SR033, got: {diags:?}"
1360        );
1361    }
1362
1363    #[test]
1364    fn sr050_upload_on_get_method() {
1365        let yaml = r#"
1366resource: assets
1367version: 1
1368schema:
1369  id:   { type: uuid, primary: true, generated: true }
1370  file: { type: file, required: true }
1371endpoints:
1372  upload_file:
1373    method: GET
1374    path: /assets/upload
1375    input: [file]
1376    upload:
1377      field: file
1378      storage: s3
1379      max_size: 5mb
1380"#;
1381        let rd = parse_resource(yaml).unwrap();
1382        let diags = diagnose_resource(&rd);
1383        assert!(
1384            diags.iter().any(|d| d.code == "SR050"),
1385            "Expected SR050 (upload on GET), got: {diags:?}"
1386        );
1387    }
1388
1389    #[test]
1390    fn sr051_upload_field_wrong_type() {
1391        let yaml = r#"
1392resource: assets
1393version: 1
1394schema:
1395  id:    { type: uuid, primary: true, generated: true }
1396  title: { type: string, required: true }
1397endpoints:
1398  upload_file:
1399    method: POST
1400    path: /assets/upload
1401    input: [title]
1402    upload:
1403      field: title
1404      storage: s3
1405      max_size: 5mb
1406"#;
1407        let rd = parse_resource(yaml).unwrap();
1408        let diags = diagnose_resource(&rd);
1409        assert!(
1410            diags.iter().any(|d| d.code == "SR051"),
1411            "Expected SR051 (upload field not type file), got: {diags:?}"
1412        );
1413    }
1414
1415    #[test]
1416    fn sr052_upload_field_missing_from_schema() {
1417        let yaml = r#"
1418resource: assets
1419version: 1
1420schema:
1421  id: { type: uuid, primary: true, generated: true }
1422endpoints:
1423  upload_file:
1424    method: POST
1425    path: /assets/upload
1426    input: [attachment]
1427    upload:
1428      field: attachment
1429      storage: s3
1430      max_size: 5mb
1431"#;
1432        let rd = parse_resource(yaml).unwrap();
1433        let diags = diagnose_resource(&rd);
1434        assert!(
1435            diags.iter().any(|d| d.code == "SR052"),
1436            "Expected SR052 (upload field not in schema), got: {diags:?}"
1437        );
1438    }
1439
1440    #[test]
1441    fn sr053_upload_invalid_storage_backend() {
1442        let yaml = r#"
1443resource: assets
1444version: 1
1445schema:
1446  id:   { type: uuid, primary: true, generated: true }
1447  file: { type: file, required: true }
1448endpoints:
1449  upload_file:
1450    method: POST
1451    path: /assets/upload
1452    input: [file]
1453    upload:
1454      field: file
1455      storage: ftp
1456      max_size: 5mb
1457"#;
1458        let rd = parse_resource(yaml).unwrap();
1459        let diags = diagnose_resource(&rd);
1460        assert!(
1461            diags.iter().any(|d| d.code == "SR053"),
1462            "Expected SR053 (invalid storage 'ftp'), got: {diags:?}"
1463        );
1464    }
1465
1466    #[test]
1467    fn sr054_upload_field_not_in_input() {
1468        let yaml = r#"
1469resource: assets
1470version: 1
1471schema:
1472  id:   { type: uuid, primary: true, generated: true }
1473  file: { type: file, required: true }
1474endpoints:
1475  upload_file:
1476    method: POST
1477    path: /assets/upload
1478    input: []
1479    upload:
1480      field: file
1481      storage: s3
1482      max_size: 5mb
1483"#;
1484        let rd = parse_resource(yaml).unwrap();
1485        let diags = diagnose_resource(&rd);
1486        assert!(
1487            diags.iter().any(|d| d.code == "SR054"),
1488            "Expected SR054 (upload field not in input), got: {diags:?}"
1489        );
1490    }
1491
1492    #[test]
1493    fn sr050_to_sr054_all_clear_for_valid_upload_endpoint() {
1494        let yaml = r#"
1495resource: assets
1496version: 1
1497schema:
1498  id:    { type: uuid, primary: true, generated: true }
1499  file:  { type: file, required: true }
1500  title: { type: string, required: true }
1501endpoints:
1502  upload_file:
1503    method: POST
1504    path: /assets/upload
1505    input: [file, title]
1506    upload:
1507      field: file
1508      storage: s3
1509      max_size: 10mb
1510"#;
1511        let rd = parse_resource(yaml).unwrap();
1512        let diags = diagnose_resource(&rd);
1513        let upload_diags: Vec<_> = diags
1514            .iter()
1515            .filter(|d| ["SR050", "SR051", "SR052", "SR053", "SR054"].contains(&d.code))
1516            .collect();
1517        assert!(
1518            upload_diags.is_empty(),
1519            "Valid upload endpoint should produce no SR050-054 diags, got: {upload_diags:?}"
1520        );
1521    }
1522
1523    #[test]
1524    fn sr073_subscriber_empty_event() {
1525        let yaml = r#"
1526resource: notifications
1527version: 1
1528schema:
1529  id: { type: uuid, primary: true, generated: true }
1530endpoints:
1531  on_user_created:
1532    method: POST
1533    path: /notifications/on_user_created
1534    handler: on_user_created_handler
1535    subscribers:
1536      - event: ""
1537        handler: send_welcome
1538"#;
1539        let rd = parse_resource(yaml).unwrap();
1540        let diags = diagnose_resource(&rd);
1541        assert!(
1542            diags.iter().any(|d| d.code == "SR073"),
1543            "Expected SR073, got: {diags:?}"
1544        );
1545    }
1546
1547    #[test]
1548    fn sr074_subscriber_empty_handler() {
1549        let yaml = r#"
1550resource: notifications
1551version: 1
1552schema:
1553  id: { type: uuid, primary: true, generated: true }
1554endpoints:
1555  on_user_created:
1556    method: POST
1557    path: /notifications/on_user_created
1558    handler: on_user_created_handler
1559    subscribers:
1560      - event: user.created
1561        handler: ""
1562"#;
1563        let rd = parse_resource(yaml).unwrap();
1564        let diags = diagnose_resource(&rd);
1565        assert!(
1566            diags.iter().any(|d| d.code == "SR074"),
1567            "Expected SR074, got: {diags:?}"
1568        );
1569    }
1570}