1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition, WASM_HOOK_PREFIX};
2
3#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
5pub struct Diagnostic {
6 pub code: &'static str,
8 pub error: String,
10 pub fix: String,
12 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
22pub 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 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 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 let names = before.names();
249 if names.is_empty() {
250 diags.push(Diagnostic {
251 code: "SR063",
252 error: format!(
253 "resource '{res}': endpoint '{action}' has an empty controller.before list"
254 ),
255 fix: "remove `before:` or list at least one hook name".into(),
256 example: "controller: { before: [validate_currency, validate_org] }".into(),
257 });
258 }
259 for name in names {
260 if name.is_empty() {
261 diags.push(Diagnostic {
262 code: "SR030",
263 error: format!(
264 "resource '{res}': endpoint '{action}' has an empty controller.before name"
265 ),
266 fix: "provide a function name for controller.before".into(),
267 example: "controller: { before: validate_input }".into(),
268 });
269 continue;
270 }
271 diagnose_controller_name(res, action, "before", name, &mut diags);
272 }
273 }
274 if let Some(after) = &controller.after {
275 let names = after.names();
276 if names.is_empty() {
277 diags.push(Diagnostic {
278 code: "SR063",
279 error: format!(
280 "resource '{res}': endpoint '{action}' has an empty controller.after list"
281 ),
282 fix: "remove `after:` or list at least one hook name".into(),
283 example: "controller: { after: [enrich_response, audit_log] }".into(),
284 });
285 }
286 for name in names {
287 if name.is_empty() {
288 diags.push(Diagnostic {
289 code: "SR031",
290 error: format!(
291 "resource '{res}': endpoint '{action}' has an empty controller.after name"
292 ),
293 fix: "provide a function name for controller.after".into(),
294 example: "controller: { after: enrich_response }".into(),
295 });
296 continue;
297 }
298 diagnose_controller_name(res, action, "after", name, &mut diags);
299 }
300 }
301 }
302
303 if let Some(events) = &ep.events {
304 for event in events {
305 if event.is_empty() {
306 diags.push(Diagnostic {
307 code: "SR032",
308 error: format!(
309 "resource '{res}': endpoint '{action}' has an empty event name"
310 ),
311 fix: "use 'resource.action' format for event names".into(),
312 example: format!("events: [{res}.created]"),
313 });
314 }
315 }
316 }
317
318 if let Some(jobs) = &ep.jobs {
319 for job in jobs {
320 if job.is_empty() {
321 diags.push(Diagnostic {
322 code: "SR033",
323 error: format!(
324 "resource '{res}': endpoint '{action}' has an empty job name"
325 ),
326 fix: "provide a snake_case job name".into(),
327 example: "jobs: [send_notification]".into(),
328 });
329 }
330 }
331 }
332
333 for (field_kind, fields) in [
335 ("input", &ep.input),
336 ("filter", &ep.filters),
337 ("search", &ep.search),
338 ("sort", &ep.sort),
339 ] {
340 if let Some(fields) = fields {
341 for field_name in fields {
342 if !rd.schema.contains_key(field_name) {
343 diags.push(Diagnostic {
344 code: "SR040",
345 error: format!(
346 "resource '{res}': endpoint '{action}' {field_kind} field '{field_name}' not found in schema"
347 ),
348 fix: format!(
349 "add '{field_name}' to the schema, or remove it from {field_kind}"
350 ),
351 example: format!("{field_name}: {{ type: string, required: true }}"),
352 });
353 }
354 }
355 }
356 }
357
358 if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
359 diags.push(Diagnostic {
360 code: "SR041",
361 error: format!(
362 "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
363 ),
364 fix: "add 'deleted_at: { type: timestamp, nullable: true }' to the schema".into(),
365 example: "deleted_at: { type: timestamp, nullable: true }".into(),
366 });
367 }
368
369 if let Some(upload) = &ep.upload {
370 if !matches!(
371 *ep.method(),
372 HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put
373 ) {
374 diags.push(Diagnostic {
375 code: "SR050",
376 error: format!(
377 "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
378 ),
379 fix: "change the method to POST, PATCH, or PUT".into(),
380 example: "method: POST".into(),
381 });
382 }
383
384 match rd.schema.get(&upload.field) {
385 Some(field) if field.field_type == FieldType::File => {}
386 Some(_) => diags.push(Diagnostic {
387 code: "SR051",
388 error: format!(
389 "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
390 upload.field
391 ),
392 fix: format!("change '{}' to type file in the schema", upload.field),
393 example: format!("{}: {{ type: file, required: true }}", upload.field),
394 }),
395 None => diags.push(Diagnostic {
396 code: "SR052",
397 error: format!(
398 "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
399 upload.field
400 ),
401 fix: format!("add '{}' as a file field in the schema", upload.field),
402 example: format!("{}: {{ type: file, required: true }}", upload.field),
403 }),
404 }
405
406 if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
407 diags.push(Diagnostic {
408 code: "SR053",
409 error: format!(
410 "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
411 upload.storage
412 ),
413 fix: "use one of: local, s3, gcs, azure".into(),
414 example: "upload: { field: file, storage: s3, max_size: 5mb }".into(),
415 });
416 }
417
418 if !ep
419 .input
420 .as_ref()
421 .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
422 {
423 diags.push(Diagnostic {
424 code: "SR054",
425 error: format!(
426 "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
427 upload.field
428 ),
429 fix: format!("add '{}' to the input array", upload.field),
430 example: format!("input: [{}]", upload.field),
431 });
432 }
433 }
434
435 if let Some(subs) = &ep.subscribers {
437 for (i, sub) in subs.iter().enumerate() {
438 if sub.event.is_empty() {
439 diags.push(Diagnostic {
440 code: "SR073",
441 error: format!(
442 "resource '{res}': endpoint '{action}' subscriber[{i}] has an empty event pattern"
443 ),
444 fix: "provide a non-empty event pattern (e.g., \"user.created\" or \"*.deleted\")".into(),
445 example: format!(
446 "subscribers:\n - event: {res}.created\n handler: my_handler"
447 ),
448 });
449 }
450 if sub.handler.is_empty() {
451 diags.push(Diagnostic {
452 code: "SR074",
453 error: format!(
454 "resource '{res}': endpoint '{action}' subscriber[{i}] has an empty handler name"
455 ),
456 fix: "provide a non-empty handler name (e.g., \"send_welcome_email\")".into(),
457 example: "subscribers:\n - event: user.created\n handler: send_welcome_email".into(),
458 });
459 }
460 }
461 }
462
463 const CONVENTIONS: &[&str] = &["list", "get", "create", "update", "delete"];
465 if !CONVENTIONS.contains(&action.as_str()) && ep.handler.is_none() {
466 diags.push(Diagnostic {
467 code: "SR075",
468 error: format!(
469 "resource '{res}': endpoint '{action}' is not a standard action (list/get/create/update/delete) and has no 'handler:' declared",
470 ),
471 fix: "add a 'handler: <function_name>' field pointing to a function in resources/<resource>.controller.rs".into(),
472 example: format!(
473 "{action}:\n method: POST\n path: /{name}/{action}\n auth: [admin]\n handler: {action}_{name}",
474 action = action,
475 name = rd.resource
476 ),
477 });
478 }
479 }
480 }
481
482 if let Some(relations) = &rd.relations {
484 for (name, rel) in relations {
485 use shaperail_core::RelationType;
486
487 if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
488 diags.push(Diagnostic {
489 code: "SR060",
490 error: format!(
491 "resource '{res}': relation '{name}' is belongs_to but has no key"
492 ),
493 fix: format!("add 'key: {res}_id' to the relation (the local FK field)"),
494 example: format!(
495 "{name}: {{ resource: {}, type: belongs_to, key: {}_id }}",
496 rel.resource, rel.resource
497 ),
498 });
499 }
500
501 if matches!(
502 rel.relation_type,
503 RelationType::HasMany | RelationType::HasOne
504 ) && rel.foreign_key.is_none()
505 {
506 diags.push(Diagnostic {
507 code: "SR061",
508 error: format!(
509 "resource '{res}': relation '{name}' is {} but has no foreign_key",
510 rel.relation_type
511 ),
512 fix: format!(
513 "add 'foreign_key: {res}_id' to the relation (the FK on the related table)"
514 ),
515 example: format!(
516 "{name}: {{ resource: {}, type: {}, foreign_key: {res}_id }}",
517 rel.resource, rel.relation_type
518 ),
519 });
520 }
521
522 if let Some(key) = &rel.key {
523 if !rd.schema.contains_key(key) {
524 diags.push(Diagnostic {
525 code: "SR062",
526 error: format!(
527 "resource '{res}': relation '{name}' key '{key}' not found in schema"
528 ),
529 fix: format!("add '{key}' as a uuid field in the schema"),
530 example: format!(
531 "{key}: {{ type: uuid, ref: {}.id, required: true }}",
532 rel.resource
533 ),
534 });
535 }
536 }
537 }
538 }
539
540 if let Some(indexes) = &rd.indexes {
542 for (i, idx) in indexes.iter().enumerate() {
543 if idx.fields.is_empty() {
544 diags.push(Diagnostic {
545 code: "SR070",
546 error: format!("resource '{res}': index {i} has no fields"),
547 fix: "add at least one field to the index".into(),
548 example: "- { fields: [field_name] }".into(),
549 });
550 }
551 for field_name in &idx.fields {
552 if !rd.schema.contains_key(field_name) {
553 diags.push(Diagnostic {
554 code: "SR071",
555 error: format!(
556 "resource '{res}': index {i} references field '{field_name}' not in schema"
557 ),
558 fix: format!("add '{field_name}' to the schema, or remove it from the index"),
559 example: format!("{field_name}: {{ type: string, required: true }}"),
560 });
561 }
562 }
563 if let Some(order) = &idx.order {
564 if order != "asc" && order != "desc" {
565 diags.push(Diagnostic {
566 code: "SR072",
567 error: format!(
568 "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
569 ),
570 fix: "use 'asc' or 'desc' for the index order".into(),
571 example: "- { fields: [created_at], order: desc }".into(),
572 });
573 }
574 }
575 }
576 }
577
578 diags
579}
580
581fn diagnose_controller_name(
582 res: &str,
583 action: &str,
584 phase: &str,
585 name: &str,
586 diags: &mut Vec<Diagnostic>,
587) {
588 if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
589 if wasm_path.is_empty() {
590 diags.push(Diagnostic {
591 code: "SR035",
592 error: format!(
593 "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
594 ),
595 fix: "provide a .wasm file path after the 'wasm:' prefix".into(),
596 example: format!("controller: {{ {phase}: \"wasm:./plugins/validator.wasm\" }}"),
597 });
598 } else if !wasm_path.ends_with(".wasm") {
599 diags.push(Diagnostic {
600 code: "SR036",
601 error: format!(
602 "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
603 ),
604 fix: "ensure the WASM plugin path ends with '.wasm'".into(),
605 example: format!("controller: {{ {phase}: \"wasm:./plugins/validator.wasm\" }}"),
606 });
607 }
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::parser::parse_resource;
615
616 #[test]
617 fn valid_resource_produces_no_diagnostics() {
618 let yaml = include_str!("../../resources/users.yaml");
619 let rd = parse_resource(yaml).unwrap();
620 let diags = diagnose_resource(&rd);
621 assert!(diags.is_empty(), "Expected no diagnostics, got: {diags:?}");
622 }
623
624 #[test]
625 fn enum_without_values_has_fix_suggestion() {
626 let yaml = r#"
627resource: items
628version: 1
629schema:
630 id: { type: uuid, primary: true, generated: true }
631 status: { type: enum, required: true }
632"#;
633 let rd = parse_resource(yaml).unwrap();
634 let diags = diagnose_resource(&rd);
635 let d = diags.iter().find(|d| d.code == "SR010").unwrap();
636 assert!(d.fix.contains("values"));
637 assert!(d.example.contains("values:"));
638 }
639
640 #[test]
641 fn missing_primary_key_has_fix_suggestion() {
642 let yaml = r#"
643resource: items
644version: 1
645schema:
646 name: { type: string, required: true }
647"#;
648 let rd = parse_resource(yaml).unwrap();
649 let diags = diagnose_resource(&rd);
650 let d = diags.iter().find(|d| d.code == "SR004").unwrap();
651 assert!(d.fix.contains("primary: true"));
652 }
653
654 #[test]
655 fn diagnostics_serialize_to_json() {
656 let d = Diagnostic {
657 code: "SR010",
658 error: "field 'role' is type enum but has no values".into(),
659 fix: "add values".into(),
660 example: "role: { type: enum, values: [a, b] }".into(),
661 };
662 let json = serde_json::to_string(&d).unwrap();
663 assert!(json.contains("SR010"));
664 assert!(json.contains("fix"));
665 }
666
667 #[test]
668 fn subscriber_with_empty_event_has_fix_suggestion() {
669 let yaml = r#"
670resource: items
671version: 1
672schema:
673 id: { type: uuid, primary: true, generated: true }
674endpoints:
675 create:
676 auth: [admin]
677 subscribers:
678 - event: ""
679 handler: my_handler
680"#;
681 let rd = parse_resource(yaml).unwrap();
682 let diags = diagnose_resource(&rd);
683 let d = diags.iter().find(|d| d.code == "SR073");
684 assert!(
685 d.is_some(),
686 "Expected SR073 diagnostic for empty subscriber event"
687 );
688 assert!(d.unwrap().fix.contains("event"));
689 }
690
691 #[test]
692 fn subscriber_with_empty_handler_has_fix_suggestion() {
693 let yaml = r#"
694resource: items
695version: 1
696schema:
697 id: { type: uuid, primary: true, generated: true }
698endpoints:
699 create:
700 auth: [admin]
701 subscribers:
702 - event: items.created
703 handler: ""
704"#;
705 let rd = parse_resource(yaml).unwrap();
706 let diags = diagnose_resource(&rd);
707 let d = diags.iter().find(|d| d.code == "SR074");
708 assert!(
709 d.is_some(),
710 "Expected SR074 diagnostic for empty subscriber handler"
711 );
712 assert!(d.unwrap().fix.contains("handler"));
713 }
714
715 #[test]
716 fn non_convention_endpoint_without_handler_produces_sr075() {
717 let yaml = r#"
718resource: items
719version: 1
720schema:
721 id: { type: uuid, primary: true, generated: true }
722endpoints:
723 archive:
724 method: POST
725 path: /items/:id/archive
726 auth: [admin]
727"#;
728 let rd = parse_resource(yaml).unwrap();
729 let diags = diagnose_resource(&rd);
730 let d = diags.iter().find(|d| d.code == "SR075");
731 assert!(
732 d.is_some(),
733 "Expected SR075 for non-convention endpoint missing handler"
734 );
735 assert!(d.unwrap().fix.contains("handler"));
736 }
737
738 #[test]
739 fn non_convention_endpoint_with_handler_no_sr075() {
740 let yaml = r#"
741resource: items
742version: 1
743schema:
744 id: { type: uuid, primary: true, generated: true }
745endpoints:
746 archive:
747 method: POST
748 path: /items/:id/archive
749 auth: [admin]
750 handler: archive_item
751"#;
752 let rd = parse_resource(yaml).unwrap();
753 let diags = diagnose_resource(&rd);
754 let has_sr075 = diags.iter().any(|d| d.code == "SR075");
755 assert!(!has_sr075, "SR075 should not fire when handler is present");
756 }
757
758 #[test]
761 fn diagnostic_display_format() {
762 let d = Diagnostic {
763 code: "SR010",
764 error: "field 'role' is type enum but has no values".into(),
765 fix: "add values".into(),
766 example: "role: { type: enum, values: [a, b] }".into(),
767 };
768 let s = d.to_string();
769 assert!(s.contains("SR010"), "Expected code in display");
770 assert!(s.contains("enum"), "Expected error text in display");
771 }
772
773 #[test]
776 fn format_feature_warnings_empty_returns_empty_string() {
777 use crate::feature_check::format_feature_warnings;
778 let s = format_feature_warnings(&[]);
779 assert!(s.is_empty());
780 }
781
782 #[test]
783 fn format_feature_warnings_nonempty_includes_feature_name() {
784 use crate::feature_check::{format_feature_warnings, RequiredFeature};
785 let feats = vec![RequiredFeature {
786 feature: "wasm-plugins",
787 reason: "resource 'items' uses WASM".into(),
788 enable_hint: "Add to Cargo.toml: shaperail-runtime = { features = [\"wasm-plugins\"] }"
789 .into(),
790 }];
791 let s = format_feature_warnings(&feats);
792 assert!(s.contains("wasm-plugins"));
793 assert!(s.contains("WASM"));
794 }
795
796 #[test]
799 fn sr002_version_zero() {
800 let yaml = r#"
801resource: items
802version: 0
803schema:
804 id: { type: uuid, primary: true, generated: true }
805"#;
806 let rd = parse_resource(yaml).unwrap();
807 let diags = diagnose_resource(&rd);
808 assert!(
809 diags.iter().any(|d| d.code == "SR002"),
810 "Expected SR002, got: {diags:?}"
811 );
812 let d = diags.iter().find(|d| d.code == "SR002").unwrap();
813 assert!(d.fix.contains("version"));
814 }
815
816 #[test]
819 fn sr003_empty_schema() {
820 use indexmap::IndexMap;
821 use shaperail_core::ResourceDefinition;
822 let rd = ResourceDefinition {
823 resource: "items".to_string(),
824 version: 1,
825 db: None,
826 tenant_key: None,
827 schema: IndexMap::new(),
828 endpoints: None,
829 relations: None,
830 indexes: None,
831 };
832 let diags = diagnose_resource(&rd);
833 assert!(
834 diags.iter().any(|d| d.code == "SR003"),
835 "Expected SR003, got: {diags:?}"
836 );
837 }
838
839 #[test]
842 fn sr005_multiple_primary_keys() {
843 let yaml = r#"
844resource: items
845version: 1
846schema:
847 id: { type: uuid, primary: true, generated: true }
848 alt: { type: uuid, primary: true, generated: true }
849 name: { type: string, required: true }
850"#;
851 let rd = parse_resource(yaml).unwrap();
852 let diags = diagnose_resource(&rd);
853 assert!(
854 diags.iter().any(|d| d.code == "SR005"),
855 "Expected SR005, got: {diags:?}"
856 );
857 }
858
859 #[test]
862 fn sr011_non_enum_with_values() {
863 let yaml = r#"
864resource: items
865version: 1
866schema:
867 id: { type: uuid, primary: true, generated: true }
868 name: { type: string, required: true, values: ["a", "b"] }
869"#;
870 let rd = parse_resource(yaml).unwrap();
871 let diags = diagnose_resource(&rd);
872 assert!(
873 diags.iter().any(|d| d.code == "SR011"),
874 "Expected SR011, got: {diags:?}"
875 );
876 let d = diags.iter().find(|d| d.code == "SR011").unwrap();
877 assert!(d.fix.contains("enum") || d.fix.contains("values"));
878 }
879
880 #[test]
883 fn sr012_ref_on_non_uuid() {
884 let yaml = r#"
885resource: items
886version: 1
887schema:
888 id: { type: uuid, primary: true, generated: true }
889 org_id: { type: string, ref: organizations.id }
890"#;
891 let rd = parse_resource(yaml).unwrap();
892 let diags = diagnose_resource(&rd);
893 assert!(
894 diags.iter().any(|d| d.code == "SR012"),
895 "Expected SR012, got: {diags:?}"
896 );
897 }
898
899 #[test]
902 fn sr013_ref_bad_format() {
903 let yaml = r#"
904resource: items
905version: 1
906schema:
907 id: { type: uuid, primary: true, generated: true }
908 org_id: { type: uuid, ref: organizations }
909"#;
910 let rd = parse_resource(yaml).unwrap();
911 let diags = diagnose_resource(&rd);
912 assert!(
913 diags.iter().any(|d| d.code == "SR013"),
914 "Expected SR013, got: {diags:?}"
915 );
916 let d = diags.iter().find(|d| d.code == "SR013").unwrap();
917 assert!(d.fix.contains("resource_name.field_name") || d.fix.contains("format"));
918 }
919
920 #[test]
923 fn sr014_array_without_items() {
924 let yaml = r#"
925resource: items
926version: 1
927schema:
928 id: { type: uuid, primary: true, generated: true }
929 tags: { type: array }
930"#;
931 let rd = parse_resource(yaml).unwrap();
932 let diags = diagnose_resource(&rd);
933 assert!(
934 diags.iter().any(|d| d.code == "SR014"),
935 "Expected SR014, got: {diags:?}"
936 );
937 }
938
939 #[test]
942 fn sr015_format_on_non_string() {
943 let yaml = r#"
944resource: items
945version: 1
946schema:
947 id: { type: uuid, primary: true, generated: true }
948 age: { type: integer, required: true, format: email }
949"#;
950 let rd = parse_resource(yaml).unwrap();
951 let diags = diagnose_resource(&rd);
952 assert!(
953 diags.iter().any(|d| d.code == "SR015"),
954 "Expected SR015, got: {diags:?}"
955 );
956 }
957
958 #[test]
961 fn sr016_primary_not_generated_or_required() {
962 let yaml = r#"
963resource: items
964version: 1
965schema:
966 id: { type: uuid, primary: true }
967"#;
968 let rd = parse_resource(yaml).unwrap();
969 let diags = diagnose_resource(&rd);
970 assert!(
971 diags.iter().any(|d| d.code == "SR016"),
972 "Expected SR016, got: {diags:?}"
973 );
974 let d = diags.iter().find(|d| d.code == "SR016").unwrap();
975 assert!(d.fix.contains("generated: true") || d.fix.contains("required: true"));
976 }
977
978 #[test]
981 fn sr020_tenant_key_not_uuid() {
982 let yaml = r#"
983resource: items
984version: 1
985tenant_key: org_name
986schema:
987 id: { type: uuid, primary: true, generated: true }
988 org_name: { type: string, required: true }
989"#;
990 let rd = parse_resource(yaml).unwrap();
991 let diags = diagnose_resource(&rd);
992 assert!(
993 diags.iter().any(|d| d.code == "SR020"),
994 "Expected SR020, got: {diags:?}"
995 );
996 }
997
998 #[test]
1001 fn sr021_tenant_key_not_in_schema() {
1002 let yaml = r#"
1003resource: items
1004version: 1
1005tenant_key: missing_field
1006schema:
1007 id: { type: uuid, primary: true, generated: true }
1008"#;
1009 let rd = parse_resource(yaml).unwrap();
1010 let diags = diagnose_resource(&rd);
1011 assert!(
1012 diags.iter().any(|d| d.code == "SR021"),
1013 "Expected SR021, got: {diags:?}"
1014 );
1015 let d = diags.iter().find(|d| d.code == "SR021").unwrap();
1016 assert!(d.fix.contains("missing_field") || d.fix.contains("add"));
1017 }
1018
1019 #[test]
1022 fn sr035_wasm_empty_path() {
1023 let yaml = r#"
1024resource: items
1025version: 1
1026schema:
1027 id: { type: uuid, primary: true, generated: true }
1028 name: { type: string, required: true }
1029endpoints:
1030 create:
1031 input: [name]
1032 controller: { before: "wasm:" }
1033"#;
1034 let rd = parse_resource(yaml).unwrap();
1035 let diags = diagnose_resource(&rd);
1036 assert!(
1037 diags.iter().any(|d| d.code == "SR035"),
1038 "Expected SR035, got: {diags:?}"
1039 );
1040 }
1041
1042 #[test]
1045 fn sr036_wasm_path_no_extension() {
1046 let yaml = r#"
1047resource: items
1048version: 1
1049schema:
1050 id: { type: uuid, primary: true, generated: true }
1051 name: { type: string, required: true }
1052endpoints:
1053 create:
1054 input: [name]
1055 controller: { after: "wasm:./plugins/my_plugin" }
1056"#;
1057 let rd = parse_resource(yaml).unwrap();
1058 let diags = diagnose_resource(&rd);
1059 assert!(
1060 diags.iter().any(|d| d.code == "SR036"),
1061 "Expected SR036, got: {diags:?}"
1062 );
1063 }
1064
1065 #[test]
1068 fn sr040_input_field_not_in_schema() {
1069 let yaml = r#"
1070resource: items
1071version: 1
1072schema:
1073 id: { type: uuid, primary: true, generated: true }
1074 name: { type: string, required: true }
1075endpoints:
1076 create:
1077 input: [name, ghost_field]
1078"#;
1079 let rd = parse_resource(yaml).unwrap();
1080 let diags = diagnose_resource(&rd);
1081 assert!(
1082 diags.iter().any(|d| d.code == "SR040"),
1083 "Expected SR040, got: {diags:?}"
1084 );
1085 }
1086
1087 #[test]
1088 fn sr040_filter_field_not_in_schema() {
1089 let yaml = r#"
1090resource: items
1091version: 1
1092schema:
1093 id: { type: uuid, primary: true, generated: true }
1094 name: { type: string, required: true }
1095endpoints:
1096 list:
1097 auth: public
1098 filters: [name, missing_filter]
1099"#;
1100 let rd = parse_resource(yaml).unwrap();
1101 let diags = diagnose_resource(&rd);
1102 assert!(
1103 diags.iter().any(|d| d.code == "SR040"),
1104 "Expected SR040, got: {diags:?}"
1105 );
1106 }
1107
1108 #[test]
1111 fn sr041_soft_delete_without_deleted_at() {
1112 let yaml = r#"
1113resource: items
1114version: 1
1115schema:
1116 id: { type: uuid, primary: true, generated: true }
1117 name: { type: string, required: true }
1118endpoints:
1119 delete:
1120 auth: [admin]
1121 soft_delete: true
1122"#;
1123 let rd = parse_resource(yaml).unwrap();
1124 let diags = diagnose_resource(&rd);
1125 assert!(
1126 diags.iter().any(|d| d.code == "SR041"),
1127 "Expected SR041, got: {diags:?}"
1128 );
1129 let d = diags.iter().find(|d| d.code == "SR041").unwrap();
1130 assert!(d.fix.contains("deleted_at"));
1131 }
1132
1133 #[test]
1136 fn sr060_belongs_to_without_key() {
1137 let yaml = r#"
1138resource: items
1139version: 1
1140schema:
1141 id: { type: uuid, primary: true, generated: true }
1142relations:
1143 org: { resource: organizations, type: belongs_to }
1144"#;
1145 let rd = parse_resource(yaml).unwrap();
1146 let diags = diagnose_resource(&rd);
1147 assert!(
1148 diags.iter().any(|d| d.code == "SR060"),
1149 "Expected SR060, got: {diags:?}"
1150 );
1151 let d = diags.iter().find(|d| d.code == "SR060").unwrap();
1152 assert!(d.fix.contains("key"));
1153 }
1154
1155 #[test]
1158 fn sr061_has_many_without_foreign_key() {
1159 let yaml = r#"
1160resource: users
1161version: 1
1162schema:
1163 id: { type: uuid, primary: true, generated: true }
1164relations:
1165 orders: { resource: orders, type: has_many }
1166"#;
1167 let rd = parse_resource(yaml).unwrap();
1168 let diags = diagnose_resource(&rd);
1169 assert!(
1170 diags.iter().any(|d| d.code == "SR061"),
1171 "Expected SR061, got: {diags:?}"
1172 );
1173 let d = diags.iter().find(|d| d.code == "SR061").unwrap();
1174 assert!(d.fix.contains("foreign_key"));
1175 }
1176
1177 #[test]
1178 fn sr061_has_one_without_foreign_key() {
1179 let yaml = r#"
1180resource: users
1181version: 1
1182schema:
1183 id: { type: uuid, primary: true, generated: true }
1184relations:
1185 profile: { resource: profiles, type: has_one }
1186"#;
1187 let rd = parse_resource(yaml).unwrap();
1188 let diags = diagnose_resource(&rd);
1189 assert!(
1190 diags.iter().any(|d| d.code == "SR061"),
1191 "Expected SR061 for has_one, got: {diags:?}"
1192 );
1193 }
1194
1195 #[test]
1198 fn sr062_relation_key_not_in_schema() {
1199 let yaml = r#"
1200resource: items
1201version: 1
1202schema:
1203 id: { type: uuid, primary: true, generated: true }
1204relations:
1205 org: { resource: organizations, type: belongs_to, key: missing_fk }
1206"#;
1207 let rd = parse_resource(yaml).unwrap();
1208 let diags = diagnose_resource(&rd);
1209 assert!(
1210 diags.iter().any(|d| d.code == "SR062"),
1211 "Expected SR062, got: {diags:?}"
1212 );
1213 }
1214
1215 #[test]
1218 fn sr070_index_no_fields() {
1219 use indexmap::IndexMap;
1220 use shaperail_core::{FieldSchema, FieldType, IndexSpec, ResourceDefinition};
1221
1222 let mut schema = IndexMap::new();
1223 schema.insert(
1224 "id".to_string(),
1225 FieldSchema {
1226 field_type: FieldType::Uuid,
1227 primary: true,
1228 generated: true,
1229 required: false,
1230 unique: false,
1231 nullable: false,
1232 reference: None,
1233 min: None,
1234 max: None,
1235 format: None,
1236 values: None,
1237 default: None,
1238 sensitive: false,
1239 search: false,
1240 items: None,
1241 transient: false,
1242 },
1243 );
1244 let rd = ResourceDefinition {
1245 resource: "items".to_string(),
1246 version: 1,
1247 db: None,
1248 tenant_key: None,
1249 schema,
1250 endpoints: None,
1251 relations: None,
1252 indexes: Some(vec![IndexSpec {
1253 fields: vec![],
1254 unique: false,
1255 order: None,
1256 }]),
1257 };
1258 let diags = diagnose_resource(&rd);
1259 assert!(
1260 diags.iter().any(|d| d.code == "SR070"),
1261 "Expected SR070, got: {diags:?}"
1262 );
1263 }
1264
1265 #[test]
1268 fn sr071_index_field_not_in_schema() {
1269 let yaml = r#"
1270resource: items
1271version: 1
1272schema:
1273 id: { type: uuid, primary: true, generated: true }
1274indexes:
1275 - fields: [missing_field]
1276"#;
1277 let rd = parse_resource(yaml).unwrap();
1278 let diags = diagnose_resource(&rd);
1279 assert!(
1280 diags.iter().any(|d| d.code == "SR071"),
1281 "Expected SR071, got: {diags:?}"
1282 );
1283 }
1284
1285 #[test]
1288 fn sr072_index_invalid_order() {
1289 let yaml = r#"
1290resource: items
1291version: 1
1292schema:
1293 id: { type: uuid, primary: true, generated: true }
1294 created_at: { type: timestamp, generated: true }
1295indexes:
1296 - fields: [created_at]
1297 order: INVALID
1298"#;
1299 let rd = parse_resource(yaml).unwrap();
1300 let diags = diagnose_resource(&rd);
1301 assert!(
1302 diags.iter().any(|d| d.code == "SR072"),
1303 "Expected SR072, got: {diags:?}"
1304 );
1305 let d = diags.iter().find(|d| d.code == "SR072").unwrap();
1306 assert!(d.fix.contains("asc") || d.fix.contains("desc"));
1307 }
1308
1309 #[test]
1312 fn sr030_empty_controller_before_name() {
1313 let yaml = r#"
1314resource: items
1315version: 1
1316schema:
1317 id: { type: uuid, primary: true, generated: true }
1318endpoints:
1319 create:
1320 input: [id]
1321 controller: { before: "" }
1322"#;
1323 let rd = parse_resource(yaml).unwrap();
1324 let diags = diagnose_resource(&rd);
1325 assert!(
1326 diags.iter().any(|d| d.code == "SR030"),
1327 "Expected SR030, got: {diags:?}"
1328 );
1329 }
1330
1331 #[test]
1332 fn sr031_empty_controller_after_name() {
1333 let yaml = r#"
1334resource: items
1335version: 1
1336schema:
1337 id: { type: uuid, primary: true, generated: true }
1338endpoints:
1339 create:
1340 input: [id]
1341 controller: { after: "" }
1342"#;
1343 let rd = parse_resource(yaml).unwrap();
1344 let diags = diagnose_resource(&rd);
1345 assert!(
1346 diags.iter().any(|d| d.code == "SR031"),
1347 "Expected SR031, got: {diags:?}"
1348 );
1349 }
1350
1351 #[test]
1352 fn sr032_empty_event_name_in_diagnostics() {
1353 let yaml = r#"
1354resource: items
1355version: 1
1356schema:
1357 id: { type: uuid, primary: true, generated: true }
1358endpoints:
1359 create:
1360 input: [id]
1361 events: [""]
1362"#;
1363 let rd = parse_resource(yaml).unwrap();
1364 let diags = diagnose_resource(&rd);
1365 assert!(
1366 diags.iter().any(|d| d.code == "SR032"),
1367 "Expected SR032, got: {diags:?}"
1368 );
1369 }
1370
1371 #[test]
1372 fn sr033_empty_job_name_in_diagnostics() {
1373 let yaml = r#"
1374resource: items
1375version: 1
1376schema:
1377 id: { type: uuid, primary: true, generated: true }
1378endpoints:
1379 create:
1380 input: [id]
1381 jobs: [""]
1382"#;
1383 let rd = parse_resource(yaml).unwrap();
1384 let diags = diagnose_resource(&rd);
1385 assert!(
1386 diags.iter().any(|d| d.code == "SR033"),
1387 "Expected SR033, got: {diags:?}"
1388 );
1389 }
1390
1391 #[test]
1392 fn sr050_upload_on_get_method() {
1393 let yaml = r#"
1394resource: assets
1395version: 1
1396schema:
1397 id: { type: uuid, primary: true, generated: true }
1398 file: { type: file, required: true }
1399endpoints:
1400 upload_file:
1401 method: GET
1402 path: /assets/upload
1403 input: [file]
1404 upload:
1405 field: file
1406 storage: s3
1407 max_size: 5mb
1408"#;
1409 let rd = parse_resource(yaml).unwrap();
1410 let diags = diagnose_resource(&rd);
1411 assert!(
1412 diags.iter().any(|d| d.code == "SR050"),
1413 "Expected SR050 (upload on GET), got: {diags:?}"
1414 );
1415 }
1416
1417 #[test]
1418 fn sr051_upload_field_wrong_type() {
1419 let yaml = r#"
1420resource: assets
1421version: 1
1422schema:
1423 id: { type: uuid, primary: true, generated: true }
1424 title: { type: string, required: true }
1425endpoints:
1426 upload_file:
1427 method: POST
1428 path: /assets/upload
1429 input: [title]
1430 upload:
1431 field: title
1432 storage: s3
1433 max_size: 5mb
1434"#;
1435 let rd = parse_resource(yaml).unwrap();
1436 let diags = diagnose_resource(&rd);
1437 assert!(
1438 diags.iter().any(|d| d.code == "SR051"),
1439 "Expected SR051 (upload field not type file), got: {diags:?}"
1440 );
1441 }
1442
1443 #[test]
1444 fn sr052_upload_field_missing_from_schema() {
1445 let yaml = r#"
1446resource: assets
1447version: 1
1448schema:
1449 id: { type: uuid, primary: true, generated: true }
1450endpoints:
1451 upload_file:
1452 method: POST
1453 path: /assets/upload
1454 input: [attachment]
1455 upload:
1456 field: attachment
1457 storage: s3
1458 max_size: 5mb
1459"#;
1460 let rd = parse_resource(yaml).unwrap();
1461 let diags = diagnose_resource(&rd);
1462 assert!(
1463 diags.iter().any(|d| d.code == "SR052"),
1464 "Expected SR052 (upload field not in schema), got: {diags:?}"
1465 );
1466 }
1467
1468 #[test]
1469 fn sr053_upload_invalid_storage_backend() {
1470 let yaml = r#"
1471resource: assets
1472version: 1
1473schema:
1474 id: { type: uuid, primary: true, generated: true }
1475 file: { type: file, required: true }
1476endpoints:
1477 upload_file:
1478 method: POST
1479 path: /assets/upload
1480 input: [file]
1481 upload:
1482 field: file
1483 storage: ftp
1484 max_size: 5mb
1485"#;
1486 let rd = parse_resource(yaml).unwrap();
1487 let diags = diagnose_resource(&rd);
1488 assert!(
1489 diags.iter().any(|d| d.code == "SR053"),
1490 "Expected SR053 (invalid storage 'ftp'), got: {diags:?}"
1491 );
1492 }
1493
1494 #[test]
1495 fn sr054_upload_field_not_in_input() {
1496 let yaml = r#"
1497resource: assets
1498version: 1
1499schema:
1500 id: { type: uuid, primary: true, generated: true }
1501 file: { type: file, required: true }
1502endpoints:
1503 upload_file:
1504 method: POST
1505 path: /assets/upload
1506 input: []
1507 upload:
1508 field: file
1509 storage: s3
1510 max_size: 5mb
1511"#;
1512 let rd = parse_resource(yaml).unwrap();
1513 let diags = diagnose_resource(&rd);
1514 assert!(
1515 diags.iter().any(|d| d.code == "SR054"),
1516 "Expected SR054 (upload field not in input), got: {diags:?}"
1517 );
1518 }
1519
1520 #[test]
1521 fn sr050_to_sr054_all_clear_for_valid_upload_endpoint() {
1522 let yaml = r#"
1523resource: assets
1524version: 1
1525schema:
1526 id: { type: uuid, primary: true, generated: true }
1527 file: { type: file, required: true }
1528 title: { type: string, required: true }
1529endpoints:
1530 upload_file:
1531 method: POST
1532 path: /assets/upload
1533 input: [file, title]
1534 upload:
1535 field: file
1536 storage: s3
1537 max_size: 10mb
1538"#;
1539 let rd = parse_resource(yaml).unwrap();
1540 let diags = diagnose_resource(&rd);
1541 let upload_diags: Vec<_> = diags
1542 .iter()
1543 .filter(|d| ["SR050", "SR051", "SR052", "SR053", "SR054"].contains(&d.code))
1544 .collect();
1545 assert!(
1546 upload_diags.is_empty(),
1547 "Valid upload endpoint should produce no SR050-054 diags, got: {upload_diags:?}"
1548 );
1549 }
1550
1551 #[test]
1552 fn sr073_subscriber_empty_event() {
1553 let yaml = r#"
1554resource: notifications
1555version: 1
1556schema:
1557 id: { type: uuid, primary: true, generated: true }
1558endpoints:
1559 on_user_created:
1560 method: POST
1561 path: /notifications/on_user_created
1562 handler: on_user_created_handler
1563 subscribers:
1564 - event: ""
1565 handler: send_welcome
1566"#;
1567 let rd = parse_resource(yaml).unwrap();
1568 let diags = diagnose_resource(&rd);
1569 assert!(
1570 diags.iter().any(|d| d.code == "SR073"),
1571 "Expected SR073, got: {diags:?}"
1572 );
1573 }
1574
1575 #[test]
1576 fn sr074_subscriber_empty_handler() {
1577 let yaml = r#"
1578resource: notifications
1579version: 1
1580schema:
1581 id: { type: uuid, primary: true, generated: true }
1582endpoints:
1583 on_user_created:
1584 method: POST
1585 path: /notifications/on_user_created
1586 handler: on_user_created_handler
1587 subscribers:
1588 - event: user.created
1589 handler: ""
1590"#;
1591 let rd = parse_resource(yaml).unwrap();
1592 let diags = diagnose_resource(&rd);
1593 assert!(
1594 diags.iter().any(|d| d.code == "SR074"),
1595 "Expected SR074, got: {diags:?}"
1596 );
1597 }
1598
1599 #[test]
1602 fn sr063_empty_controller_before_list() {
1603 let yaml = r#"
1604resource: orders
1605version: 1
1606schema:
1607 id: { type: uuid, primary: true, generated: true }
1608endpoints:
1609 create:
1610 auth: public
1611 controller: { before: [] }
1612"#;
1613 let rd = parse_resource(yaml).unwrap();
1614 let diags = diagnose_resource(&rd);
1615 assert!(
1616 diags.iter().any(|d| d.code == "SR063"),
1617 "Expected SR063, got: {diags:?}"
1618 );
1619 }
1620
1621 #[test]
1622 fn sr063_empty_controller_after_list() {
1623 let yaml = r#"
1624resource: orders
1625version: 1
1626schema:
1627 id: { type: uuid, primary: true, generated: true }
1628endpoints:
1629 create:
1630 auth: public
1631 controller: { after: [] }
1632"#;
1633 let rd = parse_resource(yaml).unwrap();
1634 let diags = diagnose_resource(&rd);
1635 assert!(
1636 diags.iter().any(|d| d.code == "SR063"),
1637 "Expected SR063, got: {diags:?}"
1638 );
1639 }
1640}