allsource_core/application/use_cases/
manage_article.rs1use 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
11pub 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 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 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 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 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 self.repository.save(&article).await?;
79
80 Ok(CreateArticleResponse {
81 article: ArticleDto::from(&article),
82 })
83 }
84}
85
86pub 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 if let Some(title) = request.title {
105 article.update_title(title)?;
106 }
107
108 if let Some(url) = request.url {
110 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 if let Some(price_cents) = request.price_cents {
121 article.update_price(price_cents)?;
122 }
123
124 if let Some(description) = request.description {
126 article.update_description(Some(description));
127 }
128
129 if let Some(reading_time) = request.estimated_reading_time_minutes {
131 article.update_reading_time(Some(reading_time));
132 }
133
134 if let Some(preview) = request.preview_content {
136 article.update_preview(Some(preview))?;
137 }
138
139 self.repository.save(&article).await?;
141
142 Ok(UpdateArticleResponse {
143 article: ArticleDto::from(&article),
144 })
145 }
146}
147
148pub 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
160pub 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
172pub 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
184pub 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
196pub 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
208pub 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 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 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 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 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 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}