1mod client;
4mod mapping;
5mod ops;
6mod registry;
7mod state;
8
9use anyhow::Result;
10
11#[cfg(test)]
12use alembic_engine::Adapter;
13#[cfg(test)]
14use alembic_engine::StateStore;
15use client::NetBoxClient;
16
17pub struct NetBoxAdapter {
19 client: NetBoxClient,
20}
21
22impl NetBoxAdapter {
23 pub fn new(url: &str, token: &str) -> Result<Self> {
25 let client = NetBoxClient::new(url, token)?;
26 Ok(Self { client })
27 }
28}
29
30#[cfg(test)]
31mod tests {
32 use super::*;
33 use crate::netbox::mapping::{build_tag_inputs, slugify};
34 use alembic_core::{key_string, JsonMap, Key, TypeName, Uid};
35 use alembic_engine::Op;
36 use httpmock::Method::{GET, POST};
37 use httpmock::{Mock, MockServer};
38 use serde_json::json;
39 use tempfile::tempdir;
40 use uuid::Uuid;
41
42 fn uid(value: u128) -> Uid {
43 Uuid::from_u128(value)
44 }
45
46 fn attrs_map(value: serde_json::Value) -> JsonMap {
47 let serde_json::Value::Object(map) = value else {
48 panic!("attrs must be a json object");
49 };
50 map.into_iter()
51 .collect::<std::collections::BTreeMap<_, _>>()
52 .into()
53 }
54
55 fn key(field: &str, value: serde_json::Value) -> Key {
56 let mut map = std::collections::BTreeMap::new();
57 map.insert(field.to_string(), value);
58 Key::from(map)
59 }
60
61 fn obj(uid: Uid, type_name: &str, key: Key, attrs: serde_json::Value) -> alembic_core::Object {
62 alembic_core::Object::new(uid, TypeName::new(type_name), key, attrs_map(attrs)).unwrap()
63 }
64
65 fn page(results: serde_json::Value) -> serde_json::Value {
66 json!({
67 "count": results.as_array().map(|a| a.len()).unwrap_or(0),
68 "next": null,
69 "previous": null,
70 "results": results
71 })
72 }
73
74 fn state_with_mappings(path: &std::path::Path) -> StateStore {
75 let mut store = StateStore::load(path).unwrap();
76 store.set_backend_id(
77 TypeName::new("dcim.site"),
78 uid(1),
79 alembic_engine::BackendId::Int(1),
80 );
81 store
82 }
83
84 fn mock_list<'a>(
85 server: &'a MockServer,
86 path: &'a str,
87 payload: serde_json::Value,
88 ) -> Mock<'a> {
89 server.mock(|when, then| {
90 when.method(GET)
91 .path(path)
92 .query_param("limit", "200")
93 .query_param("offset", "0");
94 then.status(200).json_body(page(payload));
95 })
96 }
97
98 #[tokio::test]
99 async fn observe_maps_nested_refs_to_uids() {
100 let server = MockServer::start();
101 let dir = tempdir().unwrap();
102 let state = state_with_mappings(&dir.path().join("state.json"));
103 let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
104
105 let _object_types = mock_list(
106 &server,
107 "/api/core/object-types/",
108 json!([
109 {
110 "app_label": "dcim",
111 "model": "device",
112 "rest_api_endpoint": "/api/dcim/devices/",
113 "features": ["custom-fields", "tags"]
114 },
115 {
116 "app_label": "dcim",
117 "model": "site",
118 "rest_api_endpoint": "/api/dcim/sites/",
119 "features": ["custom-fields", "tags"]
120 }
121 ]),
122 );
123 let _devices = mock_list(
124 &server,
125 "/api/dcim/devices/",
126 json!([
127 {
128 "id": 2,
129 "name": "leaf01",
130 "site": {
131 "id": 1,
132 "url": "https://netbox.example.com/api/dcim/sites/1/",
133 "name": "FRA1",
134 "slug": "fra1"
135 }
136 }
137 ]),
138 );
139 let _custom_fields = server.mock(|when, then| {
140 when.method(GET).path("/api/extras/custom-fields/");
141 then.status(200).json_body(page(json!([])));
142 });
143 let _tags = server.mock(|when, then| {
144 when.method(GET)
145 .path("/api/extras/tags/")
146 .query_param("limit", "200")
147 .query_param("offset", "0");
148 then.status(200)
149 .json_body(page(json!([{"id": 1, "name": "fabric", "slug": "fabric"}])));
150 });
151
152 let schema = alembic_core::Schema {
153 types: std::collections::BTreeMap::from([
154 (
155 "dcim.device".to_string(),
156 alembic_core::TypeSchema {
157 key: std::collections::BTreeMap::from([(
158 "name".to_string(),
159 alembic_core::FieldSchema {
160 r#type: alembic_core::FieldType::String,
161 required: true,
162 nullable: false,
163 description: None,
164 format: None,
165 pattern: None,
166 },
167 )]),
168 fields: std::collections::BTreeMap::new(),
169 },
170 ),
171 (
172 "dcim.site".to_string(),
173 alembic_core::TypeSchema {
174 key: std::collections::BTreeMap::from([(
175 "name".to_string(),
176 alembic_core::FieldSchema {
177 r#type: alembic_core::FieldType::String,
178 required: true,
179 nullable: false,
180 description: None,
181 format: None,
182 pattern: None,
183 },
184 )]),
185 fields: std::collections::BTreeMap::new(),
186 },
187 ),
188 ]),
189 };
190 let observed = adapter
191 .read(&schema, &[TypeName::new("dcim.device")], &state)
192 .await
193 .unwrap();
194
195 let device = observed
196 .by_key
197 .get(&(
198 TypeName::new("dcim.device"),
199 key_string(&key("name", json!("leaf01"))),
200 ))
201 .unwrap();
202 let site_uid = uid(1).to_string();
203 assert_eq!(
204 device.attrs.get("name").and_then(|v| v.as_str()),
205 Some("leaf01")
206 );
207 assert_eq!(
208 device.attrs.get("site").and_then(|v| v.as_str()),
209 Some(site_uid.as_str())
210 );
211 }
212
213 #[tokio::test]
214 async fn apply_orders_creates_by_dependency() {
215 let server = MockServer::start();
216 let dir = tempdir().unwrap();
217 let state = StateStore::load(dir.path().join("state.json")).unwrap();
218 let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
219
220 let _object_types = mock_list(
221 &server,
222 "/api/core/object-types/",
223 json!([
224 {
225 "app_label": "dcim",
226 "model": "site",
227 "rest_api_endpoint": "/api/dcim/sites/",
228 "features": ["custom-fields", "tags"]
229 },
230 {
231 "app_label": "dcim",
232 "model": "device",
233 "rest_api_endpoint": "/api/dcim/devices/",
234 "features": ["custom-fields", "tags"]
235 }
236 ]),
237 );
238 let _custom_fields = server.mock(|when, then| {
239 when.method(GET).path("/api/extras/custom-fields/");
240 then.status(200).json_body(page(json!([])));
241 });
242 let _site_create = server.mock(|when, then| {
243 when.method(POST)
244 .path("/api/dcim/sites/")
245 .json_body(json!({ "name": "FRA1", "slug": "fra1" }));
246 then.status(201)
247 .json_body(json!({ "id": 1, "name": "FRA1", "slug": "fra1" }));
248 });
249 let _device_create = server.mock(|when, then| {
250 when.method(POST)
251 .path("/api/dcim/devices/")
252 .json_body(json!({ "name": "leaf01", "site": 1 }));
253 then.status(201)
254 .json_body(json!({ "id": 2, "name": "leaf01" }));
255 });
256
257 let ops = vec![
258 Op::Create {
259 uid: uid(2),
260 type_name: TypeName::new("dcim.device"),
261 desired: obj(
262 uid(2),
263 "dcim.device",
264 key("name", json!("leaf01")),
265 json!({
266 "name": "leaf01",
267 "site": uid(1).to_string()
268 }),
269 ),
270 },
271 Op::Create {
272 uid: uid(1),
273 type_name: TypeName::new("dcim.site"),
274 desired: obj(
275 uid(1),
276 "dcim.site",
277 key("name", json!("fra1")),
278 json!({ "name": "FRA1", "slug": "fra1" }),
279 ),
280 },
281 ];
282
283 let schema = alembic_core::Schema {
284 types: std::collections::BTreeMap::from([
285 (
286 "dcim.device".to_string(),
287 alembic_core::TypeSchema {
288 key: std::collections::BTreeMap::from([(
289 "name".to_string(),
290 alembic_core::FieldSchema {
291 r#type: alembic_core::FieldType::String,
292 required: true,
293 nullable: false,
294 description: None,
295 format: None,
296 pattern: None,
297 },
298 )]),
299 fields: std::collections::BTreeMap::from([
300 (
301 "name".to_string(),
302 alembic_core::FieldSchema {
303 r#type: alembic_core::FieldType::String,
304 required: true,
305 nullable: false,
306 description: None,
307 format: None,
308 pattern: None,
309 },
310 ),
311 (
312 "site".to_string(),
313 alembic_core::FieldSchema {
314 r#type: alembic_core::FieldType::Ref {
315 target: "dcim.site".to_string(),
316 },
317 required: true,
318 nullable: false,
319 description: None,
320 format: None,
321 pattern: None,
322 },
323 ),
324 ]),
325 },
326 ),
327 (
328 "dcim.site".to_string(),
329 alembic_core::TypeSchema {
330 key: std::collections::BTreeMap::from([(
331 "name".to_string(),
332 alembic_core::FieldSchema {
333 r#type: alembic_core::FieldType::String,
334 required: true,
335 nullable: false,
336 description: None,
337 format: None,
338 pattern: None,
339 },
340 )]),
341 fields: std::collections::BTreeMap::from([
342 (
343 "name".to_string(),
344 alembic_core::FieldSchema {
345 r#type: alembic_core::FieldType::String,
346 required: true,
347 nullable: false,
348 description: None,
349 format: None,
350 pattern: None,
351 },
352 ),
353 (
354 "slug".to_string(),
355 alembic_core::FieldSchema {
356 r#type: alembic_core::FieldType::String,
357 required: true,
358 nullable: false,
359 description: None,
360 format: None,
361 pattern: None,
362 },
363 ),
364 ]),
365 },
366 ),
367 ]),
368 };
369 let report = adapter.write(&schema, &ops, &state).await.unwrap();
370 assert_eq!(report.applied.len(), 2);
371 }
372
373 #[tokio::test]
374 async fn apply_creates_missing_tags() {
375 let server = MockServer::start();
376 let dir = tempdir().unwrap();
377 let state = StateStore::load(dir.path().join("state.json")).unwrap();
378 let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
379
380 let _object_types = mock_list(
381 &server,
382 "/api/core/object-types/",
383 json!([{
384 "app_label": "dcim",
385 "model": "site",
386 "rest_api_endpoint": "/api/dcim/sites/",
387 "features": ["custom-fields", "tags"]
388 }]),
389 );
390 let _custom_fields = server.mock(|when, then| {
391 when.method(GET).path("/api/extras/custom-fields/");
392 then.status(200).json_body(page(json!([])));
393 });
394 let _tags = server.mock(|when, then| {
395 when.method(GET)
396 .path("/api/extras/tags/")
397 .query_param("limit", "200")
398 .query_param("offset", "0");
399 then.status(200).json_body(page(json!([])));
400 });
401 let _tag_create = server.mock(|when, then| {
402 when.method(POST).path("/api/extras/tags/");
403 then.status(201)
404 .json_body(json!({"id": 1, "name": "fabric", "slug": "fabric"}));
405 });
406 let _site_create = server.mock(|when, then| {
407 when.method(POST).path("/api/dcim/sites/");
408 then.status(201)
409 .json_body(json!({ "id": 1, "name": "FRA1", "slug": "fra1" }));
410 });
411
412 let mut attrs = std::collections::BTreeMap::new();
413 attrs.insert("name".to_string(), json!("FRA1"));
414 attrs.insert("slug".to_string(), json!("fra1"));
415 attrs.insert("tags".to_string(), json!(["fabric"]));
416
417 let ops = vec![Op::Create {
418 uid: uid(1),
419 type_name: TypeName::new("dcim.site"),
420 desired: alembic_core::Object {
421 uid: uid(1),
422 type_name: TypeName::new("dcim.site"),
423 key: Key::default(),
424 attrs: alembic_core::JsonMap::from(attrs),
425 source: None,
426 },
427 }];
428
429 let schema = alembic_core::Schema {
430 types: std::collections::BTreeMap::from([(
431 "dcim.site".to_string(),
432 alembic_core::TypeSchema {
433 key: std::collections::BTreeMap::new(),
434 fields: std::collections::BTreeMap::from([
435 (
436 "name".to_string(),
437 alembic_core::FieldSchema {
438 r#type: alembic_core::FieldType::String,
439 required: true,
440 nullable: false,
441 description: None,
442 format: None,
443 pattern: None,
444 },
445 ),
446 (
447 "slug".to_string(),
448 alembic_core::FieldSchema {
449 r#type: alembic_core::FieldType::String,
450 required: true,
451 nullable: false,
452 description: None,
453 format: None,
454 pattern: None,
455 },
456 ),
457 (
458 "tags".to_string(),
459 alembic_core::FieldSchema {
460 r#type: alembic_core::FieldType::List {
461 item: Box::new(alembic_core::FieldType::String),
462 },
463 required: false,
464 nullable: true,
465 description: None,
466 format: None,
467 pattern: None,
468 },
469 ),
470 ]),
471 },
472 )]),
473 };
474
475 let report = adapter.write(&schema, &ops, &state).await.unwrap();
476 assert_eq!(report.applied.len(), 1);
477 }
478
479 #[test]
480 fn slugify_normalizes_value() {
481 assert_eq!(slugify("EVPN Fabric"), "evpn-fabric");
482 assert_eq!(slugify("edge--core"), "edge-core");
483 }
484
485 #[test]
486 fn build_tag_inputs_uses_slugify() {
487 let tags = vec!["EVPN Fabric".to_string()];
488 let inputs = build_tag_inputs(&tags);
489 assert_eq!(inputs.len(), 1);
490 assert_eq!(inputs[0].name, "EVPN Fabric");
491 assert_eq!(inputs[0].slug, "evpn-fabric");
492 }
493
494 #[tokio::test]
495 async fn apply_handles_update_operation() {
496 use alembic_engine::FieldChange;
497 use httpmock::Method::PATCH;
498
499 let server = MockServer::start();
500 let dir = tempdir().unwrap();
501 let mut state = StateStore::load(dir.path().join("state.json")).unwrap();
502 state.set_backend_id(
503 TypeName::new("dcim.site"),
504 uid(1),
505 alembic_engine::BackendId::Int(1),
506 );
507 let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
508
509 let _object_types = mock_list(
510 &server,
511 "/api/core/object-types/",
512 json!([{
513 "app_label": "dcim",
514 "model": "site",
515 "rest_api_endpoint": "/api/dcim/sites/",
516 "features": ["custom-fields", "tags"]
517 }]),
518 );
519 let _custom_fields = server.mock(|when, then| {
520 when.method(GET).path("/api/extras/custom-fields/");
521 then.status(200).json_body(page(json!([])));
522 });
523 let _site_update = server.mock(|when, then| {
524 when.method(PATCH).path("/api/dcim/sites/1/");
525 then.status(200)
526 .json_body(json!({ "id": 1, "name": "FRA1-Updated", "slug": "fra1" }));
527 });
528
529 let mut key = std::collections::BTreeMap::new();
530 key.insert("slug".to_string(), json!("fra1"));
531 let mut attrs = std::collections::BTreeMap::new();
532 attrs.insert("name".to_string(), json!("FRA1-Updated"));
533
534 let ops = vec![Op::Update {
535 uid: uid(1),
536 type_name: TypeName::new("dcim.site"),
537 backend_id: Some(alembic_engine::BackendId::Int(1)),
538 desired: alembic_core::Object {
539 uid: uid(1),
540 type_name: TypeName::new("dcim.site"),
541 key: alembic_core::Key::from(key),
542 attrs: alembic_core::JsonMap::from(attrs),
543 source: None,
544 },
545 changes: vec![FieldChange {
546 field: "name".to_string(),
547 from: json!("FRA1"),
548 to: json!("FRA1-Updated"),
549 }],
550 }];
551
552 let schema = alembic_core::Schema {
553 types: std::collections::BTreeMap::from([(
554 "dcim.site".to_string(),
555 alembic_core::TypeSchema {
556 key: std::collections::BTreeMap::from([(
557 "slug".to_string(),
558 alembic_core::FieldSchema {
559 r#type: alembic_core::FieldType::String,
560 required: true,
561 nullable: false,
562 description: None,
563 format: None,
564 pattern: None,
565 },
566 )]),
567 fields: std::collections::BTreeMap::from([(
568 "name".to_string(),
569 alembic_core::FieldSchema {
570 r#type: alembic_core::FieldType::String,
571 required: true,
572 nullable: false,
573 description: None,
574 format: None,
575 pattern: None,
576 },
577 )]),
578 },
579 )]),
580 };
581 let report = adapter.write(&schema, &ops, &state).await.unwrap();
582 assert_eq!(report.applied.len(), 1);
583 }
584
585 #[tokio::test]
586 async fn apply_handles_delete_operation() {
587 use httpmock::Method::DELETE;
588
589 let server = MockServer::start();
590 let dir = tempdir().unwrap();
591 let mut state = StateStore::load(dir.path().join("state.json")).unwrap();
592 state.set_backend_id(
593 TypeName::new("dcim.site"),
594 uid(1),
595 alembic_engine::BackendId::Int(1),
596 );
597 let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
598
599 let _object_types = mock_list(
600 &server,
601 "/api/core/object-types/",
602 json!([{
603 "app_label": "dcim",
604 "model": "site",
605 "rest_api_endpoint": "/api/dcim/sites/",
606 "features": ["custom-fields", "tags"]
607 }]),
608 );
609 let _custom_fields = server.mock(|when, then| {
610 when.method(GET).path("/api/extras/custom-fields/");
611 then.status(200).json_body(page(json!([])));
612 });
613 let _site_delete = server.mock(|when, then| {
614 when.method(DELETE).path("/api/dcim/sites/");
615 then.status(204);
616 });
617
618 let ops = vec![Op::Delete {
619 uid: uid(1),
620 type_name: TypeName::new("dcim.site"),
621 key: key("slug", json!("fra1")),
622 backend_id: Some(alembic_engine::BackendId::Int(1)),
623 }];
624
625 let schema = alembic_core::Schema {
626 types: std::collections::BTreeMap::from([(
627 "dcim.site".to_string(),
628 alembic_core::TypeSchema {
629 key: std::collections::BTreeMap::from([(
630 "slug".to_string(),
631 alembic_core::FieldSchema {
632 r#type: alembic_core::FieldType::String,
633 required: true,
634 nullable: false,
635 description: None,
636 format: None,
637 pattern: None,
638 },
639 )]),
640 fields: std::collections::BTreeMap::new(),
641 },
642 )]),
643 };
644 let report = adapter.write(&schema, &ops, &state).await.unwrap();
645 assert_eq!(report.applied.len(), 1);
646 }
647
648 #[tokio::test]
649 async fn observe_handles_empty_types_list() {
650 let server = MockServer::start();
651 let dir = tempdir().unwrap();
652 let state = StateStore::load(dir.path().join("state.json")).unwrap();
653 let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
654
655 let _object_types = mock_list(
656 &server,
657 "/api/core/object-types/",
658 json!([{
659 "app_label": "dcim",
660 "model": "site",
661 "rest_api_endpoint": "/api/dcim/sites/",
662 "features": ["custom-fields", "tags"]
663 }]),
664 );
665 let _sites = mock_list(&server, "/api/dcim/sites/", json!([]));
666 let _custom_fields = server.mock(|when, then| {
667 when.method(GET).path("/api/extras/custom-fields/");
668 then.status(200).json_body(page(json!([])));
669 });
670 let _tags = server.mock(|when, then| {
671 when.method(GET)
672 .path("/api/extras/tags/")
673 .query_param("limit", "200")
674 .query_param("offset", "0");
675 then.status(200).json_body(page(json!([])));
676 });
677
678 let schema = alembic_core::Schema {
679 types: std::collections::BTreeMap::from([(
680 "dcim.site".to_string(),
681 alembic_core::TypeSchema {
682 key: std::collections::BTreeMap::from([(
683 "slug".to_string(),
684 alembic_core::FieldSchema {
685 r#type: alembic_core::FieldType::String,
686 required: true,
687 nullable: false,
688 description: None,
689 format: None,
690 pattern: None,
691 },
692 )]),
693 fields: std::collections::BTreeMap::new(),
694 },
695 )]),
696 };
697 let observed = adapter.read(&schema, &[], &state).await.unwrap();
699 assert!(observed.by_key.is_empty());
700 }
701
702 #[tokio::test]
703 async fn ensure_schema_creates_custom_fields() {
704 let server = MockServer::start();
705 let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
706
707 let _object_types = mock_list(
708 &server,
709 "/api/core/object-types/",
710 json!([{
711 "app_label": "dcim",
712 "model": "site",
713 "rest_api_endpoint": "/api/dcim/sites/",
714 "features": ["custom-fields", "tags"]
715 }]),
716 );
717 let _custom_fields = server.mock(|when, then| {
718 when.method(GET).path("/api/extras/custom-fields/");
719 then.status(200).json_body(page(json!([])));
720 });
721 let _sites = server.mock(|when, then| {
722 when.method(GET)
723 .path("/api/dcim/sites/")
724 .query_param("limit", "1");
725 then.status(200).json_body(page(json!([])));
726 });
727 let _cf_create = server.mock(|when, then| {
728 when.method(POST).path("/api/extras/custom-fields/");
729 then.status(201).json_body(json!({
730 "id": 1,
731 "name": "cf_test",
732 "object_types": ["dcim.site"],
733 "type": {"value": "text", "label": "Text"}
734 }));
735 });
736
737 let schema = alembic_core::Schema {
738 types: std::collections::BTreeMap::from([(
739 "dcim.site".to_string(),
740 alembic_core::TypeSchema {
741 key: std::collections::BTreeMap::from([(
742 "slug".to_string(),
743 alembic_core::FieldSchema {
744 r#type: alembic_core::FieldType::String,
745 required: true,
746 nullable: false,
747 description: None,
748 format: None,
749 pattern: None,
750 },
751 )]),
752 fields: std::collections::BTreeMap::from([(
753 "cf_test".to_string(),
754 alembic_core::FieldSchema {
755 r#type: alembic_core::FieldType::String,
756 required: false,
757 nullable: true,
758 description: None,
759 format: None,
760 pattern: None,
761 },
762 )]),
763 },
764 )]),
765 };
766
767 let report = adapter.ensure_schema(&schema).await.unwrap();
768 assert_eq!(report.created_fields, vec!["dcim.site.cf_test".to_string()]);
769 }
770
771 #[tokio::test]
772 async fn ensure_schema_creates_custom_object_types() {
773 let server = MockServer::start();
774 let adapter = NetBoxAdapter::new(&server.base_url(), "token").unwrap();
775
776 let _object_types = mock_list(
777 &server,
778 "/api/core/object-types/",
779 json!([{
780 "app_label": "dcim",
781 "model": "site",
782 "rest_api_endpoint": "/api/dcim/sites/",
783 "features": ["custom-fields", "tags"]
784 }]),
785 );
786 let _custom_fields = server.mock(|when, then| {
787 when.method(GET).path("/api/extras/custom-fields/");
788 then.status(200).json_body(page(json!([])));
789 });
790 let _custom_object_types = server.mock(|when, then| {
791 when.method(GET)
792 .path("/api/plugins/custom-objects/custom-object-types/")
793 .query_param("limit", "200")
794 .query_param("offset", "0");
795 then.status(200).json_body(page(json!([])));
796 });
797 let _custom_object_fields = server.mock(|when, then| {
798 when.method(GET)
799 .path("/api/plugins/custom-objects/custom-object-type-fields/")
800 .query_param("limit", "200")
801 .query_param("offset", "0");
802 then.status(200).json_body(page(json!([])));
803 });
804 let _custom_object_type_create = server.mock(|when, then| {
805 when.method(POST)
806 .path("/api/plugins/custom-objects/custom-object-types/");
807 then.status(201).json_body(json!({
808 "id": 42,
809 "name": "custom-asset"
810 }));
811 });
812 let _custom_object_field_create = server.mock(|when, then| {
813 when.method(POST)
814 .path("/api/plugins/custom-objects/custom-object-type-fields/");
815 then.status(201).json_body(json!({
816 "id": 100
817 }));
818 });
819
820 let schema = alembic_core::Schema {
821 types: std::collections::BTreeMap::from([(
822 "custom.asset".to_string(),
823 alembic_core::TypeSchema {
824 key: std::collections::BTreeMap::from([(
825 "name".to_string(),
826 alembic_core::FieldSchema {
827 r#type: alembic_core::FieldType::String,
828 required: true,
829 nullable: false,
830 description: None,
831 format: None,
832 pattern: None,
833 },
834 )]),
835 fields: std::collections::BTreeMap::from([(
836 "owner".to_string(),
837 alembic_core::FieldSchema {
838 r#type: alembic_core::FieldType::String,
839 required: false,
840 nullable: true,
841 description: None,
842 format: None,
843 pattern: None,
844 },
845 )]),
846 },
847 )]),
848 };
849
850 let report = adapter.ensure_schema(&schema).await.unwrap();
851 assert_eq!(
852 report.created_object_types,
853 vec!["custom.asset".to_string()]
854 );
855 assert!(report
856 .created_object_fields
857 .contains(&"custom.asset.name".to_string()));
858 assert!(report
859 .created_object_fields
860 .contains(&"custom.asset.owner".to_string()));
861 }
862}