agent_diva_files/channel.rs
1//! # 频道文件管理模块 - Channel File Management Module
2//!
3//! 本模块实现了**频道文件上传支持**,允许在逻辑上隔离的文件管理。
4//! 一个文件可以属于多个频道,实现共享和复用。
5//!
6//! ## 核心概念
7//!
8//! ### 频道 vs 文件
9//!
10//! 在这个实现中,**文件存储是全局的**(复用 SHA256 去重),
11//! 但每个文件可以与多个**频道**关联。
12//!
13//! ```text
14//! 全局文件存储:
15//! sha256:abc123 → /data/ab/c123 (物理存储)
16//! ↑
17//! │ 引用计数 ref_count = 3
18//!
19//! 频道关联:
20//! channel:telegram:chat_1 → sha256:abc123 (通过 channel_files 表)
21//! channel:discord:server_2 → sha256:abc123
22//! ```
23//!
24//! 这样,同一个文件在物理上只存储一份,但在逻辑上可以属于多个频道。
25//!
26//! ### 数据库设计
27//!
28//! 新增 `channel_files` 关联表:
29//!
30//! | 字段 | 类型 | 说明 |
31//! |------|------|------|
32//! | id | INTEGER | 主键 |
33//! | channel_id | TEXT | 频道标识符 |
34//! | file_id | TEXT | 关联的文件ID (外键) |
35//! | uploaded_by | TEXT | 上传者标识 |
36//! | uploaded_at | TEXT | 上传时间 |
37//! | message_id | TEXT | 关联的消息ID (可选) |
38//!
39//! ## 使用场景
40//!
41//! ### 场景1: Telegram 群组文件管理
42//!
43//! ```ignore
44//! // 上传文件到 Telegram 频道
45//! let handle = channel_manager
46//! .upload_to_channel("telegram:chat_123", data, metadata)
47//! .await?;
48//!
49//! // 列出该群组的所有文件
50//! let files = channel_manager.list_channel_files("telegram:chat_123").await?;
51//!
52//! // 获取特定文件
53//! let file = channel_manager.get_channel_file("telegram:chat_123", &handle.id).await?;
54//! ```
55//!
56//! ### 场景2: Discord 服务器文件共享
57//!
58//! ```ignore
59//! // 在 Discord 服务器上传文件
60//! let handle = channel_manager
61//! .upload_to_channel("discord:server_456:channel_789", data, metadata)
62//! .await?;
63//!
64//! // 如果同一个文件已在其他频道存在,直接创建关联(节省存储)
65//! // 文件ID相同,但 channel_files 表中会新增一条记录
66//! ```
67//!
68//! ### 场景3: 清理频道时保留共享文件
69//!
70//! ```ignore
71//! // 删除频道时,cleanup=false 只移除关联,不删除物理文件
72//! channel_manager.delete_channel("discord:server_456:channel_789", false).await?;
73//!
74//! // 如果其他频道还在用这个文件,物理文件不会被删除
75//! // 只有当 ref_count = 0 且没有任何频道关联时,才会被清理
76//! ```
77//!
78//! ## ChannelManager 使用方法
79//!
80//! ### 基本用法
81//!
82//! ```rust,ignore
83//! use agent_diva_files::{FileManager, channel::{ChannelManager, ChannelFileInfo}};
84//!
85//! // 创建 ChannelManager(需要已有的 FileManager)
86//! let channel_manager = ChannelManager::new(file_manager.clone());
87//!
88//! // 上传文件到频道
89//! let handle = channel_manager
90//! .upload_to_channel("my-channel", b"hello world", metadata)
91//! .await?;
92//!
93//! // 列出频道文件
94//! let files = channel_manager.list_channel_files("my-channel").await?;
95//! for file_info in files {
96//! println!("File: {} (uploaded by {:?})",
97//! file_info.file.id,
98//! file_info.uploaded_by);
99//! }
100//! ```
101//!
102//! ## 频道ID格式约定
103//!
104//! 频道ID的格式由应用层决定,建议格式:
105//!
106//! | 平台 | 格式 | 示例 |
107//! |------|------|------|
108//! | Telegram | `telegram:chat_{id}` | `telegram:chat_123456` |
109//! | Discord | `discord:{server}:{channel}` | `discord:987654:channel_111` |
110//! | Slack | `slack:{team}:{channel}` | `slack:T01234:C56789` |
111//! | UI | `ui:project_{id}` | `ui:project_42` |
112//!
113//! 格式约定的目的是:
114//! - 避免不同平台的文件冲突
115//! - 便于调试和追踪
116//! - 但 ChannelManager 本身不强制验证格式
117
118use crate::handle::{FileHandle, FileIndexEntry, FileMetadata};
119use crate::manager::FileManager;
120use crate::Result;
121use chrono::{DateTime, Utc};
122use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
123use sqlx::{Row, SqlitePool};
124use std::path::PathBuf;
125use std::sync::Arc;
126
127/// Channel file information - combines file entry with channel-specific metadata
128#[derive(Debug, Clone)]
129pub struct ChannelFileInfo {
130 /// The file entry (shared across all channels)
131 pub file: FileIndexEntry,
132
133 /// Who uploaded this file to this channel
134 pub uploaded_by: Option<String>,
135
136 /// When the file was uploaded to this channel
137 pub uploaded_at: DateTime<Utc>,
138
139 /// Associated message ID (platform-specific)
140 pub message_id: Option<String>,
141}
142
143/// Channel statistics
144#[derive(Debug, Clone)]
145pub struct ChannelStats {
146 /// The channel ID
147 pub channel_id: String,
148
149 /// Total number of file references in this channel
150 pub total_files: usize,
151
152 /// Total size of all files in this channel (accounting for sharing)
153 pub total_size: u64,
154
155 /// Number of files that are unique to this channel
156 /// (files not shared with any other channel)
157 pub unique_files: usize,
158}
159
160/// Channel manager - handles channel-specific file operations
161///
162/// Provides logical isolation and tracking for files across different channels.
163/// The actual file storage is global and shared via SHA256 deduplication.
164pub struct ChannelManager {
165 /// Shared file manager for actual storage
166 file_manager: Arc<FileManager>,
167
168 /// Database pool for channel-specific metadata
169 pool: SqlitePool,
170
171 /// Database path (for debugging/reconnection)
172 #[allow(dead_code)]
173 db_path: PathBuf,
174}
175
176impl ChannelManager {
177 /// Create a new channel manager
178 ///
179 /// # Arguments
180 /// * `file_manager` - Shared FileManager for file storage
181 /// * `db_path` - Path to the channel metadata database
182 ///
183 /// # Example
184 /// ```ignore
185 /// use agent_diva_files::FileManager;
186 /// use agent_diva_files::channel::ChannelManager;
187 ///
188 /// let file_manager = FileManager::new(config).await?;
189 /// let channel_manager = ChannelManager::new(
190 /// Arc::new(file_manager),
191 /// PathBuf::from("channels.db"),
192 /// ).await?;
193 /// ```
194 pub async fn new(file_manager: Arc<FileManager>, db_path: PathBuf) -> Result<Self> {
195 // Ensure parent directory exists
196 if let Some(parent) = db_path.parent() {
197 tokio::fs::create_dir_all(parent).await?;
198 }
199
200 let options = SqliteConnectOptions::new()
201 .filename(&db_path)
202 .create_if_missing(true);
203
204 let pool = SqlitePoolOptions::new()
205 .max_connections(3)
206 .connect_with(options)
207 .await?;
208
209 let manager = Self {
210 file_manager,
211 pool,
212 db_path: db_path.clone(),
213 };
214
215 manager.init_schema().await?;
216
217 tracing::info!("ChannelManager initialized with database at {:?}", db_path);
218 Ok(manager)
219 }
220
221 /// Initialize the database schema
222 async fn init_schema(&self) -> Result<()> {
223 sqlx::query(
224 r#"
225 CREATE TABLE IF NOT EXISTS channel_files (
226 id INTEGER PRIMARY KEY AUTOINCREMENT,
227 channel_id TEXT NOT NULL,
228 file_id TEXT NOT NULL,
229 uploaded_by TEXT,
230 uploaded_at TEXT NOT NULL,
231 message_id TEXT,
232 UNIQUE(channel_id, file_id)
233 );
234
235 CREATE INDEX IF NOT EXISTS idx_channel_files_channel ON channel_files(channel_id);
236 CREATE INDEX IF NOT EXISTS idx_channel_files_file ON channel_files(file_id);
237 CREATE INDEX IF NOT EXISTS idx_channel_files_uploaded_at ON channel_files(uploaded_at);
238 "#,
239 )
240 .execute(&self.pool)
241 .await?;
242
243 Ok(())
244 }
245
246 // ==========================================================================
247 // File Upload Operations
248 // ==========================================================================
249
250 /// Upload a file to a specific channel
251 ///
252 /// If the file content already exists (same SHA256 hash), it will be
253 /// deduplicated and associated with the new channel instead of storing
254 /// duplicate data.
255 ///
256 /// # Arguments
257 /// * `channel_id` - The channel identifier (e.g., "telegram:chat_123")
258 /// * `data` - File content bytes
259 /// * `metadata` - File metadata (name, size, mime_type, etc.)
260 /// * `uploaded_by` - Optional uploader identifier
261 /// * `message_id` - Optional associated message ID
262 ///
263 /// # Returns
264 /// A `FileHandle` for the stored file
265 ///
266 /// # Example
267 /// ```ignore
268 /// let handle = channel_manager
269 /// .upload_to_channel(
270 /// "telegram:chat_123",
271 /// b"file content",
272 /// FileMetadata { name: "document.pdf", .. },
273 /// Some("user_456"),
274 /// Some("msg_789"),
275 /// )
276 /// .await?;
277 /// ```
278 pub async fn upload_to_channel(
279 &self,
280 channel_id: &str,
281 data: &[u8],
282 metadata: FileMetadata,
283 uploaded_by: Option<&str>,
284 message_id: Option<&str>,
285 ) -> Result<FileHandle> {
286 // Step 1: Store the file globally (deduplication happens here)
287 let handle = self.file_manager.store(data, metadata).await?;
288
289 // Step 2: Create channel association
290 self.add_file_to_channel(channel_id, &handle.id, uploaded_by, message_id)
291 .await?;
292
293 tracing::info!(
294 "Uploaded file {} to channel {} (uploaded by {:?})",
295 handle.id,
296 channel_id,
297 uploaded_by
298 );
299
300 Ok(handle)
301 }
302
303 /// Add an existing file to a channel
304 ///
305 /// Use this when a file has already been uploaded and you just want
306 /// to associate it with a channel.
307 ///
308 /// # Returns
309 /// `Ok(true)` if a new association was created, `Ok(false)` if it already existed
310 pub async fn add_file_to_channel(
311 &self,
312 channel_id: &str,
313 file_id: &str,
314 uploaded_by: Option<&str>,
315 message_id: Option<&str>,
316 ) -> Result<bool> {
317 let result = sqlx::query(
318 r#"
319 INSERT OR IGNORE INTO channel_files (channel_id, file_id, uploaded_by, uploaded_at, message_id)
320 VALUES (?, ?, ?, ?, ?)
321 "#,
322 )
323 .bind(channel_id)
324 .bind(file_id)
325 .bind(uploaded_by)
326 .bind(Utc::now().to_rfc3339())
327 .bind(message_id)
328 .execute(&self.pool)
329 .await?;
330
331 Ok(result.rows_affected() > 0)
332 }
333
334 // ==========================================================================
335 // File Listing and Retrieval
336 // ==========================================================================
337
338 /// List all files in a channel
339 ///
340 /// Returns files with their channel-specific metadata (uploader, upload time, etc.)
341 ///
342 /// # Arguments
343 /// * `channel_id` - The channel to list files for
344 ///
345 /// # Returns
346 /// List of files in the channel, newest first
347 ///
348 /// # Example
349 /// ```ignore
350 /// let files = channel_manager.list_channel_files("telegram:chat_123").await?;
351 /// for info in files {
352 /// println!("{} - uploaded by {:?} at {}",
353 /// info.file.id, info.uploaded_by, info.uploaded_at);
354 /// }
355 /// ```
356 pub async fn list_channel_files(&self, channel_id: &str) -> Result<Vec<ChannelFileInfo>> {
357 let rows = sqlx::query(
358 r#"
359 SELECT
360 cf.channel_id, cf.file_id, cf.uploaded_by, cf.uploaded_at, cf.message_id,
361 f.id, f.path, f.size, f.ref_count, f.created_at, f.last_accessed_at, f.metadata_json
362 FROM channel_files cf
363 JOIN files f ON cf.file_id = f.id
364 WHERE cf.channel_id = ? AND f.deleted_at IS NULL
365 ORDER BY cf.uploaded_at DESC
366 "#,
367 )
368 .bind(channel_id)
369 .fetch_all(&self.pool)
370 .await?;
371
372 let mut results = Vec::new();
373 for row in rows {
374 let file = self.row_to_entry(&row)?;
375 let channel_info = ChannelFileInfo {
376 file,
377 uploaded_by: row.get("uploaded_by"),
378 uploaded_at: DateTime::parse_from_rfc3339(&row.get::<String, _>("uploaded_at"))
379 .map(|dt| dt.with_timezone(&Utc))
380 .unwrap_or_else(|_| Utc::now()),
381 message_id: row.get("message_id"),
382 };
383 results.push(channel_info);
384 }
385
386 Ok(results)
387 }
388
389 /// Get a specific file from a channel
390 ///
391 /// # Arguments
392 /// * `channel_id` - The channel ID
393 /// * `file_id` - The file ID to retrieve
394 ///
395 /// # Returns
396 /// `FileHandle` if found, error otherwise
397 pub async fn get_channel_file(&self, channel_id: &str, file_id: &str) -> Result<FileHandle> {
398 // First check if this file is associated with this channel
399 let exists = sqlx::query(
400 r#"
401 SELECT 1 FROM channel_files
402 WHERE channel_id = ? AND file_id = ?
403 "#,
404 )
405 .bind(channel_id)
406 .bind(file_id)
407 .fetch_optional(&self.pool)
408 .await?;
409
410 if exists.is_none() {
411 return Err(crate::FileError::NotFound(format!(
412 "File {} not found in channel {}",
413 file_id, channel_id
414 )));
415 }
416
417 // Get the file handle from the file manager
418 self.file_manager.get(file_id).await
419 }
420
421 /// List all channels a file belongs to
422 ///
423 /// # Returns
424 /// List of channel IDs that contain this file
425 pub async fn list_file_channels(&self, file_id: &str) -> Result<Vec<String>> {
426 let rows = sqlx::query(
427 r#"
428 SELECT channel_id FROM channel_files
429 WHERE file_id = ?
430 ORDER BY uploaded_at DESC
431 "#,
432 )
433 .bind(file_id)
434 .fetch_all(&self.pool)
435 .await?;
436
437 Ok(rows.iter().map(|r| r.get("channel_id")).collect())
438 }
439
440 // ==========================================================================
441 // File Removal
442 // ==========================================================================
443
444 /// Remove a file from a channel (but don't delete the actual file)
445 ///
446 /// This only removes the channel association, not the physical file.
447 /// The file will continue to exist if other channels reference it.
448 ///
449 /// # Arguments
450 /// * `channel_id` - The channel to remove from
451 /// * `file_id` - The file to remove
452 ///
453 /// # Returns
454 /// `Ok(true)` if association was removed, `Ok(false)` if it didn't exist
455 pub async fn remove_from_channel(&self, channel_id: &str, file_id: &str) -> Result<bool> {
456 let result = sqlx::query(
457 r#"
458 DELETE FROM channel_files
459 WHERE channel_id = ? AND file_id = ?
460 "#,
461 )
462 .bind(channel_id)
463 .bind(file_id)
464 .execute(&self.pool)
465 .await?;
466
467 if result.rows_affected() > 0 {
468 tracing::info!("Removed file {} from channel {}", file_id, channel_id);
469 }
470
471 Ok(result.rows_affected() > 0)
472 }
473
474 /// Delete an entire channel
475 ///
476 /// # Arguments
477 /// * `channel_id` - The channel to delete
478 /// * `cleanup` - If `true`, also delete files that are only in this channel
479 /// If `false`, only remove the channel associations
480 ///
481 /// # Behavior
482 ///
483 /// With `cleanup = false`:
484 /// - Only removes channel_file associations
485 /// - Physical files remain and can still be accessed via other channels
486 ///
487 /// With `cleanup = true`:
488 /// - Removes channel_file associations
489 /// - For files unique to this channel, also soft-deletes them
490 /// - Files shared with other channels are NOT deleted
491 ///
492 /// # Example
493 /// ```ignore
494 /// // Delete channel but keep shared files
495 /// channel_manager.delete_channel("temp_channel", false).await?;
496 ///
497 /// // Delete channel and cleanup unique files
498 /// channel_manager.delete_channel("archive_channel", true).await?;
499 /// ```
500 pub async fn delete_channel(&self, channel_id: &str, cleanup: bool) -> Result<usize> {
501 if cleanup {
502 // Find files unique to this channel
503 let unique_files = self.find_unique_channel_files(channel_id).await?;
504 let mut deleted = 0;
505
506 // Soft delete unique files
507 for file_id in unique_files {
508 if self
509 .file_manager
510 .soft_delete(&file_id, Some(channel_id))
511 .await?
512 {
513 deleted += 1;
514 }
515 }
516
517 // Remove all channel associations
518 sqlx::query("DELETE FROM channel_files WHERE channel_id = ?")
519 .bind(channel_id)
520 .execute(&self.pool)
521 .await?;
522
523 tracing::info!(
524 "Deleted channel {} and soft-deleted {} unique files",
525 channel_id,
526 deleted
527 );
528
529 Ok(deleted)
530 } else {
531 // Just remove associations
532 let result = sqlx::query("DELETE FROM channel_files WHERE channel_id = ?")
533 .bind(channel_id)
534 .execute(&self.pool)
535 .await?;
536
537 tracing::info!(
538 "Deleted channel {} associations (cleanup=false, files preserved)",
539 channel_id
540 );
541
542 Ok(result.rows_affected() as usize)
543 }
544 }
545
546 /// Find files that are unique to a channel (not in any other channel)
547 async fn find_unique_channel_files(&self, channel_id: &str) -> Result<Vec<String>> {
548 let rows = sqlx::query(
549 r#"
550 SELECT cf.file_id
551 FROM channel_files cf
552 WHERE cf.channel_id = ?
553 AND cf.file_id NOT IN (
554 SELECT file_id FROM channel_files WHERE channel_id != ?
555 )
556 "#,
557 )
558 .bind(channel_id)
559 .bind(channel_id)
560 .fetch_all(&self.pool)
561 .await?;
562
563 Ok(rows.iter().map(|r| r.get("file_id")).collect())
564 }
565
566 // ==========================================================================
567 // Statistics
568 // ==========================================================================
569
570 /// Get statistics for a channel
571 ///
572 /// # Returns
573 /// `ChannelStats` with total files, total size, and unique files count
574 pub async fn channel_stats(&self, channel_id: &str) -> Result<ChannelStats> {
575 let row = sqlx::query(
576 r#"
577 SELECT
578 COUNT(*) as total_files,
579 COALESCE(SUM(f.size), 0) as total_size,
580 COUNT(DISTINCT cf.file_id) as unique_files
581 FROM channel_files cf
582 JOIN files f ON cf.file_id = f.id
583 WHERE cf.channel_id = ? AND f.deleted_at IS NULL
584 "#,
585 )
586 .bind(channel_id)
587 .fetch_one(&self.pool)
588 .await?;
589
590 Ok(ChannelStats {
591 channel_id: channel_id.to_string(),
592 total_files: row.get::<i64, _>("total_files") as usize,
593 total_size: row.get::<i64, _>("total_size") as u64,
594 unique_files: row.get::<i64, _>("unique_files") as usize,
595 })
596 }
597
598 /// List all channels that have files
599 ///
600 /// # Returns
601 /// List of channel IDs with at least one file
602 pub async fn list_channels(&self) -> Result<Vec<String>> {
603 let rows = sqlx::query(
604 r#"
605 SELECT DISTINCT channel_id FROM channel_files
606 ORDER BY channel_id
607 "#,
608 )
609 .fetch_all(&self.pool)
610 .await?;
611
612 Ok(rows.iter().map(|r| r.get("channel_id")).collect())
613 }
614
615 // ==========================================================================
616 // Helper Methods
617 // ==========================================================================
618
619 /// Convert a database row to FileIndexEntry
620 ///
621 /// The row must contain columns: id, path, size, ref_count, created_at, last_accessed_at, metadata_json
622 fn row_to_entry(&self, row: &sqlx::sqlite::SqliteRow) -> Result<FileIndexEntry> {
623 use crate::handle::FileMetadata;
624 use chrono::DateTime;
625
626 let metadata_json: String = row.get("metadata_json");
627 let metadata: FileMetadata = serde_json::from_str(&metadata_json)?;
628
629 Ok(FileIndexEntry {
630 id: row.get("id"),
631 path: PathBuf::from(row.get::<String, _>("path")),
632 size: row.get::<i64, _>("size") as u64,
633 ref_count: row.get::<i64, _>("ref_count") as usize,
634 created_at: DateTime::parse_from_rfc3339(&row.get::<String, _>("created_at"))?
635 .with_timezone(&Utc),
636 last_accessed_at: row
637 .get::<Option<String>, _>("last_accessed_at")
638 .map(|s| DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&Utc)))
639 .transpose()?,
640 metadata,
641 })
642 }
643
644 /// Close the channel database connection
645 pub async fn close(&self) {
646 self.pool.close().await;
647 }
648}
649
650// ============================================================================
651// Unit Tests
652// ============================================================================
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657 use crate::config::FileConfig;
658 use tempfile::TempDir;
659
660 async fn create_test_managers() -> (ChannelManager, TempDir) {
661 let temp_dir = TempDir::new().unwrap();
662
663 // Create FileManager
664 let config = FileConfig::with_path(temp_dir.path().join("files"));
665 let file_manager = FileManager::new(config).await.unwrap();
666
667 // Create ChannelManager using the SAME database as FileManager
668 // FileManager stores index at storage_path.join("index.db")
669 let channel_db = temp_dir.path().join("files/index.db");
670 let channel_manager = ChannelManager::new(Arc::new(file_manager), channel_db)
671 .await
672 .unwrap();
673
674 (channel_manager, temp_dir)
675 }
676
677 fn create_test_metadata(name: &str) -> FileMetadata {
678 FileMetadata {
679 name: name.to_string(),
680 size: 100,
681 mime_type: Some("text/plain".to_string()),
682 source: Some("test".to_string()),
683 created_at: chrono::Utc::now(),
684 last_accessed_at: None,
685 preview: None,
686 }
687 }
688
689 #[tokio::test]
690 async fn test_upload_to_channel() {
691 let (manager, _temp) = create_test_managers().await;
692
693 let data = b"hello channel";
694 let metadata = create_test_metadata("test.txt");
695
696 // Upload to first channel
697 let handle = manager
698 .upload_to_channel("channel:1", data, metadata.clone(), Some("user1"), None)
699 .await
700 .unwrap();
701
702 assert!(handle.id.starts_with("sha256:"));
703
704 // List files in channel
705 let files = manager.list_channel_files("channel:1").await.unwrap();
706 assert_eq!(files.len(), 1);
707 assert_eq!(files[0].uploaded_by, Some("user1".to_string()));
708 }
709
710 #[tokio::test]
711 async fn test_channel_file_deduplication() {
712 let (manager, _temp) = create_test_managers().await;
713
714 let data = b"shared content";
715 let metadata = create_test_metadata("shared.txt");
716
717 // Upload to two different channels
718 let handle1 = manager
719 .upload_to_channel("channel:A", data, metadata.clone(), Some("user1"), None)
720 .await
721 .unwrap();
722
723 let handle2 = manager
724 .upload_to_channel("channel:B", data, metadata.clone(), Some("user2"), None)
725 .await
726 .unwrap();
727
728 // Same content = same file ID
729 assert_eq!(handle1.id, handle2.id);
730
731 // But channel associations are different
732 let files_a = manager.list_channel_files("channel:A").await.unwrap();
733 let files_b = manager.list_channel_files("channel:B").await.unwrap();
734
735 assert_eq!(files_a.len(), 1);
736 assert_eq!(files_b.len(), 1);
737 }
738
739 #[tokio::test]
740 async fn test_remove_from_channel() {
741 let (manager, _temp) = create_test_managers().await;
742
743 let data = b"removable content";
744 let metadata = create_test_metadata("remove_me.txt");
745
746 let handle = manager
747 .upload_to_channel("channel:X", data, metadata, Some("user1"), None)
748 .await
749 .unwrap();
750
751 // Remove from channel
752 let removed = manager
753 .remove_from_channel("channel:X", &handle.id)
754 .await
755 .unwrap();
756 assert!(removed);
757
758 // List should be empty
759 let files = manager.list_channel_files("channel:X").await.unwrap();
760 assert!(files.is_empty());
761 }
762
763 #[tokio::test]
764 async fn test_delete_channel_cleanup() {
765 let (manager, _temp) = create_test_managers().await;
766
767 let data = b"cleanup content";
768 let metadata = create_test_metadata("cleanup.txt");
769
770 let _handle = manager
771 .upload_to_channel("cleanup_channel", data, metadata, Some("user1"), None)
772 .await
773 .unwrap();
774
775 // Delete channel with cleanup=true
776 let deleted = manager
777 .delete_channel("cleanup_channel", true)
778 .await
779 .unwrap();
780 assert_eq!(deleted, 1);
781
782 // File should be soft-deleted (exists but not in normal list)
783 let files = manager.list_channel_files("cleanup_channel").await.unwrap();
784 assert!(files.is_empty());
785 }
786
787 #[tokio::test]
788 async fn test_list_file_channels() {
789 let (manager, _temp) = create_test_managers().await;
790
791 let data = b"multi-channel file";
792 let metadata = create_test_metadata("multi.txt");
793
794 let handle = manager
795 .upload_to_channel("ch:A", data, metadata, Some("user1"), None)
796 .await
797 .unwrap();
798
799 // Add to another channel
800 manager
801 .add_file_to_channel("ch:B", &handle.id, Some("user2"), None)
802 .await
803 .unwrap();
804
805 // List all channels for this file
806 let channels = manager.list_file_channels(&handle.id).await.unwrap();
807 assert!(channels.contains(&"ch:A".to_string()));
808 assert!(channels.contains(&"ch:B".to_string()));
809 }
810
811 #[tokio::test]
812 async fn test_channel_stats() {
813 let (manager, _temp) = create_test_managers().await;
814
815 // Upload multiple files to a channel
816 for i in 0..3 {
817 let data = format!("content {}", i);
818 let metadata = create_test_metadata(&format!("file{}.txt", i));
819 manager
820 .upload_to_channel(
821 "stats_channel",
822 data.as_bytes(),
823 metadata,
824 Some("user1"),
825 None,
826 )
827 .await
828 .unwrap();
829 }
830
831 let stats = manager.channel_stats("stats_channel").await.unwrap();
832 assert_eq!(stats.total_files, 3);
833 }
834}