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