Skip to main content

raps_reality/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2#![allow(clippy::uninlined_format_args)]
3// Copyright 2024-2025 Dmytro Yemelianov
4
5//! Reality Capture API module
6//!
7//! Handles photogrammetry processing to create 3D models from photos.
8
9// API response structs may contain fields we don't use - this is expected for external API contracts
10#![allow(dead_code)]
11
12use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::path::Path;
15use tokio::fs::File;
16use tokio::io::AsyncReadExt;
17
18use raps_kernel::auth::AuthClient;
19use raps_kernel::config::Config;
20use raps_kernel::http::{self, HttpClientConfig};
21
22/// Photoscene information
23#[derive(Debug, Clone, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct Photoscene {
26    #[serde(rename = "photosceneid")]
27    pub photoscene_id: String,
28    pub name: Option<String>,
29    #[serde(rename = "scenetype")]
30    pub scene_type: Option<String>,
31    #[serde(rename = "convertformat")]
32    pub convert_format: Option<String>,
33    pub status: Option<String>,
34    pub progress: Option<String>,
35    #[serde(rename = "progressmsg")]
36    pub progress_msg: Option<String>,
37}
38
39/// List photoscenes response
40#[derive(Debug, Deserialize)]
41#[serde(rename_all = "PascalCase")]
42pub struct ListPhotoscenesResponse {
43    #[serde(default, alias = "photoscenes")]
44    pub photoscenes: PhotoscenesList,
45}
46
47#[derive(Debug, Default, Deserialize)]
48pub struct PhotoscenesList {
49    #[serde(default)]
50    pub photoscene: Vec<Photoscene>,
51}
52
53/// Photoscene creation response
54#[derive(Debug, Deserialize)]
55#[serde(rename_all = "PascalCase")]
56pub struct CreatePhotosceneResponse {
57    #[serde(alias = "photoscene")]
58    pub photoscene: Photoscene,
59}
60
61/// Upload response
62#[derive(Debug, Deserialize)]
63#[serde(rename_all = "PascalCase")]
64pub struct UploadResponse {
65    pub files: Option<UploadFiles>,
66    pub usage: Option<String>,
67    pub resource: Option<String>,
68}
69
70#[derive(Debug, Deserialize)]
71pub struct UploadFiles {
72    pub file: Option<Vec<UploadedFile>>,
73}
74
75#[derive(Debug, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct UploadedFile {
78    pub filename: String,
79    pub fileid: String,
80    /// File size as returned by the API (string format)
81    pub filesize: Option<String>,
82    pub msg: Option<String>,
83}
84
85impl UploadedFile {
86    /// Parse filesize string into bytes. Returns None if absent or unparsable.
87    pub fn filesize_bytes(&self) -> Option<u64> {
88        self.filesize.as_deref().and_then(|s| s.parse().ok())
89    }
90}
91
92/// API-level error returned with HTTP 200 (documented Reality Capture API quirk)
93#[derive(Debug, Deserialize)]
94pub struct RcApiError {
95    pub code: Option<String>,
96    #[serde(alias = "message")]
97    pub msg: Option<String>,
98}
99
100/// Progress response
101#[derive(Debug, Deserialize)]
102#[serde(rename_all = "PascalCase")]
103pub struct ProgressResponse {
104    #[serde(alias = "photoscene")]
105    pub photoscene: Option<PhotosceneProgress>,
106    #[serde(alias = "error")]
107    pub error: Option<RcApiError>,
108}
109
110#[derive(Debug, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct PhotosceneProgress {
113    #[serde(rename = "photosceneid")]
114    pub photoscene_id: String,
115    pub progress: String,
116    #[serde(rename = "progressmsg")]
117    pub progress_msg: Option<String>,
118    pub status: Option<String>,
119}
120
121/// Result response
122#[derive(Debug, Deserialize)]
123#[serde(rename_all = "PascalCase")]
124pub struct ResultResponse {
125    #[serde(alias = "photoscene")]
126    pub photoscene: Option<PhotosceneResult>,
127    #[serde(alias = "error")]
128    pub error: Option<RcApiError>,
129}
130
131#[derive(Debug, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct PhotosceneResult {
134    #[serde(rename = "photosceneid")]
135    pub photoscene_id: String,
136    pub progress: String,
137    #[serde(rename = "progressmsg")]
138    pub progress_msg: Option<String>,
139    #[serde(rename = "scenelink")]
140    pub scene_link: Option<String>,
141    /// File size as returned by the API (string format)
142    #[serde(rename = "filesize")]
143    pub file_size: Option<String>,
144}
145
146impl PhotosceneResult {
147    /// Parse file_size string into bytes. Returns None if absent or unparsable.
148    pub fn filesize_bytes(&self) -> Option<u64> {
149        self.file_size.as_deref().and_then(|s| s.parse().ok())
150    }
151}
152
153/// Supported output formats
154#[derive(Debug, Clone, Copy)]
155pub enum OutputFormat {
156    Rcm,   // Autodesk ReCap format
157    Rcs,   // ReCap scan
158    Obj,   // Wavefront OBJ
159    Fbx,   // Autodesk FBX
160    Ortho, // Orthophoto
161}
162
163impl std::fmt::Display for OutputFormat {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        match self {
166            OutputFormat::Rcm => write!(f, "rcm"),
167            OutputFormat::Rcs => write!(f, "rcs"),
168            OutputFormat::Obj => write!(f, "obj"),
169            OutputFormat::Fbx => write!(f, "fbx"),
170            OutputFormat::Ortho => write!(f, "ortho"),
171        }
172    }
173}
174
175impl OutputFormat {
176    pub fn all() -> Vec<Self> {
177        vec![Self::Rcm, Self::Rcs, Self::Obj, Self::Fbx, Self::Ortho]
178    }
179
180    pub fn description(&self) -> &str {
181        match self {
182            OutputFormat::Rcm => "Autodesk ReCap format (point cloud)",
183            OutputFormat::Rcs => "ReCap scan format",
184            OutputFormat::Obj => "Wavefront OBJ (mesh)",
185            OutputFormat::Fbx => "Autodesk FBX (mesh)",
186            OutputFormat::Ortho => "Orthophoto (2D image)",
187        }
188    }
189}
190
191/// Scene type for photoscene
192#[derive(Debug, Clone, Copy, Serialize)]
193#[serde(rename_all = "lowercase")]
194pub enum SceneType {
195    Aerial,
196    Object,
197}
198
199impl std::fmt::Display for SceneType {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            SceneType::Aerial => write!(f, "aerial"),
203            SceneType::Object => write!(f, "object"),
204        }
205    }
206}
207
208/// Reality Capture API client
209#[derive(Clone)]
210pub struct RealityCaptureClient {
211    config: Config,
212    auth: AuthClient,
213    http_client: reqwest::Client,
214}
215
216impl RealityCaptureClient {
217    /// Create a new Reality Capture client
218    pub fn new(config: Config, auth: AuthClient) -> Self {
219        Self::new_with_http_config(config, auth, HttpClientConfig::default())
220    }
221
222    /// Create a new Reality Capture client with custom HTTP config
223    pub fn new_with_http_config(
224        config: Config,
225        auth: AuthClient,
226        http_config: HttpClientConfig,
227    ) -> Self {
228        // Create HTTP client with configured timeouts
229        let http_client = http_config
230            .create_client()
231            .unwrap_or_else(|_| reqwest::Client::new()); // Fallback to default if config fails
232
233        Self {
234            config,
235            auth,
236            http_client,
237        }
238    }
239
240    /// Create a new photoscene
241    pub async fn create_photoscene(
242        &self,
243        name: &str,
244        scene_type: SceneType,
245        format: OutputFormat,
246    ) -> Result<Photoscene> {
247        let token = self.auth.get_token().await?;
248        let url = format!("{}/photoscene", self.config.reality_capture_url());
249
250        let params = [
251            ("scenename", name),
252            ("scenetype", &scene_type.to_string()),
253            ("format", &format.to_string()),
254        ];
255
256        let response = http::send_with_retry(&self.config.http_config, || {
257            self.http_client
258                .post(&url)
259                .bearer_auth(&token)
260                .form(&params)
261        })
262        .await?;
263
264        if !response.status().is_success() {
265            let status = response.status();
266            let error_text = response.text().await.unwrap_or_default();
267            anyhow::bail!("Failed to create photoscene ({status}): {error_text}");
268        }
269
270        let create_response: CreatePhotosceneResponse = response
271            .json()
272            .await
273            .context("Failed to parse photoscene response")?;
274
275        Ok(create_response.photoscene)
276    }
277
278    /// Upload photos to a photoscene
279    pub async fn upload_photos(
280        &self,
281        photoscene_id: &str,
282        photo_paths: &[&Path],
283    ) -> Result<Vec<UploadedFile>> {
284        let token = self.auth.get_token().await?;
285        let url = format!("{}/file", self.config.reality_capture_url());
286
287        // Read all files into memory so the retry closure can rebuild the multipart form
288        let mut file_parts: Vec<(String, Vec<u8>)> = Vec::new();
289        for path in photo_paths {
290            let mut file = File::open(path)
291                .await
292                .context(format!("Failed to open file: {}", path.display()))?;
293
294            let mut buffer = Vec::new();
295            file.read_to_end(&mut buffer)
296                .await
297                .context("Failed to read file")?;
298
299            let filename = path
300                .file_name()
301                .and_then(|n| n.to_str())
302                .unwrap_or("photo.jpg")
303                .to_string();
304
305            file_parts.push((filename, buffer));
306        }
307
308        let photoscene_id_owned = photoscene_id.to_string();
309        let response = http::send_with_retry(&self.config.http_config, || {
310            let mut form = reqwest::multipart::Form::new()
311                .text("photosceneid", photoscene_id_owned.clone())
312                .text("type", "image");
313
314            for (i, (filename, buffer)) in file_parts.iter().enumerate() {
315                let part = reqwest::multipart::Part::bytes(buffer.clone())
316                    .file_name(filename.clone())
317                    .mime_str(mime_type_from_extension(filename))
318                    .expect("valid MIME type");
319
320                form = form.part(format!("file[{}]", i), part);
321            }
322
323            self.http_client
324                .post(&url)
325                .bearer_auth(&token)
326                .multipart(form)
327        })
328        .await?;
329
330        if !response.status().is_success() {
331            let status = response.status();
332            let error_text = response.text().await.unwrap_or_default();
333            anyhow::bail!("Failed to upload photos ({status}): {error_text}");
334        }
335
336        let upload_response: UploadResponse = response
337            .json()
338            .await
339            .context("Failed to parse upload response")?;
340
341        let files = upload_response
342            .files
343            .and_then(|f| f.file)
344            .unwrap_or_default();
345
346        Ok(files)
347    }
348
349    /// Start processing a photoscene
350    pub async fn start_processing(&self, photoscene_id: &str) -> Result<()> {
351        let token = self.auth.get_token().await?;
352        let url = format!(
353            "{}/photoscene/{}",
354            self.config.reality_capture_url(),
355            photoscene_id
356        );
357
358        let response = http::send_with_retry(&self.config.http_config, || {
359            self.http_client.post(&url).bearer_auth(&token)
360        })
361        .await?;
362
363        if !response.status().is_success() {
364            let status = response.status();
365            let error_text = response.text().await.unwrap_or_default();
366            anyhow::bail!("Failed to start processing ({status}): {error_text}");
367        }
368
369        Ok(())
370    }
371
372    /// Get photoscene progress
373    pub async fn get_progress(&self, photoscene_id: &str) -> Result<PhotosceneProgress> {
374        let token = self.auth.get_token().await?;
375        let url = format!(
376            "{}/photoscene/{}/progress",
377            self.config.reality_capture_url(),
378            photoscene_id
379        );
380
381        let response = http::send_with_retry(&self.config.http_config, || {
382            self.http_client.get(&url).bearer_auth(&token)
383        })
384        .await?;
385
386        if !response.status().is_success() {
387            let status = response.status();
388            let error_text = response.text().await.unwrap_or_default();
389            anyhow::bail!("Failed to get progress ({status}): {error_text}");
390        }
391
392        let progress_response: ProgressResponse = response
393            .json()
394            .await
395            .context("Failed to parse progress response")?;
396
397        if let Some(err) = progress_response.error {
398            let code = err.code.unwrap_or_default();
399            let msg = err.msg.unwrap_or_default();
400            anyhow::bail!("Reality Capture API error ({code}): {msg}");
401        }
402
403        progress_response
404            .photoscene
405            .ok_or_else(|| anyhow::anyhow!("Progress response missing Photoscene data"))
406    }
407
408    /// Get photoscene result (download link)
409    pub async fn get_result(
410        &self,
411        photoscene_id: &str,
412        format: OutputFormat,
413    ) -> Result<PhotosceneResult> {
414        let token = self.auth.get_token().await?;
415        let url = format!(
416            "{}/photoscene/{}?format={}",
417            self.config.reality_capture_url(),
418            photoscene_id,
419            format
420        );
421
422        let response = http::send_with_retry(&self.config.http_config, || {
423            self.http_client.get(&url).bearer_auth(&token)
424        })
425        .await?;
426
427        if !response.status().is_success() {
428            let status = response.status();
429            let error_text = response.text().await.unwrap_or_default();
430            anyhow::bail!("Failed to get result ({status}): {error_text}");
431        }
432
433        let result_response: ResultResponse = response
434            .json()
435            .await
436            .context("Failed to parse result response")?;
437
438        if let Some(err) = result_response.error {
439            let code = err.code.unwrap_or_default();
440            let msg = err.msg.unwrap_or_default();
441            anyhow::bail!("Reality Capture API error ({code}): {msg}");
442        }
443
444        result_response
445            .photoscene
446            .ok_or_else(|| anyhow::anyhow!("Result response missing Photoscene data"))
447    }
448
449    /// Delete a photoscene
450    pub async fn delete_photoscene(&self, photoscene_id: &str) -> Result<()> {
451        let token = self.auth.get_token().await?;
452        let url = format!(
453            "{}/photoscene/{}",
454            self.config.reality_capture_url(),
455            photoscene_id
456        );
457
458        let response = http::send_with_retry(&self.config.http_config, || {
459            self.http_client.delete(&url).bearer_auth(&token)
460        })
461        .await?;
462
463        if !response.status().is_success() {
464            let status = response.status();
465            let error_text = response.text().await.unwrap_or_default();
466            anyhow::bail!("Failed to delete photoscene ({status}): {error_text}");
467        }
468
469        Ok(())
470    }
471
472    /// List all photoscenes
473    pub async fn list_photoscenes(&self) -> Result<Vec<Photoscene>> {
474        let token = self.auth.get_token().await?;
475        let url = format!("{}/photoscene", self.config.reality_capture_url());
476
477        let response = http::send_with_retry(&self.config.http_config, || {
478            self.http_client.get(&url).bearer_auth(&token)
479        })
480        .await?;
481
482        if !response.status().is_success() {
483            let status = response.status();
484            let error_text = response.text().await.unwrap_or_default();
485            anyhow::bail!("Failed to list photoscenes ({status}): {error_text}");
486        }
487
488        let list_response: ListPhotoscenesResponse = response
489            .json()
490            .await
491            .context("Failed to parse photoscenes response")?;
492
493        Ok(list_response.photoscenes.photoscene)
494    }
495
496    /// Get available output formats
497    pub fn available_formats(&self) -> Vec<OutputFormat> {
498        OutputFormat::all()
499    }
500}
501
502/// Determine MIME type from a filename's extension
503fn mime_type_from_extension(filename: &str) -> &'static str {
504    let ext = filename
505        .rsplit('.')
506        .next()
507        .unwrap_or("")
508        .to_ascii_lowercase();
509    match ext.as_str() {
510        "jpg" | "jpeg" => "image/jpeg",
511        "png" => "image/png",
512        "tiff" | "tif" => "image/tiff",
513        "bmp" => "image/bmp",
514        "webp" => "image/webp",
515        "gif" => "image/gif",
516        _ => "application/octet-stream",
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_output_format_all() {
526        let formats = OutputFormat::all();
527        assert_eq!(formats.len(), 5);
528    }
529
530    #[test]
531    fn test_output_format_display() {
532        assert_eq!(OutputFormat::Rcm.to_string(), "rcm");
533        assert_eq!(OutputFormat::Rcs.to_string(), "rcs");
534        assert_eq!(OutputFormat::Obj.to_string(), "obj");
535        assert_eq!(OutputFormat::Fbx.to_string(), "fbx");
536        assert_eq!(OutputFormat::Ortho.to_string(), "ortho");
537    }
538
539    #[test]
540    fn test_output_format_description() {
541        assert!(!OutputFormat::Rcm.description().is_empty());
542        assert!(OutputFormat::Rcm.description().contains("ReCap"));
543        assert!(OutputFormat::Obj.description().contains("OBJ"));
544    }
545
546    #[test]
547    fn test_scene_type_display() {
548        assert_eq!(SceneType::Aerial.to_string(), "aerial");
549        assert_eq!(SceneType::Object.to_string(), "object");
550    }
551
552    #[test]
553    fn test_scene_type_serialization() {
554        assert_eq!(
555            serde_json::to_string(&SceneType::Aerial).unwrap(),
556            "\"aerial\""
557        );
558        assert_eq!(
559            serde_json::to_string(&SceneType::Object).unwrap(),
560            "\"object\""
561        );
562    }
563
564    #[test]
565    fn test_photoscene_deserialization() {
566        let json = r#"{
567            "photosceneid": "scene-123",
568            "name": "Test Scene",
569            "scenetype": "object",
570            "convertformat": "rcm",
571            "status": "Created",
572            "progress": "0"
573        }"#;
574
575        let scene: Photoscene = serde_json::from_str(json).unwrap();
576        assert_eq!(scene.photoscene_id, "scene-123");
577        assert_eq!(scene.name, Some("Test Scene".to_string()));
578    }
579
580    #[test]
581    fn test_photoscene_progress_deserialization() {
582        let json = r#"{
583            "photosceneid": "scene-123",
584            "progress": "50",
585            "progressmsg": "Processing images"
586        }"#;
587
588        let progress: PhotosceneProgress = serde_json::from_str(json).unwrap();
589        assert_eq!(progress.photoscene_id, "scene-123");
590        assert_eq!(progress.progress, "50");
591    }
592
593    #[test]
594    fn test_photoscene_result_deserialization() {
595        let json = r#"{
596            "photosceneid": "scene-123",
597            "progress": "100",
598            "progressmsg": "Complete",
599            "filesize": "5242880",
600            "scenelink": "https://example.com/download/scene.rcm"
601        }"#;
602
603        let result: PhotosceneResult = serde_json::from_str(json).unwrap();
604        assert_eq!(result.photoscene_id, "scene-123");
605        assert!(result.scene_link.is_some());
606    }
607
608    #[test]
609    fn test_create_photoscene_response_deserialization() {
610        let json = r#"{
611            "Photoscene": {
612                "photosceneid": "new-scene-456",
613                "name": "New Scene"
614            }
615        }"#;
616
617        let response: CreatePhotosceneResponse = serde_json::from_str(json).unwrap();
618        assert_eq!(response.photoscene.photoscene_id, "new-scene-456");
619    }
620
621    #[test]
622    fn test_list_photoscenes_response_deserialization() {
623        let json = r#"{
624            "Photoscenes": {
625                "photoscene": [
626                    {
627                        "photosceneid": "scene-1",
628                        "name": "Scene One",
629                        "scenetype": "aerial",
630                        "status": "Complete",
631                        "progress": "100"
632                    },
633                    {
634                        "photosceneid": "scene-2",
635                        "name": "Scene Two",
636                        "scenetype": "object",
637                        "status": "Created",
638                        "progress": "0"
639                    }
640                ]
641            }
642        }"#;
643
644        let response: ListPhotoscenesResponse = serde_json::from_str(json).unwrap();
645        assert_eq!(response.photoscenes.photoscene.len(), 2);
646        assert_eq!(response.photoscenes.photoscene[0].photoscene_id, "scene-1");
647        assert_eq!(response.photoscenes.photoscene[1].photoscene_id, "scene-2");
648        assert_eq!(
649            response.photoscenes.photoscene[0].name,
650            Some("Scene One".to_string())
651        );
652    }
653
654    #[test]
655    fn test_list_photoscenes_response_empty() {
656        let json = r#"{
657            "Photoscenes": {
658                "photoscene": []
659            }
660        }"#;
661
662        let response: ListPhotoscenesResponse = serde_json::from_str(json).unwrap();
663        assert!(response.photoscenes.photoscene.is_empty());
664    }
665
666    #[test]
667    fn test_list_photoscenes_response_missing_photoscenes() {
668        let json = r#"{}"#;
669
670        let response: ListPhotoscenesResponse = serde_json::from_str(json).unwrap();
671        assert!(response.photoscenes.photoscene.is_empty());
672    }
673
674    #[test]
675    fn test_upload_response_deserialization() {
676        let json = r#"{
677            "Files": {
678                "file": [
679                    {
680                        "filename": "photo1.jpg",
681                        "fileid": "file-001",
682                        "filesize": "2048000",
683                        "msg": "File uploaded"
684                    }
685                ]
686            },
687            "Usage": "1",
688            "Resource": "/file"
689        }"#;
690
691        let response: UploadResponse = serde_json::from_str(json).unwrap();
692        assert!(response.files.is_some());
693        let files = response.files.unwrap().file.unwrap();
694        assert_eq!(files.len(), 1);
695        assert_eq!(files[0].filename, "photo1.jpg");
696        assert_eq!(files[0].fileid, "file-001");
697    }
698
699    #[test]
700    fn test_progress_response_deserialization() {
701        let json = r#"{
702            "Photoscene": {
703                "photosceneid": "scene-789",
704                "progress": "75",
705                "progressmsg": "Processing images",
706                "status": "InProgress"
707            }
708        }"#;
709
710        let response: ProgressResponse = serde_json::from_str(json).unwrap();
711        let ps = response.photoscene.unwrap();
712        assert_eq!(ps.photoscene_id, "scene-789");
713        assert_eq!(ps.progress, "75");
714        assert_eq!(ps.progress_msg, Some("Processing images".to_string()));
715    }
716
717    #[test]
718    fn test_progress_response_with_error() {
719        let json = r#"{
720            "Usage": "0.51",
721            "Resource": "/photoscene/xyz/progress",
722            "Error": {
723                "code": "ERR-001",
724                "msg": "Scene not found"
725            }
726        }"#;
727
728        let response: ProgressResponse = serde_json::from_str(json).unwrap();
729        assert!(response.photoscene.is_none());
730        assert!(response.error.is_some());
731        let err = response.error.unwrap();
732        assert_eq!(err.code.unwrap(), "ERR-001");
733        assert_eq!(err.msg.unwrap(), "Scene not found");
734    }
735
736    #[test]
737    fn test_mime_type_from_extension() {
738        assert_eq!(mime_type_from_extension("photo.jpg"), "image/jpeg");
739        assert_eq!(mime_type_from_extension("photo.jpeg"), "image/jpeg");
740        assert_eq!(mime_type_from_extension("photo.png"), "image/png");
741        assert_eq!(mime_type_from_extension("photo.tiff"), "image/tiff");
742        assert_eq!(mime_type_from_extension("photo.tif"), "image/tiff");
743        assert_eq!(mime_type_from_extension("photo.bmp"), "image/bmp");
744        assert_eq!(mime_type_from_extension("photo.webp"), "image/webp");
745        assert_eq!(mime_type_from_extension("photo.gif"), "image/gif");
746    }
747
748    #[test]
749    fn test_mime_fallback() {
750        assert_eq!(
751            mime_type_from_extension("photo.raw"),
752            "application/octet-stream"
753        );
754        assert_eq!(
755            mime_type_from_extension("photo.xyz"),
756            "application/octet-stream"
757        );
758        assert_eq!(
759            mime_type_from_extension("photo.RAW"),
760            "application/octet-stream"
761        );
762    }
763
764    #[test]
765    fn test_mime_case_insensitive() {
766        assert_eq!(mime_type_from_extension("photo.PNG"), "image/png");
767        assert_eq!(mime_type_from_extension("photo.JPEG"), "image/jpeg");
768        assert_eq!(mime_type_from_extension("photo.Tiff"), "image/tiff");
769    }
770
771    #[test]
772    fn test_photoscene_result_filesize_bytes() {
773        let result = PhotosceneResult {
774            photoscene_id: "scene-1".to_string(),
775            progress: "100".to_string(),
776            progress_msg: None,
777            scene_link: None,
778            file_size: Some("5242880".to_string()),
779        };
780        assert_eq!(result.filesize_bytes(), Some(5_242_880));
781    }
782
783    #[test]
784    fn test_photoscene_result_filesize_bytes_none() {
785        let result = PhotosceneResult {
786            photoscene_id: "scene-1".to_string(),
787            progress: "100".to_string(),
788            progress_msg: None,
789            scene_link: None,
790            file_size: None,
791        };
792        assert_eq!(result.filesize_bytes(), None);
793    }
794
795    #[test]
796    fn test_photoscene_result_filesize_bytes_unparsable() {
797        let result = PhotosceneResult {
798            photoscene_id: "scene-1".to_string(),
799            progress: "100".to_string(),
800            progress_msg: None,
801            scene_link: None,
802            file_size: Some("not-a-number".to_string()),
803        };
804        assert_eq!(result.filesize_bytes(), None);
805    }
806
807    #[test]
808    fn test_uploaded_file_filesize_bytes() {
809        let file = UploadedFile {
810            filename: "photo.jpg".to_string(),
811            fileid: "file-1".to_string(),
812            filesize: Some("2048000".to_string()),
813            msg: None,
814        };
815        assert_eq!(file.filesize_bytes(), Some(2_048_000));
816    }
817}
818
819/// Integration tests using raps-mock
820#[cfg(test)]
821mod integration_tests {
822    use super::*;
823    use raps_kernel::auth::AuthClient;
824    use raps_kernel::config::Config;
825
826    fn create_mock_reality_client(mock_url: &str) -> RealityCaptureClient {
827        let config = Config {
828            client_id: "test-client-id".to_string(),
829            client_secret: "test-client-secret".to_string(),
830            base_url: mock_url.to_string(),
831            callback_url: "http://localhost:8080/callback".to_string(),
832            da_nickname: None,
833            http_config: HttpClientConfig::default(),
834        };
835        let auth = AuthClient::new(config.clone());
836        RealityCaptureClient::new(config, auth)
837    }
838
839    #[tokio::test]
840    async fn test_client_creation() {
841        let server = raps_mock::TestServer::start_default().await.unwrap();
842        let client = create_mock_reality_client(&server.url);
843        assert!(client.auth.config().base_url.starts_with("http://"));
844    }
845
846    #[tokio::test]
847    async fn test_list_photoscenes() {
848        let server = raps_mock::TestServer::start_default().await.unwrap();
849        let client = create_mock_reality_client(&server.url);
850        let result = client.list_photoscenes().await;
851        let _ = result;
852    }
853}