kaccy_reputation/
export_import.rs

1//! Bulk export and import utilities for reputation data
2//!
3//! Provides functionality for backing up and migrating reputation data,
4//! including scores, commitments, events, and all related information.
5//!
6//! ## CSV Export Support
7//!
8//! This module now supports CSV export in addition to JSON, enabling seamless
9//! integration with spreadsheet tools and data analysis workflows:
10//!
11//! ```rust,no_run
12//! use kaccy_reputation::*;
13//!
14//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
15//! # let pool = sqlx::PgPool::connect("").await?;
16//! let service = ExportImportService::new(pool);
17//!
18//! // Export scores to CSV for analysis
19//! let scores = vec![/* reputation scores */];
20//! let csv = service.export_scores_to_csv(&scores)?;
21//!
22//! // Export events to CSV
23//! let events = vec![/* events */];
24//! let csv = service.export_events_to_csv(&events)?;
25//! # Ok(())
26//! # }
27//! ```
28
29use chrono::{DateTime, Utc};
30use rust_decimal::Decimal;
31use serde::{Deserialize, Serialize};
32use sqlx::PgPool;
33use uuid::Uuid;
34
35use crate::error::ReputationError;
36use crate::score::{ReputationScore, ScoreComponents};
37use crate::tier::ReputationTier;
38
39// Type aliases for complex database query results
40type CommitmentRow = (
41    Uuid,
42    Uuid,
43    String,
44    String,
45    DateTime<Utc>,
46    String,
47    DateTime<Utc>,
48    Option<DateTime<Utc>>,
49);
50type RatingRow = (Uuid, Uuid, Uuid, Uuid, i32, Option<String>, DateTime<Utc>);
51
52/// Complete export of all reputation data for a user
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct UserReputationExport {
55    pub user_id: Uuid,
56    pub exported_at: DateTime<Utc>,
57    pub score: ReputationScore,
58    pub tier_history: Vec<TierHistoryExport>,
59    pub score_history: Vec<ScoreHistoryExport>,
60    pub events: Vec<EventExport>,
61    pub commitments: Vec<CommitmentExport>,
62    pub ratings_given: Vec<RatingExport>,
63    pub ratings_received: Vec<RatingExport>,
64}
65
66/// Tier history record for export
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct TierHistoryExport {
69    pub tier: ReputationTier,
70    pub score: Decimal,
71    pub changed_at: DateTime<Utc>,
72}
73
74/// Score history record for export
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ScoreHistoryExport {
77    pub score: Decimal,
78    pub components: ScoreComponents,
79    pub snapshot_date: DateTime<Utc>,
80}
81
82/// Event record for export
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct EventExport {
85    pub event_id: Uuid,
86    pub event_type: String,
87    pub delta: Decimal,
88    pub reason: String,
89    pub created_at: DateTime<Utc>,
90}
91
92/// Commitment record for export
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CommitmentExport {
95    pub commitment_id: Uuid,
96    pub token_id: Uuid,
97    pub title: String,
98    pub description: String,
99    pub deadline: DateTime<Utc>,
100    pub status: String,
101    pub created_at: DateTime<Utc>,
102    pub completed_at: Option<DateTime<Utc>>,
103}
104
105/// Rating record for export
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct RatingExport {
108    pub rating_id: Uuid,
109    pub rater_user_id: Uuid,
110    pub issuer_user_id: Uuid,
111    pub token_id: Uuid,
112    pub rating: i32,
113    pub feedback: Option<String>,
114    pub created_at: DateTime<Utc>,
115}
116
117/// Bulk export for multiple users
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct BulkReputationExport {
120    pub exported_at: DateTime<Utc>,
121    pub version: String,
122    pub user_count: usize,
123    pub users: Vec<UserReputationExport>,
124}
125
126/// Service for exporting and importing reputation data
127pub struct ExportImportService {
128    pool: PgPool,
129}
130
131impl ExportImportService {
132    /// Create a new export/import service
133    pub fn new(pool: PgPool) -> Self {
134        Self { pool }
135    }
136
137    /// Export all reputation data for a single user
138    pub async fn export_user(
139        &self,
140        user_id: Uuid,
141    ) -> Result<UserReputationExport, ReputationError> {
142        // Get current score
143        let score_row: (Uuid, Decimal) =
144            sqlx::query_as("SELECT user_id, reputation_score FROM users WHERE user_id = $1")
145                .bind(user_id)
146                .fetch_one(&self.pool)
147                .await?;
148
149        let score = ReputationScore {
150            user_id,
151            overall_score: score_row.1,
152            tier: ReputationTier::from_score(score_row.1),
153            components: ScoreComponents::default(),
154        };
155
156        // Get tier history
157        let tier_history = self.export_tier_history(user_id).await?;
158
159        // Get score history
160        let score_history = self.export_score_history(user_id).await?;
161
162        // Get events
163        let events = self.export_events(user_id).await?;
164
165        // Get commitments
166        let commitments = self.export_commitments(user_id).await?;
167
168        // Get ratings given and received
169        let ratings_given = self.export_ratings_given(user_id).await?;
170        let ratings_received = self.export_ratings_received(user_id).await?;
171
172        Ok(UserReputationExport {
173            user_id,
174            exported_at: Utc::now(),
175            score,
176            tier_history,
177            score_history,
178            events,
179            commitments,
180            ratings_given,
181            ratings_received,
182        })
183    }
184
185    /// Export reputation data for multiple users
186    pub async fn export_bulk(
187        &self,
188        user_ids: Vec<Uuid>,
189    ) -> Result<BulkReputationExport, ReputationError> {
190        let mut users = Vec::new();
191
192        for user_id in &user_ids {
193            let user_export = self.export_user(*user_id).await?;
194            users.push(user_export);
195        }
196
197        Ok(BulkReputationExport {
198            exported_at: Utc::now(),
199            version: "1.0.0".to_string(),
200            user_count: users.len(),
201            users,
202        })
203    }
204
205    /// Export all users in the system
206    pub async fn export_all(&self) -> Result<BulkReputationExport, ReputationError> {
207        let user_rows: Vec<(Uuid,)> = sqlx::query_as("SELECT user_id FROM users ORDER BY user_id")
208            .fetch_all(&self.pool)
209            .await?;
210
211        let user_ids: Vec<Uuid> = user_rows.iter().map(|row| row.0).collect();
212        self.export_bulk(user_ids).await
213    }
214
215    /// Export to JSON string
216    pub fn export_to_json(export: &BulkReputationExport) -> Result<String, ReputationError> {
217        serde_json::to_string_pretty(export)
218            .map_err(|e| ReputationError::Validation(format!("JSON serialization failed: {}", e)))
219    }
220
221    /// Import from JSON string
222    pub fn import_from_json(json: &str) -> Result<BulkReputationExport, ReputationError> {
223        serde_json::from_str(json)
224            .map_err(|e| ReputationError::Validation(format!("JSON deserialization failed: {}", e)))
225    }
226
227    // Helper methods for exporting specific data types
228
229    async fn export_tier_history(
230        &self,
231        user_id: Uuid,
232    ) -> Result<Vec<TierHistoryExport>, ReputationError> {
233        let rows: Vec<(String, Decimal, DateTime<Utc>)> = sqlx::query_as(
234            "SELECT tier, score, changed_at FROM tier_history WHERE user_id = $1 ORDER BY changed_at DESC"
235        )
236        .bind(user_id)
237        .fetch_all(&self.pool)
238        .await?;
239
240        Ok(rows
241            .into_iter()
242            .map(|row| TierHistoryExport {
243                tier: ReputationTier::from_score(row.1),
244                score: row.1,
245                changed_at: row.2,
246            })
247            .collect())
248    }
249
250    async fn export_score_history(
251        &self,
252        user_id: Uuid,
253    ) -> Result<Vec<ScoreHistoryExport>, ReputationError> {
254        let rows: Vec<(Decimal, DateTime<Utc>)> = sqlx::query_as(
255            "SELECT score, snapshot_date FROM score_snapshots WHERE user_id = $1 ORDER BY snapshot_date DESC"
256        )
257        .bind(user_id)
258        .fetch_all(&self.pool)
259        .await?;
260
261        Ok(rows
262            .into_iter()
263            .map(|row| ScoreHistoryExport {
264                score: row.0,
265                components: ScoreComponents::default(),
266                snapshot_date: row.1,
267            })
268            .collect())
269    }
270
271    async fn export_events(&self, user_id: Uuid) -> Result<Vec<EventExport>, ReputationError> {
272        let rows: Vec<(Uuid, String, Decimal, String, DateTime<Utc>)> = sqlx::query_as(
273            "SELECT event_id, event_type, delta, reason, created_at FROM reputation_events WHERE user_id = $1 ORDER BY created_at DESC"
274        )
275        .bind(user_id)
276        .fetch_all(&self.pool)
277        .await?;
278
279        Ok(rows
280            .into_iter()
281            .map(|row| EventExport {
282                event_id: row.0,
283                event_type: row.1,
284                delta: row.2,
285                reason: row.3,
286                created_at: row.4,
287            })
288            .collect())
289    }
290
291    async fn export_commitments(
292        &self,
293        user_id: Uuid,
294    ) -> Result<Vec<CommitmentExport>, ReputationError> {
295        let rows: Vec<CommitmentRow> = sqlx::query_as(
296            "SELECT commitment_id, token_id, title, description, deadline, status, created_at, completed_at FROM output_commitments WHERE user_id = $1 ORDER BY created_at DESC"
297        )
298        .bind(user_id)
299        .fetch_all(&self.pool)
300        .await?;
301
302        Ok(rows
303            .into_iter()
304            .map(|row| CommitmentExport {
305                commitment_id: row.0,
306                token_id: row.1,
307                title: row.2,
308                description: row.3,
309                deadline: row.4,
310                status: row.5,
311                created_at: row.6,
312                completed_at: row.7,
313            })
314            .collect())
315    }
316
317    async fn export_ratings_given(
318        &self,
319        user_id: Uuid,
320    ) -> Result<Vec<RatingExport>, ReputationError> {
321        let rows: Vec<RatingRow> = sqlx::query_as(
322            "SELECT rating_id, rater_user_id, issuer_user_id, token_id, rating, feedback, created_at FROM holder_ratings WHERE rater_user_id = $1 ORDER BY created_at DESC"
323        )
324        .bind(user_id)
325        .fetch_all(&self.pool)
326        .await?;
327
328        Ok(rows
329            .into_iter()
330            .map(|row| RatingExport {
331                rating_id: row.0,
332                rater_user_id: row.1,
333                issuer_user_id: row.2,
334                token_id: row.3,
335                rating: row.4,
336                feedback: row.5,
337                created_at: row.6,
338            })
339            .collect())
340    }
341
342    async fn export_ratings_received(
343        &self,
344        user_id: Uuid,
345    ) -> Result<Vec<RatingExport>, ReputationError> {
346        let rows: Vec<RatingRow> = sqlx::query_as(
347            "SELECT rating_id, rater_user_id, issuer_user_id, token_id, rating, feedback, created_at FROM holder_ratings WHERE issuer_user_id = $1 ORDER BY created_at DESC"
348        )
349        .bind(user_id)
350        .fetch_all(&self.pool)
351        .await?;
352
353        Ok(rows
354            .into_iter()
355            .map(|row| RatingExport {
356                rating_id: row.0,
357                rater_user_id: row.1,
358                issuer_user_id: row.2,
359                token_id: row.3,
360                rating: row.4,
361                feedback: row.5,
362                created_at: row.6,
363            })
364            .collect())
365    }
366
367    // ========================================================================
368    // CSV Export Methods (Phase 22)
369    // ========================================================================
370
371    /// Export reputation scores to CSV format
372    ///
373    /// Generates a CSV file with headers: user_id,overall_score,tier,timestamp
374    /// Useful for spreadsheet analysis and data visualization tools.
375    pub fn export_scores_to_csv(
376        &self,
377        scores: &[ReputationScore],
378    ) -> Result<String, ReputationError> {
379        let mut csv = String::from("user_id,overall_score,tier,timestamp\n");
380
381        let timestamp = Utc::now().to_rfc3339();
382        for score in scores {
383            csv.push_str(&format!(
384                "{},{},{},{}\n",
385                score.user_id,
386                score.overall_score,
387                score.tier.as_str(),
388                timestamp
389            ));
390        }
391
392        Ok(csv)
393    }
394
395    /// Export reputation scores with full component breakdown to CSV
396    ///
397    /// Headers: user_id,overall_score,tier,commitment_fulfillment,response_time,
398    /// quality_rating,community_trust,longevity,timestamp
399    pub fn export_scores_detailed_to_csv(
400        &self,
401        scores: &[ReputationScore],
402    ) -> Result<String, ReputationError> {
403        let mut csv = String::from(
404            "user_id,overall_score,tier,commitment_fulfillment,response_time,quality_rating,community_trust,longevity,timestamp\n",
405        );
406
407        let timestamp = Utc::now().to_rfc3339();
408        for score in scores {
409            csv.push_str(&format!(
410                "{},{},{},{},{},{},{},{},{}\n",
411                score.user_id,
412                score.overall_score,
413                score.tier.as_str(),
414                score.components.commitment_fulfillment,
415                score.components.response_time,
416                score.components.quality_rating,
417                score.components.community_trust,
418                score.components.longevity,
419                timestamp
420            ));
421        }
422
423        Ok(csv)
424    }
425
426    /// Export events to CSV format
427    ///
428    /// Headers: event_id,user_id,event_type,delta,reason,created_at
429    pub fn export_events_to_csv(&self, events: &[EventExport]) -> Result<String, ReputationError> {
430        let mut csv = String::from("event_id,user_id,event_type,delta,reason,created_at\n");
431
432        for event in events {
433            let reason = event.reason.replace(',', ";").replace('\n', " ");
434            csv.push_str(&format!(
435                "{},{},{},{},\"{}\",{}\n",
436                event.event_id,
437                "", // user_id not available in EventExport, would need to be added
438                event.event_type,
439                event.delta,
440                reason,
441                event.created_at.to_rfc3339()
442            ));
443        }
444
445        Ok(csv)
446    }
447
448    /// Export commitments to CSV format
449    ///
450    /// Headers: commitment_id,token_id,title,description,deadline,status,created_at,completed_at
451    pub fn export_commitments_to_csv(
452        &self,
453        commitments: &[CommitmentExport],
454    ) -> Result<String, ReputationError> {
455        let mut csv = String::from(
456            "commitment_id,token_id,title,description,deadline,status,created_at,completed_at\n",
457        );
458
459        for commitment in commitments {
460            let title = commitment.title.replace(',', ";").replace('\n', " ");
461            let description = commitment.description.replace(',', ";").replace('\n', " ");
462            let completed_at = commitment
463                .completed_at
464                .map(|dt| dt.to_rfc3339())
465                .unwrap_or_else(|| "".to_string());
466
467            csv.push_str(&format!(
468                "{},{},\"{}\",\"{}\",{},{},{},{}\n",
469                commitment.commitment_id,
470                commitment.token_id,
471                title,
472                description,
473                commitment.deadline.to_rfc3339(),
474                commitment.status,
475                commitment.created_at.to_rfc3339(),
476                completed_at
477            ));
478        }
479
480        Ok(csv)
481    }
482
483    /// Export ratings to CSV format
484    ///
485    /// Headers: rating_id,rater_user_id,issuer_user_id,token_id,rating,feedback,created_at
486    pub fn export_ratings_to_csv(
487        &self,
488        ratings: &[RatingExport],
489    ) -> Result<String, ReputationError> {
490        let mut csv = String::from(
491            "rating_id,rater_user_id,issuer_user_id,token_id,rating,feedback,created_at\n",
492        );
493
494        for rating in ratings {
495            let feedback = rating
496                .feedback
497                .as_ref()
498                .map(|f| f.replace(',', ";").replace('\n', " ").replace('"', "'"))
499                .unwrap_or_else(|| "".to_string());
500
501            csv.push_str(&format!(
502                "{},{},{},{},{},\"{}\",{}\n",
503                rating.rating_id,
504                rating.rater_user_id,
505                rating.issuer_user_id,
506                rating.token_id,
507                rating.rating,
508                feedback,
509                rating.created_at.to_rfc3339()
510            ));
511        }
512
513        Ok(csv)
514    }
515
516    /// Export tier history to CSV format
517    ///
518    /// Headers: tier,score,changed_at
519    pub fn export_tier_history_to_csv(
520        &self,
521        history: &[TierHistoryExport],
522    ) -> Result<String, ReputationError> {
523        let mut csv = String::from("tier,score,changed_at\n");
524
525        for entry in history {
526            csv.push_str(&format!(
527                "{},{},{}\n",
528                entry.tier.as_str(),
529                entry.score,
530                entry.changed_at.to_rfc3339()
531            ));
532        }
533
534        Ok(csv)
535    }
536
537    /// Export score history to CSV format
538    ///
539    /// Headers: score,commitment_fulfillment,response_time,quality_rating,
540    /// community_trust,longevity,snapshot_date
541    pub fn export_score_history_to_csv(
542        &self,
543        history: &[ScoreHistoryExport],
544    ) -> Result<String, ReputationError> {
545        let mut csv = String::from(
546            "score,commitment_fulfillment,response_time,quality_rating,community_trust,longevity,snapshot_date\n",
547        );
548
549        for entry in history {
550            csv.push_str(&format!(
551                "{},{},{},{},{},{},{}\n",
552                entry.score,
553                entry.components.commitment_fulfillment,
554                entry.components.response_time,
555                entry.components.quality_rating,
556                entry.components.community_trust,
557                entry.components.longevity,
558                entry.snapshot_date.to_rfc3339()
559            ));
560        }
561
562        Ok(csv)
563    }
564}
565
566/// Export statistics
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct ExportStats {
569    pub total_users: usize,
570    pub total_events: usize,
571    pub total_commitments: usize,
572    pub total_ratings: usize,
573    pub export_size_bytes: usize,
574}
575
576impl BulkReputationExport {
577    /// Get statistics about the export
578    pub fn get_stats(&self) -> ExportStats {
579        let total_events: usize = self.users.iter().map(|u| u.events.len()).sum();
580        let total_commitments: usize = self.users.iter().map(|u| u.commitments.len()).sum();
581        let total_ratings: usize = self
582            .users
583            .iter()
584            .map(|u| u.ratings_given.len() + u.ratings_received.len())
585            .sum();
586
587        ExportStats {
588            total_users: self.user_count,
589            total_events,
590            total_commitments,
591            total_ratings,
592            export_size_bytes: 0, // Would be calculated from JSON serialization
593        }
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_export_stats() {
603        let export = BulkReputationExport {
604            exported_at: Utc::now(),
605            version: "1.0.0".to_string(),
606            user_count: 2,
607            users: vec![UserReputationExport {
608                user_id: Uuid::new_v4(),
609                exported_at: Utc::now(),
610                score: ReputationScore {
611                    user_id: Uuid::new_v4(),
612                    overall_score: rust_decimal_macros::dec!(500),
613                    tier: ReputationTier::Silver,
614                    components: Default::default(),
615                },
616                tier_history: vec![],
617                score_history: vec![],
618                events: vec![],
619                commitments: vec![],
620                ratings_given: vec![],
621                ratings_received: vec![],
622            }],
623        };
624
625        let stats = export.get_stats();
626        assert_eq!(stats.total_users, 2);
627    }
628
629    #[test]
630    fn test_json_export_import() {
631        let export = BulkReputationExport {
632            exported_at: Utc::now(),
633            version: "1.0.0".to_string(),
634            user_count: 1,
635            users: vec![UserReputationExport {
636                user_id: Uuid::new_v4(),
637                exported_at: Utc::now(),
638                score: ReputationScore {
639                    user_id: Uuid::new_v4(),
640                    overall_score: rust_decimal_macros::dec!(500),
641                    tier: ReputationTier::Silver,
642                    components: Default::default(),
643                },
644                tier_history: vec![],
645                score_history: vec![],
646                events: vec![],
647                commitments: vec![],
648                ratings_given: vec![],
649                ratings_received: vec![],
650            }],
651        };
652
653        let json = ExportImportService::export_to_json(&export).unwrap();
654        let imported = ExportImportService::import_from_json(&json).unwrap();
655
656        assert_eq!(imported.user_count, 1);
657        assert_eq!(imported.version, "1.0.0");
658    }
659
660    // Phase 22 CSV Export Tests
661
662    // Helper to create a mock service for CSV export tests (doesn't use DB)
663    fn mock_export_service() -> ExportImportService {
664        // CSV export methods don't actually use the pool, so we create a dummy one
665        // This is safe because we never call async methods that require actual DB connection
666        let options = sqlx::postgres::PgConnectOptions::new();
667        let pool = PgPool::connect_lazy_with(options);
668        ExportImportService::new(pool)
669    }
670
671    #[tokio::test]
672    async fn test_csv_export_scores() {
673        use rust_decimal_macros::dec;
674
675        let service = mock_export_service();
676
677        let user_id = Uuid::new_v4();
678        let scores = vec![
679            ReputationScore {
680                user_id,
681                overall_score: dec!(750),
682                tier: ReputationTier::Gold,
683                components: Default::default(),
684            },
685            ReputationScore {
686                user_id: Uuid::new_v4(),
687                overall_score: dec!(500),
688                tier: ReputationTier::Silver,
689                components: Default::default(),
690            },
691        ];
692
693        let csv = service.export_scores_to_csv(&scores).unwrap();
694
695        // Check header
696        assert!(csv.starts_with("user_id,overall_score,tier,timestamp\n"));
697
698        // Check content
699        assert!(csv.contains(&user_id.to_string()));
700        assert!(csv.contains("750"));
701        assert!(csv.contains("gold"));
702        assert!(csv.contains("500"));
703        assert!(csv.contains("silver"));
704
705        // Check line count (header + 2 data rows)
706        assert_eq!(csv.lines().count(), 3);
707    }
708
709    #[tokio::test]
710    async fn test_csv_export_scores_detailed() {
711        use rust_decimal_macros::dec;
712
713        let service = mock_export_service();
714
715        let user_id = Uuid::new_v4();
716        let scores = vec![ReputationScore {
717            user_id,
718            overall_score: dec!(750),
719            tier: ReputationTier::Gold,
720            components: ScoreComponents {
721                commitment_fulfillment: dec!(85),
722                response_time: dec!(75),
723                quality_rating: dec!(90),
724                community_trust: dec!(80),
725                longevity: dec!(70),
726            },
727        }];
728
729        let csv = service.export_scores_detailed_to_csv(&scores).unwrap();
730
731        // Check header
732        assert!(csv.contains("commitment_fulfillment"));
733        assert!(csv.contains("response_time"));
734        assert!(csv.contains("quality_rating"));
735        assert!(csv.contains("community_trust"));
736        assert!(csv.contains("longevity"));
737
738        // Check component values
739        assert!(csv.contains("85"));
740        assert!(csv.contains("75"));
741        assert!(csv.contains("90"));
742        assert!(csv.contains("80"));
743        assert!(csv.contains("70"));
744    }
745
746    #[tokio::test]
747    async fn test_csv_export_events() {
748        let service = mock_export_service();
749
750        let events = vec![
751            EventExport {
752                event_id: Uuid::new_v4(),
753                event_type: "commitment_fulfilled".to_string(),
754                delta: rust_decimal_macros::dec!(10),
755                reason: "Delivered on time".to_string(),
756                created_at: Utc::now(),
757            },
758            EventExport {
759                event_id: Uuid::new_v4(),
760                event_type: "quality_score".to_string(),
761                delta: rust_decimal_macros::dec!(5),
762                reason: "High quality work, includes commas".to_string(),
763                created_at: Utc::now(),
764            },
765        ];
766
767        let csv = service.export_events_to_csv(&events).unwrap();
768
769        // Check header
770        assert!(csv.starts_with("event_id,user_id,event_type,delta,reason,created_at\n"));
771
772        // Check content
773        assert!(csv.contains("commitment_fulfilled"));
774        assert!(csv.contains("quality_score"));
775        assert!(csv.contains("10"));
776        assert!(csv.contains("5"));
777
778        // Check comma replacement in reason
779        assert!(csv.contains("includes commas"));
780        assert!(csv.contains(';')); // Comma should be replaced with semicolon
781    }
782
783    #[tokio::test]
784    async fn test_csv_export_commitments() {
785        let pool = PgPool::connect_lazy("postgresql://localhost/test").unwrap();
786        let service = ExportImportService::new(pool);
787
788        let commitment_id = Uuid::new_v4();
789        let token_id = Uuid::new_v4();
790        let commitments = vec![CommitmentExport {
791            commitment_id,
792            token_id,
793            title: "Complete documentation".to_string(),
794            description: "Write comprehensive API docs, includes commas and newlines\nNext line"
795                .to_string(),
796            deadline: Utc::now(),
797            status: "verified".to_string(),
798            created_at: Utc::now(),
799            completed_at: Some(Utc::now()),
800        }];
801
802        let csv = service.export_commitments_to_csv(&commitments).unwrap();
803
804        // Check header
805        assert!(csv.contains("commitment_id,token_id,title,description"));
806
807        // Check content
808        assert!(csv.contains(&commitment_id.to_string()));
809        assert!(csv.contains(&token_id.to_string()));
810        assert!(csv.contains("Complete documentation"));
811        assert!(csv.contains("verified"));
812
813        // Check special character handling
814        assert!(!csv.contains("\n\nNext")); // Newline should be replaced with space
815    }
816
817    #[tokio::test]
818    async fn test_csv_export_ratings() {
819        let pool = PgPool::connect_lazy("postgresql://localhost/test").unwrap();
820        let service = ExportImportService::new(pool);
821
822        let ratings = vec![
823            RatingExport {
824                rating_id: Uuid::new_v4(),
825                rater_user_id: Uuid::new_v4(),
826                issuer_user_id: Uuid::new_v4(),
827                token_id: Uuid::new_v4(),
828                rating: 5,
829                feedback: Some("Excellent work!".to_string()),
830                created_at: Utc::now(),
831            },
832            RatingExport {
833                rating_id: Uuid::new_v4(),
834                rater_user_id: Uuid::new_v4(),
835                issuer_user_id: Uuid::new_v4(),
836                token_id: Uuid::new_v4(),
837                rating: 3,
838                feedback: None,
839                created_at: Utc::now(),
840            },
841        ];
842
843        let csv = service.export_ratings_to_csv(&ratings).unwrap();
844
845        // Check header
846        assert!(csv.starts_with(
847            "rating_id,rater_user_id,issuer_user_id,token_id,rating,feedback,created_at\n"
848        ));
849
850        // Check content
851        assert!(csv.contains("5"));
852        assert!(csv.contains("3"));
853        assert!(csv.contains("Excellent work!"));
854
855        // Check line count
856        assert_eq!(csv.lines().count(), 3); // header + 2 ratings
857    }
858
859    #[tokio::test]
860    async fn test_csv_export_tier_history() {
861        use rust_decimal_macros::dec;
862
863        let pool = PgPool::connect_lazy("postgresql://localhost/test").unwrap();
864        let service = ExportImportService::new(pool);
865
866        let history = vec![
867            TierHistoryExport {
868                tier: ReputationTier::Bronze,
869                score: dec!(250),
870                changed_at: Utc::now(),
871            },
872            TierHistoryExport {
873                tier: ReputationTier::Silver,
874                score: dec!(450),
875                changed_at: Utc::now(),
876            },
877            TierHistoryExport {
878                tier: ReputationTier::Gold,
879                score: dec!(750),
880                changed_at: Utc::now(),
881            },
882        ];
883
884        let csv = service.export_tier_history_to_csv(&history).unwrap();
885
886        // Check header
887        assert!(csv.starts_with("tier,score,changed_at\n"));
888
889        // Check content
890        assert!(csv.contains("bronze"));
891        assert!(csv.contains("silver"));
892        assert!(csv.contains("gold"));
893        assert!(csv.contains("250"));
894        assert!(csv.contains("450"));
895        assert!(csv.contains("750"));
896
897        // Check line count
898        assert_eq!(csv.lines().count(), 4); // header + 3 entries
899    }
900
901    #[tokio::test]
902    async fn test_csv_export_score_history() {
903        use rust_decimal_macros::dec;
904
905        let pool = PgPool::connect_lazy("postgresql://localhost/test").unwrap();
906        let service = ExportImportService::new(pool);
907
908        let history = vec![ScoreHistoryExport {
909            score: dec!(750),
910            components: ScoreComponents {
911                commitment_fulfillment: dec!(85),
912                response_time: dec!(75),
913                quality_rating: dec!(90),
914                community_trust: dec!(80),
915                longevity: dec!(70),
916            },
917            snapshot_date: Utc::now(),
918        }];
919
920        let csv = service.export_score_history_to_csv(&history).unwrap();
921
922        // Check header
923        assert!(csv.contains("score,commitment_fulfillment,response_time,quality_rating,community_trust,longevity,snapshot_date"));
924
925        // Check content
926        assert!(csv.contains("750"));
927        assert!(csv.contains("85"));
928        assert!(csv.contains("75"));
929        assert!(csv.contains("90"));
930        assert!(csv.contains("80"));
931        assert!(csv.contains("70"));
932
933        // Check line count
934        assert_eq!(csv.lines().count(), 2); // header + 1 entry
935    }
936
937    #[tokio::test]
938    async fn test_csv_empty_arrays() {
939        let pool = PgPool::connect_lazy("postgresql://localhost/test").unwrap();
940        let service = ExportImportService::new(pool);
941
942        // Test with empty arrays
943        let scores: Vec<ReputationScore> = vec![];
944        let events: Vec<EventExport> = vec![];
945        let commitments: Vec<CommitmentExport> = vec![];
946
947        let csv_scores = service.export_scores_to_csv(&scores).unwrap();
948        let csv_events = service.export_events_to_csv(&events).unwrap();
949        let csv_commitments = service.export_commitments_to_csv(&commitments).unwrap();
950
951        // Should only contain headers
952        assert_eq!(csv_scores.lines().count(), 1);
953        assert_eq!(csv_events.lines().count(), 1);
954        assert_eq!(csv_commitments.lines().count(), 1);
955    }
956}