xcom_rs/media/
commands.rs1use anyhow::{Context, Result};
2use std::path::Path;
3
4use super::models::UploadResult;
5
6pub trait MediaClient {
9 fn upload_bytes(&self, data: &[u8], mime_type: &str) -> Result<String>;
11}
12
13pub struct StubMediaClient;
19
20impl MediaClient for StubMediaClient {
21 fn upload_bytes(&self, _data: &[u8], _mime_type: &str) -> Result<String> {
22 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 let media_id = format!("media_{}", uuid::Uuid::new_v4().as_simple());
37 Ok(media_id)
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct UploadArgs {
44 pub path: String,
46}
47
48pub 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 pub fn upload(&self, args: UploadArgs) -> Result<UploadResult> {
63 let path = Path::new(&args.path);
64
65 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 let mime_type = mime_from_path(path);
78
79 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
89fn 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 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 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 #[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 #[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 #[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 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}