Skip to main content

arch_toolkit/aur/
mock.rs

1//! Mock implementation of `AurApi` for testing purposes.
2
3use super::traits::AurApi;
4use crate::error::{ArchToolkitError, Result};
5use crate::types::{AurComment, AurPackage, AurPackageDetails};
6use async_trait::async_trait;
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10/// What: Mock implementation of `AurApi` for testing.
11///
12/// Inputs: None (created via `MockAurApi::new()` or builder methods)
13///
14/// Output:
15/// - `MockAurApi` instance that can be configured with predefined responses
16///
17/// Details:
18/// - Allows setting predefined results for each operation type
19/// - Supports both success and error responses
20/// - Thread-safe via `Arc<Mutex<>>` for internal state
21/// - Builder pattern for easy configuration
22/// - Useful for unit testing without hitting real AUR endpoints
23#[derive(Debug)]
24pub struct MockAurApi {
25    /// Predefined search results, keyed by query string.
26    search_results: Arc<Mutex<HashMap<String, Result<Vec<AurPackage>>>>>,
27    /// Predefined info results, keyed by sorted package names (comma-separated).
28    info_results: Arc<Mutex<HashMap<String, Result<Vec<AurPackageDetails>>>>>,
29    /// Predefined comments results, keyed by package name.
30    comments_results: Arc<Mutex<HashMap<String, Result<Vec<AurComment>>>>>,
31    /// Predefined pkgbuild results, keyed by package name.
32    pkgbuild_results: Arc<Mutex<HashMap<String, Result<String>>>>,
33    /// Default search result if no specific query match is found.
34    default_search_result: Option<Result<Vec<AurPackage>>>,
35    /// Default info result if no specific package match is found.
36    default_info_result: Option<Result<Vec<AurPackageDetails>>>,
37    /// Default comments result if no specific package match is found.
38    default_comments_result: Option<Result<Vec<AurComment>>>,
39    /// Default pkgbuild result if no specific package match is found.
40    default_pkgbuild_result: Option<Result<String>>,
41}
42
43impl Default for MockAurApi {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl MockAurApi {
50    /// What: Clone a Result containing clonable success values.
51    ///
52    /// Inputs:
53    /// - `result`: Result to clone
54    ///
55    /// Output:
56    /// - Cloned result (errors converted to Parse errors if not clonable)
57    ///
58    /// Details:
59    /// - Clones Ok values directly
60    /// - Converts non-clonable errors (Network) to Parse errors
61    fn clone_result<T: Clone>(result: &Result<T>) -> Result<T> {
62        match result {
63            Ok(ok) => Ok(ok.clone()),
64            Err(e) => Err(match e {
65                ArchToolkitError::Network(_) => {
66                    ArchToolkitError::Parse("Mock network error".to_string())
67                }
68                ArchToolkitError::SearchFailed { query, .. } => {
69                    ArchToolkitError::Parse(format!("Mock search error for query: {query}"))
70                }
71                ArchToolkitError::InfoFailed { packages, .. } => {
72                    ArchToolkitError::Parse(format!("Mock info error for packages: {packages}"))
73                }
74                ArchToolkitError::CommentsFailed { package, .. } => {
75                    ArchToolkitError::Parse(format!("Mock comments error for package: {package}"))
76                }
77                ArchToolkitError::PkgbuildFailed { package, .. } => {
78                    ArchToolkitError::Parse(format!("Mock pkgbuild error for package: {package}"))
79                }
80                ArchToolkitError::Json(_) => ArchToolkitError::Parse("Mock JSON error".to_string()),
81                ArchToolkitError::Io { path, .. } => {
82                    ArchToolkitError::Parse(format!("Mock I/O error at path: {path}"))
83                }
84                ArchToolkitError::Parse(s) => ArchToolkitError::Parse(s.clone()),
85                ArchToolkitError::RateLimited { retry_after } => ArchToolkitError::RateLimited {
86                    retry_after: *retry_after,
87                },
88                ArchToolkitError::PackageNotFound { package } => {
89                    ArchToolkitError::PackageNotFound {
90                        package: package.clone(),
91                    }
92                }
93                ArchToolkitError::InvalidInput(s) => ArchToolkitError::InvalidInput(s.clone()),
94                ArchToolkitError::EmptyInput { field, message } => ArchToolkitError::EmptyInput {
95                    field: field.clone(),
96                    message: message.clone(),
97                },
98                ArchToolkitError::InvalidPackageName { name, reason } => {
99                    ArchToolkitError::InvalidPackageName {
100                        name: name.clone(),
101                        reason: reason.clone(),
102                    }
103                }
104                ArchToolkitError::InvalidSearchQuery { reason } => {
105                    ArchToolkitError::InvalidSearchQuery {
106                        reason: reason.clone(),
107                    }
108                }
109                ArchToolkitError::InputTooLong {
110                    field,
111                    max_length,
112                    actual_length,
113                } => ArchToolkitError::InputTooLong {
114                    field: field.clone(),
115                    max_length: *max_length,
116                    actual_length: *actual_length,
117                },
118            }),
119        }
120    }
121
122    /// What: Create a new `MockAurApi` with empty configuration.
123    ///
124    /// Inputs: None
125    ///
126    /// Output:
127    /// - `MockAurApi` instance ready for configuration
128    ///
129    /// Details:
130    /// - Starts with no predefined results
131    /// - Use builder methods to configure responses
132    #[must_use]
133    pub fn new() -> Self {
134        Self {
135            search_results: Arc::new(Mutex::new(HashMap::new())),
136            info_results: Arc::new(Mutex::new(HashMap::new())),
137            comments_results: Arc::new(Mutex::new(HashMap::new())),
138            pkgbuild_results: Arc::new(Mutex::new(HashMap::new())),
139            default_search_result: None,
140            default_info_result: None,
141            default_comments_result: None,
142            default_pkgbuild_result: None,
143        }
144    }
145
146    /// What: Set a search result for a specific query.
147    ///
148    /// Inputs:
149    /// - `query`: Query string to match
150    /// - `results`: Result containing search results
151    ///
152    /// Output:
153    /// - `Self` for method chaining
154    ///
155    /// Details:
156    /// - Stores the result for the exact query string
157    /// - Overwrites any existing result for this query
158    ///
159    /// # Panics
160    /// - Panics if the internal mutex is poisoned (should never happen in practice)
161    #[must_use]
162    pub fn with_search_result(self, query: &str, result: Result<Vec<AurPackage>>) -> Self {
163        {
164            let mut results = self
165                .search_results
166                .lock()
167                .expect("MockAurApi mutex should not be poisoned");
168            results.insert(query.to_string(), result);
169        }
170        self
171    }
172
173    /// What: Set a default search result for queries without specific matches.
174    ///
175    /// Inputs:
176    /// - `result`: Default result to return
177    ///
178    /// Output:
179    /// - `Self` for method chaining
180    ///
181    /// Details:
182    /// - Used when a query doesn't have a specific match
183    /// - If not set, returns an error for unmatched queries
184    #[must_use]
185    pub fn with_default_search_result(self, result: Result<Vec<AurPackage>>) -> Self {
186        Self {
187            default_search_result: Some(result),
188            ..self
189        }
190    }
191
192    /// What: Set an info result for specific package names.
193    ///
194    /// Inputs:
195    /// - `names`: Slice of package names
196    /// - `result`: Result containing package details
197    ///
198    /// Output:
199    /// - `Self` for method chaining
200    ///
201    /// Details:
202    /// - Stores the result keyed by sorted, comma-separated package names
203    /// - Overwrites any existing result for these packages
204    ///
205    /// # Panics
206    /// - Panics if the internal mutex is poisoned (should never happen in practice)
207    #[must_use]
208    pub fn with_info_result(self, names: &[&str], result: Result<Vec<AurPackageDetails>>) -> Self {
209        let mut sorted_names = names.to_vec();
210        sorted_names.sort_unstable();
211        let key = sorted_names.join(",");
212        {
213            let mut results = self
214                .info_results
215                .lock()
216                .expect("MockAurApi mutex should not be poisoned");
217            results.insert(key, result);
218        }
219        self
220    }
221
222    /// What: Set a default info result for packages without specific matches.
223    ///
224    /// Inputs:
225    /// - `result`: Default result to return
226    ///
227    /// Output:
228    /// - `Self` for method chaining
229    #[must_use]
230    pub fn with_default_info_result(self, result: Result<Vec<AurPackageDetails>>) -> Self {
231        Self {
232            default_info_result: Some(result),
233            ..self
234        }
235    }
236
237    /// What: Set a comments result for a specific package.
238    ///
239    /// Inputs:
240    /// - `pkgname`: Package name
241    /// - `result`: Result containing comments
242    ///
243    /// Output:
244    /// - `Self` for method chaining
245    ///
246    /// # Panics
247    /// - Panics if the internal mutex is poisoned (should never happen in practice)
248    #[must_use]
249    pub fn with_comments_result(self, pkgname: &str, result: Result<Vec<AurComment>>) -> Self {
250        {
251            let mut results = self
252                .comments_results
253                .lock()
254                .expect("MockAurApi mutex should not be poisoned");
255            results.insert(pkgname.to_string(), result);
256        }
257        self
258    }
259
260    /// What: Set a default comments result for packages without specific matches.
261    ///
262    /// Inputs:
263    /// - `result`: Default result to return
264    ///
265    /// Output:
266    /// - `Self` for method chaining
267    #[must_use]
268    pub fn with_default_comments_result(self, result: Result<Vec<AurComment>>) -> Self {
269        Self {
270            default_comments_result: Some(result),
271            ..self
272        }
273    }
274
275    /// What: Set a pkgbuild result for a specific package.
276    ///
277    /// Inputs:
278    /// - `package`: Package name
279    /// - `result`: Result containing PKGBUILD content
280    ///
281    /// Output:
282    /// - `Self` for method chaining
283    ///
284    /// # Panics
285    /// - Panics if the internal mutex is poisoned (should never happen in practice)
286    #[must_use]
287    pub fn with_pkgbuild_result(self, package: &str, result: Result<String>) -> Self {
288        {
289            let mut results = self
290                .pkgbuild_results
291                .lock()
292                .expect("MockAurApi mutex should not be poisoned");
293            results.insert(package.to_string(), result);
294        }
295        self
296    }
297
298    /// What: Set a default pkgbuild result for packages without specific matches.
299    ///
300    /// Inputs:
301    /// - `result`: Default result to return
302    ///
303    /// Output:
304    /// - `Self` for method chaining
305    #[must_use]
306    pub fn with_default_pkgbuild_result(self, result: Result<String>) -> Self {
307        Self {
308            default_pkgbuild_result: Some(result),
309            ..self
310        }
311    }
312}
313
314#[async_trait]
315impl AurApi for MockAurApi {
316    /// What: Search for packages in the AUR by name (mock implementation).
317    ///
318    /// Inputs:
319    /// - `query`: Search query string
320    ///
321    /// Output:
322    /// - `Result<Vec<AurPackage>>` containing predefined search results, or an error
323    ///
324    /// Details:
325    /// - Returns predefined result for the query if available
326    /// - Falls back to default search result if set
327    /// - Returns error if no match found and no default is set
328    async fn search(&self, query: &str) -> Result<Vec<AurPackage>> {
329        let result = {
330            let results = self
331                .search_results
332                .lock()
333                .expect("MockAurApi mutex should not be poisoned");
334            results.get(query).map(Self::clone_result)
335        };
336
337        if let Some(result) = result {
338            return result;
339        }
340
341        if let Some(ref default) = self.default_search_result {
342            return Self::clone_result(default);
343        }
344
345        Err(ArchToolkitError::Parse(format!(
346            "MockAurApi: No search result configured for query '{query}'"
347        )))
348    }
349
350    /// What: Fetch detailed information for one or more AUR packages (mock implementation).
351    ///
352    /// Inputs:
353    /// - `names`: Slice of package names to fetch info for
354    ///
355    /// Output:
356    /// - `Result<Vec<AurPackageDetails>>` containing predefined package details, or an error
357    ///
358    /// Details:
359    /// - Returns predefined result for the sorted package names if available
360    /// - Falls back to default info result if set
361    /// - Returns error if no match found and no default is set
362    async fn info(&self, names: &[&str]) -> Result<Vec<AurPackageDetails>> {
363        let mut sorted_names = names.to_vec();
364        sorted_names.sort_unstable();
365        let key = sorted_names.join(",");
366
367        let result = {
368            let results = self
369                .info_results
370                .lock()
371                .expect("MockAurApi mutex should not be poisoned");
372            results.get(&key).map(Self::clone_result)
373        };
374
375        if let Some(result) = result {
376            return result;
377        }
378
379        if let Some(ref default) = self.default_info_result {
380            return Self::clone_result(default);
381        }
382
383        Err(ArchToolkitError::Parse(format!(
384            "MockAurApi: No info result configured for packages '{key}'"
385        )))
386    }
387
388    /// What: Fetch AUR package comments (mock implementation).
389    ///
390    /// Inputs:
391    /// - `pkgname`: Package name to fetch comments for
392    ///
393    /// Output:
394    /// - `Result<Vec<AurComment>>` containing predefined comments, or an error
395    ///
396    /// Details:
397    /// - Returns predefined result for the package if available
398    /// - Falls back to default comments result if set
399    /// - Returns error if no match found and no default is set
400    async fn comments(&self, pkgname: &str) -> Result<Vec<AurComment>> {
401        let result = {
402            let results = self
403                .comments_results
404                .lock()
405                .expect("MockAurApi mutex should not be poisoned");
406            results.get(pkgname).map(Self::clone_result)
407        };
408
409        if let Some(result) = result {
410            return result;
411        }
412
413        if let Some(ref default) = self.default_comments_result {
414            return Self::clone_result(default);
415        }
416
417        Err(ArchToolkitError::Parse(format!(
418            "MockAurApi: No comments result configured for package '{pkgname}'"
419        )))
420    }
421
422    /// What: Fetch PKGBUILD content for an AUR package (mock implementation).
423    ///
424    /// Inputs:
425    /// - `package`: Package name to fetch PKGBUILD for
426    ///
427    /// Output:
428    /// - `Result<String>` containing predefined PKGBUILD content, or an error
429    ///
430    /// Details:
431    /// - Returns predefined result for the package if available
432    /// - Falls back to default pkgbuild result if set
433    /// - Returns error if no match found and no default is set
434    async fn pkgbuild(&self, package: &str) -> Result<String> {
435        let result = {
436            let results = self
437                .pkgbuild_results
438                .lock()
439                .expect("MockAurApi mutex should not be poisoned");
440            results.get(package).map(Self::clone_result)
441        };
442
443        if let Some(result) = result {
444            return result;
445        }
446
447        if let Some(ref default) = self.default_pkgbuild_result {
448            return Self::clone_result(default);
449        }
450
451        Err(ArchToolkitError::Parse(format!(
452            "MockAurApi: No pkgbuild result configured for package '{package}'"
453        )))
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    // Allow unwrap in tests - these are intentional panics for test failures
460    #![allow(clippy::unwrap_used)]
461
462    use super::*;
463    use crate::types::{AurComment, AurPackage, AurPackageDetails};
464
465    #[tokio::test]
466    async fn test_mock_search_success() {
467        let mock = MockAurApi::new().with_search_result(
468            "yay",
469            Ok(vec![AurPackage {
470                name: "yay".to_string(),
471                version: "12.0.0".to_string(),
472                description: "AUR helper".to_string(),
473                popularity: Some(100.0),
474                out_of_date: None,
475                orphaned: false,
476                maintainer: Some("user".to_string()),
477            }]),
478        );
479
480        let result = mock.search("yay").await;
481        assert!(result.is_ok());
482        let packages = result.unwrap();
483        assert_eq!(packages.len(), 1);
484        assert_eq!(packages[0].name, "yay");
485    }
486
487    #[tokio::test]
488    async fn test_mock_search_error() {
489        let mock = MockAurApi::new().with_search_result(
490            "error",
491            Err(ArchToolkitError::Parse("test error".to_string())),
492        );
493
494        let result = mock.search("error").await;
495        assert!(result.is_err());
496    }
497
498    #[tokio::test]
499    async fn test_mock_search_not_found() {
500        let mock = MockAurApi::new();
501        let result = mock.search("unknown").await;
502        assert!(result.is_err());
503        assert!(
504            result
505                .unwrap_err()
506                .to_string()
507                .contains("No search result configured")
508        );
509    }
510
511    #[tokio::test]
512    async fn test_mock_search_default() {
513        let mock = MockAurApi::new().with_default_search_result(Ok(vec![AurPackage {
514            name: "default".to_string(),
515            version: "1.0.0".to_string(),
516            description: "Default package".to_string(),
517            popularity: None,
518            out_of_date: None,
519            orphaned: false,
520            maintainer: None,
521        }]));
522
523        let result = mock.search("any-query").await;
524        assert!(result.is_ok());
525        let packages = result.unwrap();
526        assert_eq!(packages.len(), 1);
527        assert_eq!(packages[0].name, "default");
528    }
529
530    #[tokio::test]
531    async fn test_mock_info_success() {
532        let mock = MockAurApi::new().with_info_result(
533            &["yay"],
534            Ok(vec![AurPackageDetails {
535                name: "yay".to_string(),
536                version: "12.0.0".to_string(),
537                description: "AUR helper".to_string(),
538                url: "https://github.com/Jguer/yay".to_string(),
539                licenses: vec!["MIT".to_string()],
540                groups: vec![],
541                provides: vec![],
542                depends: vec![],
543                make_depends: vec![],
544                opt_depends: vec![],
545                conflicts: vec![],
546                replaces: vec![],
547                maintainer: Some("user".to_string()),
548                first_submitted: None,
549                last_modified: None,
550                popularity: Some(100.0),
551                num_votes: Some(1000),
552                out_of_date: None,
553                orphaned: false,
554            }]),
555        );
556
557        let result = mock.info(&["yay"]).await;
558        assert!(result.is_ok());
559        let packages = result.unwrap();
560        assert_eq!(packages.len(), 1);
561        assert_eq!(packages[0].name, "yay");
562    }
563
564    #[tokio::test]
565    async fn test_mock_info_sorted() {
566        let mock = MockAurApi::new().with_info_result(
567            &["yay", "paru"],
568            Ok(vec![AurPackageDetails {
569                name: "yay".to_string(),
570                version: "12.0.0".to_string(),
571                description: "AUR helper".to_string(),
572                url: String::new(),
573                licenses: vec![],
574                groups: vec![],
575                provides: vec![],
576                depends: vec![],
577                make_depends: vec![],
578                opt_depends: vec![],
579                conflicts: vec![],
580                replaces: vec![],
581                maintainer: None,
582                first_submitted: None,
583                last_modified: None,
584                popularity: None,
585                num_votes: None,
586                out_of_date: None,
587                orphaned: false,
588            }]),
589        );
590
591        // Should work with different order
592        let result1 = mock.info(&["yay", "paru"]).await;
593        assert!(result1.is_ok());
594
595        let result2 = mock.info(&["paru", "yay"]).await;
596        assert!(result2.is_ok());
597    }
598
599    #[tokio::test]
600    async fn test_mock_comments_success() {
601        let mock = MockAurApi::new().with_comments_result(
602            "yay",
603            Ok(vec![AurComment {
604                id: Some("1".to_string()),
605                author: "user".to_string(),
606                date: "2024-01-01".to_string(),
607                date_timestamp: Some(1_704_067_200),
608                date_url: None,
609                content: "Great package!".to_string(),
610                pinned: false,
611            }]),
612        );
613
614        let result = mock.comments("yay").await;
615        assert!(result.is_ok());
616        let comments = result.unwrap();
617        assert_eq!(comments.len(), 1);
618        assert_eq!(comments[0].author, "user");
619    }
620
621    #[tokio::test]
622    async fn test_mock_pkgbuild_success() {
623        let mock = MockAurApi::new()
624            .with_pkgbuild_result("yay", Ok("pkgname=yay\npkgver=12.0.0".to_string()));
625
626        let result = mock.pkgbuild("yay").await;
627        assert!(result.is_ok());
628        let pkgbuild = result.unwrap();
629        assert!(pkgbuild.contains("yay"));
630    }
631}