Skip to main content

allsource_core/application/use_cases/
manage_article.rs

1use crate::application::dto::{
2    ArticleDto, CreateArticleRequest, CreateArticleResponse, ListArticlesResponse,
3    UpdateArticleRequest, UpdateArticleResponse,
4};
5use crate::domain::entities::PaywallArticle;
6use crate::domain::repositories::ArticleRepository;
7use crate::domain::value_objects::{ArticleId, CreatorId, TenantId};
8use crate::error::Result;
9use std::sync::Arc;
10
11/// Use Case: Create Article
12///
13/// Creates a new paywalled article for a creator.
14///
15/// Responsibilities:
16/// - Validate input (DTO validation)
17/// - Check for duplicate URL
18/// - Create domain PaywallArticle entity (with domain validation)
19/// - Persist via repository
20/// - Return response DTO
21pub struct CreateArticleUseCase {
22    repository: Arc<dyn ArticleRepository>,
23}
24
25impl CreateArticleUseCase {
26    pub fn new(repository: Arc<dyn ArticleRepository>) -> Self {
27        Self { repository }
28    }
29
30    pub async fn execute(&self, request: CreateArticleRequest) -> Result<CreateArticleResponse> {
31        // Check for duplicate URL
32        if self.repository.url_exists(&request.url).await? {
33            return Err(crate::error::AllSourceError::ValidationError(
34                "Article with this URL already exists".to_string(),
35            ));
36        }
37
38        // Parse IDs
39        let article_id = ArticleId::new(request.article_id)?;
40        let tenant_id = TenantId::new(request.tenant_id)?;
41        let creator_id = CreatorId::parse(&request.creator_id)?;
42
43        // Create domain article entity
44        let mut article = if request.is_draft.unwrap_or(false) {
45            PaywallArticle::new_draft(
46                article_id,
47                tenant_id,
48                creator_id,
49                request.title,
50                request.url,
51                request.price_cents,
52            )?
53        } else {
54            PaywallArticle::new(
55                article_id,
56                tenant_id,
57                creator_id,
58                request.title,
59                request.url,
60                request.price_cents,
61            )?
62        };
63
64        // Set optional fields
65        if let Some(description) = request.description {
66            article.update_description(Some(description));
67        }
68
69        if let Some(reading_time) = request.estimated_reading_time_minutes {
70            article.update_reading_time(Some(reading_time));
71        }
72
73        if let Some(preview) = request.preview_content {
74            article.update_preview(Some(preview))?;
75        }
76
77        // Persist via repository
78        self.repository.save(&article).await?;
79
80        Ok(CreateArticleResponse {
81            article: ArticleDto::from(&article),
82        })
83    }
84}
85
86/// Use Case: Update Article
87///
88/// Updates an existing article's information.
89pub struct UpdateArticleUseCase {
90    repository: Arc<dyn ArticleRepository>,
91}
92
93impl UpdateArticleUseCase {
94    pub fn new(repository: Arc<dyn ArticleRepository>) -> Self {
95        Self { repository }
96    }
97
98    pub async fn execute(
99        &self,
100        mut article: PaywallArticle,
101        request: UpdateArticleRequest,
102    ) -> Result<UpdateArticleResponse> {
103        // Update title if provided
104        if let Some(title) = request.title {
105            article.update_title(title)?;
106        }
107
108        // Update URL if provided
109        if let Some(url) = request.url {
110            // Check for duplicate URL (if different from current)
111            if article.url() != url && self.repository.url_exists(&url).await? {
112                return Err(crate::error::AllSourceError::ValidationError(
113                    "Another article with this URL already exists".to_string(),
114                ));
115            }
116            article.update_url(url)?;
117        }
118
119        // Update price if provided
120        if let Some(price_cents) = request.price_cents {
121            article.update_price(price_cents)?;
122        }
123
124        // Update description if provided
125        if let Some(description) = request.description {
126            article.update_description(Some(description));
127        }
128
129        // Update reading time if provided
130        if let Some(reading_time) = request.estimated_reading_time_minutes {
131            article.update_reading_time(Some(reading_time));
132        }
133
134        // Update preview if provided
135        if let Some(preview) = request.preview_content {
136            article.update_preview(Some(preview))?;
137        }
138
139        // Persist changes
140        self.repository.save(&article).await?;
141
142        Ok(UpdateArticleResponse {
143            article: ArticleDto::from(&article),
144        })
145    }
146}
147
148/// Use Case: Publish Article
149///
150/// Publishes a draft article, making it available for purchase.
151pub struct PublishArticleUseCase;
152
153impl PublishArticleUseCase {
154    pub fn execute(mut article: PaywallArticle) -> Result<ArticleDto> {
155        article.publish()?;
156        Ok(ArticleDto::from(&article))
157    }
158}
159
160/// Use Case: Archive Article
161///
162/// Archives an article, preventing new purchases while preserving existing access.
163pub struct ArchiveArticleUseCase;
164
165impl ArchiveArticleUseCase {
166    pub fn execute(mut article: PaywallArticle) -> Result<ArticleDto> {
167        article.archive();
168        Ok(ArticleDto::from(&article))
169    }
170}
171
172/// Use Case: Restore Article
173///
174/// Restores an archived article, making it available for purchase again.
175pub struct RestoreArticleUseCase;
176
177impl RestoreArticleUseCase {
178    pub fn execute(mut article: PaywallArticle) -> Result<ArticleDto> {
179        article.restore()?;
180        Ok(ArticleDto::from(&article))
181    }
182}
183
184/// Use Case: Delete Article
185///
186/// Marks an article as deleted (soft delete).
187pub struct DeleteArticleUseCase;
188
189impl DeleteArticleUseCase {
190    pub fn execute(mut article: PaywallArticle) -> Result<ArticleDto> {
191        article.delete();
192        Ok(ArticleDto::from(&article))
193    }
194}
195
196/// Use Case: Record Article Purchase
197///
198/// Records a purchase for analytics tracking.
199pub struct RecordArticlePurchaseUseCase;
200
201impl RecordArticlePurchaseUseCase {
202    pub fn execute(mut article: PaywallArticle, amount_cents: u64) -> Result<ArticleDto> {
203        article.record_purchase(amount_cents);
204        Ok(ArticleDto::from(&article))
205    }
206}
207
208/// Use Case: List Articles
209///
210/// Returns a list of articles.
211pub struct ListArticlesUseCase;
212
213impl ListArticlesUseCase {
214    pub fn execute(articles: Vec<PaywallArticle>) -> ListArticlesResponse {
215        let article_dtos: Vec<ArticleDto> = articles.iter().map(ArticleDto::from).collect();
216        let count = article_dtos.len();
217
218        ListArticlesResponse {
219            articles: article_dtos,
220            count,
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::domain::entities::ArticleStatus;
229    use crate::domain::repositories::ArticleQuery;
230    use async_trait::async_trait;
231    use std::sync::Mutex;
232
233    struct MockArticleRepository {
234        articles: Mutex<Vec<PaywallArticle>>,
235    }
236
237    impl MockArticleRepository {
238        fn new() -> Self {
239            Self {
240                articles: Mutex::new(Vec::new()),
241            }
242        }
243    }
244
245    #[async_trait]
246    impl ArticleRepository for MockArticleRepository {
247        async fn save(&self, article: &PaywallArticle) -> Result<()> {
248            let mut articles = self.articles.lock().unwrap();
249            // Update if exists, otherwise insert
250            if let Some(pos) = articles.iter().position(|a| a.id() == article.id()) {
251                articles[pos] = article.clone();
252            } else {
253                articles.push(article.clone());
254            }
255            Ok(())
256        }
257
258        async fn find_by_id(&self, id: &ArticleId) -> Result<Option<PaywallArticle>> {
259            let articles = self.articles.lock().unwrap();
260            Ok(articles.iter().find(|a| a.id() == id).cloned())
261        }
262
263        async fn find_by_url(&self, url: &str) -> Result<Option<PaywallArticle>> {
264            let articles = self.articles.lock().unwrap();
265            Ok(articles.iter().find(|a| a.url() == url).cloned())
266        }
267
268        async fn find_by_creator(
269            &self,
270            creator_id: &CreatorId,
271            _limit: usize,
272            _offset: usize,
273        ) -> Result<Vec<PaywallArticle>> {
274            let articles = self.articles.lock().unwrap();
275            Ok(articles
276                .iter()
277                .filter(|a| a.creator_id() == creator_id)
278                .cloned()
279                .collect())
280        }
281
282        async fn find_by_tenant(
283            &self,
284            _tenant_id: &TenantId,
285            _limit: usize,
286            _offset: usize,
287        ) -> Result<Vec<PaywallArticle>> {
288            Ok(Vec::new())
289        }
290
291        async fn find_active_by_creator(
292            &self,
293            _creator_id: &CreatorId,
294            _limit: usize,
295            _offset: usize,
296        ) -> Result<Vec<PaywallArticle>> {
297            Ok(Vec::new())
298        }
299
300        async fn find_by_status(
301            &self,
302            _status: ArticleStatus,
303            _limit: usize,
304            _offset: usize,
305        ) -> Result<Vec<PaywallArticle>> {
306            Ok(Vec::new())
307        }
308
309        async fn count(&self) -> Result<usize> {
310            Ok(self.articles.lock().unwrap().len())
311        }
312
313        async fn count_by_creator(&self, _creator_id: &CreatorId) -> Result<usize> {
314            Ok(0)
315        }
316
317        async fn count_by_status(&self, _status: ArticleStatus) -> Result<usize> {
318            Ok(0)
319        }
320
321        async fn delete(&self, _id: &ArticleId) -> Result<bool> {
322            Ok(false)
323        }
324
325        async fn query(&self, _query: &ArticleQuery) -> Result<Vec<PaywallArticle>> {
326            Ok(Vec::new())
327        }
328
329        async fn find_top_by_revenue(
330            &self,
331            _creator_id: Option<&CreatorId>,
332            _limit: usize,
333        ) -> Result<Vec<PaywallArticle>> {
334            Ok(Vec::new())
335        }
336
337        async fn find_recent(
338            &self,
339            _creator_id: Option<&CreatorId>,
340            _limit: usize,
341        ) -> Result<Vec<PaywallArticle>> {
342            Ok(Vec::new())
343        }
344    }
345
346    #[tokio::test]
347    async fn test_create_article() {
348        let repo = Arc::new(MockArticleRepository::new());
349        let use_case = CreateArticleUseCase::new(repo.clone());
350
351        let request = CreateArticleRequest {
352            article_id: "test-article".to_string(),
353            tenant_id: "test-tenant".to_string(),
354            creator_id: uuid::Uuid::new_v4().to_string(),
355            title: "My Test Article".to_string(),
356            url: "https://blog.example.com/article".to_string(),
357            price_cents: 50,
358            description: Some("A great article".to_string()),
359            estimated_reading_time_minutes: Some(5),
360            preview_content: None,
361            is_draft: None,
362        };
363
364        let response = use_case.execute(request).await;
365        assert!(response.is_ok());
366
367        let response = response.unwrap();
368        assert_eq!(response.article.title, "My Test Article");
369        assert_eq!(response.article.price_cents, 50);
370        assert!(response.article.is_purchasable);
371    }
372
373    #[tokio::test]
374    async fn test_create_draft_article() {
375        let repo = Arc::new(MockArticleRepository::new());
376        let use_case = CreateArticleUseCase::new(repo.clone());
377
378        let request = CreateArticleRequest {
379            article_id: "draft-article".to_string(),
380            tenant_id: "test-tenant".to_string(),
381            creator_id: uuid::Uuid::new_v4().to_string(),
382            title: "Draft Article".to_string(),
383            url: "https://blog.example.com/draft".to_string(),
384            price_cents: 100,
385            description: None,
386            estimated_reading_time_minutes: None,
387            preview_content: None,
388            is_draft: Some(true),
389        };
390
391        let response = use_case.execute(request).await.unwrap();
392        assert!(!response.article.is_purchasable);
393        assert_eq!(
394            response.article.status,
395            crate::application::dto::ArticleStatusDto::Draft
396        );
397    }
398
399    #[tokio::test]
400    async fn test_create_article_duplicate_url() {
401        let repo = Arc::new(MockArticleRepository::new());
402        let use_case = CreateArticleUseCase::new(repo.clone());
403
404        // First article
405        let request1 = CreateArticleRequest {
406            article_id: "article-1".to_string(),
407            tenant_id: "test-tenant".to_string(),
408            creator_id: uuid::Uuid::new_v4().to_string(),
409            title: "Article 1".to_string(),
410            url: "https://blog.example.com/article".to_string(),
411            price_cents: 50,
412            description: None,
413            estimated_reading_time_minutes: None,
414            preview_content: None,
415            is_draft: None,
416        };
417        use_case.execute(request1).await.unwrap();
418
419        // Second article with same URL
420        let request2 = CreateArticleRequest {
421            article_id: "article-2".to_string(),
422            tenant_id: "test-tenant".to_string(),
423            creator_id: uuid::Uuid::new_v4().to_string(),
424            title: "Article 2".to_string(),
425            url: "https://blog.example.com/article".to_string(),
426            price_cents: 50,
427            description: None,
428            estimated_reading_time_minutes: None,
429            preview_content: None,
430            is_draft: None,
431        };
432
433        let result = use_case.execute(request2).await;
434        assert!(result.is_err());
435    }
436
437    #[test]
438    fn test_publish_article() {
439        let article_id = ArticleId::new("test-article".to_string()).unwrap();
440        let tenant_id = TenantId::new("test-tenant".to_string()).unwrap();
441        let creator_id = CreatorId::new();
442
443        let article = PaywallArticle::new_draft(
444            article_id,
445            tenant_id,
446            creator_id,
447            "Draft".to_string(),
448            "https://example.com".to_string(),
449            50,
450        )
451        .unwrap();
452
453        assert!(!article.is_purchasable());
454
455        let result = PublishArticleUseCase::execute(article);
456        assert!(result.is_ok());
457        assert!(result.unwrap().is_purchasable);
458    }
459
460    #[test]
461    fn test_archive_and_restore() {
462        let article_id = ArticleId::new("test-article".to_string()).unwrap();
463        let tenant_id = TenantId::new("test-tenant".to_string()).unwrap();
464        let creator_id = CreatorId::new();
465
466        let article = PaywallArticle::new(
467            article_id,
468            tenant_id,
469            creator_id,
470            "Article".to_string(),
471            "https://example.com".to_string(),
472            50,
473        )
474        .unwrap();
475
476        // Archive
477        let archived = ArchiveArticleUseCase::execute(article).unwrap();
478        assert_eq!(
479            archived.status,
480            crate::application::dto::ArticleStatusDto::Archived
481        );
482        assert!(!archived.is_purchasable);
483
484        // Restore (need to recreate since we don't have the entity)
485        let article_id = ArticleId::new("test-article-2".to_string()).unwrap();
486        let tenant_id = TenantId::new("test-tenant".to_string()).unwrap();
487        let creator_id = CreatorId::new();
488
489        let mut article = PaywallArticle::new(
490            article_id,
491            tenant_id,
492            creator_id,
493            "Article".to_string(),
494            "https://example.com/2".to_string(),
495            50,
496        )
497        .unwrap();
498        article.archive();
499
500        let restored = RestoreArticleUseCase::execute(article).unwrap();
501        assert_eq!(
502            restored.status,
503            crate::application::dto::ArticleStatusDto::Active
504        );
505        assert!(restored.is_purchasable);
506    }
507
508    #[test]
509    fn test_list_articles() {
510        let tenant_id = TenantId::new("test-tenant".to_string()).unwrap();
511        let creator_id = CreatorId::new();
512
513        let articles = vec![
514            PaywallArticle::new(
515                ArticleId::new("article-1".to_string()).unwrap(),
516                tenant_id.clone(),
517                creator_id,
518                "Article 1".to_string(),
519                "https://example.com/1".to_string(),
520                50,
521            )
522            .unwrap(),
523            PaywallArticle::new(
524                ArticleId::new("article-2".to_string()).unwrap(),
525                tenant_id,
526                creator_id,
527                "Article 2".to_string(),
528                "https://example.com/2".to_string(),
529                100,
530            )
531            .unwrap(),
532        ];
533
534        let response = ListArticlesUseCase::execute(articles);
535        assert_eq!(response.count, 2);
536        assert_eq!(response.articles.len(), 2);
537    }
538}