mas_storage_pg/user/
registration_token.rs

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