Skip to main content

stmo_cli/
models.rs

1#![allow(clippy::missing_errors_doc)]
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
6where
7    T: Deserialize<'de>,
8    D: Deserializer<'de>,
9{
10    Ok(Option::deserialize(deserializer)?.unwrap_or_default())
11}
12
13#[derive(Debug, Serialize, Deserialize, Clone)]
14pub struct Query {
15    pub id: u64,
16    pub name: String,
17    pub description: Option<String>,
18    #[serde(rename = "query")]
19    pub sql: String,
20    pub data_source_id: u64,
21    #[serde(default)]
22    pub user: Option<QueryUser>,
23    pub schedule: Option<Schedule>,
24    pub options: QueryOptions,
25    #[serde(default)]
26    pub visualizations: Vec<Visualization>,
27    pub tags: Option<Vec<String>>,
28    pub is_archived: bool,
29    pub is_draft: bool,
30    pub updated_at: String,
31    pub created_at: String,
32}
33
34#[derive(Debug, Serialize, Clone)]
35pub struct CreateQuery {
36    pub name: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub description: Option<String>,
39    #[serde(rename = "query")]
40    pub sql: String,
41    pub data_source_id: u64,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub schedule: Option<Schedule>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub options: Option<QueryOptions>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub tags: Option<Vec<String>>,
48    pub is_archived: bool,
49    pub is_draft: bool,
50}
51
52#[derive(Debug, Serialize, Deserialize, Clone)]
53pub struct QueryUser {
54    pub id: u64,
55    pub name: String,
56    pub email: String,
57}
58
59#[derive(Debug, Serialize, Deserialize, Clone)]
60pub struct QueryOptions {
61    #[serde(default)]
62    pub parameters: Vec<Parameter>,
63}
64
65#[derive(Debug, Serialize, Deserialize, Clone)]
66pub struct Parameter {
67    pub name: String,
68    pub title: String,
69    #[serde(rename = "type")]
70    pub param_type: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub value: Option<serde_json::Value>,
73    #[serde(rename = "enumOptions", skip_serializing_if = "Option::is_none")]
74    pub enum_options: Option<String>,
75    #[serde(rename = "queryId", skip_serializing_if = "Option::is_none")]
76    pub query_id: Option<u64>,
77    #[serde(rename = "multiValuesOptions", skip_serializing_if = "Option::is_none")]
78    pub multi_values_options: Option<MultiValuesOptions>,
79}
80
81#[derive(Debug, Serialize, Deserialize, Clone)]
82pub struct MultiValuesOptions {
83    #[serde(rename = "prefix", skip_serializing_if = "Option::is_none")]
84    pub prefix: Option<String>,
85    #[serde(rename = "suffix", skip_serializing_if = "Option::is_none")]
86    pub suffix: Option<String>,
87    #[serde(rename = "separator", skip_serializing_if = "Option::is_none")]
88    pub separator: Option<String>,
89    #[serde(rename = "quoteCharacter", skip_serializing_if = "Option::is_none")]
90    pub quote_character: Option<String>,
91}
92
93#[derive(Debug, Serialize, Deserialize, Clone)]
94pub struct Schedule {
95    pub interval: Option<u64>,
96    pub time: Option<String>,
97    pub day_of_week: Option<String>,
98    pub until: Option<String>,
99}
100
101#[derive(Debug, Serialize, Deserialize, Clone)]
102pub struct Visualization {
103    pub id: u64,
104    pub name: String,
105    #[serde(rename = "type")]
106    pub viz_type: String,
107    pub options: serde_json::Value,
108    pub description: Option<String>,
109}
110
111#[derive(Debug, Serialize, Clone)]
112pub struct CreateVisualization {
113    pub query_id: u64,
114    pub name: String,
115    #[serde(rename = "type")]
116    pub viz_type: String,
117    pub options: serde_json::Value,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub description: Option<String>,
120}
121
122#[derive(Debug, Serialize, Deserialize)]
123pub struct QueriesResponse {
124    pub results: Vec<Query>,
125    pub count: u64,
126    pub page: u64,
127    pub page_size: u64,
128}
129
130#[derive(Debug, Serialize, Deserialize)]
131pub struct QueryMetadata {
132    pub id: u64,
133    pub name: String,
134    pub description: Option<String>,
135    pub data_source_id: u64,
136    #[serde(default)]
137    pub user_id: Option<u64>,
138    pub schedule: Option<Schedule>,
139    pub options: QueryOptions,
140    pub visualizations: Vec<Visualization>,
141    pub tags: Option<Vec<String>>,
142}
143
144#[derive(Debug, Serialize, Deserialize, Clone)]
145pub struct User {
146    pub id: u64,
147    pub name: String,
148    pub email: String,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub profile_image_url: Option<String>,
151}
152
153#[derive(Debug, Serialize, Deserialize, Clone)]
154pub struct DataSource {
155    pub id: u64,
156    pub name: String,
157    #[serde(rename = "type")]
158    pub ds_type: String,
159    pub syntax: Option<String>,
160    pub description: Option<String>,
161    pub paused: u8,
162    pub pause_reason: Option<String>,
163    pub view_only: bool,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub queue_name: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub scheduled_queue_name: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub groups: Option<serde_json::Value>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub options: Option<serde_json::Value>,
172}
173
174#[derive(Debug, Serialize, Deserialize)]
175pub struct DataSourceSchema {
176    pub schema: Vec<SchemaTable>,
177}
178
179#[derive(Debug, Serialize, Deserialize)]
180pub struct SchemaTable {
181    pub name: String,
182    pub columns: Vec<SchemaColumn>,
183}
184
185#[derive(Debug, Serialize, Deserialize)]
186pub struct SchemaColumn {
187    pub name: String,
188    #[serde(rename = "type")]
189    pub column_type: String,
190}
191
192#[derive(Debug, Serialize, Deserialize)]
193pub struct RefreshRequest {
194    pub max_age: u64,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub parameters: Option<std::collections::HashMap<String, serde_json::Value>>,
197}
198
199#[derive(Debug, Serialize, Deserialize)]
200pub struct JobResponse {
201    pub job: Job,
202}
203
204#[derive(Debug, Serialize, Deserialize)]
205pub struct Job {
206    pub id: String,
207    pub status: u8,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub query_result_id: Option<u64>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub error: Option<String>,
212}
213
214#[derive(Debug, Serialize, Deserialize)]
215pub struct QueryResultResponse {
216    pub query_result: QueryResult,
217}
218
219#[derive(Debug, Serialize, Deserialize)]
220pub struct QueryResult {
221    pub id: u64,
222    pub data: QueryResultData,
223    pub runtime: f64,
224    pub retrieved_at: String,
225}
226
227#[derive(Debug, Serialize, Deserialize)]
228pub struct QueryResultData {
229    pub columns: Vec<Column>,
230    pub rows: Vec<serde_json::Value>,
231}
232
233#[derive(Debug, Serialize, Deserialize)]
234pub struct Column {
235    pub name: String,
236    #[serde(rename = "type")]
237    pub type_name: String,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub friendly_name: Option<String>,
240}
241
242#[derive(Debug, Clone, Copy)]
243pub enum JobStatus {
244    Pending = 1,
245    Started = 2,
246    Success = 3,
247    Failure = 4,
248    Cancelled = 5,
249}
250
251impl JobStatus {
252    pub fn from_u8(status: u8) -> anyhow::Result<Self> {
253        match status {
254            1 => Ok(Self::Pending),
255            2 => Ok(Self::Started),
256            3 => Ok(Self::Success),
257            4 => Ok(Self::Failure),
258            5 => Ok(Self::Cancelled),
259            _ => Err(anyhow::anyhow!("Invalid job status: {status}")),
260        }
261    }
262}
263
264#[derive(Debug, Serialize, Deserialize)]
265pub struct Dashboard {
266    pub id: u64,
267    pub name: String,
268    pub slug: String,
269    pub user_id: u64,
270    pub is_archived: bool,
271    pub is_draft: bool,
272    #[serde(rename = "dashboard_filters_enabled")]
273    pub filters_enabled: bool,
274    pub tags: Vec<String>,
275    #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
276    pub widgets: Vec<Widget>,
277}
278
279#[derive(Debug, Serialize)]
280pub struct CreateDashboard {
281    pub name: String,
282}
283
284#[derive(Debug, Serialize, Deserialize)]
285pub struct Widget {
286    pub id: u64,
287    pub dashboard_id: u64,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub visualization_id: Option<u64>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub visualization: Option<WidgetVisualization>,
292    #[serde(default)]
293    pub text: String,
294    pub options: WidgetOptions,
295}
296
297#[derive(Debug, Serialize, Deserialize)]
298pub struct WidgetVisualization {
299    pub id: u64,
300    pub name: String,
301    pub query: VisualizationQuery,
302}
303
304#[derive(Debug, Serialize, Deserialize)]
305pub struct VisualizationQuery {
306    pub id: u64,
307    pub name: String,
308}
309
310#[derive(Debug, Serialize, Deserialize, Clone)]
311pub struct WidgetOptions {
312    pub position: WidgetPosition,
313    #[serde(default, skip_serializing_if = "Option::is_none", rename = "parameterMappings")]
314    pub parameter_mappings: Option<serde_json::Value>,
315}
316
317#[derive(Debug, Serialize, Deserialize, Clone)]
318pub struct WidgetPosition {
319    pub col: u32,
320    pub row: u32,
321    #[serde(rename = "sizeX")]
322    pub size_x: u32,
323    #[serde(rename = "sizeY")]
324    pub size_y: u32,
325}
326
327#[derive(Debug, Serialize, Deserialize)]
328pub struct DashboardMetadata {
329    pub id: u64,
330    pub name: String,
331    pub slug: String,
332    pub user_id: u64,
333    pub is_draft: bool,
334    pub is_archived: bool,
335    #[serde(rename = "dashboard_filters_enabled")]
336    pub filters_enabled: bool,
337    pub tags: Vec<String>,
338    pub widgets: Vec<WidgetMetadata>,
339}
340
341#[derive(Debug, Serialize, Deserialize)]
342pub struct WidgetMetadata {
343    pub id: u64,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub visualization_id: Option<u64>,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub query_id: Option<u64>,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub visualization_name: Option<String>,
350    #[serde(default, skip_serializing_if = "String::is_empty")]
351    pub text: String,
352    pub options: WidgetOptions,
353}
354
355#[derive(Debug, Deserialize)]
356pub struct DashboardsResponse {
357    pub results: Vec<DashboardSummary>,
358    pub count: u64,
359}
360
361#[derive(Debug, Deserialize)]
362pub struct DashboardSummary {
363    #[allow(dead_code)]
364    pub id: u64,
365    pub name: String,
366    #[allow(dead_code)]
367    pub slug: String,
368    pub is_draft: bool,
369    pub is_archived: bool,
370}
371
372#[derive(Debug, Serialize)]
373pub struct CreateWidget {
374    pub dashboard_id: u64,
375    pub visualization_id: Option<u64>,
376    pub text: String,
377    pub width: u32,
378    pub options: WidgetOptions,
379}
380
381#[must_use]
382pub fn build_dashboard_level_parameter_mappings(parameters: &[Parameter]) -> serde_json::Value {
383    let mut mappings = serde_json::Map::new();
384    for param in parameters {
385        mappings.insert(param.name.clone(), serde_json::json!({
386            "mapTo": param.name,
387            "name": param.name,
388            "title": "",
389            "type": "dashboard-level",
390            "value": null,
391        }));
392    }
393    serde_json::Value::Object(mappings)
394}
395
396#[cfg(test)]
397#[allow(clippy::missing_errors_doc)]
398#[allow(clippy::unnecessary_literal_unwrap)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_job_status_from_u8_valid() {
404        assert!(matches!(JobStatus::from_u8(1).unwrap(), JobStatus::Pending));
405        assert!(matches!(JobStatus::from_u8(2).unwrap(), JobStatus::Started));
406        assert!(matches!(JobStatus::from_u8(3).unwrap(), JobStatus::Success));
407        assert!(matches!(JobStatus::from_u8(4).unwrap(), JobStatus::Failure));
408        assert!(matches!(JobStatus::from_u8(5).unwrap(), JobStatus::Cancelled));
409    }
410
411    #[test]
412    fn test_job_status_from_u8_invalid() {
413        assert!(JobStatus::from_u8(0).is_err());
414        assert!(JobStatus::from_u8(6).is_err());
415        assert!(JobStatus::from_u8(255).is_err());
416
417        let err = JobStatus::from_u8(10).unwrap_err();
418        assert!(err.to_string().contains("Invalid job status"));
419    }
420
421    #[test]
422    fn test_query_serialization() {
423        let query = Query {
424            id: 1,
425            name: "Test Query".to_string(),
426            description: None,
427            sql: "SELECT * FROM table".to_string(),
428            data_source_id: 63,
429            user: None,
430            schedule: None,
431            options: QueryOptions { parameters: vec![] },
432            visualizations: vec![],
433            tags: None,
434            is_archived: false,
435            is_draft: false,
436            updated_at: "2026-01-21".to_string(),
437            created_at: "2026-01-21".to_string(),
438        };
439
440        let json = serde_json::to_string(&query).unwrap();
441        assert!(json.contains("\"query\":"));
442        assert!(json.contains("SELECT * FROM table"));
443    }
444
445    #[test]
446    fn test_query_metadata_deserialization() {
447        let yaml = r"
448id: 100064
449name: Test Query
450description: null
451data_source_id: 63
452user_id: 530
453schedule: null
454options:
455  parameters:
456    - name: project
457      title: project
458      type: enum
459      value:
460        - try
461      enumOptions: |
462        try
463        autoland
464visualizations: []
465tags:
466  - bug 1840828
467";
468
469        let metadata: QueryMetadata = serde_yaml::from_str(yaml).unwrap();
470        assert_eq!(metadata.id, 100_064);
471        assert_eq!(metadata.name, "Test Query");
472        assert_eq!(metadata.data_source_id, 63);
473        assert_eq!(metadata.options.parameters.len(), 1);
474        assert_eq!(metadata.options.parameters[0].name, "project");
475    }
476
477    #[test]
478    fn test_datasource_deserialization() {
479        let json = r#"{
480            "id": 63,
481            "name": "Test DB",
482            "type": "bigquery",
483            "description": null,
484            "syntax": "sql",
485            "paused": 0,
486            "pause_reason": null,
487            "view_only": false,
488            "queue_name": "queries",
489            "scheduled_queue_name": "scheduled_queries",
490            "groups": {},
491            "options": {}
492        }"#;
493
494        let ds: DataSource = serde_json::from_str(json).unwrap();
495        assert_eq!(ds.id, 63);
496        assert_eq!(ds.name, "Test DB");
497        assert_eq!(ds.ds_type, "bigquery");
498        assert_eq!(ds.syntax, Some("sql".to_string()));
499        assert_eq!(ds.description, None);
500        assert_eq!(ds.paused, 0);
501        assert!(!ds.view_only);
502        assert_eq!(ds.queue_name, Some("queries".to_string()));
503    }
504
505    #[test]
506    fn test_datasource_with_nulls() {
507        let json = r#"{
508            "id": 10,
509            "name": "Minimal DB",
510            "type": "pg",
511            "description": "Test description",
512            "syntax": null,
513            "paused": 1,
514            "pause_reason": "Maintenance",
515            "view_only": true,
516            "queue_name": null,
517            "scheduled_queue_name": null,
518            "groups": null,
519            "options": null
520        }"#;
521
522        let ds: DataSource = serde_json::from_str(json).unwrap();
523        assert_eq!(ds.id, 10);
524        assert_eq!(ds.name, "Minimal DB");
525        assert_eq!(ds.ds_type, "pg");
526        assert_eq!(ds.description, Some("Test description".to_string()));
527        assert_eq!(ds.syntax, None);
528        assert_eq!(ds.paused, 1);
529        assert_eq!(ds.pause_reason, Some("Maintenance".to_string()));
530        assert!(ds.view_only);
531        assert_eq!(ds.queue_name, None);
532    }
533
534    #[test]
535    fn test_datasource_schema_deserialization() {
536        let json = r#"{
537            "schema": [
538                {
539                    "name": "table1",
540                    "columns": [
541                        {"name": "col1", "type": "STRING"},
542                        {"name": "col2", "type": "INTEGER"}
543                    ]
544                },
545                {
546                    "name": "table2",
547                    "columns": [{"name": "id", "type": "INTEGER"}]
548                }
549            ]
550        }"#;
551
552        let schema: DataSourceSchema = serde_json::from_str(json).unwrap();
553        assert_eq!(schema.schema.len(), 2);
554        assert_eq!(schema.schema[0].name, "table1");
555        assert_eq!(schema.schema[0].columns.len(), 2);
556        assert_eq!(schema.schema[0].columns[0].name, "col1");
557        assert_eq!(schema.schema[0].columns[0].column_type, "STRING");
558        assert_eq!(schema.schema[1].name, "table2");
559        assert_eq!(schema.schema[1].columns.len(), 1);
560    }
561
562    #[test]
563    fn test_schema_table_structure() {
564        let json = r#"{
565            "name": "users",
566            "columns": [
567                {"name": "id", "type": "INTEGER"},
568                {"name": "name", "type": "STRING"},
569                {"name": "email", "type": "STRING"}
570            ]
571        }"#;
572
573        let table: SchemaTable = serde_json::from_str(json).unwrap();
574        assert_eq!(table.name, "users");
575        assert_eq!(table.columns.len(), 3);
576        assert_eq!(table.columns[0].name, "id");
577        assert_eq!(table.columns[0].column_type, "INTEGER");
578        assert_eq!(table.columns[1].name, "name");
579        assert_eq!(table.columns[1].column_type, "STRING");
580        assert_eq!(table.columns[2].name, "email");
581        assert_eq!(table.columns[2].column_type, "STRING");
582    }
583
584    #[test]
585    fn test_datasource_serialization() {
586        let ds = DataSource {
587            id: 123,
588            name: "My DB".to_string(),
589            ds_type: "mysql".to_string(),
590            syntax: Some("sql".to_string()),
591            description: Some("Test".to_string()),
592            paused: 0,
593            pause_reason: None,
594            view_only: false,
595            queue_name: Some("queries".to_string()),
596            scheduled_queue_name: None,
597            groups: None,
598            options: None,
599        };
600
601        let json = serde_json::to_string(&ds).unwrap();
602        assert!(json.contains("\"id\":123"));
603        assert!(json.contains("\"name\":\"My DB\""));
604        assert!(json.contains("\"type\":\"mysql\""));
605        assert!(json.contains("\"syntax\":\"sql\""));
606    }
607
608    #[test]
609    fn test_dashboard_deserialization() {
610        let json = r#"{
611            "id": 2570,
612            "name": "Test Dashboard",
613            "slug": "test-dashboard",
614            "user_id": 530,
615            "is_archived": false,
616            "is_draft": false,
617            "dashboard_filters_enabled": true,
618            "tags": ["tag1", "tag2"],
619            "widgets": []
620        }"#;
621
622        let dashboard: Dashboard = serde_json::from_str(json).unwrap();
623        assert_eq!(dashboard.id, 2570);
624        assert_eq!(dashboard.name, "Test Dashboard");
625        assert_eq!(dashboard.slug, "test-dashboard");
626        assert_eq!(dashboard.user_id, 530);
627        assert!(!dashboard.is_archived);
628        assert!(!dashboard.is_draft);
629        assert!(dashboard.filters_enabled);
630        assert_eq!(dashboard.tags, vec!["tag1", "tag2"]);
631        assert_eq!(dashboard.widgets.len(), 0);
632    }
633
634    #[test]
635    fn test_dashboard_with_widgets() {
636        let json = r##"{
637            "id": 2570,
638            "name": "Test Dashboard",
639            "slug": "test-dashboard",
640            "user_id": 530,
641            "is_archived": false,
642            "is_draft": false,
643            "dashboard_filters_enabled": false,
644            "tags": [],
645            "widgets": [
646                {
647                    "id": 75035,
648                    "dashboard_id": 2570,
649                    "text": "# Test Widget",
650                    "options": {
651                        "position": {
652                            "col": 0,
653                            "row": 0,
654                            "sizeX": 6,
655                            "sizeY": 2
656                        }
657                    }
658                },
659                {
660                    "id": 75029,
661                    "dashboard_id": 2570,
662                    "visualization_id": 279588,
663                    "visualization": {
664                        "id": 279588,
665                        "name": "Total MAU",
666                        "query": {
667                            "id": 114049,
668                            "name": "MAU Query"
669                        }
670                    },
671                    "text": "",
672                    "options": {
673                        "position": {
674                            "col": 3,
675                            "row": 2,
676                            "sizeX": 3,
677                            "sizeY": 8
678                        },
679                        "parameterMappings": {
680                            "channel": {
681                                "name": "channel",
682                                "type": "dashboard-level"
683                            }
684                        }
685                    }
686                }
687            ]
688        }"##;
689
690        let dashboard: Dashboard = serde_json::from_str(json).unwrap();
691        assert_eq!(dashboard.widgets.len(), 2);
692        assert_eq!(dashboard.widgets[0].id, 75035);
693        assert_eq!(dashboard.widgets[0].text, "# Test Widget");
694        assert!(dashboard.widgets[0].visualization_id.is_none());
695        assert_eq!(dashboard.widgets[1].id, 75029);
696        assert_eq!(dashboard.widgets[1].visualization_id, Some(279_588));
697        let viz = dashboard.widgets[1].visualization.as_ref().unwrap();
698        assert_eq!(viz.id, 279_588);
699        assert_eq!(viz.query.id, 114_049);
700    }
701
702    #[test]
703    fn test_widget_position_serde() {
704        let json = r#"{
705            "col": 3,
706            "row": 5,
707            "sizeX": 6,
708            "sizeY": 4
709        }"#;
710
711        let position: WidgetPosition = serde_json::from_str(json).unwrap();
712        assert_eq!(position.col, 3);
713        assert_eq!(position.row, 5);
714        assert_eq!(position.size_x, 6);
715        assert_eq!(position.size_y, 4);
716
717        let serialized = serde_json::to_string(&position).unwrap();
718        assert!(serialized.contains("\"sizeX\":6"));
719        assert!(serialized.contains("\"sizeY\":4"));
720    }
721
722    #[test]
723    fn test_dashboard_metadata_yaml() {
724        let yaml = r"
725id: 2570
726name: Test Dashboard
727slug: test-dashboard
728user_id: 530
729is_draft: false
730is_archived: false
731dashboard_filters_enabled: true
732tags:
733  - tag1
734  - tag2
735widgets:
736  - id: 75035
737    visualization_id: null
738    query_id: null
739    visualization_name: null
740    text: '# Test Widget'
741    options:
742      position:
743        col: 0
744        row: 0
745        sizeX: 6
746        sizeY: 2
747      parameter_mappings: null
748";
749
750        let metadata: DashboardMetadata = serde_yaml::from_str(yaml).unwrap();
751        assert_eq!(metadata.id, 2570);
752        assert_eq!(metadata.name, "Test Dashboard");
753        assert_eq!(metadata.slug, "test-dashboard");
754        assert_eq!(metadata.user_id, 530);
755        assert!(!metadata.is_draft);
756        assert!(!metadata.is_archived);
757        assert!(metadata.filters_enabled);
758        assert_eq!(metadata.tags, vec!["tag1", "tag2"]);
759        assert_eq!(metadata.widgets.len(), 1);
760        assert_eq!(metadata.widgets[0].id, 75035);
761        assert_eq!(metadata.widgets[0].text, "# Test Widget");
762    }
763
764    #[test]
765    fn test_widget_metadata_text_widget() {
766        let yaml = r"
767id: 75035
768visualization_id: null
769query_id: null
770visualization_name: null
771text: '## Section Header'
772options:
773  position:
774    col: 0
775    row: 0
776    sizeX: 6
777    sizeY: 2
778  parameter_mappings: null
779";
780
781        let widget: WidgetMetadata = serde_yaml::from_str(yaml).unwrap();
782        assert_eq!(widget.id, 75035);
783        assert!(widget.visualization_id.is_none());
784        assert!(widget.query_id.is_none());
785        assert!(widget.visualization_name.is_none());
786        assert_eq!(widget.text, "## Section Header");
787        assert_eq!(widget.options.position.col, 0);
788        assert_eq!(widget.options.position.size_x, 6);
789    }
790
791    #[test]
792    fn test_widget_metadata_viz_widget() {
793        let yaml = r"
794id: 75029
795visualization_id: 279588
796query_id: 114049
797visualization_name: Total MAU
798text: ''
799options:
800  position:
801    col: 3
802    row: 2
803    sizeX: 3
804    sizeY: 8
805  parameterMappings:
806    channel:
807      name: channel
808      type: dashboard-level
809";
810
811        let widget: WidgetMetadata = serde_yaml::from_str(yaml).unwrap();
812        assert_eq!(widget.id, 75029);
813        assert_eq!(widget.visualization_id, Some(279_588));
814        assert_eq!(widget.query_id, Some(114_049));
815        assert_eq!(widget.visualization_name, Some("Total MAU".to_string()));
816        assert_eq!(widget.text, "");
817        assert!(widget.options.parameter_mappings.is_some());
818    }
819
820    #[test]
821    fn test_create_widget_serialization() {
822        let widget = CreateWidget {
823            dashboard_id: 2570,
824            visualization_id: Some(279_588),
825            text: String::new(),
826            width: 1,
827            options: WidgetOptions {
828                position: WidgetPosition {
829                    col: 0,
830                    row: 0,
831                    size_x: 3,
832                    size_y: 2,
833                },
834                parameter_mappings: None,
835            },
836        };
837
838        let json = serde_json::to_string(&widget).unwrap();
839        assert!(json.contains("\"dashboard_id\":2570"));
840        assert!(json.contains("\"visualization_id\":279588"));
841        assert!(json.contains("\"sizeX\":3"));
842        assert!(json.contains("\"sizeY\":2"));
843    }
844
845    #[test]
846    fn test_create_text_widget_serialization() {
847        let widget = CreateWidget {
848            dashboard_id: 2570,
849            visualization_id: None,
850            text: "Some text".to_string(),
851            width: 1,
852            options: WidgetOptions {
853                position: WidgetPosition {
854                    col: 0,
855                    row: 0,
856                    size_x: 3,
857                    size_y: 2,
858                },
859                parameter_mappings: None,
860            },
861        };
862
863        let json = serde_json::to_string(&widget).unwrap();
864        assert!(json.contains("\"visualization_id\":null"));
865    }
866
867    #[test]
868    fn test_dashboards_response() {
869        let json = r#"{
870            "results": [
871                {
872                    "id": 2570,
873                    "name": "Dashboard 1",
874                    "slug": "dashboard-1",
875                    "is_draft": false,
876                    "is_archived": false
877                },
878                {
879                    "id": 2558,
880                    "name": "Dashboard 2",
881                    "slug": "dashboard-2",
882                    "is_draft": true,
883                    "is_archived": false
884                }
885            ],
886            "count": 2
887        }"#;
888
889        let response: DashboardsResponse = serde_json::from_str(json).unwrap();
890        assert_eq!(response.results.len(), 2);
891        assert_eq!(response.count, 2);
892        assert_eq!(response.results[0].id, 2570);
893        assert_eq!(response.results[0].name, "Dashboard 1");
894        assert_eq!(response.results[0].slug, "dashboard-1");
895        assert!(!response.results[0].is_draft);
896        assert!(!response.results[0].is_archived);
897        assert_eq!(response.results[1].id, 2558);
898        assert_eq!(response.results[1].slug, "dashboard-2");
899        assert!(response.results[1].is_draft);
900    }
901
902    #[test]
903    fn test_build_dashboard_level_parameter_mappings_empty() {
904        let result = build_dashboard_level_parameter_mappings(&[]);
905        assert_eq!(result, serde_json::json!({}));
906    }
907
908    #[test]
909    fn test_build_dashboard_level_parameter_mappings_with_params() {
910        let params = vec![
911            Parameter {
912                name: "channel".to_string(),
913                title: "Channel".to_string(),
914                param_type: "enum".to_string(),
915                value: None,
916                enum_options: None,
917                query_id: None,
918                multi_values_options: None,
919            },
920            Parameter {
921                name: "date".to_string(),
922                title: "Date".to_string(),
923                param_type: "date".to_string(),
924                value: None,
925                enum_options: None,
926                query_id: None,
927                multi_values_options: None,
928            },
929        ];
930
931        let result = build_dashboard_level_parameter_mappings(&params);
932
933        let expected = serde_json::json!({
934            "channel": {
935                "mapTo": "channel",
936                "name": "channel",
937                "title": "",
938                "type": "dashboard-level",
939                "value": null,
940            },
941            "date": {
942                "mapTo": "date",
943                "name": "date",
944                "title": "",
945                "type": "dashboard-level",
946                "value": null,
947            },
948        });
949
950        assert_eq!(result, expected);
951    }
952}