allsource_core/application/use_cases/
manage_article.rs1use 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
15pub 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 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 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 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 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 self.repository.save(&article).await?;
83
84 Ok(CreateArticleResponse {
85 article: ArticleDto::from(&article),
86 })
87 }
88}
89
90pub 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 if let Some(title) = request.title {
109 article.update_title(title)?;
110 }
111
112 if let Some(url) = request.url {
114 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 if let Some(price_cents) = request.price_cents {
125 article.update_price(price_cents)?;
126 }
127
128 if let Some(description) = request.description {
130 article.update_description(Some(description));
131 }
132
133 if let Some(reading_time) = request.estimated_reading_time_minutes {
135 article.update_reading_time(Some(reading_time));
136 }
137
138 if let Some(preview) = request.preview_content {
140 article.update_preview(Some(preview))?;
141 }
142
143 self.repository.save(&article).await?;
145
146 Ok(UpdateArticleResponse {
147 article: ArticleDto::from(&article),
148 })
149 }
150}
151
152pub 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
164pub 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
176pub 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
188pub 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
200pub 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
212pub 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 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 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 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 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 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}