Skip to main content

mas_storage_pg/user/
registration_token.rs

1// Copyright 2026 Element Creations Ltd.
2// Copyright 2025 New Vector Ltd.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use mas_data_model::{Clock, UlidExt as _, UserRegistrationToken};
10use mas_storage::{
11    Page, Pagination,
12    pagination::Node,
13    user::{UserRegistrationTokenFilter, UserRegistrationTokenRepository},
14};
15use rand::Rng;
16use sea_query::{Condition, Expr, ExprTrait, PostgresQueryBuilder, Query, enum_def};
17use sea_query_sqlx::SqlxBinder;
18use sqlx::PgConnection;
19use ulid::Ulid;
20use uuid::Uuid;
21
22use crate::{
23    DatabaseInconsistencyError,
24    errors::DatabaseError,
25    filter::{Filter, StatementExt},
26    iden::UserRegistrationTokens,
27    pagination::QueryBuilderExt,
28    tracing::ExecuteExt,
29};
30
31/// An implementation of [`mas_storage::user::UserRegistrationTokenRepository`]
32/// for a PostgreSQL connection
33pub struct PgUserRegistrationTokenRepository<'c> {
34    conn: &'c mut PgConnection,
35}
36
37impl<'c> PgUserRegistrationTokenRepository<'c> {
38    /// Create a new [`PgUserRegistrationTokenRepository`] from an active
39    /// PostgreSQL connection
40    pub fn new(conn: &'c mut PgConnection) -> Self {
41        Self { conn }
42    }
43}
44
45#[derive(Debug, Clone, sqlx::FromRow)]
46#[enum_def]
47struct UserRegistrationTokenLookup {
48    user_registration_token_id: Uuid,
49    token: String,
50    usage_limit: Option<i32>,
51    times_used: i32,
52    created_at: DateTime<Utc>,
53    last_used_at: Option<DateTime<Utc>>,
54    expires_at: Option<DateTime<Utc>>,
55    revoked_at: Option<DateTime<Utc>>,
56}
57
58impl Node<Ulid> for UserRegistrationTokenLookup {
59    fn cursor(&self) -> Ulid {
60        self.user_registration_token_id.into()
61    }
62}
63
64impl Filter for UserRegistrationTokenFilter {
65    fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition {
66        sea_query::Condition::all()
67            .add_option(self.has_been_used().map(|has_been_used| {
68                if has_been_used {
69                    Expr::col((
70                        UserRegistrationTokens::Table,
71                        UserRegistrationTokens::TimesUsed,
72                    ))
73                    .gt(0)
74                } else {
75                    Expr::col((
76                        UserRegistrationTokens::Table,
77                        UserRegistrationTokens::TimesUsed,
78                    ))
79                    .eq(0)
80                }
81            }))
82            .add_option(self.is_revoked().map(|is_revoked| {
83                if is_revoked {
84                    Expr::col((
85                        UserRegistrationTokens::Table,
86                        UserRegistrationTokens::RevokedAt,
87                    ))
88                    .is_not_null()
89                } else {
90                    Expr::col((
91                        UserRegistrationTokens::Table,
92                        UserRegistrationTokens::RevokedAt,
93                    ))
94                    .is_null()
95                }
96            }))
97            .add_option(self.is_expired().map(|is_expired| {
98                if is_expired {
99                    Condition::all()
100                        .add(
101                            Expr::col((
102                                UserRegistrationTokens::Table,
103                                UserRegistrationTokens::ExpiresAt,
104                            ))
105                            .is_not_null(),
106                        )
107                        .add(
108                            Expr::col((
109                                UserRegistrationTokens::Table,
110                                UserRegistrationTokens::ExpiresAt,
111                            ))
112                            .lt(Expr::val(self.now())),
113                        )
114                } else {
115                    Condition::any()
116                        .add(
117                            Expr::col((
118                                UserRegistrationTokens::Table,
119                                UserRegistrationTokens::ExpiresAt,
120                            ))
121                            .is_null(),
122                        )
123                        .add(
124                            Expr::col((
125                                UserRegistrationTokens::Table,
126                                UserRegistrationTokens::ExpiresAt,
127                            ))
128                            .gte(Expr::val(self.now())),
129                        )
130                }
131            }))
132            .add_option(self.is_valid().map(|is_valid| {
133                let valid = Condition::all()
134                    // Has not reached its usage limit
135                    .add(
136                        Condition::any()
137                            .add(
138                                Expr::col((
139                                    UserRegistrationTokens::Table,
140                                    UserRegistrationTokens::UsageLimit,
141                                ))
142                                .is_null(),
143                            )
144                            .add(
145                                Expr::col((
146                                    UserRegistrationTokens::Table,
147                                    UserRegistrationTokens::TimesUsed,
148                                ))
149                                .lt(Expr::col((
150                                    UserRegistrationTokens::Table,
151                                    UserRegistrationTokens::UsageLimit,
152                                ))),
153                            ),
154                    )
155                    // Has not been revoked
156                    .add(
157                        Expr::col((
158                            UserRegistrationTokens::Table,
159                            UserRegistrationTokens::RevokedAt,
160                        ))
161                        .is_null(),
162                    )
163                    // Has not expired
164                    .add(
165                        Condition::any()
166                            .add(
167                                Expr::col((
168                                    UserRegistrationTokens::Table,
169                                    UserRegistrationTokens::ExpiresAt,
170                                ))
171                                .is_null(),
172                            )
173                            .add(
174                                Expr::col((
175                                    UserRegistrationTokens::Table,
176                                    UserRegistrationTokens::ExpiresAt,
177                                ))
178                                .gte(Expr::val(self.now())),
179                            ),
180                    );
181
182                if is_valid { valid } else { valid.not() }
183            }))
184    }
185}
186
187impl TryFrom<UserRegistrationTokenLookup> for UserRegistrationToken {
188    type Error = DatabaseInconsistencyError;
189
190    fn try_from(res: UserRegistrationTokenLookup) -> Result<Self, Self::Error> {
191        let id = Ulid::from(res.user_registration_token_id);
192
193        let usage_limit = res
194            .usage_limit
195            .map(u32::try_from)
196            .transpose()
197            .map_err(|e| {
198                DatabaseInconsistencyError::on("user_registration_tokens")
199                    .column("usage_limit")
200                    .row(id)
201                    .source(e)
202            })?;
203
204        let times_used = res.times_used.try_into().map_err(|e| {
205            DatabaseInconsistencyError::on("user_registration_tokens")
206                .column("times_used")
207                .row(id)
208                .source(e)
209        })?;
210
211        Ok(UserRegistrationToken {
212            id,
213            token: res.token,
214            usage_limit,
215            times_used,
216            created_at: res.created_at,
217            last_used_at: res.last_used_at,
218            expires_at: res.expires_at,
219            revoked_at: res.revoked_at,
220        })
221    }
222}
223
224#[async_trait]
225impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
226    type Error = DatabaseError;
227
228    #[tracing::instrument(
229        name = "db.user_registration_token.list",
230        skip_all,
231        fields(
232            db.query.text,
233        ),
234    )]
235    async fn list(
236        &mut self,
237        filter: UserRegistrationTokenFilter,
238        pagination: Pagination,
239    ) -> Result<Page<UserRegistrationToken>, Self::Error> {
240        let (sql, arguments) = Query::select()
241            .expr_as(
242                Expr::col((
243                    UserRegistrationTokens::Table,
244                    UserRegistrationTokens::UserRegistrationTokenId,
245                )),
246                UserRegistrationTokenLookupIden::UserRegistrationTokenId,
247            )
248            .expr_as(
249                Expr::col((UserRegistrationTokens::Table, UserRegistrationTokens::Token)),
250                UserRegistrationTokenLookupIden::Token,
251            )
252            .expr_as(
253                Expr::col((
254                    UserRegistrationTokens::Table,
255                    UserRegistrationTokens::UsageLimit,
256                )),
257                UserRegistrationTokenLookupIden::UsageLimit,
258            )
259            .expr_as(
260                Expr::col((
261                    UserRegistrationTokens::Table,
262                    UserRegistrationTokens::TimesUsed,
263                )),
264                UserRegistrationTokenLookupIden::TimesUsed,
265            )
266            .expr_as(
267                Expr::col((
268                    UserRegistrationTokens::Table,
269                    UserRegistrationTokens::CreatedAt,
270                )),
271                UserRegistrationTokenLookupIden::CreatedAt,
272            )
273            .expr_as(
274                Expr::col((
275                    UserRegistrationTokens::Table,
276                    UserRegistrationTokens::LastUsedAt,
277                )),
278                UserRegistrationTokenLookupIden::LastUsedAt,
279            )
280            .expr_as(
281                Expr::col((
282                    UserRegistrationTokens::Table,
283                    UserRegistrationTokens::ExpiresAt,
284                )),
285                UserRegistrationTokenLookupIden::ExpiresAt,
286            )
287            .expr_as(
288                Expr::col((
289                    UserRegistrationTokens::Table,
290                    UserRegistrationTokens::RevokedAt,
291                )),
292                UserRegistrationTokenLookupIden::RevokedAt,
293            )
294            .from(UserRegistrationTokens::Table)
295            .apply_filter(filter)
296            .generate_pagination(
297                (
298                    UserRegistrationTokens::Table,
299                    UserRegistrationTokens::UserRegistrationTokenId,
300                ),
301                pagination,
302            )
303            .build_sqlx(PostgresQueryBuilder);
304
305        let edges: Vec<UserRegistrationTokenLookup> = sqlx::query_as_with(&sql, arguments)
306            .traced()
307            .fetch_all(&mut *self.conn)
308            .await?;
309
310        let page = pagination
311            .process(edges)
312            .try_map(UserRegistrationToken::try_from)?;
313
314        Ok(page)
315    }
316
317    #[tracing::instrument(
318        name = "db.user_registration_token.count",
319        skip_all,
320        fields(
321            db.query.text,
322            user_registration_token.filter = ?filter,
323        ),
324    )]
325    async fn count(&mut self, filter: UserRegistrationTokenFilter) -> Result<usize, Self::Error> {
326        let (sql, values) = Query::select()
327            .expr(
328                Expr::col((
329                    UserRegistrationTokens::Table,
330                    UserRegistrationTokens::UserRegistrationTokenId,
331                ))
332                .count(),
333            )
334            .from(UserRegistrationTokens::Table)
335            .apply_filter(filter)
336            .build_sqlx(PostgresQueryBuilder);
337
338        let count: i64 = sqlx::query_scalar_with(&sql, values)
339            .traced()
340            .fetch_one(&mut *self.conn)
341            .await?;
342
343        count
344            .try_into()
345            .map_err(DatabaseError::to_invalid_operation)
346    }
347
348    #[tracing::instrument(
349        name = "db.user_registration_token.lookup",
350        skip_all,
351        fields(
352            db.query.text,
353            user_registration_token.id = %id,
354        ),
355    )]
356    async fn lookup(&mut self, id: Ulid) -> Result<Option<UserRegistrationToken>, Self::Error> {
357        let res = sqlx::query_as!(
358            UserRegistrationTokenLookup,
359            r#"
360                SELECT user_registration_token_id,
361                       token,
362                       usage_limit,
363                       times_used,
364                       created_at,
365                       last_used_at,
366                       expires_at,
367                       revoked_at
368                FROM user_registration_tokens
369                WHERE user_registration_token_id = $1
370            "#,
371            Uuid::from(id)
372        )
373        .traced()
374        .fetch_optional(&mut *self.conn)
375        .await?;
376
377        let Some(res) = res else {
378            return Ok(None);
379        };
380
381        Ok(Some(res.try_into()?))
382    }
383
384    #[tracing::instrument(
385        name = "db.user_registration_token.find_by_token",
386        skip_all,
387        fields(
388            db.query.text,
389            token = %token,
390        ),
391    )]
392    async fn find_by_token(
393        &mut self,
394        token: &str,
395    ) -> Result<Option<UserRegistrationToken>, Self::Error> {
396        let res = sqlx::query_as!(
397            UserRegistrationTokenLookup,
398            r#"
399                SELECT user_registration_token_id,
400                       token,
401                       usage_limit,
402                       times_used,
403                       created_at,
404                       last_used_at,
405                       expires_at,
406                       revoked_at
407                FROM user_registration_tokens
408                WHERE token = $1
409            "#,
410            token
411        )
412        .traced()
413        .fetch_optional(&mut *self.conn)
414        .await?;
415
416        let Some(res) = res else {
417            return Ok(None);
418        };
419
420        Ok(Some(res.try_into()?))
421    }
422
423    #[tracing::instrument(
424        name = "db.user_registration_token.add",
425        skip_all,
426        fields(
427            db.query.text,
428            user_registration_token.token = %token,
429        ),
430    )]
431    async fn add(
432        &mut self,
433        rng: &mut (dyn Rng + Send),
434        clock: &dyn mas_data_model::Clock,
435        token: String,
436        usage_limit: Option<u32>,
437        expires_at: Option<DateTime<Utc>>,
438    ) -> Result<UserRegistrationToken, Self::Error> {
439        let created_at = clock.now();
440        let id = Ulid::from_datetime_with_rng(created_at, rng);
441
442        let usage_limit_i32 = usage_limit
443            .map(i32::try_from)
444            .transpose()
445            .map_err(DatabaseError::to_invalid_operation)?;
446
447        sqlx::query!(
448            r#"
449                INSERT INTO user_registration_tokens
450                    (user_registration_token_id, token, usage_limit, created_at, expires_at)
451                VALUES ($1, $2, $3, $4, $5)
452            "#,
453            Uuid::from(id),
454            &token,
455            usage_limit_i32,
456            created_at,
457            expires_at,
458        )
459        .traced()
460        .execute(&mut *self.conn)
461        .await?;
462
463        Ok(UserRegistrationToken {
464            id,
465            token,
466            usage_limit,
467            times_used: 0,
468            created_at,
469            last_used_at: None,
470            expires_at,
471            revoked_at: None,
472        })
473    }
474
475    #[tracing::instrument(
476        name = "db.user_registration_token.use_token",
477        skip_all,
478        fields(
479            db.query.text,
480            user_registration_token.id = %token.id,
481        ),
482    )]
483    async fn use_token(
484        &mut self,
485        clock: &dyn Clock,
486        token: UserRegistrationToken,
487    ) -> Result<UserRegistrationToken, Self::Error> {
488        let now = clock.now();
489        let new_times_used = sqlx::query_scalar!(
490            r#"
491                UPDATE user_registration_tokens
492                SET times_used = times_used + 1,
493                    last_used_at = $2
494                WHERE user_registration_token_id = $1 AND revoked_at IS NULL
495                RETURNING times_used
496            "#,
497            Uuid::from(token.id),
498            now,
499        )
500        .traced()
501        .fetch_one(&mut *self.conn)
502        .await?;
503
504        let new_times_used = new_times_used
505            .try_into()
506            .map_err(DatabaseError::to_invalid_operation)?;
507
508        Ok(UserRegistrationToken {
509            times_used: new_times_used,
510            last_used_at: Some(now),
511            ..token
512        })
513    }
514
515    #[tracing::instrument(
516        name = "db.user_registration_token.revoke",
517        skip_all,
518        fields(
519            db.query.text,
520            user_registration_token.id = %token.id,
521        ),
522    )]
523    async fn revoke(
524        &mut self,
525        clock: &dyn Clock,
526        mut token: UserRegistrationToken,
527    ) -> Result<UserRegistrationToken, Self::Error> {
528        let revoked_at = clock.now();
529        let res = sqlx::query!(
530            r#"
531                UPDATE user_registration_tokens
532                SET revoked_at = $2
533                WHERE user_registration_token_id = $1
534            "#,
535            Uuid::from(token.id),
536            revoked_at,
537        )
538        .traced()
539        .execute(&mut *self.conn)
540        .await?;
541
542        DatabaseError::ensure_affected_rows(&res, 1)?;
543
544        token.revoked_at = Some(revoked_at);
545
546        Ok(token)
547    }
548
549    #[tracing::instrument(
550        name = "db.user_registration_token.unrevoke",
551        skip_all,
552        fields(
553            db.query.text,
554            user_registration_token.id = %token.id,
555        ),
556    )]
557    async fn unrevoke(
558        &mut self,
559        mut token: UserRegistrationToken,
560    ) -> Result<UserRegistrationToken, Self::Error> {
561        let res = sqlx::query!(
562            r#"
563                UPDATE user_registration_tokens
564                SET revoked_at = NULL
565                WHERE user_registration_token_id = $1
566            "#,
567            Uuid::from(token.id),
568        )
569        .traced()
570        .execute(&mut *self.conn)
571        .await?;
572
573        DatabaseError::ensure_affected_rows(&res, 1)?;
574
575        token.revoked_at = None;
576
577        Ok(token)
578    }
579
580    #[tracing::instrument(
581        name = "db.user_registration_token.set_expiry",
582        skip_all,
583        fields(
584            db.query.text,
585            user_registration_token.id = %token.id,
586        ),
587    )]
588    async fn set_expiry(
589        &mut self,
590        mut token: UserRegistrationToken,
591        expires_at: Option<DateTime<Utc>>,
592    ) -> Result<UserRegistrationToken, Self::Error> {
593        let res = sqlx::query!(
594            r#"
595                UPDATE user_registration_tokens
596                SET expires_at = $2
597                WHERE user_registration_token_id = $1
598            "#,
599            Uuid::from(token.id),
600            expires_at,
601        )
602        .traced()
603        .execute(&mut *self.conn)
604        .await?;
605
606        DatabaseError::ensure_affected_rows(&res, 1)?;
607
608        token.expires_at = expires_at;
609
610        Ok(token)
611    }
612
613    #[tracing::instrument(
614        name = "db.user_registration_token.set_usage_limit",
615        skip_all,
616        fields(
617            db.query.text,
618            user_registration_token.id = %token.id,
619        ),
620    )]
621    async fn set_usage_limit(
622        &mut self,
623        mut token: UserRegistrationToken,
624        usage_limit: Option<u32>,
625    ) -> Result<UserRegistrationToken, Self::Error> {
626        let usage_limit_i32 = usage_limit
627            .map(i32::try_from)
628            .transpose()
629            .map_err(DatabaseError::to_invalid_operation)?;
630
631        let res = sqlx::query!(
632            r#"
633                UPDATE user_registration_tokens
634                SET usage_limit = $2
635                WHERE user_registration_token_id = $1
636            "#,
637            Uuid::from(token.id),
638            usage_limit_i32,
639        )
640        .traced()
641        .execute(&mut *self.conn)
642        .await?;
643
644        DatabaseError::ensure_affected_rows(&res, 1)?;
645
646        token.usage_limit = usage_limit;
647
648        Ok(token)
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use chrono::Duration;
655    use mas_data_model::{Clock as _, clock::MockClock};
656    use mas_storage::{Pagination, user::UserRegistrationTokenFilter};
657    use rand::SeedableRng;
658    use rand_chacha::ChaChaRng;
659    use sqlx::PgPool;
660
661    use crate::PgRepository;
662
663    #[sqlx::test(migrator = "crate::MIGRATOR")]
664    async fn test_unrevoke(pool: PgPool) {
665        let mut rng = ChaChaRng::seed_from_u64(42);
666        let clock = MockClock::default();
667
668        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
669
670        // Create a token
671        let token = repo
672            .user_registration_token()
673            .add(&mut rng, &clock, "test_token".to_owned(), None, None)
674            .await
675            .unwrap();
676
677        // Revoke the token
678        let revoked_token = repo
679            .user_registration_token()
680            .revoke(&clock, token)
681            .await
682            .unwrap();
683
684        // Verify it's revoked
685        assert!(revoked_token.revoked_at.is_some());
686
687        // Unrevoke the token
688        let unrevoked_token = repo
689            .user_registration_token()
690            .unrevoke(revoked_token)
691            .await
692            .unwrap();
693
694        // Verify it's no longer revoked
695        assert!(unrevoked_token.revoked_at.is_none());
696
697        // Check that we can find it with the non-revoked filter
698        let non_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false);
699        let page = repo
700            .user_registration_token()
701            .list(non_revoked_filter, Pagination::first(10))
702            .await
703            .unwrap();
704
705        assert!(page.edges.iter().any(|t| t.node.id == unrevoked_token.id));
706    }
707
708    #[sqlx::test(migrator = "crate::MIGRATOR")]
709    async fn test_set_expiry(pool: PgPool) {
710        let mut rng = ChaChaRng::seed_from_u64(42);
711        let clock = MockClock::default();
712
713        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
714
715        // Create a token without expiry
716        let token = repo
717            .user_registration_token()
718            .add(&mut rng, &clock, "test_token_expiry".to_owned(), None, None)
719            .await
720            .unwrap();
721
722        // Verify it has no expiration
723        assert!(token.expires_at.is_none());
724
725        // Set an expiration
726        let future_time = clock.now() + Duration::days(30);
727        let updated_token = repo
728            .user_registration_token()
729            .set_expiry(token, Some(future_time))
730            .await
731            .unwrap();
732
733        // Verify expiration is set
734        assert_eq!(updated_token.expires_at, Some(future_time));
735
736        // Remove the expiration
737        let final_token = repo
738            .user_registration_token()
739            .set_expiry(updated_token, None)
740            .await
741            .unwrap();
742
743        // Verify expiration is removed
744        assert!(final_token.expires_at.is_none());
745    }
746
747    #[sqlx::test(migrator = "crate::MIGRATOR")]
748    async fn test_set_usage_limit(pool: PgPool) {
749        let mut rng = ChaChaRng::seed_from_u64(42);
750        let clock = MockClock::default();
751
752        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
753
754        // Create a token without usage limit
755        let token = repo
756            .user_registration_token()
757            .add(&mut rng, &clock, "test_token_limit".to_owned(), None, None)
758            .await
759            .unwrap();
760
761        // Verify it has no usage limit
762        assert!(token.usage_limit.is_none());
763
764        // Set a usage limit
765        let updated_token = repo
766            .user_registration_token()
767            .set_usage_limit(token, Some(5))
768            .await
769            .unwrap();
770
771        // Verify usage limit is set
772        assert_eq!(updated_token.usage_limit, Some(5));
773
774        // Change the usage limit
775        let changed_token = repo
776            .user_registration_token()
777            .set_usage_limit(updated_token, Some(10))
778            .await
779            .unwrap();
780
781        // Verify usage limit is changed
782        assert_eq!(changed_token.usage_limit, Some(10));
783
784        // Remove the usage limit
785        let final_token = repo
786            .user_registration_token()
787            .set_usage_limit(changed_token, None)
788            .await
789            .unwrap();
790
791        // Verify usage limit is removed
792        assert!(final_token.usage_limit.is_none());
793    }
794
795    #[sqlx::test(migrator = "crate::MIGRATOR")]
796    async fn test_list_and_count(pool: PgPool) {
797        let mut rng = ChaChaRng::seed_from_u64(42);
798        let clock = MockClock::default();
799
800        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
801
802        // Create different types of tokens
803        // 1. A regular token
804        let _token1 = repo
805            .user_registration_token()
806            .add(&mut rng, &clock, "token1".to_owned(), None, None)
807            .await
808            .unwrap();
809
810        // 2. A token that has been used
811        let token2 = repo
812            .user_registration_token()
813            .add(&mut rng, &clock, "token2".to_owned(), None, None)
814            .await
815            .unwrap();
816        let token2 = repo
817            .user_registration_token()
818            .use_token(&clock, token2)
819            .await
820            .unwrap();
821
822        // 3. A token that is expired
823        let past_time = clock.now() - Duration::days(1);
824        let token3 = repo
825            .user_registration_token()
826            .add(&mut rng, &clock, "token3".to_owned(), None, Some(past_time))
827            .await
828            .unwrap();
829
830        // 4. A token that is revoked
831        let token4 = repo
832            .user_registration_token()
833            .add(&mut rng, &clock, "token4".to_owned(), None, None)
834            .await
835            .unwrap();
836        let token4 = repo
837            .user_registration_token()
838            .revoke(&clock, token4)
839            .await
840            .unwrap();
841
842        // Test list with empty filter
843        let empty_filter = UserRegistrationTokenFilter::new(clock.now());
844        let page = repo
845            .user_registration_token()
846            .list(empty_filter, Pagination::first(10))
847            .await
848            .unwrap();
849        assert_eq!(page.edges.len(), 4);
850
851        // Test count with empty filter
852        let count = repo
853            .user_registration_token()
854            .count(empty_filter)
855            .await
856            .unwrap();
857        assert_eq!(count, 4);
858
859        // Test has_been_used filter
860        let used_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(true);
861        let page = repo
862            .user_registration_token()
863            .list(used_filter, Pagination::first(10))
864            .await
865            .unwrap();
866        assert_eq!(page.edges.len(), 1);
867        assert_eq!(page.edges[0].node.id, token2.id);
868
869        // Test unused filter
870        let unused_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(false);
871        let page = repo
872            .user_registration_token()
873            .list(unused_filter, Pagination::first(10))
874            .await
875            .unwrap();
876        assert_eq!(page.edges.len(), 3);
877
878        // Test is_expired filter
879        let expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(true);
880        let page = repo
881            .user_registration_token()
882            .list(expired_filter, Pagination::first(10))
883            .await
884            .unwrap();
885        assert_eq!(page.edges.len(), 1);
886        assert_eq!(page.edges[0].node.id, token3.id);
887
888        let not_expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(false);
889        let page = repo
890            .user_registration_token()
891            .list(not_expired_filter, Pagination::first(10))
892            .await
893            .unwrap();
894        assert_eq!(page.edges.len(), 3);
895
896        // Test is_revoked filter
897        let revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(true);
898        let page = repo
899            .user_registration_token()
900            .list(revoked_filter, Pagination::first(10))
901            .await
902            .unwrap();
903        assert_eq!(page.edges.len(), 1);
904        assert_eq!(page.edges[0].node.id, token4.id);
905
906        let not_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false);
907        let page = repo
908            .user_registration_token()
909            .list(not_revoked_filter, Pagination::first(10))
910            .await
911            .unwrap();
912        assert_eq!(page.edges.len(), 3);
913
914        // Test is_valid filter
915        let valid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(true);
916        let page = repo
917            .user_registration_token()
918            .list(valid_filter, Pagination::first(10))
919            .await
920            .unwrap();
921        assert_eq!(page.edges.len(), 2);
922
923        let invalid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(false);
924        let page = repo
925            .user_registration_token()
926            .list(invalid_filter, Pagination::first(10))
927            .await
928            .unwrap();
929        assert_eq!(page.edges.len(), 2);
930
931        // Test combined filters
932        let combined_filter = UserRegistrationTokenFilter::new(clock.now())
933            .with_been_used(false)
934            .with_revoked(true);
935        let page = repo
936            .user_registration_token()
937            .list(combined_filter, Pagination::first(10))
938            .await
939            .unwrap();
940        assert_eq!(page.edges.len(), 1);
941        assert_eq!(page.edges[0].node.id, token4.id);
942
943        // Test pagination
944        let page = repo
945            .user_registration_token()
946            .list(empty_filter, Pagination::first(2))
947            .await
948            .unwrap();
949        assert_eq!(page.edges.len(), 2);
950    }
951}