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