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