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