kaccy_reputation/
commitment.rs

1//! Commitment tracking
2
3use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use sqlx::{FromRow, PgPool};
8use std::fmt;
9use uuid::Uuid;
10
11use crate::error::{ReputationError, Result};
12
13#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
14pub struct OutputCommitment {
15    pub commitment_id: Uuid,
16    pub user_id: Uuid,
17    pub token_id: Uuid,
18    pub title: String,
19    pub description: Option<String>,
20    pub deadline: DateTime<Utc>,
21    pub status: CommitmentStatus,
22    pub evidence_url: Option<String>,
23    pub evidence_description: Option<String>,
24    pub verified_at: Option<DateTime<Utc>>,
25    pub verified_by: Option<Uuid>,
26    pub created_at: DateTime<Utc>,
27}
28
29impl fmt::Display for OutputCommitment {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        write!(
32            f,
33            "Commitment({}, {}, status={:?})",
34            self.commitment_id, self.title, self.status
35        )
36    }
37}
38
39#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, PartialEq, Eq)]
40#[sqlx(type_name = "varchar", rename_all = "lowercase")]
41#[derive(Default)]
42pub enum CommitmentStatus {
43    #[default]
44    Pending,
45    Completed,
46    Verified,
47    Failed,
48    Expired,
49}
50
51impl CommitmentStatus {
52    /// Check if this is a terminal status (cannot be changed)
53    pub fn is_terminal(&self) -> bool {
54        matches!(
55            self,
56            CommitmentStatus::Verified | CommitmentStatus::Failed | CommitmentStatus::Expired
57        )
58    }
59
60    /// Check if this commitment is still active (can be updated)
61    pub fn is_active(&self) -> bool {
62        matches!(
63            self,
64            CommitmentStatus::Pending | CommitmentStatus::Completed
65        )
66    }
67
68    /// Check if this commitment has a positive outcome
69    pub fn is_successful(&self) -> bool {
70        matches!(self, CommitmentStatus::Verified)
71    }
72
73    /// Check if this commitment has a negative outcome
74    pub fn is_failed(&self) -> bool {
75        matches!(self, CommitmentStatus::Failed | CommitmentStatus::Expired)
76    }
77
78    /// Check if transition to another status is allowed
79    pub fn can_transition_to(&self, target: CommitmentStatus) -> bool {
80        match self {
81            CommitmentStatus::Pending => matches!(
82                target,
83                CommitmentStatus::Completed | CommitmentStatus::Expired | CommitmentStatus::Failed
84            ),
85            CommitmentStatus::Completed => matches!(
86                target,
87                CommitmentStatus::Verified | CommitmentStatus::Failed
88            ),
89            // Terminal states cannot transition
90            CommitmentStatus::Verified | CommitmentStatus::Failed | CommitmentStatus::Expired => {
91                false
92            }
93        }
94    }
95
96    /// Get all possible next states from current status
97    pub fn possible_next_states(&self) -> Vec<CommitmentStatus> {
98        match self {
99            CommitmentStatus::Pending => vec![
100                CommitmentStatus::Completed,
101                CommitmentStatus::Expired,
102                CommitmentStatus::Failed,
103            ],
104            CommitmentStatus::Completed => {
105                vec![CommitmentStatus::Verified, CommitmentStatus::Failed]
106            }
107            // Terminal states have no next states
108            CommitmentStatus::Verified | CommitmentStatus::Failed | CommitmentStatus::Expired => {
109                vec![]
110            }
111        }
112    }
113
114    /// Get all status variants
115    pub fn all_variants() -> [Self; 5] {
116        [
117            Self::Pending,
118            Self::Completed,
119            Self::Verified,
120            Self::Failed,
121            Self::Expired,
122        ]
123    }
124
125    /// Get status as string (lowercase)
126    pub fn as_str(&self) -> &'static str {
127        match self {
128            Self::Pending => "pending",
129            Self::Completed => "completed",
130            Self::Verified => "verified",
131            Self::Failed => "failed",
132            Self::Expired => "expired",
133        }
134    }
135}
136
137impl fmt::Display for CommitmentStatus {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        match self {
140            CommitmentStatus::Pending => write!(f, "pending"),
141            CommitmentStatus::Completed => write!(f, "completed"),
142            CommitmentStatus::Verified => write!(f, "verified"),
143            CommitmentStatus::Failed => write!(f, "failed"),
144            CommitmentStatus::Expired => write!(f, "expired"),
145        }
146    }
147}
148
149impl std::str::FromStr for CommitmentStatus {
150    type Err = ReputationError;
151
152    fn from_str(s: &str) -> Result<Self> {
153        match s.to_lowercase().as_str() {
154            "pending" => Ok(CommitmentStatus::Pending),
155            "completed" => Ok(CommitmentStatus::Completed),
156            "verified" => Ok(CommitmentStatus::Verified),
157            "failed" => Ok(CommitmentStatus::Failed),
158            "expired" => Ok(CommitmentStatus::Expired),
159            _ => Err(ReputationError::Validation(format!(
160                "Invalid commitment status: {}. Valid values: pending, completed, verified, failed, expired",
161                s
162            ))),
163        }
164    }
165}
166
167#[derive(Debug, Deserialize)]
168pub struct CreateCommitmentRequest {
169    pub token_id: Uuid,
170    pub title: String,
171    pub description: Option<String>,
172    pub deadline: DateTime<Utc>,
173}
174
175impl CreateCommitmentRequest {
176    pub fn validate(&self) -> Result<()> {
177        if self.title.is_empty() {
178            return Err(ReputationError::Validation("Title is required".to_string()));
179        }
180        if self.title.len() > 200 {
181            return Err(ReputationError::Validation(
182                "Title must be at most 200 characters".to_string(),
183            ));
184        }
185        if let Some(ref desc) = self.description {
186            if desc.len() > 2000 {
187                return Err(ReputationError::Validation(
188                    "Description must be at most 2000 characters".to_string(),
189                ));
190            }
191        }
192        if self.deadline < Utc::now() {
193            return Err(ReputationError::Validation(
194                "Deadline must be in the future".to_string(),
195            ));
196        }
197        Ok(())
198    }
199
200    /// Create a new builder for CreateCommitmentRequest
201    pub fn builder(token_id: Uuid, title: impl Into<String>) -> CreateCommitmentRequestBuilder {
202        CreateCommitmentRequestBuilder::new(token_id, title)
203    }
204}
205
206/// Builder for CreateCommitmentRequest
207pub struct CreateCommitmentRequestBuilder {
208    token_id: Uuid,
209    title: String,
210    description: Option<String>,
211    deadline: Option<DateTime<Utc>>,
212}
213
214impl CreateCommitmentRequestBuilder {
215    /// Create a new builder with required fields
216    pub fn new(token_id: Uuid, title: impl Into<String>) -> Self {
217        Self {
218            token_id,
219            title: title.into(),
220            description: None,
221            deadline: None,
222        }
223    }
224
225    /// Set the description
226    pub fn description(mut self, description: impl Into<String>) -> Self {
227        self.description = Some(description.into());
228        self
229    }
230
231    /// Set the deadline
232    pub fn deadline(mut self, deadline: DateTime<Utc>) -> Self {
233        self.deadline = Some(deadline);
234        self
235    }
236
237    /// Set deadline as days from now
238    pub fn deadline_days_from_now(mut self, days: i64) -> Self {
239        self.deadline = Some(Utc::now() + chrono::Duration::days(days));
240        self
241    }
242
243    /// Build the request
244    pub fn build(self) -> Result<CreateCommitmentRequest> {
245        let deadline = self
246            .deadline
247            .ok_or_else(|| ReputationError::Validation("Deadline is required".to_string()))?;
248
249        let request = CreateCommitmentRequest {
250            token_id: self.token_id,
251            title: self.title,
252            description: self.description,
253            deadline,
254        };
255
256        request.validate()?;
257        Ok(request)
258    }
259}
260
261#[derive(Debug, Deserialize)]
262pub struct SubmitEvidenceRequest {
263    pub evidence_url: String,
264    pub evidence_description: Option<String>,
265}
266
267impl SubmitEvidenceRequest {
268    pub fn validate(&self) -> Result<()> {
269        if self.evidence_url.is_empty() {
270            return Err(ReputationError::Validation(
271                "Evidence URL is required".to_string(),
272            ));
273        }
274        if !self.evidence_url.starts_with("https://") {
275            return Err(ReputationError::Validation(
276                "Evidence URL must be HTTPS".to_string(),
277            ));
278        }
279        Ok(())
280    }
281}
282
283#[derive(Debug, Deserialize)]
284pub struct VerifyCommitmentRequest {
285    pub fulfilled: bool,
286    pub quality_score: Option<Decimal>,
287    pub notes: Option<String>,
288}
289
290/// Service for managing commitments
291pub struct CommitmentService {
292    pool: PgPool,
293}
294
295impl CommitmentService {
296    pub fn new(pool: PgPool) -> Self {
297        Self { pool }
298    }
299
300    /// Create a new commitment
301    pub async fn create(
302        &self,
303        user_id: Uuid,
304        request: &CreateCommitmentRequest,
305    ) -> Result<OutputCommitment> {
306        request.validate()?;
307
308        let commitment = sqlx::query_as::<_, OutputCommitment>(
309            r#"
310            INSERT INTO output_commitments (user_id, token_id, title, description, deadline)
311            VALUES ($1, $2, $3, $4, $5)
312            RETURNING *
313            "#,
314        )
315        .bind(user_id)
316        .bind(request.token_id)
317        .bind(&request.title)
318        .bind(&request.description)
319        .bind(request.deadline)
320        .fetch_one(&self.pool)
321        .await?;
322
323        tracing::info!(commitment_id = %commitment.commitment_id, "Commitment created");
324        Ok(commitment)
325    }
326
327    /// Submit evidence for a commitment
328    pub async fn submit_evidence(
329        &self,
330        commitment_id: Uuid,
331        user_id: Uuid,
332        request: &SubmitEvidenceRequest,
333    ) -> Result<OutputCommitment> {
334        request.validate()?;
335
336        let commitment = sqlx::query_as::<_, OutputCommitment>(
337            r#"
338            UPDATE output_commitments
339            SET evidence_url = $3, evidence_description = $4, status = 'completed'
340            WHERE commitment_id = $1 AND user_id = $2 AND status = 'pending'
341            RETURNING *
342            "#,
343        )
344        .bind(commitment_id)
345        .bind(user_id)
346        .bind(&request.evidence_url)
347        .bind(&request.evidence_description)
348        .fetch_optional(&self.pool)
349        .await?
350        .ok_or_else(|| ReputationError::CommitmentNotFound(commitment_id.to_string()))?;
351
352        tracing::info!(commitment_id = %commitment_id, "Evidence submitted");
353        Ok(commitment)
354    }
355
356    /// Verify a commitment (admin only)
357    pub async fn verify(
358        &self,
359        commitment_id: Uuid,
360        admin_id: Uuid,
361        request: &VerifyCommitmentRequest,
362    ) -> Result<(OutputCommitment, Decimal)> {
363        let mut tx = self.pool.begin().await?;
364
365        // Get the commitment
366        let commitment: OutputCommitment = sqlx::query_as(
367            r#"
368            SELECT * FROM output_commitments
369            WHERE commitment_id = $1 AND status = 'completed'
370            FOR UPDATE
371            "#,
372        )
373        .bind(commitment_id)
374        .fetch_optional(&mut *tx)
375        .await?
376        .ok_or_else(|| ReputationError::CommitmentNotFound(commitment_id.to_string()))?;
377
378        let new_status = if request.fulfilled {
379            CommitmentStatus::Verified
380        } else {
381            CommitmentStatus::Failed
382        };
383
384        // Update commitment status
385        let updated: OutputCommitment = sqlx::query_as(
386            r#"
387            UPDATE output_commitments
388            SET status = $2, verified_at = NOW(), verified_by = $3
389            WHERE commitment_id = $1
390            RETURNING *
391            "#,
392        )
393        .bind(commitment_id)
394        .bind(new_status.to_string())
395        .bind(admin_id)
396        .fetch_one(&mut *tx)
397        .await?;
398
399        // Calculate reputation delta
400        let base_delta = if request.fulfilled {
401            dec!(10) // +10 for fulfillment
402        } else {
403            dec!(-20) // -20 for failure (2x penalty)
404        };
405
406        // Add quality bonus if fulfilled
407        let quality_bonus = if request.fulfilled {
408            request
409                .quality_score
410                .map(|q| (q - dec!(50)) / dec!(10)) // Quality 50 = 0 bonus, 100 = +5
411                .unwrap_or(dec!(0))
412        } else {
413            dec!(0)
414        };
415
416        let total_delta = base_delta + quality_bonus;
417
418        // Update user reputation
419        sqlx::query(
420            r#"
421            UPDATE users
422            SET reputation_score = GREATEST(0, LEAST(1000, reputation_score + $2))
423            WHERE user_id = $1
424            "#,
425        )
426        .bind(commitment.user_id)
427        .bind(total_delta)
428        .execute(&mut *tx)
429        .await?;
430
431        // Record reputation event
432        sqlx::query(
433            r#"
434            INSERT INTO reputation_events (user_id, event_type, delta, reason)
435            VALUES ($1, $2, $3, $4)
436            "#,
437        )
438        .bind(commitment.user_id)
439        .bind(if request.fulfilled {
440            "commitment_fulfilled"
441        } else {
442            "commitment_failed"
443        })
444        .bind(total_delta)
445        .bind(&request.notes)
446        .execute(&mut *tx)
447        .await?;
448
449        tx.commit().await?;
450
451        tracing::info!(
452            commitment_id = %commitment_id,
453            fulfilled = request.fulfilled,
454            delta = %total_delta,
455            "Commitment verified"
456        );
457
458        Ok((updated, total_delta))
459    }
460
461    /// Get user's commitments
462    pub async fn get_user_commitments(&self, user_id: Uuid) -> Result<Vec<OutputCommitment>> {
463        let commitments = sqlx::query_as::<_, OutputCommitment>(
464            r#"
465            SELECT * FROM output_commitments
466            WHERE user_id = $1
467            ORDER BY created_at DESC
468            "#,
469        )
470        .bind(user_id)
471        .fetch_all(&self.pool)
472        .await?;
473
474        Ok(commitments)
475    }
476
477    /// Get token's commitments
478    pub async fn get_token_commitments(&self, token_id: Uuid) -> Result<Vec<OutputCommitment>> {
479        let commitments = sqlx::query_as::<_, OutputCommitment>(
480            r#"
481            SELECT * FROM output_commitments
482            WHERE token_id = $1
483            ORDER BY created_at DESC
484            "#,
485        )
486        .bind(token_id)
487        .fetch_all(&self.pool)
488        .await?;
489
490        Ok(commitments)
491    }
492
493    /// Get pending commitments for admin verification
494    pub async fn get_pending_verification(&self, limit: i64) -> Result<Vec<OutputCommitment>> {
495        let commitments = sqlx::query_as::<_, OutputCommitment>(
496            r#"
497            SELECT * FROM output_commitments
498            WHERE status = 'completed'
499            ORDER BY created_at ASC
500            LIMIT $1
501            "#,
502        )
503        .bind(limit)
504        .fetch_all(&self.pool)
505        .await?;
506
507        Ok(commitments)
508    }
509
510    /// Expire overdue commitments
511    pub async fn expire_overdue(&self) -> Result<u64> {
512        let mut tx = self.pool.begin().await?;
513
514        // Get overdue commitments
515        let overdue: Vec<OutputCommitment> = sqlx::query_as(
516            r#"
517            SELECT * FROM output_commitments
518            WHERE status = 'pending' AND deadline < NOW()
519            FOR UPDATE
520            "#,
521        )
522        .fetch_all(&mut *tx)
523        .await?;
524
525        let count = overdue.len() as u64;
526
527        for commitment in &overdue {
528            // Mark as expired
529            sqlx::query(
530                r#"
531                UPDATE output_commitments
532                SET status = 'expired'
533                WHERE commitment_id = $1
534                "#,
535            )
536            .bind(commitment.commitment_id)
537            .execute(&mut *tx)
538            .await?;
539
540            // Penalize reputation
541            let penalty = dec!(-15);
542            sqlx::query(
543                r#"
544                UPDATE users
545                SET reputation_score = GREATEST(0, reputation_score + $2)
546                WHERE user_id = $1
547                "#,
548            )
549            .bind(commitment.user_id)
550            .bind(penalty)
551            .execute(&mut *tx)
552            .await?;
553
554            // Record event
555            sqlx::query(
556                r#"
557                INSERT INTO reputation_events (user_id, event_type, delta, reason)
558                VALUES ($1, 'commitment_expired', $2, $3)
559                "#,
560            )
561            .bind(commitment.user_id)
562            .bind(penalty)
563            .bind(format!("Commitment expired: {}", commitment.title))
564            .execute(&mut *tx)
565            .await?;
566        }
567
568        tx.commit().await?;
569
570        if count > 0 {
571            tracing::info!(count = count, "Expired overdue commitments");
572        }
573
574        Ok(count)
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn test_commitment_status_is_terminal() {
584        assert!(CommitmentStatus::Verified.is_terminal());
585        assert!(CommitmentStatus::Failed.is_terminal());
586        assert!(CommitmentStatus::Expired.is_terminal());
587        assert!(!CommitmentStatus::Pending.is_terminal());
588        assert!(!CommitmentStatus::Completed.is_terminal());
589    }
590
591    #[test]
592    fn test_commitment_status_is_active() {
593        assert!(CommitmentStatus::Pending.is_active());
594        assert!(CommitmentStatus::Completed.is_active());
595        assert!(!CommitmentStatus::Verified.is_active());
596        assert!(!CommitmentStatus::Failed.is_active());
597        assert!(!CommitmentStatus::Expired.is_active());
598    }
599
600    #[test]
601    fn test_commitment_status_is_successful() {
602        assert!(CommitmentStatus::Verified.is_successful());
603        assert!(!CommitmentStatus::Pending.is_successful());
604        assert!(!CommitmentStatus::Completed.is_successful());
605        assert!(!CommitmentStatus::Failed.is_successful());
606        assert!(!CommitmentStatus::Expired.is_successful());
607    }
608
609    #[test]
610    fn test_commitment_status_is_failed() {
611        assert!(CommitmentStatus::Failed.is_failed());
612        assert!(CommitmentStatus::Expired.is_failed());
613        assert!(!CommitmentStatus::Pending.is_failed());
614        assert!(!CommitmentStatus::Completed.is_failed());
615        assert!(!CommitmentStatus::Verified.is_failed());
616    }
617
618    #[test]
619    fn test_commitment_status_can_transition() {
620        // Pending can transition to Completed, Expired, or Failed
621        assert!(CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Completed));
622        assert!(CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Expired));
623        assert!(CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Failed));
624        assert!(!CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Verified));
625        assert!(!CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Pending));
626
627        // Completed can transition to Verified or Failed
628        assert!(CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Verified));
629        assert!(CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Failed));
630        assert!(!CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Pending));
631        assert!(!CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Expired));
632        assert!(!CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Completed));
633
634        // Terminal states cannot transition
635        assert!(!CommitmentStatus::Verified.can_transition_to(CommitmentStatus::Failed));
636        assert!(!CommitmentStatus::Failed.can_transition_to(CommitmentStatus::Verified));
637        assert!(!CommitmentStatus::Expired.can_transition_to(CommitmentStatus::Pending));
638    }
639
640    #[test]
641    fn test_commitment_status_possible_next_states() {
642        let pending_next = CommitmentStatus::Pending.possible_next_states();
643        assert_eq!(pending_next.len(), 3);
644        assert!(pending_next.contains(&CommitmentStatus::Completed));
645        assert!(pending_next.contains(&CommitmentStatus::Expired));
646        assert!(pending_next.contains(&CommitmentStatus::Failed));
647
648        let completed_next = CommitmentStatus::Completed.possible_next_states();
649        assert_eq!(completed_next.len(), 2);
650        assert!(completed_next.contains(&CommitmentStatus::Verified));
651        assert!(completed_next.contains(&CommitmentStatus::Failed));
652
653        // Terminal states have no next states
654        assert_eq!(CommitmentStatus::Verified.possible_next_states().len(), 0);
655        assert_eq!(CommitmentStatus::Failed.possible_next_states().len(), 0);
656        assert_eq!(CommitmentStatus::Expired.possible_next_states().len(), 0);
657    }
658
659    #[test]
660    fn test_commitment_status_display() {
661        assert_eq!(CommitmentStatus::Pending.to_string(), "pending");
662        assert_eq!(CommitmentStatus::Completed.to_string(), "completed");
663        assert_eq!(CommitmentStatus::Verified.to_string(), "verified");
664        assert_eq!(CommitmentStatus::Failed.to_string(), "failed");
665        assert_eq!(CommitmentStatus::Expired.to_string(), "expired");
666    }
667
668    #[test]
669    fn test_commitment_status_default() {
670        assert_eq!(CommitmentStatus::default(), CommitmentStatus::Pending);
671    }
672
673    #[test]
674    fn test_commitment_status_from_str_valid() {
675        assert_eq!(
676            "pending".parse::<CommitmentStatus>().unwrap(),
677            CommitmentStatus::Pending
678        );
679        assert_eq!(
680            "completed".parse::<CommitmentStatus>().unwrap(),
681            CommitmentStatus::Completed
682        );
683        assert_eq!(
684            "verified".parse::<CommitmentStatus>().unwrap(),
685            CommitmentStatus::Verified
686        );
687        assert_eq!(
688            "failed".parse::<CommitmentStatus>().unwrap(),
689            CommitmentStatus::Failed
690        );
691        assert_eq!(
692            "expired".parse::<CommitmentStatus>().unwrap(),
693            CommitmentStatus::Expired
694        );
695    }
696
697    #[test]
698    fn test_commitment_status_from_str_case_insensitive() {
699        assert_eq!(
700            "PENDING".parse::<CommitmentStatus>().unwrap(),
701            CommitmentStatus::Pending
702        );
703        assert_eq!(
704            "Completed".parse::<CommitmentStatus>().unwrap(),
705            CommitmentStatus::Completed
706        );
707        assert_eq!(
708            "VERIFIED".parse::<CommitmentStatus>().unwrap(),
709            CommitmentStatus::Verified
710        );
711        assert_eq!(
712            "Failed".parse::<CommitmentStatus>().unwrap(),
713            CommitmentStatus::Failed
714        );
715        assert_eq!(
716            "EXPIRED".parse::<CommitmentStatus>().unwrap(),
717            CommitmentStatus::Expired
718        );
719    }
720
721    #[test]
722    fn test_commitment_status_from_str_invalid() {
723        assert!("invalid".parse::<CommitmentStatus>().is_err());
724        assert!("unknown".parse::<CommitmentStatus>().is_err());
725        assert!("".parse::<CommitmentStatus>().is_err());
726    }
727
728    // Phase 18 tests - New functionality
729    #[test]
730    fn test_commitment_status_all_variants() {
731        let variants = CommitmentStatus::all_variants();
732        assert_eq!(variants.len(), 5);
733        assert_eq!(variants[0], CommitmentStatus::Pending);
734        assert_eq!(variants[1], CommitmentStatus::Completed);
735        assert_eq!(variants[2], CommitmentStatus::Verified);
736        assert_eq!(variants[3], CommitmentStatus::Failed);
737        assert_eq!(variants[4], CommitmentStatus::Expired);
738    }
739
740    #[test]
741    fn test_commitment_status_as_str() {
742        assert_eq!(CommitmentStatus::Pending.as_str(), "pending");
743        assert_eq!(CommitmentStatus::Completed.as_str(), "completed");
744        assert_eq!(CommitmentStatus::Verified.as_str(), "verified");
745        assert_eq!(CommitmentStatus::Failed.as_str(), "failed");
746        assert_eq!(CommitmentStatus::Expired.as_str(), "expired");
747    }
748
749    #[test]
750    fn test_create_commitment_request_builder() {
751        let token_id = Uuid::new_v4();
752        let request = CreateCommitmentRequest::builder(token_id, "Test Commitment")
753            .description("Test description")
754            .deadline_days_from_now(7)
755            .build();
756
757        assert!(request.is_ok());
758        let req = request.unwrap();
759        assert_eq!(req.token_id, token_id);
760        assert_eq!(req.title, "Test Commitment");
761        assert_eq!(req.description, Some("Test description".to_string()));
762    }
763
764    #[test]
765    fn test_create_commitment_request_builder_no_deadline() {
766        let token_id = Uuid::new_v4();
767        let request = CreateCommitmentRequest::builder(token_id, "Test")
768            .description("Desc")
769            .build();
770
771        assert!(request.is_err());
772    }
773
774    #[test]
775    fn test_create_commitment_request_builder_validation() {
776        let token_id = Uuid::new_v4();
777        // Title too long
778        let long_title = "a".repeat(201);
779        let request = CreateCommitmentRequest::builder(token_id, long_title)
780            .deadline_days_from_now(7)
781            .build();
782
783        assert!(request.is_err());
784    }
785}