refactoring hexagonal and clean architecture
This commit is contained in:
@@ -4,7 +4,7 @@ use tokio::sync::Semaphore;
|
||||
use tracing::info;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::application::services::file_service::FileService;
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileManagementUseCase};
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::common::config::AppConfig;
|
||||
@@ -59,7 +59,8 @@ pub struct BatchStats {
|
||||
|
||||
/// Servicio de operaciones por lotes
|
||||
pub struct BatchOperationService {
|
||||
file_service: Arc<FileService>,
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>,
|
||||
config: AppConfig,
|
||||
semaphore: Arc<Semaphore>,
|
||||
@@ -68,7 +69,8 @@ pub struct BatchOperationService {
|
||||
impl BatchOperationService {
|
||||
/// Crea una nueva instancia del servicio de operaciones por lotes
|
||||
pub fn new(
|
||||
file_service: Arc<FileService>,
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>,
|
||||
config: AppConfig
|
||||
) -> Self {
|
||||
@@ -76,7 +78,8 @@ impl BatchOperationService {
|
||||
let max_concurrency = config.concurrency.max_concurrent_files;
|
||||
|
||||
Self {
|
||||
file_service,
|
||||
file_retrieval,
|
||||
file_management,
|
||||
folder_service,
|
||||
config,
|
||||
semaphore: Arc::new(Semaphore::new(max_concurrency)),
|
||||
@@ -85,10 +88,11 @@ impl BatchOperationService {
|
||||
|
||||
/// Crea una nueva instancia con la configuración por defecto
|
||||
pub fn default(
|
||||
file_service: Arc<FileService>,
|
||||
file_retrieval: Arc<dyn FileRetrievalUseCase>,
|
||||
file_management: Arc<dyn FileManagementUseCase>,
|
||||
folder_service: Arc<FolderService>
|
||||
) -> Self {
|
||||
Self::new(file_service, folder_service, AppConfig::default())
|
||||
Self::new(file_retrieval, file_management, folder_service, AppConfig::default())
|
||||
}
|
||||
|
||||
/// Copia múltiples archivos en paralelo
|
||||
@@ -112,7 +116,7 @@ impl BatchOperationService {
|
||||
|
||||
// Definir la operación a realizar para cada archivo
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let file_service = self.file_service.clone();
|
||||
let mgmt = self.file_management.clone();
|
||||
let target_folder = target_folder_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
@@ -120,7 +124,7 @@ impl BatchOperationService {
|
||||
// Adquirir permiso del semáforo
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let copy_result = file_service.move_file(&file_id, target_folder.clone()).await;
|
||||
let copy_result = mgmt.move_file(&file_id, target_folder.clone()).await;
|
||||
|
||||
// Liberar el permiso explícitamente (también se libera al hacer drop)
|
||||
drop(permit);
|
||||
@@ -183,7 +187,7 @@ impl BatchOperationService {
|
||||
|
||||
// Definir la operación a realizar para cada archivo
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let file_service = self.file_service.clone();
|
||||
let mgmt = self.file_management.clone();
|
||||
let target_folder = target_folder_id.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
@@ -191,7 +195,7 @@ impl BatchOperationService {
|
||||
// Adquirir permiso del semáforo
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let move_result = file_service.move_file(&file_id, target_folder.clone()).await;
|
||||
let move_result = mgmt.move_file(&file_id, target_folder.clone()).await;
|
||||
|
||||
// Liberar el permiso explícitamente
|
||||
drop(permit);
|
||||
@@ -253,7 +257,7 @@ impl BatchOperationService {
|
||||
|
||||
// Definir la operación a realizar para cada archivo
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let file_service = self.file_service.clone();
|
||||
let mgmt = self.file_management.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let id_clone = file_id.clone();
|
||||
|
||||
@@ -261,7 +265,7 @@ impl BatchOperationService {
|
||||
// Adquirir permiso del semáforo
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let delete_result = file_service.delete_file(&file_id).await;
|
||||
let delete_result = mgmt.delete_file(&file_id).await;
|
||||
|
||||
// Liberar el permiso explícitamente
|
||||
drop(permit);
|
||||
@@ -323,14 +327,14 @@ impl BatchOperationService {
|
||||
|
||||
// Definir la operación a realizar para cada archivo
|
||||
let operations = file_ids.into_iter().map(|file_id| {
|
||||
let file_service = self.file_service.clone();
|
||||
let retrieval = self.file_retrieval.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
|
||||
async move {
|
||||
// Adquirir permiso del semáforo
|
||||
let permit = semaphore.acquire().await.unwrap();
|
||||
|
||||
let get_result = file_service.get_file(&file_id).await;
|
||||
let get_result = retrieval.get_file(&file_id).await;
|
||||
|
||||
// Liberar el permiso explícitamente
|
||||
drop(permit);
|
||||
@@ -665,87 +669,17 @@ impl BatchOperationService {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use mockall::predicate::*;
|
||||
use mockall::mock;
|
||||
|
||||
// Crear mocks para los servicios
|
||||
mock! {
|
||||
FileSvcMock {}
|
||||
|
||||
#[async_trait]
|
||||
impl FileService for FileSvcMock {
|
||||
async fn create_file(&self, name: String, folder_id: Option<String>, content_type: String, content: Vec<u8>) -> Result<File, DomainError>;
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError>;
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
|
||||
async fn move_file(&self, id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
|
||||
async fn copy_file(&self, id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
}
|
||||
|
||||
mock! {
|
||||
FolderSvcMock {}
|
||||
|
||||
#[async_trait]
|
||||
impl FolderService for FolderSvcMock {
|
||||
async fn create_folder(&self, name: String, parent_id: Option<String>) -> Result<Folder, DomainError>;
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
|
||||
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>;
|
||||
async fn delete_folder_recursive(&self, id: &str) -> Result<(), DomainError>;
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
|
||||
async fn move_folder(&self, id: &str, target_parent_id: Option<String>) -> Result<Folder, DomainError>;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_batch_delete_files() {
|
||||
// Crear mocks
|
||||
let mut file_service = MockFileSvcMock::new();
|
||||
|
||||
// Configurar comportamiento esperado
|
||||
file_service.expect_delete_file()
|
||||
.times(3)
|
||||
.returning(|id| {
|
||||
if id == "error-id" {
|
||||
Err(DomainError::not_found("FileService", "File not found"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
|
||||
// Crear el servicio de batch con los mocks
|
||||
let batch_service = BatchOperationService::new(
|
||||
Arc::new(file_service),
|
||||
Arc::new(MockFolderSvcMock::new()),
|
||||
AppConfig::default()
|
||||
);
|
||||
|
||||
// Ejecutar la operación de batch
|
||||
let file_ids = vec![
|
||||
"id1".to_string(),
|
||||
"id2".to_string(),
|
||||
"error-id".to_string()
|
||||
];
|
||||
|
||||
let result = batch_service.delete_files(file_ids).await.unwrap();
|
||||
|
||||
// Verificar los resultados
|
||||
assert_eq!(result.stats.total, 3);
|
||||
assert_eq!(result.stats.successful, 2);
|
||||
assert_eq!(result.stats.failed, 1);
|
||||
assert_eq!(result.successful.len(), 2);
|
||||
assert_eq!(result.failed.len(), 1);
|
||||
assert_eq!(result.failed[0].0, "error-id");
|
||||
}
|
||||
use crate::common::stubs::{StubFileRetrievalUseCase, StubFileManagementUseCase};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_generic_batch_operation() {
|
||||
// Crear el servicio de batch
|
||||
// Crear el servicio de batch with stubs
|
||||
let batch_service = BatchOperationService::new(
|
||||
Arc::new(MockFileSvcMock::new()),
|
||||
Arc::new(MockFolderSvcMock::new()),
|
||||
Arc::new(StubFileRetrievalUseCase),
|
||||
Arc::new(StubFileManagementUseCase),
|
||||
Arc::new(FolderService::new(
|
||||
Arc::new(crate::common::stubs::StubFolderStoragePort)
|
||||
)),
|
||||
AppConfig::default()
|
||||
);
|
||||
|
||||
@@ -759,7 +693,7 @@ mod tests {
|
||||
Ok(item * 2)
|
||||
} else {
|
||||
// Simular error para números impares
|
||||
Err(DomainError::invalid_input("Test", "Odd number not allowed"))
|
||||
Err(DomainError::validation_error("Odd number not allowed"))
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ impl ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
||||
|
||||
// Check if user is owner
|
||||
if address_book.owner_id == user_id {
|
||||
if address_book.owner_id() == user_id {
|
||||
return Ok(address_book);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ impl ContactService {
|
||||
}
|
||||
|
||||
// Check if address book is public
|
||||
if address_book.is_public {
|
||||
if address_book.is_public() {
|
||||
return Ok(address_book);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ impl ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
||||
|
||||
// Check if user is owner
|
||||
if address_book.owner_id == user_id {
|
||||
if address_book.owner_id() == user_id {
|
||||
return Ok(address_book);
|
||||
}
|
||||
|
||||
@@ -93,12 +93,12 @@ impl ContactService {
|
||||
let line = lines[i].trim();
|
||||
|
||||
if line.starts_with("FN:") {
|
||||
contact.full_name = Some(line[3..].to_string());
|
||||
contact.set_full_name(Some(line[3..].to_string()));
|
||||
} else if line.starts_with("N:") {
|
||||
let parts: Vec<&str> = line[2..].split(';').collect();
|
||||
if parts.len() >= 2 {
|
||||
contact.last_name = Some(parts[0].to_string());
|
||||
contact.first_name = Some(parts[1].to_string());
|
||||
contact.set_last_name(Some(parts[0].to_string()));
|
||||
contact.set_first_name(Some(parts[1].to_string()));
|
||||
}
|
||||
} else if line.starts_with("EMAIL") {
|
||||
let value = line.split(':').nth(1).unwrap_or("");
|
||||
@@ -111,10 +111,10 @@ impl ContactService {
|
||||
"other"
|
||||
};
|
||||
|
||||
contact.email.push(Email {
|
||||
contact.push_email(Email {
|
||||
email: value.to_string(),
|
||||
r#type: email_type.to_string(),
|
||||
is_primary: contact.email.is_empty(), // First one is primary
|
||||
is_primary: contact.email_is_empty(), // First one is primary
|
||||
});
|
||||
}
|
||||
} else if line.starts_with("TEL") {
|
||||
@@ -132,26 +132,26 @@ impl ContactService {
|
||||
"other"
|
||||
};
|
||||
|
||||
contact.phone.push(Phone {
|
||||
contact.push_phone(Phone {
|
||||
number: value.to_string(),
|
||||
r#type: phone_type.to_string(),
|
||||
is_primary: contact.phone.is_empty(), // First one is primary
|
||||
is_primary: contact.phone_is_empty(), // First one is primary
|
||||
});
|
||||
}
|
||||
} else if line.starts_with("ORG:") {
|
||||
contact.organization = Some(line[4..].to_string());
|
||||
contact.set_organization(Some(line[4..].to_string()));
|
||||
} else if line.starts_with("TITLE:") {
|
||||
contact.title = Some(line[6..].to_string());
|
||||
contact.set_title(Some(line[6..].to_string()));
|
||||
} else if line.starts_with("NOTE:") {
|
||||
contact.notes = Some(line[5..].to_string());
|
||||
contact.set_notes(Some(line[5..].to_string()));
|
||||
} else if line.starts_with("UID:") {
|
||||
contact.uid = line[4..].to_string();
|
||||
contact.set_uid(line[4..].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Store the original vCard data
|
||||
contact.vcard = vcard_data.to_string();
|
||||
contact.etag = Uuid::new_v4().to_string();
|
||||
contact.set_vcard(vcard_data.to_string());
|
||||
contact.set_etag(Uuid::new_v4().to_string());
|
||||
|
||||
Ok(contact)
|
||||
}
|
||||
@@ -160,26 +160,26 @@ impl ContactService {
|
||||
let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n");
|
||||
|
||||
// UID
|
||||
vcard.push_str(&format!("UID:{}\r\n", contact.uid));
|
||||
vcard.push_str(&format!("UID:{}\r\n", contact.uid()));
|
||||
|
||||
// Name fields
|
||||
if let Some(full_name) = &contact.full_name {
|
||||
if let Some(full_name) = contact.full_name() {
|
||||
vcard.push_str(&format!("FN:{}\r\n", full_name));
|
||||
}
|
||||
|
||||
let last_name = contact.last_name.clone().unwrap_or_default();
|
||||
let first_name = contact.first_name.clone().unwrap_or_default();
|
||||
let last_name = contact.last_name().unwrap_or_default().to_string();
|
||||
let first_name = contact.first_name().unwrap_or_default().to_string();
|
||||
vcard.push_str(&format!("N:{};{};;;\r\n", last_name, first_name));
|
||||
|
||||
// Email addresses
|
||||
for email in &contact.email {
|
||||
for email in contact.email() {
|
||||
vcard.push_str(&format!("EMAIL;TYPE={}:{}\r\n",
|
||||
email.r#type.to_uppercase(),
|
||||
email.email));
|
||||
}
|
||||
|
||||
// Phone numbers
|
||||
for phone in &contact.phone {
|
||||
for phone in contact.phone() {
|
||||
let tel_type = match phone.r#type.as_str() {
|
||||
"mobile" => "CELL",
|
||||
"home" => "HOME",
|
||||
@@ -191,7 +191,7 @@ impl ContactService {
|
||||
}
|
||||
|
||||
// Addresses
|
||||
for addr in &contact.address {
|
||||
for addr in contact.address() {
|
||||
let addr_type = addr.r#type.to_uppercase();
|
||||
let street = addr.street.clone().unwrap_or_default();
|
||||
let city = addr.city.clone().unwrap_or_default();
|
||||
@@ -204,27 +204,27 @@ impl ContactService {
|
||||
}
|
||||
|
||||
// Organization
|
||||
if let Some(org) = &contact.organization {
|
||||
if let Some(org) = contact.organization() {
|
||||
vcard.push_str(&format!("ORG:{}\r\n", org));
|
||||
}
|
||||
|
||||
// Title
|
||||
if let Some(title) = &contact.title {
|
||||
if let Some(title) = contact.title() {
|
||||
vcard.push_str(&format!("TITLE:{}\r\n", title));
|
||||
}
|
||||
|
||||
// Notes
|
||||
if let Some(notes) = &contact.notes {
|
||||
if let Some(notes) = contact.notes() {
|
||||
vcard.push_str(&format!("NOTE:{}\r\n", notes));
|
||||
}
|
||||
|
||||
// Birthday
|
||||
if let Some(birthday) = &contact.birthday {
|
||||
if let Some(birthday) = contact.birthday() {
|
||||
vcard.push_str(&format!("BDAY:{}\r\n", birthday.format("%Y%m%d")));
|
||||
}
|
||||
|
||||
// Revision (last update)
|
||||
vcard.push_str(&format!("REV:{}\r\n", contact.updated_at.format("%Y%m%dT%H%M%SZ")));
|
||||
vcard.push_str(&format!("REV:{}\r\n", contact.updated_at().format("%Y%m%dT%H%M%SZ")));
|
||||
|
||||
vcard.push_str("END:VCARD\r\n");
|
||||
|
||||
@@ -235,19 +235,13 @@ impl ContactService {
|
||||
#[async_trait]
|
||||
impl AddressBookUseCase for ContactService {
|
||||
async fn create_address_book(&self, dto: CreateAddressBookDto) -> Result<AddressBookDto, DomainError> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
|
||||
let address_book = AddressBook {
|
||||
id,
|
||||
name: dto.name,
|
||||
owner_id: dto.owner_id,
|
||||
description: dto.description,
|
||||
color: dto.color,
|
||||
is_public: dto.is_public.unwrap_or(false),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
let address_book = AddressBook::new(
|
||||
dto.name,
|
||||
dto.owner_id,
|
||||
dto.description,
|
||||
dto.color,
|
||||
dto.is_public.unwrap_or(false),
|
||||
);
|
||||
|
||||
let created_address_book = self.address_book_repository.create_address_book(address_book).await?;
|
||||
Ok(AddressBookDto::from(created_address_book))
|
||||
@@ -261,16 +255,16 @@ impl AddressBookUseCase for ContactService {
|
||||
let address_book = self.check_address_book_write_access(&id, &update.user_id).await?;
|
||||
|
||||
// Apply updates
|
||||
let updated_address_book = AddressBook {
|
||||
let updated_address_book = AddressBook::from_raw(
|
||||
id,
|
||||
name: update.name.unwrap_or(address_book.name),
|
||||
owner_id: address_book.owner_id,
|
||||
description: update.description.or(address_book.description),
|
||||
color: update.color.or(address_book.color),
|
||||
is_public: update.is_public.unwrap_or(address_book.is_public),
|
||||
created_at: address_book.created_at,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
update.name.unwrap_or_else(|| address_book.name().to_string()),
|
||||
address_book.owner_id().to_string(),
|
||||
update.description.or_else(|| address_book.description().map(|s| s.to_string())),
|
||||
update.color.or_else(|| address_book.color().map(|s| s.to_string())),
|
||||
update.is_public.unwrap_or(address_book.is_public()),
|
||||
*address_book.created_at(),
|
||||
Utc::now(),
|
||||
);
|
||||
|
||||
let result = self.address_book_repository.update_address_book(updated_address_book).await?;
|
||||
Ok(AddressBookDto::from(result))
|
||||
@@ -285,7 +279,7 @@ impl AddressBookUseCase for ContactService {
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
||||
|
||||
if address_book.owner_id != user_id {
|
||||
if address_book.owner_id() != user_id {
|
||||
return Err(DomainError::unauthorized("Only the owner can delete an address book"));
|
||||
}
|
||||
|
||||
@@ -315,16 +309,16 @@ impl AddressBookUseCase for ContactService {
|
||||
let mut address_book_map = std::collections::HashMap::new();
|
||||
|
||||
for address_book in owned_address_books {
|
||||
address_book_map.insert(address_book.id, address_book);
|
||||
address_book_map.insert(*address_book.id(), address_book);
|
||||
}
|
||||
|
||||
for address_book in shared_address_books {
|
||||
address_book_map.insert(address_book.id, address_book);
|
||||
address_book_map.insert(*address_book.id(), address_book);
|
||||
}
|
||||
|
||||
for address_book in public_address_books {
|
||||
if address_book.owner_id != user_id && !address_book_map.contains_key(&address_book.id) {
|
||||
address_book_map.insert(address_book.id, address_book);
|
||||
if address_book.owner_id() != user_id && !address_book_map.contains_key(address_book.id()) {
|
||||
address_book_map.insert(*address_book.id(), address_book);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +345,7 @@ impl AddressBookUseCase for ContactService {
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
||||
|
||||
if address_book.owner_id != user_id {
|
||||
if address_book.owner_id() != user_id {
|
||||
return Err(DomainError::unauthorized("Only the owner can share an address book"));
|
||||
}
|
||||
|
||||
@@ -373,7 +367,7 @@ impl AddressBookUseCase for ContactService {
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
||||
|
||||
if address_book.owner_id != user_id {
|
||||
if address_book.owner_id() != user_id {
|
||||
return Err(DomainError::unauthorized("Only the owner can unshare an address book"));
|
||||
}
|
||||
|
||||
@@ -390,7 +384,7 @@ impl AddressBookUseCase for ContactService {
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
|
||||
|
||||
if address_book.owner_id != user_id {
|
||||
if address_book.owner_id() != user_id {
|
||||
return Err(DomainError::unauthorized("Only the owner can view address book shares"));
|
||||
}
|
||||
|
||||
@@ -408,10 +402,6 @@ impl ContactUseCase for ContactService {
|
||||
// Check if user has write access to the address book
|
||||
self.check_address_book_write_access(&address_book_id, &dto.user_id).await?;
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
let uid = format!("{}@oxicloud", id);
|
||||
|
||||
// Convert DTOs to domain entities
|
||||
let email: Vec<Email> = dto.email.into_iter()
|
||||
.map(|e| Email {
|
||||
@@ -441,33 +431,28 @@ impl ContactUseCase for ContactService {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let contact = Contact {
|
||||
id,
|
||||
let mut contact = Contact::new(
|
||||
address_book_id,
|
||||
uid,
|
||||
full_name: dto.full_name,
|
||||
first_name: dto.first_name,
|
||||
last_name: dto.last_name,
|
||||
nickname: dto.nickname,
|
||||
dto.full_name,
|
||||
dto.first_name,
|
||||
dto.last_name,
|
||||
dto.nickname,
|
||||
email,
|
||||
phone,
|
||||
address,
|
||||
organization: dto.organization,
|
||||
title: dto.title,
|
||||
notes: dto.notes,
|
||||
photo_url: dto.photo_url,
|
||||
birthday: dto.birthday,
|
||||
anniversary: dto.anniversary,
|
||||
vcard: String::new(), // Will be generated after creation
|
||||
etag: Uuid::new_v4().to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
dto.organization,
|
||||
dto.title,
|
||||
dto.notes,
|
||||
dto.photo_url,
|
||||
dto.birthday,
|
||||
dto.anniversary,
|
||||
String::new(), // Will be generated after creation
|
||||
);
|
||||
|
||||
// Generate vCard data
|
||||
let vcard = self.generate_vcard(&contact);
|
||||
let mut contact_with_vcard = contact;
|
||||
contact_with_vcard.vcard = vcard;
|
||||
contact.set_vcard(vcard);
|
||||
let contact_with_vcard = contact;
|
||||
|
||||
// Create the contact
|
||||
let created_contact = self.contact_repository.create_contact(contact_with_vcard).await?;
|
||||
@@ -485,17 +470,12 @@ impl ContactUseCase for ContactService {
|
||||
let mut contact = self.parse_vcard(&dto.vcard)?;
|
||||
|
||||
// Set address book ID
|
||||
contact.address_book_id = address_book_id;
|
||||
|
||||
// Generate a new ID if needed
|
||||
if contact.id == Uuid::nil() {
|
||||
contact.id = Uuid::new_v4();
|
||||
}
|
||||
contact.set_address_book_id(address_book_id);
|
||||
|
||||
// The contact was created with Contact::default() which generates a new ID
|
||||
// Set creation and update timestamps
|
||||
let now = Utc::now();
|
||||
contact.created_at = now;
|
||||
contact.updated_at = now;
|
||||
contact.set_updated_at(now);
|
||||
|
||||
// Create the contact
|
||||
let created_contact = self.contact_repository.create_contact(contact).await?;
|
||||
@@ -512,7 +492,10 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.check_address_book_write_access(&contact.address_book_id, &update.user_id).await?;
|
||||
self.check_address_book_write_access(contact.address_book_id(), &update.user_id).await?;
|
||||
|
||||
// Destructure contact into owned parts for updates
|
||||
let parts = contact.into_parts();
|
||||
|
||||
// Convert DTO fields to domain entities
|
||||
let email = if let Some(email_dtos) = update.email {
|
||||
@@ -524,7 +507,7 @@ impl ContactUseCase for ContactService {
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
contact.email
|
||||
parts.email
|
||||
};
|
||||
|
||||
let phone = if let Some(phone_dtos) = update.phone {
|
||||
@@ -536,7 +519,7 @@ impl ContactUseCase for ContactService {
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
contact.phone
|
||||
parts.phone
|
||||
};
|
||||
|
||||
let address = if let Some(address_dtos) = update.address {
|
||||
@@ -552,37 +535,37 @@ impl ContactUseCase for ContactService {
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
contact.address
|
||||
parts.address
|
||||
};
|
||||
|
||||
// Update the contact object
|
||||
let updated_contact = Contact {
|
||||
let mut updated_contact = Contact::from_raw(
|
||||
id,
|
||||
address_book_id: contact.address_book_id,
|
||||
uid: contact.uid,
|
||||
full_name: update.full_name.or(contact.full_name),
|
||||
first_name: update.first_name.or(contact.first_name),
|
||||
last_name: update.last_name.or(contact.last_name),
|
||||
nickname: update.nickname.or(contact.nickname),
|
||||
parts.address_book_id,
|
||||
parts.uid,
|
||||
update.full_name.or(parts.full_name),
|
||||
update.first_name.or(parts.first_name),
|
||||
update.last_name.or(parts.last_name),
|
||||
update.nickname.or(parts.nickname),
|
||||
email,
|
||||
phone,
|
||||
address,
|
||||
organization: update.organization.or(contact.organization),
|
||||
title: update.title.or(contact.title),
|
||||
notes: update.notes.or(contact.notes),
|
||||
photo_url: update.photo_url.or(contact.photo_url),
|
||||
birthday: update.birthday.or(contact.birthday),
|
||||
anniversary: update.anniversary.or(contact.anniversary),
|
||||
vcard: contact.vcard, // Will be regenerated
|
||||
etag: Uuid::new_v4().to_string(), // Generate new ETag
|
||||
created_at: contact.created_at,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
update.organization.or(parts.organization),
|
||||
update.title.or(parts.title),
|
||||
update.notes.or(parts.notes),
|
||||
update.photo_url.or(parts.photo_url),
|
||||
update.birthday.or(parts.birthday),
|
||||
update.anniversary.or(parts.anniversary),
|
||||
parts.vcard, // Will be regenerated
|
||||
Uuid::new_v4().to_string(), // Generate new ETag
|
||||
parts.created_at,
|
||||
Utc::now(),
|
||||
);
|
||||
|
||||
// Generate new vCard data
|
||||
let vcard = self.generate_vcard(&updated_contact);
|
||||
let mut contact_with_vcard = updated_contact;
|
||||
contact_with_vcard.vcard = vcard;
|
||||
updated_contact.set_vcard(vcard);
|
||||
let contact_with_vcard = updated_contact;
|
||||
|
||||
// Update the contact
|
||||
let result = self.contact_repository.update_contact(contact_with_vcard).await?;
|
||||
@@ -599,7 +582,7 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.check_address_book_write_access(&contact.address_book_id, user_id).await?;
|
||||
self.check_address_book_write_access(contact.address_book_id(), user_id).await?;
|
||||
|
||||
// Delete the contact
|
||||
self.contact_repository.delete_contact(&id).await?;
|
||||
@@ -616,7 +599,7 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||
|
||||
// Check if user has access to the address book
|
||||
self.check_address_book_access(&contact.address_book_id, user_id).await?;
|
||||
self.check_address_book_access(contact.address_book_id(), user_id).await?;
|
||||
|
||||
Ok(ContactDto::from(contact))
|
||||
}
|
||||
@@ -656,16 +639,10 @@ impl ContactUseCase for ContactService {
|
||||
// Check if user has write access to the address book
|
||||
self.check_address_book_write_access(&address_book_id, &dto.user_id).await?;
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
|
||||
let group = ContactGroup {
|
||||
id,
|
||||
let group = ContactGroup::new(
|
||||
address_book_id,
|
||||
name: dto.name,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
dto.name,
|
||||
);
|
||||
|
||||
let created_group = self.contact_group_repository.create_group(group).await?;
|
||||
Ok(ContactGroupDto::from(created_group))
|
||||
@@ -681,16 +658,16 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.check_address_book_write_access(&group.address_book_id, &update.user_id).await?;
|
||||
self.check_address_book_write_access(group.address_book_id(), &update.user_id).await?;
|
||||
|
||||
// Update the group
|
||||
let updated_group = ContactGroup {
|
||||
let updated_group = ContactGroup::from_raw(
|
||||
id,
|
||||
address_book_id: group.address_book_id,
|
||||
name: update.name,
|
||||
created_at: group.created_at,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
*group.address_book_id(),
|
||||
update.name,
|
||||
*group.created_at(),
|
||||
Utc::now(),
|
||||
);
|
||||
|
||||
let result = self.contact_group_repository.update_group(updated_group).await?;
|
||||
Ok(ContactGroupDto::from(result))
|
||||
@@ -706,7 +683,7 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.check_address_book_write_access(&group.address_book_id, user_id).await?;
|
||||
self.check_address_book_write_access(group.address_book_id(), user_id).await?;
|
||||
|
||||
// Delete the group
|
||||
self.contact_group_repository.delete_group(&id).await?;
|
||||
@@ -723,7 +700,7 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||
|
||||
// Check if user has access to the address book
|
||||
self.check_address_book_access(&group.address_book_id, user_id).await?;
|
||||
self.check_address_book_access(group.address_book_id(), user_id).await?;
|
||||
|
||||
// Get the number of contacts in the group
|
||||
let contacts = self.contact_group_repository.get_contacts_in_group(&id).await?;
|
||||
@@ -761,7 +738,7 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.check_address_book_write_access(&group.address_book_id, user_id).await?;
|
||||
self.check_address_book_write_access(group.address_book_id(), user_id).await?;
|
||||
|
||||
// Add contact to group
|
||||
self.contact_group_repository.add_contact_to_group(&group_id, &contact_id).await?;
|
||||
@@ -781,7 +758,7 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.check_address_book_write_access(&group.address_book_id, user_id).await?;
|
||||
self.check_address_book_write_access(group.address_book_id(), user_id).await?;
|
||||
|
||||
// Remove contact from group
|
||||
self.contact_group_repository.remove_contact_from_group(&group_id, &contact_id).await?;
|
||||
@@ -798,7 +775,7 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||
|
||||
// Check if user has access to the address book
|
||||
self.check_address_book_access(&group.address_book_id, user_id).await?;
|
||||
self.check_address_book_access(group.address_book_id(), user_id).await?;
|
||||
|
||||
// Get contacts in group
|
||||
let contacts = self.contact_group_repository.get_contacts_in_group(&id).await?;
|
||||
@@ -817,7 +794,7 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||
|
||||
// Check if user has access to the address book
|
||||
self.check_address_book_access(&contact.address_book_id, user_id).await?;
|
||||
self.check_address_book_access(contact.address_book_id(), user_id).await?;
|
||||
|
||||
// Get groups for contact
|
||||
let groups = self.contact_group_repository.get_groups_for_contact(&id).await?;
|
||||
@@ -836,10 +813,10 @@ impl ContactUseCase for ContactService {
|
||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||
|
||||
// Check if user has access to the address book
|
||||
self.check_address_book_access(&contact.address_book_id, user_id).await?;
|
||||
self.check_address_book_access(contact.address_book_id(), user_id).await?;
|
||||
|
||||
// Return the vCard data
|
||||
Ok(contact.vcard)
|
||||
Ok(contact.vcard().to_string())
|
||||
}
|
||||
|
||||
async fn get_contacts_as_vcards(&self, address_book_id: &str, user_id: &str) -> Result<Vec<(String, String)>, DomainError> {
|
||||
@@ -854,7 +831,7 @@ impl ContactUseCase for ContactService {
|
||||
|
||||
// Convert to Vec<(id, vcard)>
|
||||
let vcards = contacts.into_iter()
|
||||
.map(|contact| (contact.id.to_string(), contact.vcard))
|
||||
.map(|contact| (contact.id().to_string(), contact.vcard().to_string()))
|
||||
.collect();
|
||||
|
||||
Ok(vcards)
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
use tracing::{info, error};
|
||||
use uuid::Uuid;
|
||||
use tracing::info;
|
||||
use crate::common::errors::{Result, DomainError, ErrorKind};
|
||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||
use crate::application::ports::favorites_ports::{FavoritesUseCase, FavoritesRepositoryPort};
|
||||
use crate::application::dtos::favorites_dto::FavoriteItemDto;
|
||||
|
||||
/// Implementation of the FavoritesUseCase for managing user favorites
|
||||
/// Implementation of the FavoritesUseCase for managing user favorites.
|
||||
///
|
||||
/// Depends on `FavoritesRepositoryPort` (outbound port) instead of
|
||||
/// accessing the database directly, following hexagonal architecture.
|
||||
pub struct FavoritesService {
|
||||
db_pool: Arc<PgPool>,
|
||||
repo: Arc<dyn FavoritesRepositoryPort>,
|
||||
}
|
||||
|
||||
impl FavoritesService {
|
||||
/// Create a new FavoritesService with the given database pool
|
||||
pub fn new(db_pool: Arc<PgPool>) -> Self {
|
||||
Self { db_pool }
|
||||
/// Create a new FavoritesService with the given repository port
|
||||
pub fn new(repo: Arc<dyn FavoritesRepositoryPort>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,168 +25,43 @@ impl FavoritesUseCase for FavoritesService {
|
||||
/// Get all favorites for a user
|
||||
async fn get_favorites(&self, user_id: &str) -> Result<Vec<FavoriteItemDto>> {
|
||||
info!("Getting favorites for user: {}", user_id);
|
||||
|
||||
// Parse user ID as UUID
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Execute raw query to avoid sqlx macros issues
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id::TEXT as "id",
|
||||
user_id::TEXT as "user_id",
|
||||
item_id as "item_id",
|
||||
item_type as "item_type",
|
||||
created_at as "created_at"
|
||||
FROM auth.user_favorites
|
||||
WHERE user_id = $1::TEXT
|
||||
ORDER BY created_at DESC
|
||||
"#
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.fetch_all(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Database error fetching favorites: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Favorites",
|
||||
format!("Failed to fetch favorites: {}", e)
|
||||
)
|
||||
})?;
|
||||
|
||||
// Map rows to DTOs
|
||||
let mut favorites = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
favorites.push(FavoriteItemDto {
|
||||
id: row.get("id"),
|
||||
user_id: row.get("user_id"),
|
||||
item_id: row.get("item_id"),
|
||||
item_type: row.get("item_type"),
|
||||
created_at: row.get("created_at"),
|
||||
});
|
||||
}
|
||||
|
||||
let favorites = self.repo.get_favorites(user_id).await?;
|
||||
info!("Retrieved {} favorites for user {}", favorites.len(), user_id);
|
||||
Ok(favorites)
|
||||
}
|
||||
|
||||
|
||||
/// Add an item to user's favorites
|
||||
async fn add_to_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> {
|
||||
info!("Adding {} '{}' to favorites for user {}", item_type, item_id, user_id);
|
||||
|
||||
// Validate item_type
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Favorites",
|
||||
"Item type must be 'file' or 'folder'"
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
|
||||
// Parse user ID as UUID
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Execute raw query to avoid sqlx macros issues
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.user_favorites (user_id, item_id, item_type)
|
||||
VALUES ($1::TEXT, $2, $3)
|
||||
ON CONFLICT (user_id, item_id, item_type) DO NOTHING
|
||||
"#
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.bind(item_id)
|
||||
.bind(item_type)
|
||||
.execute(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Database error adding favorite: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Favorites",
|
||||
format!("Failed to add to favorites: {}", e)
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
self.repo.add_favorite(user_id, item_id, item_type).await?;
|
||||
info!("Successfully added {} '{}' to favorites for user {}", item_type, item_id, user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Remove an item from user's favorites
|
||||
async fn remove_from_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool> {
|
||||
info!("Removing {} '{}' from favorites for user {}", item_type, item_id, user_id);
|
||||
|
||||
// Parse user ID as UUID
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Execute raw query to avoid sqlx macros issues
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.user_favorites
|
||||
WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3
|
||||
"#
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.bind(item_id)
|
||||
.bind(item_type)
|
||||
.execute(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Database error removing favorite: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Favorites",
|
||||
format!("Failed to remove from favorites: {}", e)
|
||||
)
|
||||
})?;
|
||||
|
||||
let removed = result.rows_affected() > 0;
|
||||
let removed = self.repo.remove_favorite(user_id, item_id, item_type).await?;
|
||||
info!(
|
||||
"{} {} '{}' from favorites for user {}",
|
||||
"{} {} '{}' from favorites for user {}",
|
||||
if removed { "Successfully removed" } else { "Did not find" },
|
||||
item_type,
|
||||
item_id,
|
||||
user_id
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
|
||||
/// Check if an item is in user's favorites
|
||||
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool> {
|
||||
info!("Checking if {} '{}' is favorite for user {}", item_type, item_id, user_id);
|
||||
|
||||
// Parse user ID as UUID
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Execute raw query to avoid sqlx macros issues
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM auth.user_favorites
|
||||
WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3
|
||||
) AS "is_favorite"
|
||||
"#
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.bind(item_id)
|
||||
.bind(item_type)
|
||||
.fetch_one(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Database error checking favorite status: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Favorites",
|
||||
format!("Failed to check favorite status: {}", e)
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get the boolean value from the row
|
||||
let is_favorite: bool = row.try_get("is_favorite")
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(is_favorite)
|
||||
self.repo.is_favorite(user_id, item_id, item_type).await
|
||||
}
|
||||
}
|
||||
@@ -3,39 +3,155 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::application::ports::storage_ports::FileWritePort;
|
||||
use crate::application::ports::storage_ports::{FileWritePort, FileReadPort};
|
||||
use crate::application::ports::dedup_ports::DedupPort;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::errors::DomainError;
|
||||
use tracing::{debug, info, warn, error};
|
||||
|
||||
/// Service for file management operations
|
||||
/// Service for file management operations (move, delete).
|
||||
///
|
||||
/// The `delete_with_cleanup` method internalises:
|
||||
/// 1. Content-hash computation for dedup tracking
|
||||
/// 2. Trash-first soft-delete
|
||||
/// 3. Fallback to permanent delete
|
||||
/// 4. Dedup reference-count decrement
|
||||
pub struct FileManagementService {
|
||||
file_repository: Arc<dyn FileWritePort>,
|
||||
file_read: Option<Arc<dyn FileReadPort>>,
|
||||
trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
dedup_service: Option<Arc<dyn DedupPort>>,
|
||||
}
|
||||
|
||||
impl FileManagementService {
|
||||
/// Creates a new file management service
|
||||
/// Backward-compatible constructor (no trash, no dedup).
|
||||
pub fn new(file_repository: Arc<dyn FileWritePort>) -> Self {
|
||||
Self { file_repository }
|
||||
Self {
|
||||
file_repository,
|
||||
file_read: None,
|
||||
trash_service: None,
|
||||
dedup_service: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Full constructor with trash + dedup ports.
|
||||
pub fn new_full(
|
||||
file_repository: Arc<dyn FileWritePort>,
|
||||
file_read: Arc<dyn FileReadPort>,
|
||||
trash_service: Option<Arc<dyn TrashUseCase>>,
|
||||
dedup_service: Arc<dyn DedupPort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_repository,
|
||||
file_read: Some(file_read),
|
||||
trash_service,
|
||||
dedup_service: Some(dedup_service),
|
||||
}
|
||||
}
|
||||
|
||||
/// Setter for late-bound trash service.
|
||||
pub fn with_trash_service(mut self, trash_service: Arc<dyn TrashUseCase>) -> Self {
|
||||
self.trash_service = Some(trash_service);
|
||||
self
|
||||
}
|
||||
|
||||
// ── private helpers ──────────────────────────────────────────
|
||||
|
||||
/// Compute the content hash for dedup tracking. Returns `None` on failure.
|
||||
async fn compute_content_hash(&self, id: &str) -> Option<String> {
|
||||
let dedup = self.dedup_service.as_ref()?;
|
||||
let file_read = self.file_read.as_ref()?;
|
||||
match file_read.get_file_content(id).await {
|
||||
Ok(content) => {
|
||||
let hash = dedup.hash_bytes(&content);
|
||||
debug!("🔗 DEDUP: File {} has content hash: {}", id, &hash[..12]);
|
||||
Some(hash)
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Could not read file content for dedup: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrement dedup reference count; log result.
|
||||
async fn decrement_dedup_ref(&self, hash: &str) {
|
||||
let Some(dedup) = &self.dedup_service else { return };
|
||||
match dedup.remove_reference(hash).await {
|
||||
Ok(true) => info!("🗑️ DEDUP: Blob {} deleted (no more references)", &hash[..12]),
|
||||
Ok(false) => debug!("🔗 DEDUP: Reference removed from blob {}", &hash[..12]),
|
||||
Err(e) => warn!("⚠️ DEDUP: Failed to decrement reference: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileManagementUseCase for FileManagementService {
|
||||
async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> Result<FileDto, DomainError> {
|
||||
tracing::info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id);
|
||||
|
||||
let moved_file = self.file_repository.move_file(file_id, folder_id).await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error moving file (ID: {}): {}", file_id, e);
|
||||
e
|
||||
})?;
|
||||
|
||||
tracing::info!("File moved successfully: {} (ID: {}) to folder: {:?}",
|
||||
moved_file.name(), moved_file.id(), moved_file.folder_id());
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
folder_id: Option<String>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id);
|
||||
|
||||
let moved_file = self.file_repository.move_file(file_id, folder_id).await.map_err(|e| {
|
||||
error!("Error moving file (ID: {}): {}", file_id, e);
|
||||
e
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"File moved successfully: {} (ID: {}) to folder: {:?}",
|
||||
moved_file.name(),
|
||||
moved_file.id(),
|
||||
moved_file.folder_id()
|
||||
);
|
||||
|
||||
Ok(FileDto::from(moved_file))
|
||||
}
|
||||
|
||||
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
|
||||
self.file_repository.delete_file(id).await
|
||||
}
|
||||
|
||||
/// Smart delete: trash-first with dedup reference cleanup.
|
||||
async fn delete_with_cleanup(
|
||||
&self,
|
||||
id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<bool, DomainError> {
|
||||
// Step 1: Compute content hash for dedup tracking
|
||||
let content_hash = self.compute_content_hash(id).await;
|
||||
|
||||
// Step 2: Try trash (soft delete)
|
||||
if let Some(trash) = &self.trash_service {
|
||||
info!("Moving file to trash: {}", id);
|
||||
match trash.move_to_trash(id, "file", user_id).await {
|
||||
Ok(_) => {
|
||||
info!("File successfully moved to trash: {}", id);
|
||||
if let Some(hash) = &content_hash {
|
||||
self.decrement_dedup_ref(hash).await;
|
||||
}
|
||||
return Ok(true); // trashed
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Could not move file to trash: {:?}", err);
|
||||
warn!("Falling back to permanent delete");
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Trash service not available, using permanent delete");
|
||||
}
|
||||
|
||||
// Step 3: Permanent delete
|
||||
warn!("Permanently deleting file: {}", id);
|
||||
self.file_repository.delete_file(id).await?;
|
||||
info!("File permanently deleted: {}", id);
|
||||
|
||||
if let Some(hash) = &content_hash {
|
||||
self.decrement_dedup_ref(hash).await;
|
||||
}
|
||||
|
||||
Ok(false) // permanently deleted
|
||||
}
|
||||
}
|
||||
@@ -4,39 +4,258 @@ use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent};
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::ports::cache_ports::{WriteBehindCachePort, ContentCachePort};
|
||||
use crate::application::ports::transcode_ports::{ImageTranscodePort, OutputFormat};
|
||||
use crate::common::errors::DomainError;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Threshold below which files are served from RAM cache (10 MB).
|
||||
const CACHE_THRESHOLD: u64 = 10 * 1024 * 1024;
|
||||
/// Threshold above which mmap is used instead of streaming (100 MB).
|
||||
const MMAP_THRESHOLD: u64 = 100 * 1024 * 1024;
|
||||
|
||||
/// Servicio para operaciones de recuperación de archivos
|
||||
///
|
||||
/// Implements a multi-tier download strategy:
|
||||
/// - Tier 0: Write-behind cache (just-uploaded files still in RAM)
|
||||
/// - Tier 1: Hot cache + optional WebP transcoding (<10 MB)
|
||||
/// - Tier 2: Memory-mapped I/O (10–100 MB)
|
||||
/// - Tier 3: Streaming (≥100 MB)
|
||||
pub struct FileRetrievalService {
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
file_read: Arc<dyn FileReadPort>,
|
||||
write_behind: Option<Arc<dyn WriteBehindCachePort>>,
|
||||
content_cache: Option<Arc<dyn ContentCachePort>>,
|
||||
transcode: Option<Arc<dyn ImageTranscodePort>>,
|
||||
}
|
||||
|
||||
impl FileRetrievalService {
|
||||
/// Crea un nuevo servicio de recuperación de archivos
|
||||
/// Backward-compatible constructor (simple pass-through).
|
||||
pub fn new(file_repository: Arc<dyn FileReadPort>) -> Self {
|
||||
Self { file_repository }
|
||||
Self {
|
||||
file_read: file_repository,
|
||||
write_behind: None,
|
||||
content_cache: None,
|
||||
transcode: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Full constructor with all infrastructure ports.
|
||||
pub fn new_full(
|
||||
file_read: Arc<dyn FileReadPort>,
|
||||
write_behind: Arc<dyn WriteBehindCachePort>,
|
||||
content_cache: Arc<dyn ContentCachePort>,
|
||||
transcode: Arc<dyn ImageTranscodePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_read,
|
||||
write_behind: Some(write_behind),
|
||||
content_cache: Some(content_cache),
|
||||
transcode: Some(transcode),
|
||||
}
|
||||
}
|
||||
|
||||
// ── private helpers ──────────────────────────────────────────
|
||||
|
||||
/// Try to transcode image content to WebP and return transcoded variant.
|
||||
async fn try_transcode(
|
||||
&self,
|
||||
id: &str,
|
||||
content: &Bytes,
|
||||
mime: &str,
|
||||
file_size: u64,
|
||||
accept_webp: bool,
|
||||
) -> Option<(Bytes, String)> {
|
||||
if !accept_webp {
|
||||
return None;
|
||||
}
|
||||
let transcode = self.transcode.as_ref()?;
|
||||
if !transcode.should_transcode(mime, file_size) {
|
||||
return None;
|
||||
}
|
||||
let format = OutputFormat::WebP;
|
||||
match transcode.get_transcoded(id, content, mime, format).await {
|
||||
Ok((transcoded, webp_mime, true)) => {
|
||||
debug!(
|
||||
"🖼️ WebP transcode: {} -> {} bytes ({:.0}% smaller)",
|
||||
content.len(),
|
||||
transcoded.len(),
|
||||
(1.0 - transcoded.len() as f64 / content.len().max(1) as f64) * 100.0
|
||||
);
|
||||
Some((transcoded, webp_mime))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileRetrievalUseCase for FileRetrievalService {
|
||||
async fn get_file(&self, id: &str) -> Result<FileDto, DomainError> {
|
||||
let file = self.file_repository.get_file(id).await?;
|
||||
let file = self.file_read.get_file(id).await?;
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
|
||||
// Normalize the path (remove leading/trailing slashes)
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
|
||||
// List all files and find the one with matching path
|
||||
let all_files = self.list_files(None).await?;
|
||||
|
||||
for file in all_files {
|
||||
let file_path = file.path.trim_start_matches('/').trim_end_matches('/');
|
||||
if file_path == path
|
||||
|| file_path.ends_with(&format!("/{}", path))
|
||||
|| path.ends_with(&format!("/{}", file_path))
|
||||
{
|
||||
return Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
Err(DomainError::not_found("File", format!("not found at path: {}", path)))
|
||||
}
|
||||
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
|
||||
let files = self.file_repository.list_files(folder_id).await?;
|
||||
let files = self.file_read.list_files(folder_id).await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
self.file_repository.get_file_content(id).await
|
||||
self.file_read.get_file_content(id).await
|
||||
}
|
||||
|
||||
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.file_repository.get_file_stream(id).await
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.file_read.get_file_stream(id).await
|
||||
}
|
||||
|
||||
/// Multi-tier optimized download.
|
||||
async fn get_file_optimized(
|
||||
&self,
|
||||
id: &str,
|
||||
accept_webp: bool,
|
||||
prefer_original: bool,
|
||||
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
|
||||
let file = self.file_read.get_file(id).await?;
|
||||
let dto = FileDto::from(file);
|
||||
let mime_type = dto.mime_type.clone();
|
||||
let file_size = dto.size;
|
||||
let file_name = dto.name.clone();
|
||||
let modified_at = dto.modified_at.clone();
|
||||
let do_transcode = accept_webp && !prefer_original;
|
||||
|
||||
// ── Tier 0: Write-behind cache ───────────────────────
|
||||
if let Some(wb) = &self.write_behind {
|
||||
if let Some(pending) = wb.get_pending(id).await {
|
||||
debug!("⚡ TIER 0 Write-Behind HIT: {} ({} bytes)", file_name, pending.len());
|
||||
let (data, mime) = if do_transcode {
|
||||
if let Some((t, m)) = self.try_transcode(id, &pending, &mime_type, file_size, true).await {
|
||||
(t, m)
|
||||
} else {
|
||||
(pending, mime_type.clone())
|
||||
}
|
||||
} else {
|
||||
(pending, mime_type.clone())
|
||||
};
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
data,
|
||||
mime_type: mime,
|
||||
was_transcoded: do_transcode,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
|
||||
if file_size < CACHE_THRESHOLD {
|
||||
// Check content cache first
|
||||
if let Some(cache) = &self.content_cache {
|
||||
if let Some((cached, _etag, _ct)) = cache.get(id).await {
|
||||
debug!("🔥 TIER 1 Cache HIT: {} ({} bytes)", file_name, cached.len());
|
||||
if do_transcode {
|
||||
if let Some((t, m)) = self.try_transcode(id, &cached, &mime_type, file_size, true).await {
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
data: t,
|
||||
mime_type: m,
|
||||
was_transcoded: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
data: cached,
|
||||
mime_type: mime_type.clone(),
|
||||
was_transcoded: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss – load from disk
|
||||
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", file_name);
|
||||
let content = self.file_read.get_file_content(id).await?;
|
||||
let content_bytes = Bytes::from(content);
|
||||
|
||||
// Store in cache
|
||||
if let Some(cache) = &self.content_cache {
|
||||
let etag = format!("\"{}-{}\"", id, modified_at);
|
||||
cache.put(id.to_string(), content_bytes.clone(), etag, mime_type.clone()).await;
|
||||
}
|
||||
|
||||
if do_transcode {
|
||||
if let Some((t, m)) = self.try_transcode(id, &content_bytes, &mime_type, file_size, true).await {
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
data: t,
|
||||
mime_type: m,
|
||||
was_transcoded: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return Ok((dto, OptimizedFileContent::Bytes {
|
||||
data: content_bytes,
|
||||
mime_type: mime_type.clone(),
|
||||
was_transcoded: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Tier 2: MMAP (10–100 MB) ────────────────────────
|
||||
if file_size < MMAP_THRESHOLD {
|
||||
info!("🗺️ TIER 2 MMAP: {} ({} MB)", file_name, file_size / (1024 * 1024));
|
||||
match self.file_read.get_file_mmap(id).await {
|
||||
Ok(mmap_content) => {
|
||||
return Ok((dto, OptimizedFileContent::Mmap(mmap_content)));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("MMAP failed, falling back to streaming: {}", e);
|
||||
// fall through to streaming
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier 3: Streaming (≥100 MB) ─────────────────────
|
||||
info!("📡 TIER 3 STREAMING: {} ({} MB)", file_name, file_size / (1024 * 1024));
|
||||
match self.file_read.get_file_stream(id).await {
|
||||
Ok(stream) => Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream)))),
|
||||
Err(e) => {
|
||||
warn!("Streaming failed, last-resort content load: {}", e);
|
||||
let content = self.file_read.get_file_content(id).await?;
|
||||
Ok((dto, OptimizedFileContent::Bytes {
|
||||
data: Bytes::from(content),
|
||||
mime_type: mime_type.clone(),
|
||||
was_transcoded: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Range-based streaming for HTTP Range Requests.
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.file_read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::domain::repositories::file_repository::FileRepositoryError;
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::inbound::FileUseCase;
|
||||
use crate::application::ports::outbound::FileStoragePort;
|
||||
use crate::common::errors::DomainError;
|
||||
use futures::Stream;
|
||||
use bytes::Bytes;
|
||||
|
||||
/**
|
||||
* File service-specific error types.
|
||||
*
|
||||
* This enum represents the application-level errors that can occur during file operations,
|
||||
* providing a translation layer between domain/infrastructure errors and application errors.
|
||||
*/
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FileServiceError {
|
||||
/// Returned when a requested file cannot be found
|
||||
#[error("File not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// Returned when a file operation conflicts with existing files
|
||||
#[error("File already exists: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// Returned when file access fails due to permissions or I/O issues
|
||||
#[error("File access error: {0}")]
|
||||
AccessError(String),
|
||||
|
||||
/// Returned when a file path is invalid
|
||||
#[error("Invalid file path: {0}")]
|
||||
InvalidPath(String),
|
||||
|
||||
/// Generic internal error for unexpected failures
|
||||
#[error("Internal error: {0}")]
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts repository errors to service errors.
|
||||
*
|
||||
* This implementation maps low-level repository errors to more
|
||||
* application-appropriate error types, abstracting away the implementation details.
|
||||
*/
|
||||
impl From<FileRepositoryError> for FileServiceError {
|
||||
fn from(err: FileRepositoryError) -> Self {
|
||||
match err {
|
||||
FileRepositoryError::NotFound(id) => FileServiceError::NotFound(id),
|
||||
FileRepositoryError::AlreadyExists(path) => FileServiceError::Conflict(path),
|
||||
FileRepositoryError::InvalidPath(path) => FileServiceError::InvalidPath(path),
|
||||
FileRepositoryError::IoError(e) => FileServiceError::AccessError(e.to_string()),
|
||||
FileRepositoryError::Timeout(msg) => FileServiceError::AccessError(format!("Operation timed out: {}", msg)),
|
||||
_ => FileServiceError::InternalError(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts domain errors to service errors.
|
||||
*
|
||||
* This implementation ensures that general domain errors are properly translated
|
||||
* to file service-specific errors while preserving their semantic meaning.
|
||||
*/
|
||||
impl From<DomainError> for FileServiceError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
match err.kind {
|
||||
crate::common::errors::ErrorKind::NotFound => FileServiceError::NotFound(err.to_string()),
|
||||
crate::common::errors::ErrorKind::AlreadyExists => FileServiceError::Conflict(err.to_string()),
|
||||
crate::common::errors::ErrorKind::InvalidInput => FileServiceError::InvalidPath(err.to_string()),
|
||||
crate::common::errors::ErrorKind::AccessDenied => FileServiceError::AccessError(err.to_string()),
|
||||
_ => FileServiceError::InternalError(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts service errors to domain errors.
|
||||
*
|
||||
* This implementation allows service errors to be propagated up the call stack as
|
||||
* domain errors when crossing architectural boundaries.
|
||||
*/
|
||||
impl From<FileServiceError> for DomainError {
|
||||
fn from(err: FileServiceError) -> Self {
|
||||
match err {
|
||||
FileServiceError::NotFound(id) => DomainError::not_found("File", id),
|
||||
FileServiceError::Conflict(path) => DomainError::already_exists("File", path),
|
||||
FileServiceError::InvalidPath(path) => DomainError::validation_error(format!("Invalid path: {}", path)),
|
||||
FileServiceError::AccessError(msg) => DomainError::access_denied("File", msg),
|
||||
FileServiceError::InternalError(msg) => DomainError::internal_error("File", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for results of file service operations.
|
||||
*
|
||||
* Provides a convenient way to return either a successful value or a FileServiceError.
|
||||
*/
|
||||
pub type FileServiceResult<T> = Result<T, FileServiceError>;
|
||||
|
||||
/**
|
||||
* Service component for file operations in the application layer.
|
||||
*
|
||||
* The FileService implements the application use cases related to files by orchestrating
|
||||
* domain logic and infrastructure components. It acts as an adapter between the inbound
|
||||
* ports (interfaces) and outbound ports (repositories), translating between DTOs and
|
||||
* domain entities.
|
||||
*/
|
||||
pub struct FileService {
|
||||
/// Repository responsible for file storage operations
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
}
|
||||
|
||||
impl FileService {
|
||||
/// Creates a new file service
|
||||
pub fn new(file_repository: Arc<dyn FileStoragePort>) -> Self {
|
||||
Self { file_repository }
|
||||
}
|
||||
|
||||
/// Creates a stub implementation for testing and middleware
|
||||
pub fn new_stub() -> impl FileUseCase {
|
||||
struct FileServiceStub;
|
||||
|
||||
#[async_trait]
|
||||
impl FileUseCase for FileServiceStub {
|
||||
async fn upload_file(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_content: Vec<u8>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::empty())
|
||||
}
|
||||
|
||||
async fn get_file(&self, _id: &str) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::empty())
|
||||
}
|
||||
|
||||
async fn get_file_by_path(&self, _path: &str) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::empty())
|
||||
}
|
||||
|
||||
async fn create_file(&self, _parent_path: &str, _filename: &str, _content: &[u8], _content_type: &str) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::empty())
|
||||
}
|
||||
|
||||
async fn update_file(&self, _path: &str, _content: &[u8]) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn get_file_stream(&self, _id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
let empty_stream = futures::stream::empty();
|
||||
Ok(Box::new(empty_stream))
|
||||
}
|
||||
|
||||
async fn move_file(&self, _file_id: &str, _folder_id: Option<String>) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::empty())
|
||||
}
|
||||
}
|
||||
|
||||
FileServiceStub
|
||||
}
|
||||
|
||||
/// Uploads a new file from bytes
|
||||
pub async fn upload_file_from_bytes(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> FileServiceResult<FileDto>
|
||||
{
|
||||
let file = self.file_repository.save_file(name, folder_id, content_type, content).await
|
||||
.map_err(FileServiceError::from)?;
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
/// Uploads a file using streaming - writes directly to disk without memory accumulation
|
||||
///
|
||||
/// This is the preferred method for large file uploads:
|
||||
/// - Constant memory usage (~10MB) regardless of file size
|
||||
/// - Better handling of slow connections
|
||||
/// - Atomic writes with crash safety
|
||||
pub async fn upload_file_from_stream(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
stream: std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>>,
|
||||
) -> FileServiceResult<FileDto>
|
||||
{
|
||||
let file = self.file_repository.save_file_from_stream(name, folder_id, content_type, stream).await
|
||||
.map_err(FileServiceError::from)?;
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
/// Registers a file for write-behind upload (ZERO LATENCY)
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Creates file metadata and ID (~0.1ms)
|
||||
/// 2. Returns immediately WITHOUT writing content to disk
|
||||
/// 3. Caller must use WriteBehindCache to write content asynchronously
|
||||
///
|
||||
/// Returns: (FileDto, PathBuf) where PathBuf is where content should be written
|
||||
pub async fn register_file_deferred(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
size: u64,
|
||||
) -> FileServiceResult<(FileDto, std::path::PathBuf)>
|
||||
{
|
||||
let (file, path) = self.file_repository.register_file_deferred(name, folder_id, content_type, size).await
|
||||
.map_err(FileServiceError::from)?;
|
||||
Ok((FileDto::from(file), path))
|
||||
}
|
||||
|
||||
/// Gets a file by ID
|
||||
pub async fn get_file(&self, id: &str) -> FileServiceResult<FileDto> {
|
||||
let file = self.file_repository.get_file(id).await
|
||||
.map_err(FileServiceError::from)?;
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
/// Gets a file by path (needed for WebDAV)
|
||||
pub async fn get_file_by_path(&self, path: &str) -> FileServiceResult<FileDto> {
|
||||
// This is a simple implementation for WebDAV support
|
||||
// First, normalize the path (remove leading/trailing slashes)
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
|
||||
// List all files and find the one with matching path
|
||||
let all_files = self.list_files(None).await?;
|
||||
|
||||
for file in all_files {
|
||||
let file_path = file.path.trim_start_matches('/').trim_end_matches('/');
|
||||
if file_path == path || file_path.ends_with(&format!("/{}", path)) || path.ends_with(&format!("/{}", file_path)) {
|
||||
return Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
// If no file found, return an error
|
||||
Err(FileServiceError::NotFound(format!("File not found at path: {}", path)))
|
||||
}
|
||||
|
||||
/// Creates or updates a file at a specific path (needed for WebDAV)
|
||||
pub async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> FileServiceResult<FileDto> {
|
||||
// Get parent folder ID if parent path is not empty
|
||||
let parent_id = if !parent_path.is_empty() {
|
||||
match self.file_repository.get_parent_folder_id(parent_path).await {
|
||||
Ok(id) => Some(id),
|
||||
Err(_) => None // If parent doesn't exist, use root
|
||||
}
|
||||
} else {
|
||||
None // Root folder
|
||||
};
|
||||
|
||||
// Save the file with the provided filename and parent folder
|
||||
let file = self.file_repository.save_file(
|
||||
filename.to_string(),
|
||||
parent_id,
|
||||
content_type.to_string(),
|
||||
content.to_vec()
|
||||
).await.map_err(FileServiceError::from)?;
|
||||
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
/// Updates an existing file (needed for WebDAV)
|
||||
pub async fn update_file(&self, path: &str, content: &[u8]) -> FileServiceResult<()> {
|
||||
// First, try to get the file by path
|
||||
match self.get_file_by_path(path).await {
|
||||
Ok(file) => {
|
||||
// Update the file content
|
||||
self.file_repository.update_file_content(&file.id, content.to_vec())
|
||||
.await
|
||||
.map_err(FileServiceError::from)
|
||||
},
|
||||
Err(_) => {
|
||||
// If file doesn't exist, extract filename and parent path and create it
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
let (parent_path, filename) = if let Some(idx) = path.rfind('/') {
|
||||
(&path[..idx], &path[idx+1..])
|
||||
} else {
|
||||
("", path)
|
||||
};
|
||||
|
||||
// Create new file
|
||||
self.create_file(parent_path, filename, content, "application/octet-stream").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists files in a folder
|
||||
pub async fn list_files(&self, folder_id: Option<&str>) -> FileServiceResult<Vec<FileDto>> {
|
||||
let files = self.file_repository.list_files(folder_id).await
|
||||
.map_err(FileServiceError::from)?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
/// Deletes a file
|
||||
pub async fn delete_file(&self, id: &str) -> FileServiceResult<()> {
|
||||
self.file_repository.delete_file(id).await
|
||||
.map_err(FileServiceError::from)
|
||||
}
|
||||
|
||||
/// Gets file content as bytes - use for small files only
|
||||
pub async fn get_file_content(&self, id: &str) -> FileServiceResult<Vec<u8>> {
|
||||
self.file_repository.get_file_content(id).await
|
||||
.map_err(FileServiceError::from)
|
||||
}
|
||||
|
||||
/// Gets file content as stream - better for large files
|
||||
pub async fn get_file_stream(&self, id: &str) -> FileServiceResult<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
|
||||
self.file_repository.get_file_stream(id).await
|
||||
.map_err(FileServiceError::from)
|
||||
}
|
||||
|
||||
/// Gets a range of file content as stream - for HTTP Range Requests
|
||||
///
|
||||
/// Supports:
|
||||
/// - Video seeking
|
||||
/// - Resumable downloads
|
||||
/// - Parallel chunk downloads
|
||||
pub async fn get_file_range_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
start: u64,
|
||||
end: Option<u64>
|
||||
) -> FileServiceResult<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
|
||||
self.file_repository.get_file_range_stream(id, start, end).await
|
||||
.map_err(FileServiceError::from)
|
||||
}
|
||||
|
||||
/// Memory-maps a file for zero-copy access (optimal for 10-100MB files)
|
||||
///
|
||||
/// Uses kernel mmap for files where:
|
||||
/// - Full RAM cache would be wasteful
|
||||
/// - Streaming adds unnecessary chunking overhead
|
||||
pub async fn get_file_mmap(&self, id: &str) -> FileServiceResult<Bytes> {
|
||||
self.file_repository.get_file_mmap(id).await
|
||||
.map_err(FileServiceError::from)
|
||||
}
|
||||
|
||||
/// Moves a file to a new folder using filesystem operations directly
|
||||
pub async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> FileServiceResult<FileDto> {
|
||||
tracing::info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id);
|
||||
|
||||
// Use the efficient repository implementation that uses rename
|
||||
let moved_file = self.file_repository.move_file(file_id, folder_id).await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error moving file (ID: {}): {}", file_id, e);
|
||||
FileServiceError::from(e)
|
||||
})?;
|
||||
|
||||
tracing::info!("File moved successfully: {} (ID: {}) to folder: {:?}",
|
||||
moved_file.name(), moved_file.id(), moved_file.folder_id());
|
||||
|
||||
Ok(FileDto::from(moved_file))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileUseCase for FileService {
|
||||
async fn upload_file(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
FileService::upload_file_from_bytes(self, name, folder_id, content_type, content).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_file(&self, id: &str) -> Result<FileDto, DomainError> {
|
||||
FileService::get_file(self, id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
|
||||
FileService::get_file_by_path(self, path).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> Result<FileDto, DomainError> {
|
||||
FileService::create_file(self, parent_path, filename, content, content_type).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError> {
|
||||
FileService::update_file(self, path, content).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
|
||||
FileService::list_files(self, folder_id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
|
||||
FileService::delete_file(self, id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
FileService::get_file_content(self, id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
FileService::get_file_stream(self, id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> Result<FileDto, DomainError> {
|
||||
FileService::move_file(self, file_id, folder_id).await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,136 @@
|
||||
use std::sync::Arc;
|
||||
use std::pin::Pin;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::file_ports::FileUploadUseCase;
|
||||
use crate::application::ports::storage_ports::FileWritePort;
|
||||
use crate::application::ports::file_ports::{FileUploadUseCase, UploadStrategy};
|
||||
use crate::application::ports::storage_ports::{FileWritePort, FileReadPort};
|
||||
use crate::application::ports::cache_ports::WriteBehindCachePort;
|
||||
use crate::application::ports::dedup_ports::DedupPort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Threshold for using streaming upload (files >= 1MB use streaming)
|
||||
const STREAMING_UPLOAD_THRESHOLD: usize = 1 * 1024 * 1024;
|
||||
/// Threshold for write-behind cache (files < 256KB get instant response)
|
||||
const WRITE_BEHIND_THRESHOLD: usize = 256 * 1024;
|
||||
|
||||
/// Helper function to extract username from folder path string
|
||||
fn extract_username_from_path(path: &str) -> Option<String> {
|
||||
// Check if path contains the folder pattern
|
||||
if !path.contains("Mi Carpeta - ") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Split by the pattern and get the second part
|
||||
let parts: Vec<&str> = path.split("Mi Carpeta - ").collect();
|
||||
if parts.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Trim and return as owned String
|
||||
Some(parts[1].trim().to_string())
|
||||
}
|
||||
|
||||
/// Servicio para operaciones de subida de archivos
|
||||
///
|
||||
/// Encapsulates the three-tier upload strategy:
|
||||
/// 1. **Write-Behind** (<256 KB): store in RAM, respond instantly, flush async.
|
||||
/// 2. **Buffered** (256 KB – 1 MB): collect bytes, write, respond.
|
||||
/// 3. **Streaming** (≥1 MB): pipe chunks to disk with constant memory.
|
||||
///
|
||||
/// Also runs deduplication so duplicate content is never stored twice.
|
||||
pub struct FileUploadService {
|
||||
file_repository: Arc<dyn FileWritePort>,
|
||||
storage_usage_service: Option<Arc<dyn StorageUsagePort>>,
|
||||
/// Write port — handles save, streaming, deferred registration
|
||||
file_write: Arc<dyn FileWritePort>,
|
||||
/// Read port — needed for WebDAV create_file / update_file
|
||||
file_read: Option<Arc<dyn FileReadPort>>,
|
||||
/// Optional write-behind cache for instant uploads
|
||||
write_behind: Option<Arc<dyn WriteBehindCachePort>>,
|
||||
/// Optional dedup service for content-addressable storage
|
||||
dedup: Option<Arc<dyn DedupPort>>,
|
||||
/// Optional storage usage tracking
|
||||
storage_usage_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>>,
|
||||
}
|
||||
|
||||
impl FileUploadService {
|
||||
/// Crea un nuevo servicio de subida de archivos
|
||||
/// Backward-compatible constructor (no write-behind, no dedup).
|
||||
pub fn new(file_repository: Arc<dyn FileWritePort>) -> Self {
|
||||
Self {
|
||||
file_repository,
|
||||
Self {
|
||||
file_write: file_repository,
|
||||
file_read: None,
|
||||
write_behind: None,
|
||||
dedup: None,
|
||||
storage_usage_service: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Full constructor with all infrastructure ports.
|
||||
pub fn new_full(
|
||||
file_write: Arc<dyn FileWritePort>,
|
||||
file_read: Arc<dyn FileReadPort>,
|
||||
write_behind: Arc<dyn WriteBehindCachePort>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_write,
|
||||
file_read: Some(file_read),
|
||||
write_behind: Some(write_behind),
|
||||
dedup: Some(dedup),
|
||||
storage_usage_service: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configura el servicio de uso de almacenamiento
|
||||
pub fn with_storage_usage_service(
|
||||
mut self,
|
||||
storage_usage_service: Arc<dyn StorageUsagePort>
|
||||
mut self,
|
||||
storage_usage_service: Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>,
|
||||
) -> Self {
|
||||
self.storage_usage_service = Some(storage_usage_service);
|
||||
self
|
||||
}
|
||||
|
||||
// ── private helpers ──────────────────────────────────────────
|
||||
|
||||
/// Run dedup tracking (non-fatal on failure).
|
||||
async fn run_dedup(&self, data: &[u8], content_type: &str) {
|
||||
let Some(dedup) = &self.dedup else { return };
|
||||
match dedup.store_bytes(data, Some(content_type.to_string())).await {
|
||||
Ok(result) => {
|
||||
if result.was_deduplicated() {
|
||||
info!(
|
||||
"🔗 DEDUP: content already exists (hash: {}, saved {} bytes)",
|
||||
&result.hash()[..12],
|
||||
result.size()
|
||||
);
|
||||
} else {
|
||||
info!("💾 DEDUP: new content stored (hash: {})", &result.hash()[..12]);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("⚠️ DEDUP: Failed to store in blob store: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Optionally update storage usage after a successful upload.
|
||||
fn maybe_update_storage_usage(&self, file: &FileDto) {
|
||||
if let Some(storage_service) = &self.storage_usage_service {
|
||||
// Extract username from the file's own path (contains folder structure)
|
||||
let file_path = file.path.clone();
|
||||
if let Some(username) = extract_username_from_path(&file_path) {
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(async move {
|
||||
match service_clone.update_user_storage_usage(&username).await {
|
||||
Ok(usage) => debug!("Updated storage usage for user {} to {} bytes", username, usage),
|
||||
Err(e) => warn!("Failed to update storage usage for {}: {}", username, e),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileUploadUseCase for FileUploadService {
|
||||
/// Simple byte-based upload (backward compatible).
|
||||
async fn upload_file(
|
||||
&self,
|
||||
name: String,
|
||||
@@ -59,43 +138,178 @@ impl FileUploadUseCase for FileUploadService {
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
// Upload the file
|
||||
let file = self.file_repository.save_file(name, folder_id, content_type, content).await?;
|
||||
|
||||
// Extract the owner's user ID if available
|
||||
// We could make this more explicit by adding a user_id parameter
|
||||
if let Some(storage_service) = &self.storage_usage_service {
|
||||
// Extract user ID from folder pattern 'Mi Carpeta - {username}'
|
||||
if let Some(folder_id) = file.folder_id() {
|
||||
// Since we don't have direct access to folder details,
|
||||
// we'll use pattern matching on the folder ID
|
||||
// In a more complete implementation, we would use a folder repository
|
||||
let folder_id_str = folder_id;
|
||||
|
||||
// Check if we can extract a username from context
|
||||
if let Ok(folder_path) = self.file_repository.get_folder_path_str(folder_id_str).await {
|
||||
// Process the string to extract username without creating borrowing issues
|
||||
if let Some(username) = extract_username_from_path(&folder_path) {
|
||||
// Find user by username and update their storage usage
|
||||
// We do this asynchronously to avoid blocking the upload response
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(async move {
|
||||
match service_clone.update_user_storage_usage(&username).await {
|
||||
Ok(usage) => {
|
||||
debug!("Updated storage usage for user {} to {} bytes", username, usage);
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("Failed to update storage usage for {}: {}", username, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
let file = self.file_write.save_file(name, folder_id, content_type, content).await?;
|
||||
let dto = FileDto::from(file);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
/// Smart three-tier upload with write-behind cache and dedup.
|
||||
async fn smart_upload(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
chunks: Vec<Bytes>,
|
||||
total_size: usize,
|
||||
) -> Result<(FileDto, UploadStrategy), DomainError> {
|
||||
use futures::stream;
|
||||
|
||||
// ─── Dedup (runs for all tiers) ──────────────────────
|
||||
{
|
||||
let dedup_data: Vec<u8> = {
|
||||
let mut combined = Vec::with_capacity(total_size);
|
||||
for chunk in &chunks {
|
||||
combined.extend_from_slice(chunk);
|
||||
}
|
||||
combined
|
||||
};
|
||||
self.run_dedup(&dedup_data, &content_type).await;
|
||||
}
|
||||
|
||||
// ─── TIER 1: Write-Behind (<256 KB) ──────────────────
|
||||
if total_size < WRITE_BEHIND_THRESHOLD {
|
||||
if let Some(wb) = &self.write_behind {
|
||||
if wb.is_eligible_size(total_size) {
|
||||
let data: Bytes = if chunks.len() == 1 {
|
||||
chunks.into_iter().next().unwrap()
|
||||
} else {
|
||||
let mut combined = Vec::with_capacity(total_size);
|
||||
for chunk in chunks {
|
||||
combined.extend_from_slice(&chunk);
|
||||
}
|
||||
combined.into()
|
||||
};
|
||||
|
||||
let (file, target_path) = self
|
||||
.file_write
|
||||
.register_file_deferred(name.clone(), folder_id, content_type, total_size as u64)
|
||||
.await?;
|
||||
let dto = FileDto::from(file);
|
||||
|
||||
if let Err(e) = wb.put_pending(dto.id.clone(), data, target_path).await {
|
||||
return Err(DomainError::internal_error("file", format!(
|
||||
"Write-behind cache failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
warn!("Could not get folder path for ID: {}", folder_id_str);
|
||||
|
||||
info!("⚡ WRITE-BEHIND UPLOAD: {} (ID: {}, ~0ms latency)", name, dto.id);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
return Ok((dto, UploadStrategy::WriteBehind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FileDto::from(file))
|
||||
|
||||
// ─── TIER 2: Streaming (≥1 MB) ──────────────────────
|
||||
if total_size >= STREAMING_UPLOAD_THRESHOLD {
|
||||
let chunk_stream = stream::iter(chunks.into_iter().map(|c| Ok::<_, std::io::Error>(c)));
|
||||
let pinned_stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> =
|
||||
Box::pin(chunk_stream);
|
||||
|
||||
let file = self
|
||||
.file_write
|
||||
.save_file_from_stream(name.clone(), folder_id, content_type, pinned_stream)
|
||||
.await?;
|
||||
let dto = FileDto::from(file);
|
||||
info!(
|
||||
"✅ STREAMING UPLOAD: {} ({} MB, ID: {})",
|
||||
name,
|
||||
total_size / (1024 * 1024),
|
||||
dto.id
|
||||
);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
return Ok((dto, UploadStrategy::Streaming));
|
||||
}
|
||||
|
||||
// ─── TIER 3: Buffered (256 KB – 1 MB) ───────────────
|
||||
let data = if chunks.len() == 1 {
|
||||
chunks.into_iter().next().unwrap().to_vec()
|
||||
} else {
|
||||
let mut combined = Vec::with_capacity(total_size);
|
||||
for chunk in chunks {
|
||||
combined.extend_from_slice(&chunk);
|
||||
}
|
||||
combined
|
||||
};
|
||||
|
||||
let file = self
|
||||
.file_write
|
||||
.save_file(name.clone(), folder_id, content_type, data)
|
||||
.await?;
|
||||
let dto = FileDto::from(file);
|
||||
info!("✅ BUFFERED UPLOAD: {} (ID: {})", name, dto.id);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
Ok((dto, UploadStrategy::Buffered))
|
||||
}
|
||||
|
||||
/// Creates a file at a specific path (for WebDAV PUT on new resource).
|
||||
async fn create_file(
|
||||
&self,
|
||||
parent_path: &str,
|
||||
filename: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
// Resolve parent folder ID from path
|
||||
let parent_id = if !parent_path.is_empty() {
|
||||
if let Some(file_read) = &self.file_read {
|
||||
match file_read.get_parent_folder_id(parent_path).await {
|
||||
Ok(id) => Some(id),
|
||||
Err(_) => None, // If parent doesn't exist, use root
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let file = self
|
||||
.file_write
|
||||
.save_file(
|
||||
filename.to_string(),
|
||||
parent_id,
|
||||
content_type.to_string(),
|
||||
content.to_vec(),
|
||||
)
|
||||
.await?;
|
||||
let dto = FileDto::from(file);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
/// Updates an existing file's content, or creates it if not found (for WebDAV PUT).
|
||||
async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError> {
|
||||
let path_normalized = path.trim_start_matches('/').trim_end_matches('/');
|
||||
|
||||
// Try to find the existing file by path
|
||||
if let Some(file_read) = &self.file_read {
|
||||
let all_files = file_read.list_files(None).await?;
|
||||
for file in &all_files {
|
||||
let dto = FileDto::from(file.clone());
|
||||
let dto_path = dto.path.trim_start_matches('/').trim_end_matches('/');
|
||||
if dto_path == path_normalized
|
||||
|| dto_path.ends_with(&format!("/{}", path_normalized))
|
||||
|| path_normalized.ends_with(&format!("/{}", dto_path))
|
||||
{
|
||||
// Found it — update in place
|
||||
self.file_write
|
||||
.update_file_content(file.id(), content.to_vec())
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File not found — create it
|
||||
let (parent_path, filename) = if let Some(idx) = path_normalized.rfind('/') {
|
||||
(&path_normalized[..idx], &path_normalized[idx + 1..])
|
||||
} else {
|
||||
("", path_normalized)
|
||||
};
|
||||
self.create_file(parent_path, filename, content, "application/octet-stream")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ pub mod contact_service;
|
||||
pub mod favorites_service;
|
||||
pub mod file_management_service;
|
||||
pub mod file_retrieval_service;
|
||||
pub mod file_service;
|
||||
pub mod file_upload_service;
|
||||
pub mod file_use_case_factory;
|
||||
pub mod folder_service;
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Row};
|
||||
use tracing::{info, error};
|
||||
use uuid::Uuid;
|
||||
use tracing::info;
|
||||
use crate::common::errors::{Result, DomainError, ErrorKind};
|
||||
use crate::application::ports::recent_ports::RecentItemsUseCase;
|
||||
use crate::application::ports::recent_ports::{RecentItemsUseCase, RecentItemsRepositoryPort};
|
||||
use crate::application::dtos::recent_dto::RecentItemDto;
|
||||
|
||||
/// Implementación del caso de uso para gestionar elementos recientes
|
||||
/// Implementación del caso de uso para gestionar elementos recientes.
|
||||
///
|
||||
/// Depende de `RecentItemsRepositoryPort` (outbound port) en lugar
|
||||
/// de acceder directamente a `PgPool`, siguiendo la arquitectura hexagonal.
|
||||
pub struct RecentService {
|
||||
db_pool: Arc<PgPool>,
|
||||
max_recent_items: i32, // Número máximo de elementos recientes a mantener por usuario
|
||||
repo: Arc<dyn RecentItemsRepositoryPort>,
|
||||
max_recent_items: i32,
|
||||
}
|
||||
|
||||
impl RecentService {
|
||||
/// Crear un nuevo servicio de elementos recientes
|
||||
pub fn new(db_pool: Arc<PgPool>, max_recent_items: i32) -> Self {
|
||||
Self {
|
||||
db_pool,
|
||||
max_recent_items: max_recent_items.max(1).min(100), // Entre 1 y 100
|
||||
pub fn new(repo: Arc<dyn RecentItemsRepositoryPort>, max_recent_items: i32) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
max_recent_items: max_recent_items.max(1).min(100),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,205 +29,48 @@ impl RecentItemsUseCase for RecentService {
|
||||
/// Obtener elementos recientes de un usuario
|
||||
async fn get_recent_items(&self, user_id: &str, limit: Option<i32>) -> Result<Vec<RecentItemDto>> {
|
||||
info!("Obteniendo elementos recientes para usuario: {}", user_id);
|
||||
|
||||
// Convertir user_id a UUID
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Determinar límite (usar el especificado o el máximo del servicio)
|
||||
let limit_value = limit.unwrap_or(self.max_recent_items).min(self.max_recent_items);
|
||||
|
||||
// Ejecutar consulta SQL
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id::TEXT as "id",
|
||||
user_id::TEXT as "user_id",
|
||||
item_id as "item_id",
|
||||
item_type as "item_type",
|
||||
accessed_at as "accessed_at"
|
||||
FROM auth.user_recent_files
|
||||
WHERE user_id = $1::TEXT
|
||||
ORDER BY accessed_at DESC
|
||||
LIMIT $2
|
||||
"#
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.bind(limit_value)
|
||||
.fetch_all(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Error de base de datos al obtener elementos recientes: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"RecentItems",
|
||||
format!("Fallo al obtener elementos recientes: {}", e)
|
||||
)
|
||||
})?;
|
||||
|
||||
// Convertir filas a DTOs
|
||||
let mut recent_items = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
recent_items.push(RecentItemDto {
|
||||
id: row.get("id"),
|
||||
user_id: row.get("user_id"),
|
||||
item_id: row.get("item_id"),
|
||||
item_type: row.get("item_type"),
|
||||
accessed_at: row.get("accessed_at"),
|
||||
});
|
||||
}
|
||||
|
||||
info!("Recuperados {} elementos recientes para usuario {}", recent_items.len(), user_id);
|
||||
Ok(recent_items)
|
||||
let items = self.repo.get_recent_items(user_id, limit_value).await?;
|
||||
info!("Recuperados {} elementos recientes para usuario {}", items.len(), user_id);
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
|
||||
/// Registrar acceso a un elemento
|
||||
async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> {
|
||||
info!("Registrando acceso a {} '{}' para usuario {}", item_type, item_id, user_id);
|
||||
|
||||
// Validar tipo de elemento
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"RecentItems",
|
||||
"El tipo de elemento debe ser 'file' o 'folder'"
|
||||
"El tipo de elemento debe ser 'file' o 'folder'",
|
||||
));
|
||||
}
|
||||
|
||||
// Convertir user_id a UUID
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Ejecutar consulta SQL con UPSERT para mantener un único registro por elemento
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.user_recent_files (user_id, item_id, item_type, accessed_at)
|
||||
VALUES ($1::TEXT, $2, $3, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (user_id, item_id, item_type)
|
||||
DO UPDATE SET accessed_at = CURRENT_TIMESTAMP
|
||||
"#
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.bind(item_id)
|
||||
.bind(item_type)
|
||||
.execute(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Error de base de datos al registrar acceso a elemento: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"RecentItems",
|
||||
format!("Fallo al registrar acceso a elemento: {}", e)
|
||||
)
|
||||
})?;
|
||||
|
||||
// Eliminar elementos antiguos que excedan el límite
|
||||
self.prune_old_items(user_id).await?;
|
||||
|
||||
|
||||
self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
|
||||
info!("Registrado correctamente acceso a {} '{}' para usuario {}", item_type, item_id, user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Eliminar un elemento de recientes
|
||||
async fn remove_from_recent(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool> {
|
||||
info!("Eliminando {} '{}' de recientes para usuario {}", item_type, item_id, user_id);
|
||||
|
||||
// Convertir user_id a UUID
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Ejecutar consulta SQL
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.user_recent_files
|
||||
WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3
|
||||
"#
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.bind(item_id)
|
||||
.bind(item_type)
|
||||
.execute(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Error de base de datos al eliminar elemento de recientes: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"RecentItems",
|
||||
format!("Fallo al eliminar de recientes: {}", e)
|
||||
)
|
||||
})?;
|
||||
|
||||
let removed = result.rows_affected() > 0;
|
||||
let removed = self.repo.remove_item(user_id, item_id, item_type).await?;
|
||||
info!(
|
||||
"{} {} '{}' de recientes para usuario {}",
|
||||
"{} {} '{}' de recientes para usuario {}",
|
||||
if removed { "Eliminado correctamente" } else { "No se encontró" },
|
||||
item_type,
|
||||
item_id,
|
||||
user_id
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
|
||||
/// Limpiar todos los elementos recientes
|
||||
async fn clear_recent_items(&self, user_id: &str) -> Result<()> {
|
||||
info!("Limpiando todos los elementos recientes para usuario {}", user_id);
|
||||
|
||||
// Convertir user_id a UUID
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Ejecutar consulta SQL
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.user_recent_files
|
||||
WHERE user_id = $1::TEXT
|
||||
"#
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.execute(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Error de base de datos al limpiar elementos recientes: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"RecentItems",
|
||||
format!("Fallo al limpiar elementos recientes: {}", e)
|
||||
)
|
||||
})?;
|
||||
|
||||
self.repo.clear_all(user_id).await?;
|
||||
info!("Limpiados todos los elementos recientes para usuario {}", user_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RecentService {
|
||||
/// Método auxiliar para eliminar elementos antiguos que excedan el límite
|
||||
async fn prune_old_items(&self, user_id: &str) -> Result<()> {
|
||||
// Convertir user_id a UUID
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Eliminar elementos antiguos que excedan el límite
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.user_recent_files
|
||||
WHERE id IN (
|
||||
SELECT id FROM auth.user_recent_files
|
||||
WHERE user_id = $1::TEXT
|
||||
ORDER BY accessed_at DESC
|
||||
OFFSET $2
|
||||
)
|
||||
"#
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.bind(self.max_recent_items)
|
||||
.execute(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Error al podar elementos recientes antiguos: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"RecentItems",
|
||||
format!("Fallo al limpiar elementos recientes antiguos: {}", e)
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort};
|
||||
use crate::application::ports::outbound::FolderStoragePort;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
|
||||
/**
|
||||
* Implementación del servicio de búsqueda para archivos y carpetas.
|
||||
@@ -22,7 +23,7 @@ use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort};
|
||||
*/
|
||||
pub struct SearchService {
|
||||
/// Repositorio para operaciones con archivos
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
|
||||
/// Repositorio para operaciones con carpetas
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
@@ -66,7 +67,7 @@ impl SearchService {
|
||||
* @param max_cache_size Tamaño máximo de la caché
|
||||
*/
|
||||
pub fn new(
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
cache_ttl: u64,
|
||||
max_cache_size: usize,
|
||||
|
||||
@@ -10,8 +10,10 @@ use crate::{
|
||||
share_dto::{CreateShareDto, ShareDto, UpdateShareDto},
|
||||
},
|
||||
ports::{
|
||||
outbound::{FileStoragePort, FolderStoragePort},
|
||||
auth_ports::PasswordHasherPort,
|
||||
outbound::FolderStoragePort,
|
||||
share_ports::{ShareStoragePort, ShareUseCase},
|
||||
storage_ports::FileReadPort,
|
||||
},
|
||||
},
|
||||
common::{config::AppConfig, errors::DomainError},
|
||||
@@ -56,22 +58,25 @@ impl From<ShareServiceError> for DomainError {
|
||||
pub struct ShareService {
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<dyn ShareStoragePort>,
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
}
|
||||
|
||||
impl ShareService {
|
||||
pub fn new(
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<dyn ShareStoragePort>,
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
folder_repository: Arc<dyn FolderStoragePort>,
|
||||
password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
share_repository,
|
||||
file_repository,
|
||||
folder_repository,
|
||||
password_hasher,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +103,19 @@ impl ShareService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash de contraseña
|
||||
/// Hash de contraseña usando Argon2id (resistente a timing attacks y GPU attacks)
|
||||
fn hash_password(&self, password: &str) -> String {
|
||||
// En una implementación real, usar un algoritmo seguro como bcrypt
|
||||
// Para simplificar, solo devolvemos la misma contraseña
|
||||
password.to_string()
|
||||
use argon2::{Argon2, PasswordHasher};
|
||||
use argon2::password_hash::SaltString;
|
||||
use rand_core::OsRng;
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.expect("Failed to hash share password")
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,8 +327,13 @@ impl ShareUseCase for ShareService {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
|
||||
// Verificar la contraseña
|
||||
Ok(share.verify_password(password))
|
||||
// Verificar la contraseña usando el port de infraestructura
|
||||
match share.password_hash() {
|
||||
Some(hash) => {
|
||||
self.password_hasher.verify_password(password, hash)
|
||||
}
|
||||
None => Ok(true), // No password required
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
|
||||
@@ -348,87 +366,147 @@ impl ShareUseCase for ShareService {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::application::ports::share_ports::ShareStoragePort;
|
||||
use crate::application::ports::auth_ports::PasswordHasherPort;
|
||||
use crate::application::dtos::share_dto::SharePermissionsDto;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockPasswordHasher;
|
||||
|
||||
impl PasswordHasherPort for MockPasswordHasher {
|
||||
fn hash_password(&self, password: &str) -> Result<String, DomainError> {
|
||||
Ok(format!("hashed_{}", password))
|
||||
}
|
||||
|
||||
fn verify_password(&self, _password: &str, _hash: &str) -> Result<bool, DomainError> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct MockFileRepository;
|
||||
struct MockFolderRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FileStoragePort for MockFileRepository {
|
||||
async fn find_file_by_id(&self, id: &str) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
impl FileReadPort for MockFileRepository {
|
||||
async fn get_file(&self, id: &str) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
if id == "test_file_id" {
|
||||
let file = crate::domain::entities::file::File::new(
|
||||
id.to_string(),
|
||||
"test.txt".to_string(),
|
||||
"/path/to/test.txt".to_string(),
|
||||
"/test.txt".to_string(),
|
||||
crate::domain::services::path_service::StoragePath::from_string("/path/to/test.txt"),
|
||||
123,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
Ok(file)
|
||||
} else {
|
||||
Err(DomainError::NotFound(format!("File {} not found", id)))
|
||||
Err(DomainError::not_found("File", id))
|
||||
}
|
||||
}
|
||||
|
||||
// Implementación dummy para el resto de métodos requeridos
|
||||
async fn find_files_in_folder(&self, _folder_id: &str) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn save_file(&self, _file: &crate::domain::entities::file::File) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn find_all_files(&self) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, _id: &str) -> Result<bytes::Bytes, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FolderStoragePort for MockFolderRepository {
|
||||
async fn find_folder_by_id(&self, id: &str) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
impl FolderRepository for MockFolderRepository {
|
||||
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
if id == "test_folder_id" {
|
||||
let folder = crate::domain::entities::folder::Folder::new(
|
||||
id.to_string(),
|
||||
"test".to_string(),
|
||||
"/path/to/test".to_string(),
|
||||
"/test".to_string(),
|
||||
None,
|
||||
None,
|
||||
crate::domain::services::path_service::StoragePath::from_string("/path/to/test"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
Ok(folder)
|
||||
} else {
|
||||
Err(DomainError::NotFound(format!("Folder {} not found", id)))
|
||||
Err(DomainError::not_found("Folder", id))
|
||||
}
|
||||
}
|
||||
|
||||
// Implementación dummy para el resto de métodos requeridos
|
||||
async fn find_folders_in_folder(&self, _folder_id: &str) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
|
||||
async fn get_folder_by_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn save_folder(&self, _folder: &crate::domain::entities::folder::Folder) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(&self, _parent_id: Option<&str>, _offset: usize, _limit: usize, _include_total: bool) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn find_all_folders(&self) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
|
||||
async fn folder_exists(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<bool, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
@@ -453,8 +531,8 @@ mod tests {
|
||||
let mut shares = self.shares.lock().unwrap();
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
|
||||
shares.insert(share.id.clone(), share.clone());
|
||||
tokens.insert(share.token.clone(), share.id.clone());
|
||||
shares.insert(share.id().to_string(), share.clone());
|
||||
tokens.insert(share.token().to_string(), share.id().to_string());
|
||||
|
||||
Ok(share.clone())
|
||||
}
|
||||
@@ -464,7 +542,7 @@ mod tests {
|
||||
|
||||
shares.get(id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Share with ID {} not found", id)))
|
||||
.ok_or_else(|| DomainError::not_found("Share", id))
|
||||
}
|
||||
|
||||
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
|
||||
@@ -472,11 +550,11 @@ mod tests {
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
let id = tokens.get(token)
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Share with token {} not found", token)))?;
|
||||
.ok_or_else(|| DomainError::not_found("Share", token))?;
|
||||
|
||||
shares.get(id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Share with ID {} not found", id)))
|
||||
.ok_or_else(|| DomainError::not_found("Share", id.as_str()))
|
||||
}
|
||||
|
||||
async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result<Vec<Share>, DomainError> {
|
||||
@@ -484,7 +562,7 @@ mod tests {
|
||||
|
||||
let type_str = item_type.to_string();
|
||||
let result: Vec<Share> = shares.values()
|
||||
.filter(|s| s.item_id == item_id && s.item_type.to_string() == type_str)
|
||||
.filter(|s| s.item_id() == item_id && s.item_type().to_string() == type_str)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
@@ -494,11 +572,12 @@ mod tests {
|
||||
async fn update_share(&self, share: &Share) -> Result<Share, DomainError> {
|
||||
let mut shares = self.shares.lock().unwrap();
|
||||
|
||||
if !shares.contains_key(&share.id) {
|
||||
return Err(DomainError::not_found("Share", &share.id));
|
||||
let id_str = share.id().to_string();
|
||||
if !shares.contains_key(&id_str) {
|
||||
return Err(DomainError::not_found("Share", &id_str));
|
||||
}
|
||||
|
||||
shares.insert(share.id.clone(), share.clone());
|
||||
shares.insert(id_str, share.clone());
|
||||
|
||||
Ok(share.clone())
|
||||
}
|
||||
@@ -512,7 +591,7 @@ mod tests {
|
||||
.ok_or_else(|| DomainError::not_found("Share", id))?;
|
||||
|
||||
// Remove token mapping
|
||||
tokens.remove(&share.token);
|
||||
tokens.remove(share.token());
|
||||
|
||||
// Remove the share
|
||||
shares.remove(id);
|
||||
@@ -524,7 +603,7 @@ mod tests {
|
||||
let shares = self.shares.lock().unwrap();
|
||||
|
||||
let user_shares: Vec<Share> = shares.values()
|
||||
.filter(|s| s.created_by == user_id)
|
||||
.filter(|s| s.created_by() == user_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
@@ -542,23 +621,14 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_shared_link() {
|
||||
let config = Arc::new(Config {
|
||||
base_url: "http://localhost:8085".to_string(),
|
||||
storage_path: "/tmp/storage".to_string(),
|
||||
log_level: "info".to_string(),
|
||||
port: 8085,
|
||||
database_url: "".to_string(),
|
||||
jwt_secret: "test_secret".to_string(),
|
||||
jwt_expiration: 3600,
|
||||
enable_cors: false,
|
||||
cors_origins: vec![],
|
||||
});
|
||||
let config = Arc::new(AppConfig::default());
|
||||
|
||||
let share_repo = Arc::new(MockShareRepository::new());
|
||||
let file_repo = Arc::new(MockFileRepository);
|
||||
let folder_repo = Arc::new(MockFolderRepository);
|
||||
let password_hasher = Arc::new(MockPasswordHasher);
|
||||
|
||||
let service = ShareService::new(config, share_repo, file_repo, folder_repo);
|
||||
let service = ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher);
|
||||
|
||||
// Test creating a file share
|
||||
let dto = CreateShareDto {
|
||||
@@ -580,6 +650,6 @@ mod tests {
|
||||
assert_eq!(share_dto.item_id, "test_file_id");
|
||||
assert_eq!(share_dto.item_type, "file");
|
||||
assert!(share_dto.has_password);
|
||||
assert!(share_dto.url.starts_with("http://localhost:8085/s/"));
|
||||
assert!(share_dto.url.starts_with("http://127.0.0.1:8085/s/"));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::repositories::folder_repository::{FolderRepository, FolderRepositoryError};
|
||||
use crate::domain::repositories::file_repository::FileRepositoryError;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use crate::application::ports::outbound::{IdMappingPort, StoragePort};
|
||||
use crate::application::ports::outbound::{IdMappingPort, StoragePort, FolderStoragePort};
|
||||
|
||||
/// Errores específicos del mediador de almacenamiento
|
||||
#[derive(Debug, Error)]
|
||||
@@ -32,30 +29,6 @@ pub enum StorageMediatorError {
|
||||
DomainError(#[from] crate::common::errors::DomainError),
|
||||
}
|
||||
|
||||
impl From<FolderRepositoryError> for StorageMediatorError {
|
||||
fn from(err: FolderRepositoryError) -> Self {
|
||||
match err {
|
||||
FolderRepositoryError::NotFound(id) => StorageMediatorError::NotFound(id),
|
||||
FolderRepositoryError::AlreadyExists(path) => StorageMediatorError::AlreadyExists(path),
|
||||
FolderRepositoryError::InvalidPath(path) => StorageMediatorError::InvalidPath(path),
|
||||
FolderRepositoryError::IoError(e) => StorageMediatorError::AccessError(e.to_string()),
|
||||
_ => StorageMediatorError::InternalError(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FileRepositoryError> for StorageMediatorError {
|
||||
fn from(err: FileRepositoryError) -> Self {
|
||||
match err {
|
||||
FileRepositoryError::NotFound(id) => StorageMediatorError::NotFound(id),
|
||||
FileRepositoryError::AlreadyExists(path) => StorageMediatorError::AlreadyExists(path),
|
||||
FileRepositoryError::InvalidPath(path) => StorageMediatorError::InvalidPath(path),
|
||||
FileRepositoryError::IoError(e) => StorageMediatorError::AccessError(e.to_string()),
|
||||
_ => StorageMediatorError::InternalError(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tipo de resultado para las operaciones del mediador
|
||||
pub type StorageMediatorResult<T> = Result<T, StorageMediatorError>;
|
||||
|
||||
@@ -98,113 +71,20 @@ pub trait StorageMediator: Send + Sync + 'static {
|
||||
|
||||
/// Implementación concreta del mediador de almacenamiento
|
||||
pub struct FileSystemStorageMediator {
|
||||
pub folder_repository: Arc<dyn FolderRepository>,
|
||||
pub folder_storage_port: Arc<dyn FolderStoragePort>,
|
||||
pub path_service: Arc<dyn StoragePort>,
|
||||
pub id_mapping: Arc<dyn IdMappingPort>,
|
||||
}
|
||||
|
||||
impl FileSystemStorageMediator {
|
||||
pub fn new(folder_repository: Arc<dyn FolderRepository>, path_service: Arc<dyn StoragePort>, id_mapping: Arc<dyn IdMappingPort>) -> Self {
|
||||
Self { folder_repository, path_service, id_mapping }
|
||||
pub fn new(folder_storage_port: Arc<dyn FolderStoragePort>, path_service: Arc<dyn StoragePort>, id_mapping: Arc<dyn IdMappingPort>) -> Self {
|
||||
Self { folder_storage_port, path_service, id_mapping }
|
||||
}
|
||||
|
||||
/// Creates a stub implementation for initialization bootstrapping
|
||||
pub fn new_stub() -> StubStorageMediator {
|
||||
StubStorageMediator::new()
|
||||
}
|
||||
|
||||
/// Overload para implementar inicialización diferida con repository placeholder
|
||||
pub fn new_with_lazy_folder(
|
||||
_folder_repository: Arc<RwLock<Option<Arc<dyn FolderRepository>>>>,
|
||||
path_service: Arc<dyn StoragePort>,
|
||||
id_mapping: Arc<dyn IdMappingPort>
|
||||
) -> Self {
|
||||
// Create temporary stub repository
|
||||
let temp_repo = Arc::new(FolderRepositoryStub {});
|
||||
|
||||
Self {
|
||||
folder_repository: temp_repo,
|
||||
path_service,
|
||||
id_mapping,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub repository for initialization
|
||||
#[derive(Debug)]
|
||||
pub struct FolderRepositoryStub {}
|
||||
|
||||
#[async_trait]
|
||||
impl FolderRepository for FolderRepositoryStub {
|
||||
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn get_folder_by_id(&self, _id: &str) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn get_folder_by_storage_path(&self, _storage_path: &StoragePath) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<Folder>, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool
|
||||
) -> Result<(Vec<Folder>, Option<usize>), FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, _id: &str) -> Result<(), FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
async fn folder_exists_at_storage_path(&self, _storage_path: &StoragePath) -> Result<bool, FolderRepositoryError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn get_folder_storage_path(&self, _id: &str) -> Result<StoragePath, FolderRepositoryError> {
|
||||
Ok(StoragePath::root())
|
||||
}
|
||||
|
||||
// Legacy methods
|
||||
#[allow(deprecated)]
|
||||
async fn folder_exists(&self, _path: &std::path::PathBuf) -> Result<bool, FolderRepositoryError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
async fn get_folder_by_path(&self, _path: &std::path::PathBuf) -> Result<Folder, FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::Other("Stub repository".to_string()))
|
||||
}
|
||||
|
||||
// Trash functionality stubs
|
||||
async fn move_to_trash(&self, _folder_id: &str) -> Result<(), FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::OperationNotSupported("Trash feature temporarily disabled".to_string()))
|
||||
}
|
||||
|
||||
async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::OperationNotSupported("Trash feature temporarily disabled".to_string()))
|
||||
}
|
||||
|
||||
async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), FolderRepositoryError> {
|
||||
Err(FolderRepositoryError::OperationNotSupported("Trash feature temporarily disabled".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub implementation for initialization dependency issues
|
||||
@@ -276,7 +156,7 @@ impl StorageMediator for StubStorageMediator {
|
||||
#[async_trait]
|
||||
impl StorageMediator for FileSystemStorageMediator {
|
||||
async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult<PathBuf> {
|
||||
let folder = self.folder_repository.get_folder_by_id(folder_id).await
|
||||
let folder = self.folder_storage_port.get_folder(folder_id).await
|
||||
.map_err(StorageMediatorError::from)?;
|
||||
|
||||
// Need to get the path from folder ID
|
||||
@@ -289,7 +169,7 @@ impl StorageMediator for FileSystemStorageMediator {
|
||||
}
|
||||
|
||||
async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult<StoragePath> {
|
||||
let folder = self.folder_repository.get_folder_by_id(folder_id).await
|
||||
let folder = self.folder_storage_port.get_folder(folder_id).await
|
||||
.map_err(StorageMediatorError::from)?;
|
||||
|
||||
// Get path by folder ID - will already be a StoragePath
|
||||
@@ -300,7 +180,7 @@ impl StorageMediator for FileSystemStorageMediator {
|
||||
}
|
||||
|
||||
async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult<Folder> {
|
||||
let folder = self.folder_repository.get_folder_by_id(folder_id).await
|
||||
let folder = self.folder_storage_port.get_folder(folder_id).await
|
||||
.map_err(StorageMediatorError::from)?;
|
||||
|
||||
Ok(folder)
|
||||
|
||||
@@ -3,8 +3,7 @@ use async_trait::async_trait;
|
||||
use tokio::task;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::application::ports::auth_ports::UserStoragePort;
|
||||
use crate::application::ports::outbound::FileStoragePort;
|
||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
|
||||
use tracing::{info, error, debug};
|
||||
|
||||
/**
|
||||
@@ -14,14 +13,14 @@ use tracing::{info, error, debug};
|
||||
* is using and updating this information in the user records.
|
||||
*/
|
||||
pub struct StorageUsageService {
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
user_repository: Arc<dyn UserStoragePort>,
|
||||
}
|
||||
|
||||
impl StorageUsageService {
|
||||
/// Creates a new storage usage service
|
||||
pub fn new(
|
||||
file_repository: Arc<dyn FileStoragePort>,
|
||||
file_repository: Arc<dyn FileReadPort>,
|
||||
user_repository: Arc<dyn UserStoragePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -90,7 +89,7 @@ impl StorageUsageService {
|
||||
async fn calculate_folder_size(&self, folder_id: &str) -> Result<i64, DomainError> {
|
||||
// Implementation with explicit boxing to handle recursion in async functions
|
||||
async fn inner_calculate_size(
|
||||
repo: Arc<dyn FileStoragePort>,
|
||||
repo: Arc<dyn FileReadPort>,
|
||||
folder_id: &str,
|
||||
) -> Result<i64, DomainError> {
|
||||
let mut total_size: i64 = 0;
|
||||
|
||||
@@ -5,10 +5,10 @@ use tracing::{debug, error, info, instrument};
|
||||
|
||||
use crate::application::dtos::trash_dto::TrashedItemDto;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::ports::outbound::FolderStoragePort;
|
||||
use crate::common::errors::{Result, DomainError, ErrorKind};
|
||||
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||
use crate::domain::repositories::file_repository::FileRepository;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
* repositories while enforcing business rules like retention policies.
|
||||
*
|
||||
* This service follows the Clean Architecture pattern by:
|
||||
* - Depending on domain interfaces rather than concrete implementations
|
||||
* - Depending on application ports rather than domain/infrastructure traits
|
||||
* - Orchestrating domain operations without containing domain logic
|
||||
* - Exposing its functionality through the TrashUseCase port
|
||||
*/
|
||||
@@ -28,11 +28,14 @@ pub struct TrashService {
|
||||
/// Repository for trash-specific operations like listing and retrieving trashed items
|
||||
trash_repository: Arc<dyn TrashRepository>,
|
||||
|
||||
/// Repository for file operations used when trashing, restoring, or deleting files
|
||||
file_repository: Arc<dyn FileRepository>,
|
||||
/// Port for file read operations (get file metadata)
|
||||
file_read_port: Arc<dyn FileReadPort>,
|
||||
|
||||
/// Repository for folder operations used when trashing, restoring, or deleting folders
|
||||
folder_repository: Arc<dyn FolderRepository>,
|
||||
/// Port for file write operations (trash, restore, delete)
|
||||
file_write_port: Arc<dyn FileWritePort>,
|
||||
|
||||
/// Port for folder operations (get folder, trash, restore, delete)
|
||||
folder_storage_port: Arc<dyn FolderStoragePort>,
|
||||
|
||||
/// Number of days items should be kept in trash before automatic cleanup
|
||||
retention_days: u32,
|
||||
@@ -41,33 +44,35 @@ pub struct TrashService {
|
||||
impl TrashService {
|
||||
pub fn new(
|
||||
trash_repository: Arc<dyn TrashRepository>,
|
||||
file_repository: Arc<dyn FileRepository>,
|
||||
folder_repository: Arc<dyn FolderRepository>,
|
||||
file_read_port: Arc<dyn FileReadPort>,
|
||||
file_write_port: Arc<dyn FileWritePort>,
|
||||
folder_storage_port: Arc<dyn FolderStoragePort>,
|
||||
retention_days: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
trash_repository,
|
||||
file_repository,
|
||||
folder_repository,
|
||||
file_read_port,
|
||||
file_write_port,
|
||||
folder_storage_port,
|
||||
retention_days,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a TrashedItem entity to a DTO
|
||||
fn to_dto(&self, item: TrashedItem) -> TrashedItemDto {
|
||||
// Calculate days_until_deletion before moving item.original_path
|
||||
// Calculate days_until_deletion before moving item fields
|
||||
let days_until_deletion = item.days_until_deletion();
|
||||
|
||||
TrashedItemDto {
|
||||
id: item.id.to_string(),
|
||||
original_id: item.original_id.to_string(),
|
||||
item_type: match item.item_type {
|
||||
id: item.id().to_string(),
|
||||
original_id: item.original_id().to_string(),
|
||||
item_type: match item.item_type() {
|
||||
TrashedItemType::File => "file".to_string(),
|
||||
TrashedItemType::Folder => "folder".to_string(),
|
||||
},
|
||||
name: item.name,
|
||||
original_path: item.original_path,
|
||||
trashed_at: item.trashed_at,
|
||||
name: item.name().to_string(),
|
||||
original_path: item.original_path().to_string(),
|
||||
trashed_at: item.trashed_at(),
|
||||
days_until_deletion,
|
||||
}
|
||||
}
|
||||
@@ -83,10 +88,10 @@ impl TrashService {
|
||||
|
||||
match self.trash_repository.get_trash_item(&item_uuid, &user_uuid).await? {
|
||||
Some(item) => {
|
||||
if item.user_id != user_uuid {
|
||||
if item.user_id() != user_uuid {
|
||||
error!(
|
||||
"User {} attempted to access trash item {} owned by {}",
|
||||
user_id, item_id, item.user_id
|
||||
user_id, item_id, item.user_id()
|
||||
);
|
||||
return Err(DomainError::access_denied(
|
||||
"TrashItem",
|
||||
@@ -130,10 +135,9 @@ impl TrashUseCase for TrashService {
|
||||
info!("Moving to trash: type={}, id={}, user={}", item_type, item_id, user_id);
|
||||
debug!("User UUID validation: {}", user_id);
|
||||
|
||||
// Validate user ownership
|
||||
debug!("Validating user permissions");
|
||||
self.validate_user_ownership(item_id, user_id).await?;
|
||||
debug!("User permissions validated");
|
||||
// Note: We do NOT call validate_user_ownership here because the item
|
||||
// is not yet in the trash. Ownership validation is only for operations
|
||||
// on already-trashed items (restore, delete_permanently).
|
||||
|
||||
// Parse UUIDs with detailed error handling
|
||||
debug!("Validating item UUID: {}", item_id);
|
||||
@@ -166,7 +170,7 @@ impl TrashUseCase for TrashService {
|
||||
|
||||
// Get the file to verify it exists and capture its data
|
||||
debug!("Getting file data: {}", item_id);
|
||||
let file = match self.file_repository.get_file_by_id(item_id).await {
|
||||
let file = match self.file_read_port.get_file(item_id).await {
|
||||
Ok(file) => {
|
||||
debug!("File found: {} ({})", file.name(), item_id);
|
||||
file
|
||||
@@ -194,7 +198,7 @@ impl TrashUseCase for TrashService {
|
||||
original_path,
|
||||
self.retention_days,
|
||||
);
|
||||
debug!("TrashedItem created successfully: {} -> {}", file.name(), trashed_item.id);
|
||||
debug!("TrashedItem created successfully: {} -> {}", file.name(), trashed_item.id());
|
||||
|
||||
// First add to trash index to register the item
|
||||
info!("Adding file {} to trash index", item_id);
|
||||
@@ -210,7 +214,7 @@ impl TrashUseCase for TrashService {
|
||||
|
||||
// Then physically move the file to trash
|
||||
info!("Physically moving file to trash: {}", item_id);
|
||||
match self.file_repository.move_to_trash(item_id).await {
|
||||
match self.file_write_port.move_to_trash(item_id).await {
|
||||
Ok(_) => {
|
||||
debug!("File physically moved to trash successfully: {}", item_id);
|
||||
},
|
||||
@@ -229,7 +233,7 @@ impl TrashUseCase for TrashService {
|
||||
},
|
||||
"folder" => {
|
||||
// Get the folder to verify it exists and capture its data
|
||||
let folder = self.folder_repository.get_folder_by_id(item_id).await
|
||||
let folder = self.folder_storage_port.get_folder(item_id).await
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Folder",
|
||||
@@ -259,7 +263,7 @@ impl TrashUseCase for TrashService {
|
||||
};
|
||||
|
||||
// Then physically move the folder to trash
|
||||
self.folder_repository.move_to_trash(item_id).await
|
||||
self.folder_storage_port.move_to_trash(item_id).await
|
||||
.map_err(|e| DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
@@ -306,17 +310,17 @@ impl TrashUseCase for TrashService {
|
||||
match item_result {
|
||||
Ok(Some(item)) => {
|
||||
info!("Found item in trash: ID={}, Type={:?}, OriginalID={}",
|
||||
trash_id, item.item_type, item.original_id);
|
||||
trash_id, item.item_type(), item.original_id());
|
||||
|
||||
// Restore based on type
|
||||
match item.item_type {
|
||||
match item.item_type() {
|
||||
TrashedItemType::File => {
|
||||
// Restore the file to its original location
|
||||
let file_id = item.original_id.to_string();
|
||||
let original_path = item.original_path.clone();
|
||||
let file_id = item.original_id().to_string();
|
||||
let original_path = item.original_path().to_string();
|
||||
|
||||
info!("Restoring file from trash: ID={}, OriginalPath={}", file_id, original_path);
|
||||
match self.file_repository.restore_from_trash(&file_id, &original_path).await {
|
||||
match self.file_write_port.restore_from_trash(&file_id, &original_path).await {
|
||||
Ok(_) => {
|
||||
info!("Successfully restored file from trash: {}", file_id);
|
||||
},
|
||||
@@ -339,11 +343,11 @@ impl TrashUseCase for TrashService {
|
||||
},
|
||||
TrashedItemType::Folder => {
|
||||
// Restore the folder to its original location
|
||||
let folder_id = item.original_id.to_string();
|
||||
let original_path = item.original_path.clone();
|
||||
let folder_id = item.original_id().to_string();
|
||||
let original_path = item.original_path().to_string();
|
||||
|
||||
info!("Restoring folder from trash: ID={}, OriginalPath={}", folder_id, original_path);
|
||||
match self.folder_repository.restore_from_trash(&folder_id, &original_path).await {
|
||||
match self.folder_storage_port.restore_from_trash(&folder_id, &original_path).await {
|
||||
Ok(_) => {
|
||||
info!("Successfully restored folder from trash: {}", folder_id);
|
||||
},
|
||||
@@ -431,16 +435,16 @@ impl TrashUseCase for TrashService {
|
||||
match item_result {
|
||||
Ok(Some(item)) => {
|
||||
info!("Found item in trash: ID={}, Type={:?}, OriginalID={}",
|
||||
trash_id, item.item_type, item.original_id);
|
||||
trash_id, item.item_type(), item.original_id());
|
||||
|
||||
// Permanently delete based on type
|
||||
match item.item_type {
|
||||
match item.item_type() {
|
||||
TrashedItemType::File => {
|
||||
// Eliminar el archivo permanentemente
|
||||
let file_id = item.original_id.to_string();
|
||||
let file_id = item.original_id().to_string();
|
||||
|
||||
info!("Permanently deleting file: {}", file_id);
|
||||
match self.file_repository.delete_file_permanently(&file_id).await {
|
||||
match self.file_write_port.delete_file_permanently(&file_id).await {
|
||||
Ok(_) => {
|
||||
info!("Successfully deleted file permanently: {}", file_id);
|
||||
},
|
||||
@@ -463,10 +467,10 @@ impl TrashUseCase for TrashService {
|
||||
},
|
||||
TrashedItemType::Folder => {
|
||||
// Eliminar la carpeta permanentemente
|
||||
let folder_id = item.original_id.to_string();
|
||||
let folder_id = item.original_id().to_string();
|
||||
|
||||
info!("Permanently deleting folder: {}", folder_id);
|
||||
match self.folder_repository.delete_folder_permanently(&folder_id).await {
|
||||
match self.folder_storage_port.delete_folder_permanently(&folder_id).await {
|
||||
Ok(_) => {
|
||||
info!("Successfully deleted folder permanently: {}", folder_id);
|
||||
},
|
||||
@@ -532,18 +536,18 @@ impl TrashUseCase for TrashService {
|
||||
|
||||
// Permanently delete each item
|
||||
for item in items {
|
||||
match item.item_type {
|
||||
match item.item_type() {
|
||||
TrashedItemType::File => {
|
||||
// Permanently delete the file
|
||||
let file_id = item.original_id.to_string();
|
||||
if let Err(e) = self.file_repository.delete_file_permanently(&file_id).await {
|
||||
let file_id = item.original_id().to_string();
|
||||
if let Err(e) = self.file_write_port.delete_file_permanently(&file_id).await {
|
||||
error!("Error permanently deleting file {}: {}", file_id, e);
|
||||
}
|
||||
},
|
||||
TrashedItemType::Folder => {
|
||||
// Permanently delete the folder
|
||||
let folder_id = item.original_id.to_string();
|
||||
if let Err(e) = self.folder_repository.delete_folder_permanently(&folder_id).await {
|
||||
let folder_id = item.original_id().to_string();
|
||||
if let Err(e) = self.folder_storage_port.delete_folder_permanently(&folder_id).await {
|
||||
error!("Error permanently deleting folder {}: {}", folder_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::path::PathBuf;
|
||||
use chrono::Utc;
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
|
||||
use crate::common::errors::{Result, DomainError};
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||
use crate::domain::repositories::file_repository::{FileRepository, FileRepositoryResult, FileRepositoryError};
|
||||
use crate::domain::repositories::folder_repository::{FolderRepository, FolderRepositoryResult, FolderRepositoryError};
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::application::services::trash_service::TrashService;
|
||||
|
||||
// Mock repositories for testing
|
||||
@@ -31,14 +34,14 @@ impl MockTrashRepository {
|
||||
impl TrashRepository for MockTrashRepository {
|
||||
async fn add_to_trash(&self, item: &TrashedItem) -> Result<()> {
|
||||
let mut items = self.trash_items.lock().unwrap();
|
||||
items.insert(item.id, item.clone());
|
||||
items.insert(item.id(), item.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
|
||||
let items = self.trash_items.lock().unwrap();
|
||||
let user_items = items.values()
|
||||
.filter(|item| item.user_id == *user_id)
|
||||
.filter(|item| item.user_id() == *user_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok(user_items)
|
||||
@@ -47,7 +50,7 @@ impl TrashRepository for MockTrashRepository {
|
||||
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
|
||||
let items = self.trash_items.lock().unwrap();
|
||||
let item = items.get(id)
|
||||
.filter(|item| item.user_id == *user_id)
|
||||
.filter(|item| item.user_id() == *user_id)
|
||||
.cloned();
|
||||
Ok(item)
|
||||
}
|
||||
@@ -55,7 +58,7 @@ impl TrashRepository for MockTrashRepository {
|
||||
async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()> {
|
||||
let mut items = self.trash_items.lock().unwrap();
|
||||
if let Some(item) = items.get(id) {
|
||||
if item.user_id == *user_id {
|
||||
if item.user_id() == *user_id {
|
||||
items.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +68,7 @@ impl TrashRepository for MockTrashRepository {
|
||||
async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()> {
|
||||
let mut items = self.trash_items.lock().unwrap();
|
||||
if let Some(item) = items.get(id) {
|
||||
if item.user_id == *user_id {
|
||||
if item.user_id() == *user_id {
|
||||
items.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -74,7 +77,7 @@ impl TrashRepository for MockTrashRepository {
|
||||
|
||||
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
|
||||
let mut items = self.trash_items.lock().unwrap();
|
||||
items.retain(|_, item| item.user_id != *user_id);
|
||||
items.retain(|_, item| item.user_id() != *user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -82,7 +85,7 @@ impl TrashRepository for MockTrashRepository {
|
||||
let items = self.trash_items.lock().unwrap();
|
||||
let now = Utc::now();
|
||||
let expired = items.values()
|
||||
.filter(|item| item.deletion_date <= now)
|
||||
.filter(|item| item.deletion_date() <= now)
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok(expired)
|
||||
@@ -118,17 +121,102 @@ impl MockFileRepository {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileRepository for MockFileRepository {
|
||||
async fn get_file_by_id(&self, id: &str) -> FileRepositoryResult<File> {
|
||||
impl FileReadPort for MockFileRepository {
|
||||
async fn get_file(&self, id: &str) -> std::result::Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
if let Some(file) = files.get(id) {
|
||||
Ok(file.clone())
|
||||
} else {
|
||||
Err(FileRepositoryError::NotFound(id.to_string()))
|
||||
Err(DomainError::not_found("File", id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, id: &str) -> FileRepositoryResult<()> {
|
||||
async fn list_files(&self, _folder_id: Option<&str>) -> std::result::Result<Vec<File>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, _id: &str) -> std::result::Result<Vec<u8>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> std::result::Result<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> std::result::Result<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, _id: &str) -> std::result::Result<Bytes, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, _id: &str) -> std::result::Result<StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileWritePort for MockFileRepository {
|
||||
async fn save_file(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_content: Vec<u8>,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn save_file_from_stream(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_stream: std::pin::Pin<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> std::result::Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content(&self, _file_id: &str, _content: Vec<u8>) -> std::result::Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_size: u64,
|
||||
) -> std::result::Result<(File, PathBuf), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> {
|
||||
let mut files = self.files.lock().unwrap();
|
||||
let mut trashed = self.trashed_files.lock().unwrap();
|
||||
|
||||
@@ -136,11 +224,11 @@ impl FileRepository for MockFileRepository {
|
||||
trashed.insert(id.to_string(), file);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FileRepositoryError::NotFound(id.to_string()))
|
||||
Err(DomainError::not_found("File", id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_from_trash(&self, id: &str, original_path: &str) -> FileRepositoryResult<()> {
|
||||
async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> {
|
||||
let mut files = self.files.lock().unwrap();
|
||||
let mut trashed = self.trashed_files.lock().unwrap();
|
||||
|
||||
@@ -148,26 +236,18 @@ impl FileRepository for MockFileRepository {
|
||||
files.insert(id.to_string(), file);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FileRepositoryError::NotFound(format!("File {} not found in trash", id)))
|
||||
Err(DomainError::not_found("File", format!("File {} not found in trash", id)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_file_permanently(&self, id: &str) -> FileRepositoryResult<()> {
|
||||
async fn delete_file_permanently(&self, id: &str) -> std::result::Result<(), DomainError> {
|
||||
let mut trashed = self.trashed_files.lock().unwrap();
|
||||
if trashed.remove(id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FileRepositoryError::NotFound(format!("File {} not found in trash", id)))
|
||||
Err(DomainError::not_found("File", format!("File {} not found in trash", id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Other methods required by the trait (not used in tests)
|
||||
async fn save_file(&self, _file: &File) -> FileRepositoryResult<()> { Ok(()) }
|
||||
async fn delete_file(&self, _id: &str) -> FileRepositoryResult<()> { Ok(()) }
|
||||
async fn get_files_in_folder(&self, _folder_id: Option<&str>) -> FileRepositoryResult<Vec<File>> { Ok(vec![]) }
|
||||
async fn move_file(&self, _id: &str, _new_folder_id: Option<&str>) -> FileRepositoryResult<()> { Ok(()) }
|
||||
async fn update_file_data(&self, _id: &str, _new_data: &[u8]) -> FileRepositoryResult<()> { Ok(()) }
|
||||
async fn get_file_data(&self, _id: &str) -> FileRepositoryResult<Vec<u8>> { Ok(vec![]) }
|
||||
}
|
||||
|
||||
struct MockFolderRepository {
|
||||
@@ -198,16 +278,58 @@ impl MockFolderRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl FolderRepository for MockFolderRepository {
|
||||
async fn get_folder_by_id(&self, id: &str) -> FolderRepositoryResult<Folder> {
|
||||
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> std::result::Result<Folder, DomainError> {
|
||||
let folders = self.folders.lock().unwrap();
|
||||
if let Some(folder) = folders.get(id) {
|
||||
Ok(folder.clone())
|
||||
} else {
|
||||
Err(FolderRepositoryError::NotFound(id.to_string()))
|
||||
Err(DomainError::not_found("Folder", id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, id: &str) -> FolderRepositoryResult<()> {
|
||||
async fn get_folder_by_path(&self, _storage_path: &StoragePath) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders(&self, _parent_id: Option<&str>) -> std::result::Result<Vec<Folder>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
) -> std::result::Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
Ok((vec![], Some(0)))
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, _id: &str, _new_name: String) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> std::result::Result<Folder, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, _id: &str) -> std::result::Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn folder_exists(&self, _storage_path: &StoragePath) -> std::result::Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn get_folder_path(&self, _id: &str) -> std::result::Result<StoragePath, DomainError> {
|
||||
Ok(StoragePath::from_string("/"))
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> {
|
||||
let mut folders = self.folders.lock().unwrap();
|
||||
let mut trashed = self.trashed_folders.lock().unwrap();
|
||||
|
||||
@@ -215,11 +337,11 @@ impl FolderRepository for MockFolderRepository {
|
||||
trashed.insert(id.to_string(), folder);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FolderRepositoryError::NotFound(id.to_string()))
|
||||
Err(DomainError::not_found("Folder", id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_from_trash(&self, id: &str, original_path: &str) -> FolderRepositoryResult<()> {
|
||||
async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> {
|
||||
let mut folders = self.folders.lock().unwrap();
|
||||
let mut trashed = self.trashed_folders.lock().unwrap();
|
||||
|
||||
@@ -227,29 +349,24 @@ impl FolderRepository for MockFolderRepository {
|
||||
folders.insert(id.to_string(), folder);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FolderRepositoryError::NotFound(format!("Folder {} not found in trash", id)))
|
||||
Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_folder_permanently(&self, id: &str) -> FolderRepositoryResult<()> {
|
||||
async fn delete_folder_permanently(&self, id: &str) -> std::result::Result<(), DomainError> {
|
||||
let mut trashed = self.trashed_folders.lock().unwrap();
|
||||
if trashed.remove(id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FolderRepositoryError::NotFound(format!("Folder {} not found in trash", id)))
|
||||
Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Other methods required by the trait (not used in tests)
|
||||
async fn save_folder(&self, _folder: &Folder) -> FolderRepositoryResult<()> { Ok(()) }
|
||||
async fn delete_folder(&self, _id: &str) -> FolderRepositoryResult<()> { Ok(()) }
|
||||
async fn get_folders_in_folder(&self, _parent_id: Option<&str>) -> FolderRepositoryResult<Vec<Folder>> { Ok(vec![]) }
|
||||
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> FolderRepositoryResult<()> { Ok(()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_move_file_to_trash() {
|
||||
@@ -260,7 +377,8 @@ mod tests {
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
@@ -284,10 +402,10 @@ mod tests {
|
||||
assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash");
|
||||
let trash_item = &trash_items[0];
|
||||
|
||||
assert_eq!(trash_item.original_id.to_string(), file_id, "Original ID should match file ID");
|
||||
assert_eq!(trash_item.user_id.to_string(), user_id, "User ID should match");
|
||||
assert_eq!(trash_item.item_type, TrashedItemType::File, "Item type should be File");
|
||||
assert_eq!(trash_item.name, "test.txt", "File name should match");
|
||||
assert_eq!(trash_item.original_id().to_string(), file_id, "Original ID should match file ID");
|
||||
assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match");
|
||||
assert_eq!(*trash_item.item_type(), TrashedItemType::File, "Item type should be File");
|
||||
assert_eq!(trash_item.name(), "test.txt", "File name should match");
|
||||
|
||||
// Verify file is moved in file repository
|
||||
let files = file_repo.files.lock().unwrap();
|
||||
@@ -306,7 +424,8 @@ mod tests {
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
@@ -330,10 +449,10 @@ mod tests {
|
||||
assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash");
|
||||
let trash_item = &trash_items[0];
|
||||
|
||||
assert_eq!(trash_item.original_id.to_string(), folder_id, "Original ID should match folder ID");
|
||||
assert_eq!(trash_item.user_id.to_string(), user_id, "User ID should match");
|
||||
assert_eq!(trash_item.item_type, TrashedItemType::Folder, "Item type should be Folder");
|
||||
assert_eq!(trash_item.name, "test_folder", "Folder name should match");
|
||||
assert_eq!(trash_item.original_id().to_string(), folder_id, "Original ID should match folder ID");
|
||||
assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match");
|
||||
assert_eq!(*trash_item.item_type(), TrashedItemType::Folder, "Item type should be Folder");
|
||||
assert_eq!(trash_item.name(), "test_folder", "Folder name should match");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -345,7 +464,8 @@ mod tests {
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
@@ -361,7 +481,7 @@ mod tests {
|
||||
// Get the trash item ID
|
||||
let user_uuid = Uuid::parse_str(user_id).unwrap();
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
let trash_id = trash_items[0].id.to_string();
|
||||
let trash_id = trash_items[0].id().to_string();
|
||||
|
||||
// Act
|
||||
let result = service.restore_item(&trash_id, user_id).await;
|
||||
@@ -390,7 +510,8 @@ mod tests {
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
@@ -405,7 +526,7 @@ mod tests {
|
||||
// Get the trash item ID
|
||||
let user_uuid = Uuid::parse_str(user_id).unwrap();
|
||||
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
|
||||
let trash_id = trash_items[0].id.to_string();
|
||||
let trash_id = trash_items[0].id().to_string();
|
||||
|
||||
// Act
|
||||
let result = service.delete_permanently(&trash_id, user_id).await;
|
||||
@@ -434,7 +555,8 @@ mod tests {
|
||||
|
||||
let service = TrashService::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone(),
|
||||
file_repo.clone() as Arc<dyn FileReadPort>,
|
||||
file_repo.clone() as Arc<dyn FileWritePort>,
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user