Skip to main content

xcom_rs/media/
commands.rs

1use anyhow::{Context, Result};
2use std::path::Path;
3
4use super::models::UploadResult;
5
6/// Trait for uploading media to the X API.
7/// This abstraction allows mocking in tests.
8pub trait MediaClient {
9    /// Upload raw bytes and return the media ID.
10    fn upload_bytes(&self, data: &[u8], mime_type: &str) -> Result<String>;
11}
12
13/// Production stub client (env-var driven for tests).
14///
15/// In a real implementation this would call `POST /2/media/upload`.
16/// For now it returns a deterministic fake ID so the CLI is fully wired
17/// without requiring live credentials.
18pub struct StubMediaClient;
19
20impl MediaClient for StubMediaClient {
21    fn upload_bytes(&self, _data: &[u8], _mime_type: &str) -> Result<String> {
22        // Check for simulated errors via environment variable (testing hook)
23        if let Ok(err) = std::env::var("XCOM_MEDIA_SIMULATE_ERROR") {
24            match err.as_str() {
25                "auth" => {
26                    anyhow::bail!("AuthRequired: media.write scope is required for media upload")
27                }
28                "server_error" => {
29                    anyhow::bail!("ServiceUnavailable: X API returned 503")
30                }
31                _ => {}
32            }
33        }
34
35        // Return a deterministic fake media ID
36        let media_id = format!("media_{}", uuid::Uuid::new_v4().as_simple());
37        Ok(media_id)
38    }
39}
40
41/// Arguments for a media upload operation
42#[derive(Debug, Clone)]
43pub struct UploadArgs {
44    /// Filesystem path of the file to upload
45    pub path: String,
46}
47
48/// Media command handler
49pub struct MediaCommand<C: MediaClient> {
50    client: C,
51}
52
53impl<C: MediaClient> MediaCommand<C> {
54    pub fn new(client: C) -> Self {
55        Self { client }
56    }
57
58    /// Upload a media file.
59    ///
60    /// Validates the path before reading the file, then delegates to the
61    /// configured [`MediaClient`].
62    pub fn upload(&self, args: UploadArgs) -> Result<UploadResult> {
63        let path = Path::new(&args.path);
64
65        // Task 2.1 – file existence and readability check
66        if !path.exists() {
67            anyhow::bail!("InvalidInput: file does not exist: {}", args.path);
68        }
69        if !path.is_file() {
70            anyhow::bail!("InvalidInput: path is not a regular file: {}", args.path);
71        }
72
73        let data =
74            std::fs::read(path).with_context(|| format!("Failed to read file: {}", args.path))?;
75
76        // Detect MIME type from extension (basic heuristic)
77        let mime_type = mime_from_path(path);
78
79        // Task 2.2 – delegate to API client
80        let media_id = self
81            .client
82            .upload_bytes(&data, mime_type)
83            .context("Media upload failed")?;
84
85        Ok(UploadResult::new(media_id))
86    }
87}
88
89/// Infer a MIME type from a file path extension.
90fn mime_from_path(path: &Path) -> &'static str {
91    match path
92        .extension()
93        .and_then(|e| e.to_str())
94        .map(|e| e.to_ascii_lowercase())
95        .as_deref()
96    {
97        Some("jpg") | Some("jpeg") => "image/jpeg",
98        Some("png") => "image/png",
99        Some("gif") => "image/gif",
100        Some("webp") => "image/webp",
101        Some("mp4") => "video/mp4",
102        Some("mov") => "video/quicktime",
103        _ => "application/octet-stream",
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::io::Write;
111    use tempfile::NamedTempFile;
112
113    /// Mock client that always returns a fixed media_id
114    struct MockMediaClient {
115        media_id: String,
116    }
117
118    impl MockMediaClient {
119        fn new(media_id: impl Into<String>) -> Self {
120            Self {
121                media_id: media_id.into(),
122            }
123        }
124    }
125
126    impl MediaClient for MockMediaClient {
127        fn upload_bytes(&self, _data: &[u8], _mime_type: &str) -> Result<String> {
128            Ok(self.media_id.clone())
129        }
130    }
131
132    /// Mock client that always returns an error
133    struct FailingMediaClient {
134        message: String,
135    }
136
137    impl FailingMediaClient {
138        fn new(message: impl Into<String>) -> Self {
139            Self {
140                message: message.into(),
141            }
142        }
143    }
144
145    impl MediaClient for FailingMediaClient {
146        fn upload_bytes(&self, _data: &[u8], _mime_type: &str) -> Result<String> {
147            anyhow::bail!("{}", self.message)
148        }
149    }
150
151    // Task 5.1 – upload success test
152    #[test]
153    fn test_upload_success_returns_media_id() {
154        let client = MockMediaClient::new("fixture_media_id_1234");
155        let cmd = MediaCommand::new(client);
156
157        let mut tmp = NamedTempFile::new().unwrap();
158        tmp.write_all(b"fake image data").unwrap();
159
160        let args = UploadArgs {
161            path: tmp.path().to_str().unwrap().to_string(),
162        };
163
164        let result = cmd.upload(args).unwrap();
165        assert_eq!(result.media_id, "fixture_media_id_1234");
166    }
167
168    // Task 5.1 – upload failure test: file not found
169    #[test]
170    fn test_upload_nonexistent_file_returns_error() {
171        let client = MockMediaClient::new("should_not_be_called");
172        let cmd = MediaCommand::new(client);
173
174        let args = UploadArgs {
175            path: "/nonexistent/path/image.jpg".to_string(),
176        };
177
178        let err = cmd.upload(args).unwrap_err();
179        assert!(
180            err.to_string().contains("InvalidInput"),
181            "Expected InvalidInput error, got: {}",
182            err
183        );
184    }
185
186    // Task 5.1 – upload failure test: client error
187    #[test]
188    fn test_upload_client_error_propagates() {
189        let client = FailingMediaClient::new("AuthRequired: missing scope");
190        let cmd = MediaCommand::new(client);
191
192        let mut tmp = NamedTempFile::new().unwrap();
193        tmp.write_all(b"data").unwrap();
194
195        let args = UploadArgs {
196            path: tmp.path().to_str().unwrap().to_string(),
197        };
198
199        let err = cmd.upload(args).unwrap_err();
200        // The error is wrapped with anyhow context; check the full chain
201        let chain = format!("{:#}", err);
202        assert!(
203            chain.contains("AuthRequired"),
204            "Expected AuthRequired in error chain, got: {}",
205            chain
206        );
207    }
208
209    #[test]
210    fn test_mime_from_extension() {
211        assert_eq!(mime_from_path(Path::new("image.jpg")), "image/jpeg");
212        assert_eq!(mime_from_path(Path::new("image.jpeg")), "image/jpeg");
213        assert_eq!(mime_from_path(Path::new("image.png")), "image/png");
214        assert_eq!(mime_from_path(Path::new("image.gif")), "image/gif");
215        assert_eq!(mime_from_path(Path::new("video.mp4")), "video/mp4");
216        assert_eq!(
217            mime_from_path(Path::new("unknown.bin")),
218            "application/octet-stream"
219        );
220    }
221}