Merge pull request #362 from EdouardVanbelle/feat/copyFolder
This commit is contained in:
@@ -334,6 +334,16 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
|
|||||||
"copy_folder_tree not implemented",
|
"copy_folder_tree not implemented",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copies a folder tree, enforcing that `caller_id` owns both the source folder
|
||||||
|
/// and the target parent folder.
|
||||||
|
async fn copy_folder_tree_owned(
|
||||||
|
&self,
|
||||||
|
source_folder_id: &str,
|
||||||
|
caller_id: Uuid,
|
||||||
|
target_parent_id: Option<String>,
|
||||||
|
dest_name: Option<String>,
|
||||||
|
) -> Result<CopyFolderTreeResult, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Factory for creating file use case implementations
|
/// Factory for creating file use case implementations
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::application::dtos::file_dto::FileDto;
|
|||||||
use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto};
|
use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto};
|
||||||
use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase};
|
use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase};
|
||||||
use crate::application::ports::inbound::FolderUseCase;
|
use crate::application::ports::inbound::FolderUseCase;
|
||||||
|
use crate::application::ports::storage_ports::CopyFolderTreeResult;
|
||||||
use crate::application::ports::trash_ports::TrashUseCase;
|
use crate::application::ports::trash_ports::TrashUseCase;
|
||||||
use crate::application::services::file_management_service::FileManagementService;
|
use crate::application::services::file_management_service::FileManagementService;
|
||||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||||
@@ -616,6 +617,73 @@ impl BatchOperationService {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copies multiple folder trees to a target parent in parallel
|
||||||
|
pub async fn copy_folders(
|
||||||
|
&self,
|
||||||
|
folder_ids: Vec<String>,
|
||||||
|
target_folder_id: Option<String>,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<BatchResult<CopyFolderTreeResult>, BatchOperationError> {
|
||||||
|
info!("Starting batch copy of {} folders", folder_ids.len());
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
|
||||||
|
let mut result = BatchResult {
|
||||||
|
successful: Vec::new(),
|
||||||
|
failed: Vec::new(),
|
||||||
|
stats: BatchStats {
|
||||||
|
total: folder_ids.len(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let target: Option<Arc<str>> = target_folder_id.map(|s| Arc::from(s.as_str()));
|
||||||
|
|
||||||
|
let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| {
|
||||||
|
let file_management = self.file_management.clone();
|
||||||
|
let target = target.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let copy_result = file_management
|
||||||
|
.copy_folder_tree_owned(
|
||||||
|
&folder_id,
|
||||||
|
user_id,
|
||||||
|
target.map(|s| s.to_string()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
(folder_id, copy_result)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffer_unordered(self.config.concurrency.max_concurrent_files);
|
||||||
|
|
||||||
|
while let Some((folder_id, operation_result)) = operation_stream.next().await {
|
||||||
|
match operation_result {
|
||||||
|
Ok(copy_result) => {
|
||||||
|
result.successful.push(copy_result);
|
||||||
|
result.stats.successful += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
result.failed.push((folder_id, e.to_string()));
|
||||||
|
result.stats.failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.stats.execution_time_ms = start_time.elapsed().as_millis();
|
||||||
|
result.stats.max_concurrency = self
|
||||||
|
.config
|
||||||
|
.concurrency
|
||||||
|
.max_concurrent_files
|
||||||
|
.min(result.stats.total);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Batch folder copy completed: {}/{} successful in {}ms",
|
||||||
|
result.stats.successful, result.stats.total, result.stats.execution_time_ms
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
/// Downloads multiple files/folders as a single ZIP archive.
|
/// Downloads multiple files/folders as a single ZIP archive.
|
||||||
///
|
///
|
||||||
/// Writes the archive to a temporary file so RAM usage is O(buffer_size)
|
/// Writes the archive to a temporary file so RAM usage is O(buffer_size)
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ impl FileManagementUseCase for FileManagementService {
|
|||||||
target_folder_id: Option<String>,
|
target_folder_id: Option<String>,
|
||||||
) -> Result<FileDto, DomainError> {
|
) -> Result<FileDto, DomainError> {
|
||||||
self.verify_owner(file_id, caller_id).await?;
|
self.verify_owner(file_id, caller_id).await?;
|
||||||
|
self.verify_target_folder_owner(&target_folder_id, caller_id)
|
||||||
|
.await?;
|
||||||
self.copy_file(file_id, target_folder_id).await
|
self.copy_file(file_id, target_folder_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,4 +328,31 @@ impl FileManagementUseCase for FileManagementService {
|
|||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn copy_folder_tree_owned(
|
||||||
|
&self,
|
||||||
|
source_folder_id: &str,
|
||||||
|
caller_id: Uuid,
|
||||||
|
target_parent_id: Option<String>,
|
||||||
|
dest_name: Option<String>,
|
||||||
|
) -> Result<CopyFolderTreeResult, DomainError> {
|
||||||
|
if let Some(folder_repo) = &self.folder_repo {
|
||||||
|
let owner = folder_repo.get_folder_user_id(source_folder_id).await?;
|
||||||
|
if owner != caller_id {
|
||||||
|
return Err(DomainError::not_found(
|
||||||
|
"Folder",
|
||||||
|
"Source folder not found or access denied",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(DomainError::internal_error(
|
||||||
|
"FileManagement",
|
||||||
|
"Folder ownership verification unavailable",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.verify_target_folder_owner(&target_parent_id, caller_id)
|
||||||
|
.await?;
|
||||||
|
self.copy_folder_tree(source_folder_id, target_parent_id, dest_name)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,12 +404,7 @@ impl FolderUseCase for FolderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the folder exists and belongs to the caller
|
// Verify the folder exists and belongs to the caller
|
||||||
let existing_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
let existing_folder = self.folder_storage.get_folder(id).await?;
|
||||||
DomainError::internal_error(
|
|
||||||
"FolderStorage",
|
|
||||||
format!("Failed to get folder with ID: {} for renaming: {}", id, e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if existing_folder.owner_id() != Some(caller_id) {
|
if existing_folder.owner_id() != Some(caller_id) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -444,12 +439,7 @@ impl FolderUseCase for FolderService {
|
|||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
) -> Result<FolderDto, DomainError> {
|
) -> Result<FolderDto, DomainError> {
|
||||||
// Verify the source folder exists and belongs to the caller
|
// Verify the source folder exists and belongs to the caller
|
||||||
let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
let source_folder = self.folder_storage.get_folder(id).await?;
|
||||||
DomainError::internal_error(
|
|
||||||
"FolderStorage",
|
|
||||||
format!("Failed to get folder with ID: {} for moving: {}", id, e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if source_folder.owner_id() != Some(caller_id) {
|
if source_folder.owner_id() != Some(caller_id) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -461,7 +451,7 @@ impl FolderUseCase for FolderService {
|
|||||||
return Err(DomainError::not_found("Folder", id));
|
return Err(DomainError::not_found("Folder", id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a parent_id is specified, verify it exists
|
// If a parent_id is specified, verify it exists and belongs to the caller
|
||||||
if let Some(parent_id) = &dto.parent_id {
|
if let Some(parent_id) = &dto.parent_id {
|
||||||
// Verify we are not trying to move the folder into itself or one of its descendants
|
// Verify we are not trying to move the folder into itself or one of its descendants
|
||||||
if parent_id == id {
|
if parent_id == id {
|
||||||
@@ -472,9 +462,19 @@ impl FolderUseCase for FolderService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the destination exists
|
// Verify the destination exists and is owned by the caller
|
||||||
let parent_exists = self.folder_storage.get_folder(parent_id).await.is_ok();
|
let parent = self
|
||||||
if !parent_exists {
|
.folder_storage
|
||||||
|
.get_folder(parent_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| DomainError::not_found("Folder", parent_id))?;
|
||||||
|
if parent.owner_id() != Some(caller_id) {
|
||||||
|
tracing::warn!(
|
||||||
|
"move_folder: user '{}' attempted to move into folder '{}' owned by '{:?}'",
|
||||||
|
caller_id,
|
||||||
|
parent_id,
|
||||||
|
parent.owner_id()
|
||||||
|
);
|
||||||
return Err(DomainError::not_found("Folder", parent_id));
|
return Err(DomainError::not_found("Folder", parent_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,12 +500,7 @@ impl FolderUseCase for FolderService {
|
|||||||
/// Deletes a folder after verifying ownership.
|
/// Deletes a folder after verifying ownership.
|
||||||
async fn delete_folder(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
async fn delete_folder(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
||||||
// Verify the folder exists and belongs to the caller
|
// Verify the folder exists and belongs to the caller
|
||||||
let folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
let folder = self.folder_storage.get_folder(id).await?;
|
||||||
DomainError::internal_error(
|
|
||||||
"FolderStorage",
|
|
||||||
format!("Failed to get folder with ID: {} for deletion: {}", id, e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if folder.owner_id() != Some(caller_id) {
|
if folder.owner_id() != Some(caller_id) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|||||||
@@ -671,6 +671,19 @@ impl FileManagementUseCase for StubFileManagementUseCase {
|
|||||||
) -> Result<FileDto, DomainError> {
|
) -> Result<FileDto, DomainError> {
|
||||||
Ok(FileDto::default())
|
Ok(FileDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn copy_folder_tree_owned(
|
||||||
|
&self,
|
||||||
|
_source_folder_id: &str,
|
||||||
|
_caller_id: Uuid,
|
||||||
|
_target_parent_id: Option<String>,
|
||||||
|
_dest_name: Option<String>,
|
||||||
|
) -> Result<crate::application::ports::storage_ports::CopyFolderTreeResult, DomainError> {
|
||||||
|
Err(DomainError::internal_error(
|
||||||
|
"StubFileManagement",
|
||||||
|
"copy_folder_tree_owned not implemented",
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use utoipa::ToSchema;
|
|||||||
|
|
||||||
use crate::application::dtos::file_dto::FileDto;
|
use crate::application::dtos::file_dto::FileDto;
|
||||||
use crate::application::dtos::folder_dto::FolderDto;
|
use crate::application::dtos::folder_dto::FolderDto;
|
||||||
|
use crate::application::ports::storage_ports::CopyFolderTreeResult;
|
||||||
use crate::application::services::batch_operations::{
|
use crate::application::services::batch_operations::{
|
||||||
BatchOperationService, BatchResult, BatchStats,
|
BatchOperationService, BatchResult, BatchStats,
|
||||||
};
|
};
|
||||||
@@ -851,6 +852,90 @@ pub async fn move_folders_batch(
|
|||||||
Ok((status_code, Json(response)).into_response())
|
Ok((status_code, Json(response)).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DTO returned for each successfully copied folder tree
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct CopiedFolderDto {
|
||||||
|
/// UUID of the newly created root folder
|
||||||
|
pub new_root_folder_id: String,
|
||||||
|
/// Total folders created (including root)
|
||||||
|
pub folders_copied: i64,
|
||||||
|
/// Total files copied (zero-copy via dedup)
|
||||||
|
pub files_copied: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CopyFolderTreeResult> for CopiedFolderDto {
|
||||||
|
fn from(r: CopyFolderTreeResult) -> Self {
|
||||||
|
Self {
|
||||||
|
new_root_folder_id: r.new_root_folder_id,
|
||||||
|
folders_copied: r.folders_copied,
|
||||||
|
files_copied: r.files_copied,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler for copying multiple folder trees in batch
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/batch/folders/copy",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "All folders copied"),
|
||||||
|
(status = 206, description = "Partial success"),
|
||||||
|
(status = 400, description = "Bad request"),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
),
|
||||||
|
tag = "batch"
|
||||||
|
)]
|
||||||
|
pub async fn copy_folders_batch(
|
||||||
|
State(state): State<BatchHandlerState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Json(request): Json<BatchFolderOperationRequest>,
|
||||||
|
) -> ApiResult<impl IntoResponse> {
|
||||||
|
if request.folder_ids.is_empty() {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "No folder IDs provided"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
if request.folder_ids.len() > MAX_BATCH_SIZE {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("Batch size {} exceeds maximum of {}", request.folder_ids.len(), MAX_BATCH_SIZE)
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = state
|
||||||
|
.batch_service
|
||||||
|
.copy_folders(request.folder_ids, request.target_folder_id, auth_user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Batch copy_folders failed: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch operation failed".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let response: BatchOperationResponse<CopiedFolderDto> = result.into();
|
||||||
|
|
||||||
|
let status_code = if response.stats.failed > 0 {
|
||||||
|
if response.stats.successful > 0 {
|
||||||
|
StatusCode::PARTIAL_CONTENT
|
||||||
|
} else {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StatusCode::OK
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((status_code, Json(response)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
// Hander as a workarround for drag & drop (does not support POST requests)
|
// Hander as a workarround for drag & drop (does not support POST requests)
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
.route("/folders/delete", post(batch_handler::delete_folders_batch))
|
.route("/folders/delete", post(batch_handler::delete_folders_batch))
|
||||||
.route("/folders/create", post(batch_handler::create_folders_batch))
|
.route("/folders/create", post(batch_handler::create_folders_batch))
|
||||||
.route("/folders/get", post(batch_handler::get_folders_batch))
|
.route("/folders/get", post(batch_handler::get_folders_batch))
|
||||||
|
.route("/folders/copy", post(batch_handler::copy_folders_batch))
|
||||||
.route("/folders/move", post(batch_handler::move_folders_batch))
|
.route("/folders/move", post(batch_handler::move_folders_batch))
|
||||||
// Trash operations (soft delete)
|
// Trash operations (soft delete)
|
||||||
.route("/trash", post(batch_handler::trash_batch))
|
.route("/trash", post(batch_handler::trash_batch))
|
||||||
|
|||||||
+4
-27
@@ -997,18 +997,8 @@ const ui = {
|
|||||||
setContextTarget(card, info);
|
setContextTarget(card, info);
|
||||||
const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu';
|
const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu';
|
||||||
const menu = document.getElementById(menuId);
|
const menu = document.getElementById(menuId);
|
||||||
if (contextMenus && typeof contextMenus.syncFavoriteOptionLabels === 'function') {
|
contextMenus.sync();
|
||||||
contextMenus.syncFavoriteOptionLabels();
|
|
||||||
}
|
|
||||||
if (contextMenus && typeof contextMenus.syncWopiOptionVisibility === 'function') {
|
|
||||||
contextMenus.syncWopiOptionVisibility().catch(() => {});
|
|
||||||
}
|
|
||||||
if (contextMenus && typeof contextMenus.syncAddToPlaylistOption === 'function') {
|
|
||||||
contextMenus.syncAddToPlaylistOption();
|
|
||||||
}
|
|
||||||
if (contextMenus && typeof contextMenus.syncOpenParentFolderOption === 'function') {
|
|
||||||
contextMenus.syncOpenParentFolderOption();
|
|
||||||
}
|
|
||||||
if (menu) {
|
if (menu) {
|
||||||
menu.style.left = `${e.pageX}px`;
|
menu.style.left = `${e.pageX}px`;
|
||||||
menu.style.top = `${e.pageY}px`;
|
menu.style.top = `${e.pageY}px`;
|
||||||
@@ -1201,9 +1191,7 @@ const ui = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep context-menu label in sync if available
|
// Keep context-menu label in sync if available
|
||||||
if (contextMenus && typeof contextMenus.syncFavoriteOptionLabels === 'function') {
|
contextMenus.syncFavoriteOptionLabels();
|
||||||
contextMenus.syncFavoriteOptionLabels();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const shared = el.querySelector('.file-badge-shared');
|
const shared = el.querySelector('.file-badge-shared');
|
||||||
@@ -1547,18 +1535,7 @@ function showContextMenuAtElement(triggerElement, menuId) {
|
|||||||
top = rect.top - 4 + window.scrollY; // flip above if no room
|
top = rect.top - 4 + window.scrollY; // flip above if no room
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contextMenus && typeof contextMenus.syncFavoriteOptionLabels === 'function') {
|
contextMenus.sync();
|
||||||
contextMenus.syncFavoriteOptionLabels();
|
|
||||||
}
|
|
||||||
if (contextMenus && typeof contextMenus.syncWopiOptionVisibility === 'function') {
|
|
||||||
contextMenus.syncWopiOptionVisibility().catch(() => {});
|
|
||||||
}
|
|
||||||
if (contextMenus && typeof contextMenus.syncAddToPlaylistOption === 'function') {
|
|
||||||
contextMenus.syncAddToPlaylistOption();
|
|
||||||
}
|
|
||||||
if (contextMenus && typeof contextMenus.syncOpenParentFolderOption === 'function') {
|
|
||||||
contextMenus.syncOpenParentFolderOption();
|
|
||||||
}
|
|
||||||
|
|
||||||
menu.style.left = `${left}px`;
|
menu.style.left = `${left}px`;
|
||||||
menu.style.top = `${top}px`;
|
menu.style.top = `${top}px`;
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ const contextMenus = {
|
|||||||
const option = document.getElementById('open-parent-folder-option');
|
const option = document.getElementById('open-parent-folder-option');
|
||||||
if (!option) return;
|
if (!option) return;
|
||||||
const folderId = app?.contextMenuTargetFile?.folder_id;
|
const folderId = app?.contextMenuTargetFile?.folder_id;
|
||||||
const alreadyViewing = folderId && folderId === app?.currentPath;
|
const isFilesSection = app.currentSection === 'files';
|
||||||
option.classList.toggle('hidden', !folderId || alreadyViewing);
|
option.classList.toggle('hidden', !folderId || isFilesSection);
|
||||||
},
|
},
|
||||||
|
|
||||||
syncAddToPlaylistOption() {
|
syncAddToPlaylistOption() {
|
||||||
@@ -87,6 +87,12 @@ const contextMenus = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sync() {
|
||||||
|
this.syncFavoriteOptionLabels();
|
||||||
|
this.syncWopiOptionVisibility().catch(() => {});
|
||||||
|
this.syncAddToPlaylistOption();
|
||||||
|
this.syncOpenParentFolderOption();
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Assign events to menu items and dialogs
|
* Assign events to menu items and dialogs
|
||||||
*/
|
*/
|
||||||
@@ -688,8 +694,8 @@ const contextMenus = {
|
|||||||
/**
|
/**
|
||||||
* Load all folders for the move dialog (batch operations)
|
* Load all folders for the move dialog (batch operations)
|
||||||
* Uses the same navigation pattern as loadMoveDialogFolders
|
* Uses the same navigation pattern as loadMoveDialogFolders
|
||||||
* @param {string} itemId - ID of the item being moved (unused, kept for compatibility)
|
* @param {string} _itemId - ID of the item being moved (unused, kept for compatibility)
|
||||||
* @param {string} mode - 'batch' for batch operations
|
* @param {string} _mode - 'batch' for batch operations
|
||||||
*/
|
*/
|
||||||
async loadAllFolders(_itemId, _mode) {
|
async loadAllFolders(_itemId, _mode) {
|
||||||
// For batch mode, use the same navigation as regular move dialog
|
// For batch mode, use the same navigation as regular move dialog
|
||||||
@@ -731,13 +737,13 @@ const contextMenus = {
|
|||||||
if (itemName) itemName.textContent = item.name;
|
if (itemName) itemName.textContent = item.name;
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
const pwField = document.getElementById('share-password');
|
const pwField = /** @type HTMLInputElement */ (document.getElementById('share-password'));
|
||||||
const expField = document.getElementById('share-expiration');
|
const expField = /** @type HTMLInputElement */ (document.getElementById('share-expiration'));
|
||||||
if (pwField) pwField.value = '';
|
if (pwField) pwField.value = '';
|
||||||
if (expField) expField.value = '';
|
if (expField) expField.value = '';
|
||||||
const permRead = document.getElementById('share-permission-read');
|
const permRead = /** @type HTMLInputElement */ (document.getElementById('share-permission-read'));
|
||||||
const permWrite = document.getElementById('share-permission-write');
|
const permWrite = /** @type HTMLInputElement */ (document.getElementById('share-permission-write'));
|
||||||
const permReshare = document.getElementById('share-permission-reshare');
|
const permReshare = /** @type HTMLInputElement */ (document.getElementById('share-permission-reshare'));
|
||||||
if (permRead) permRead.checked = true;
|
if (permRead) permRead.checked = true;
|
||||||
if (permWrite) permWrite.checked = false;
|
if (permWrite) permWrite.checked = false;
|
||||||
if (permReshare) permReshare.checked = false;
|
if (permReshare) permReshare.checked = false;
|
||||||
@@ -862,11 +868,11 @@ const contextMenus = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get values from form
|
// Get values from form
|
||||||
const password = document.getElementById('share-password').value;
|
const password = /** @type HTMLInputElement */ (document.getElementById('share-password')).value;
|
||||||
const expirationDate = document.getElementById('share-expiration').value;
|
const expirationDate = /** @type HTMLInputElement */ (document.getElementById('share-expiration')).value;
|
||||||
const permissionRead = document.getElementById('share-permission-read').checked;
|
const permissionRead = /** @type HTMLInputElement */ (document.getElementById('share-permission-read')).checked;
|
||||||
const permissionWrite = document.getElementById('share-permission-write').checked;
|
const permissionWrite = /** @type HTMLInputElement */ (document.getElementById('share-permission-write')).checked;
|
||||||
const permissionReshare = document.getElementById('share-permission-reshare').checked;
|
const permissionReshare = /** @type HTMLInputElement */ (document.getElementById('share-permission-reshare')).checked;
|
||||||
|
|
||||||
const item = app.shareDialogItem;
|
const item = app.shareDialogItem;
|
||||||
const itemType = app.shareDialogItemType;
|
const itemType = app.shareDialogItemType;
|
||||||
@@ -905,7 +911,7 @@ const contextMenus = {
|
|||||||
const shareInfo = await response.json();
|
const shareInfo = await response.json();
|
||||||
|
|
||||||
// Update UI with new share
|
// Update UI with new share
|
||||||
const shareUrl = document.getElementById('generated-share-url');
|
const shareUrl = /** @type HTMLInputElement */ (document.getElementById('generated-share-url'));
|
||||||
if (shareUrl) {
|
if (shareUrl) {
|
||||||
shareUrl.value = shareInfo.url;
|
shareUrl.value = shareInfo.url;
|
||||||
document.getElementById('new-share-section').classList.remove('hidden');
|
document.getElementById('new-share-section').classList.remove('hidden');
|
||||||
@@ -920,7 +926,7 @@ const contextMenus = {
|
|||||||
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
|
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating shared link:', error);
|
console.error('Error creating shared link:', error);
|
||||||
ui.showNotification('Error', error.message || 'Could not create shared link');
|
ui.showNotification('Error', /** @type {Error} */ (error).message || 'Could not create shared link');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -931,8 +937,8 @@ const contextMenus = {
|
|||||||
showEmailNotificationDialog(shareUrl) {
|
showEmailNotificationDialog(shareUrl) {
|
||||||
// Update dialog content
|
// Update dialog content
|
||||||
document.getElementById('notification-share-url').textContent = shareUrl;
|
document.getElementById('notification-share-url').textContent = shareUrl;
|
||||||
document.getElementById('notification-email').value = '';
|
/** @type HTMLInputElement */ (document.getElementById('notification-email')).value = '';
|
||||||
document.getElementById('notification-message').value = '';
|
/** @type HTMLInputElement */ (document.getElementById('notification-message')).value = '';
|
||||||
|
|
||||||
// Store the URL for later use
|
// Store the URL for later use
|
||||||
app.notificationShareUrl = shareUrl;
|
app.notificationShareUrl = shareUrl;
|
||||||
@@ -945,8 +951,8 @@ const contextMenus = {
|
|||||||
* Send share notification email
|
* Send share notification email
|
||||||
*/
|
*/
|
||||||
sendShareNotification() {
|
sendShareNotification() {
|
||||||
const email = document.getElementById('notification-email').value.trim();
|
const email = /** @type HTMLInputElement */ (document.getElementById('notification-email')).value.trim();
|
||||||
const message = document.getElementById('notification-message').value.trim();
|
const message = /** @type HTMLInputElement */ (document.getElementById('notification-message')).value.trim();
|
||||||
const shareUrl = app.notificationShareUrl;
|
const shareUrl = app.notificationShareUrl;
|
||||||
|
|
||||||
if (!email || !shareUrl) {
|
if (!email || !shareUrl) {
|
||||||
@@ -1013,7 +1019,7 @@ const contextMenus = {
|
|||||||
container.innerHTML = '<div class="folder-select-loading"><i class="fas fa-spinner fa-spin"></i></div>';
|
container.innerHTML = '<div class="folder-select-loading"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||||
|
|
||||||
// Reset add button state
|
// Reset add button state
|
||||||
const addBtn = document.getElementById('playlist-add-btn');
|
const addBtn = /** @type {HTMLButtonElement} */ (document.getElementById('playlist-add-btn'));
|
||||||
if (addBtn) addBtn.disabled = true;
|
if (addBtn) addBtn.disabled = true;
|
||||||
|
|
||||||
// Show dialog
|
// Show dialog
|
||||||
@@ -1057,7 +1063,7 @@ const contextMenus = {
|
|||||||
});
|
});
|
||||||
item.classList.add('selected');
|
item.classList.add('selected');
|
||||||
this._selectedPlaylistId = playlist.id;
|
this._selectedPlaylistId = playlist.id;
|
||||||
const addBtn = document.getElementById('playlist-add-btn');
|
const addBtn = /** @type {HTMLButtonElement} */ (document.getElementById('playlist-add-btn'));
|
||||||
if (addBtn) addBtn.disabled = false;
|
if (addBtn) addBtn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1071,7 +1077,7 @@ const contextMenus = {
|
|||||||
|
|
||||||
if (!playlistId || files.length === 0) return;
|
if (!playlistId || files.length === 0) return;
|
||||||
|
|
||||||
const addBtn = document.getElementById('playlist-add-btn');
|
const addBtn = /** @type {HTMLButtonElement} */ (document.getElementById('playlist-add-btn'));
|
||||||
if (addBtn) addBtn.disabled = true;
|
if (addBtn) addBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1101,7 +1107,7 @@ const contextMenus = {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error adding to playlist:', err);
|
console.error('Error adding to playlist:', err);
|
||||||
ui.showNotification(i18n.t('music.error'), err.message || i18n.t('music.add_error'));
|
ui.showNotification(i18n.t('music.error'), /** @type {Error} */ (err).message || i18n.t('music.add_error'));
|
||||||
if (addBtn) addBtn.disabled = false;
|
if (addBtn) addBtn.disabled = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ const fileOps = {
|
|||||||
safeUpdateFile(0, 'error');
|
safeUpdateFile(0, 'error');
|
||||||
finalize({
|
finalize({
|
||||||
ok: false,
|
ok: false,
|
||||||
errorMsg: `Client send() failed: ${e?.message || 'unknown error'}`
|
errorMsg: `Client send() failed: ${/** @type {Error} */ (e)?.message || 'unknown error'}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -286,7 +286,7 @@ const fileOps = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Legacy progress bar (inside dropzone) — keep working for drag-drop
|
// Legacy progress bar (inside dropzone) — keep working for drag-drop
|
||||||
const progressBar = document.querySelector('.progress-fill');
|
const progressBar = /** @type {HTMLDivElement} */ (document.querySelector('.progress-fill'));
|
||||||
const uploadProgressDiv = document.querySelector('.upload-progress');
|
const uploadProgressDiv = document.querySelector('.upload-progress');
|
||||||
if (uploadProgressDiv) {
|
if (uploadProgressDiv) {
|
||||||
uploadProgressDiv.classList.remove('hidden');
|
uploadProgressDiv.classList.remove('hidden');
|
||||||
@@ -447,7 +447,7 @@ const fileOps = {
|
|||||||
}
|
}
|
||||||
this._isUploading = true;
|
this._isUploading = true;
|
||||||
|
|
||||||
const progressBar = document.querySelector('.progress-fill');
|
const progressBar = /** @type {HTMLDivElement} */ (document.querySelector('.progress-fill'));
|
||||||
const uploadProgressDiv = document.querySelector('.upload-progress');
|
const uploadProgressDiv = document.querySelector('.upload-progress');
|
||||||
if (uploadProgressDiv) {
|
if (uploadProgressDiv) {
|
||||||
uploadProgressDiv.classList.remove('hidden');
|
uploadProgressDiv.classList.remove('hidden');
|
||||||
@@ -928,23 +928,31 @@ const fileOps = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy a folder to another folder
|
* Copy a folder to another folder
|
||||||
* Note: Backend folder copy is not yet implemented, this shows a notification
|
|
||||||
* @param {string} folderId - Folder ID
|
* @param {string} folderId - Folder ID
|
||||||
* @param {string} targetFolderId - Target folder ID
|
* @param {string} targetFolderId - Target folder ID
|
||||||
* @returns {Promise<boolean>} - Success status
|
* @returns {Promise<boolean>} - Success status
|
||||||
*/
|
*/
|
||||||
async copyFolder(_folderId, _targetFolderId) {
|
async copyFolder(folderId, targetFolderId) {
|
||||||
// Folder copy is not yet implemented in the backend
|
const res = await fetch('/api/batch/folders/copy', {
|
||||||
ui.showNotification('Not implemented', 'Folder copy is not yet supported');
|
method: 'POST',
|
||||||
return false;
|
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ folder_ids: [folderId], target_folder_id: targetFolderId })
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BatchCopyReturn
|
||||||
|
* @property {number} success
|
||||||
|
* @property {number} errors
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy files & folders
|
* Copy files & folders
|
||||||
* @param {string[]} fileIds - File IDs
|
* @param {string[]} fileIds - File IDs
|
||||||
* @param {string[]} folderIds - Folder IDs
|
* @param {string[]} folderIds - Folder IDs
|
||||||
* @param {string} targetFolderId - Target folder ID
|
* @param {string} targetFolderId - Target folder ID
|
||||||
* @returns {Promise<boolean>} - Success status
|
* @returns {Promise<BatchCopyReturn>} - Success status
|
||||||
*/
|
*/
|
||||||
async batchCopy(fileIds, folderIds, targetFolderId) {
|
async batchCopy(fileIds, folderIds, targetFolderId) {
|
||||||
// FIXME ensure not moving a folder into itself
|
// FIXME ensure not moving a folder into itself
|
||||||
@@ -964,13 +972,22 @@ const fileOps = {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
success += data.stats?.successful || 0;
|
success += data.stats?.successful || 0;
|
||||||
errors += data.stats?.failed || 0;
|
errors += data.stats?.failed || (!res.ok && !data.stats ? fileIds.length : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Folder copy is not yet implemented in batch API
|
// Batch copy folders
|
||||||
if (folderIds.length > 0) {
|
if (folderIds.length > 0) {
|
||||||
ui.showNotification('Info', 'Folder copy is not yet supported in batch mode');
|
const res = await fetch('/api/batch/folders/copy', {
|
||||||
errors += folderIds.lenngth;
|
method: 'POST',
|
||||||
|
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder_ids: folderIds,
|
||||||
|
target_folder_id: targetFolderId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
success += data.stats?.successful || 0;
|
||||||
|
errors += data.stats?.failed || (!res.ok && !data.stats ? folderIds.length : 0);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Batch copy error:', err);
|
console.error('Batch copy error:', err);
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud – Batch folder copy end-to-end scenario
|
||||||
|
# =============================================================
|
||||||
|
# Verifies that copying a folder (containing one file) into a
|
||||||
|
# target folder produces an independent copy: the copied folder
|
||||||
|
# appears inside the target, and its child file is present.
|
||||||
|
#
|
||||||
|
# Run standalone:
|
||||||
|
# hurl --variables-file tests/api/test.env --test \
|
||||||
|
# tests/api/batch_folder_copy.hurl
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 – Login
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "{{username}}",
|
||||||
|
"password": "{{password}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
token: jsonpath "$.access_token"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.access_token" isString
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 – Create source folder
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"name": "hurl-copy-source"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
source_folder_id: jsonpath "$.id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.name" == "hurl-copy-source"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 – Upload one file into the source folder
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{source_folder_id}}
|
||||||
|
file: file,fixtures/hello.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
source_file_id: jsonpath "$.id"
|
||||||
|
source_file_name: jsonpath "$.name"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.folder_id" == "{{source_folder_id}}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 – Create target folder (copy destination)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"name": "hurl-copy-target"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
target_folder_id: jsonpath "$.id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.name" == "hurl-copy-target"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 – Copy source folder into target folder
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/batch/folders/copy
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"folder_ids": ["{{source_folder_id}}"],
|
||||||
|
"target_folder_id": "{{target_folder_id}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
new_root_folder_id: jsonpath "$.successful[0].new_root_folder_id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.stats.successful" == 1
|
||||||
|
jsonpath "$.stats.failed" == 0
|
||||||
|
jsonpath "$.successful[0].new_root_folder_id" isString
|
||||||
|
jsonpath "$.successful[0].folders_copied" >= 1
|
||||||
|
jsonpath "$.successful[0].files_copied" == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 – Verify the copied folder is inside the target folder
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/folders/{{target_folder_id}}/listing
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.folders" count == 1
|
||||||
|
jsonpath "$.folders[0].id" == "{{new_root_folder_id}}"
|
||||||
|
jsonpath "$.folders[0].name" == "hurl-copy-source"
|
||||||
|
jsonpath "$.files" count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 7 – Verify the copied file is inside the copied folder
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/files?folder_id={{new_root_folder_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].name" == "{{source_file_name}}"
|
||||||
|
jsonpath "$[0].id" != "{{source_file_id}}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 8 – Verify the original source folder is unchanged
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/files?folder_id={{source_folder_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].id" == "{{source_file_id}}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 9 – Cleanup: delete the copied folder tree
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/folders/{{new_root_folder_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 10 – Verify source folder and file are still intact
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/folders/{{source_folder_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" == "{{source_folder_id}}"
|
||||||
|
jsonpath "$.name" == "hurl-copy-source"
|
||||||
|
|
||||||
|
GET {{base_url}}/api/files?folder_id={{source_folder_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].id" == "{{source_file_id}}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 11 – Cleanup: delete source file and folders
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/files/{{source_file_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/folders/{{source_folder_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/folders/{{target_folder_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
@@ -167,7 +167,7 @@ file_id: jsonpath "$.id"
|
|||||||
jsonpath "$.id" isString
|
jsonpath "$.id" isString
|
||||||
jsonpath "$.name" == "hello.txt"
|
jsonpath "$.name" == "hello.txt"
|
||||||
jsonpath "$.folder_id" == {{test2_id}}
|
jsonpath "$.folder_id" == {{test2_id}}
|
||||||
jsonpath "$.size" == 21
|
jsonpath "$.size" == 32
|
||||||
jsonpath "$.mime_type" == "text/plain"
|
jsonpath "$.mime_type" == "text/plain"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
|||||||
"$API_DIR/favorites.hurl" \
|
"$API_DIR/favorites.hurl" \
|
||||||
"$API_DIR/trash.hurl" \
|
"$API_DIR/trash.hurl" \
|
||||||
"$API_DIR/recent.hurl" \
|
"$API_DIR/recent.hurl" \
|
||||||
|
"$API_DIR/batch_folder_copy.hurl" \
|
||||||
"$API_DIR/contacts.hurl"
|
"$API_DIR/contacts.hurl"
|
||||||
|
|
||||||
log "All tests passed."
|
log "All tests passed."
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
hello from hurl test
|
Hello from OxiCloud Hurl tests.
|
||||||
|
|||||||
Reference in New Issue
Block a user