Skip to main content

allsource_core/application/use_cases/
manage_article.rs

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