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::RngCore;
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        err,
235    )]
236    async fn list(
237        &mut self,
238        filter: UserRegistrationTokenFilter,
239        pagination: Pagination,
240    ) -> Result<Page<UserRegistrationToken>, Self::Error> {
241        let (sql, arguments) = Query::select()
242            .expr_as(
243                Expr::col((
244                    UserRegistrationTokens::Table,
245                    UserRegistrationTokens::UserRegistrationTokenId,
246                )),
247                UserRegistrationTokenLookupIden::UserRegistrationTokenId,
248            )
249            .expr_as(
250                Expr::col((UserRegistrationTokens::Table, UserRegistrationTokens::Token)),
251                UserRegistrationTokenLookupIden::Token,
252            )
253            .expr_as(
254                Expr::col((
255                    UserRegistrationTokens::Table,
256                    UserRegistrationTokens::UsageLimit,
257                )),
258                UserRegistrationTokenLookupIden::UsageLimit,
259            )
260            .expr_as(
261                Expr::col((
262                    UserRegistrationTokens::Table,
263                    UserRegistrationTokens::TimesUsed,
264                )),
265                UserRegistrationTokenLookupIden::TimesUsed,
266            )
267            .expr_as(
268                Expr::col((
269                    UserRegistrationTokens::Table,
270                    UserRegistrationTokens::CreatedAt,
271                )),
272                UserRegistrationTokenLookupIden::CreatedAt,
273            )
274            .expr_as(
275                Expr::col((
276                    UserRegistrationTokens::Table,
277                    UserRegistrationTokens::LastUsedAt,
278                )),
279                UserRegistrationTokenLookupIden::LastUsedAt,
280            )
281            .expr_as(
282                Expr::col((
283                    UserRegistrationTokens::Table,
284                    UserRegistrationTokens::ExpiresAt,
285                )),
286                UserRegistrationTokenLookupIden::ExpiresAt,
287            )
288            .expr_as(
289                Expr::col((
290                    UserRegistrationTokens::Table,
291                    UserRegistrationTokens::RevokedAt,
292                )),
293                UserRegistrationTokenLookupIden::RevokedAt,
294            )
295            .from(UserRegistrationTokens::Table)
296            .apply_filter(filter)
297            .generate_pagination(
298                (
299                    UserRegistrationTokens::Table,
300                    UserRegistrationTokens::UserRegistrationTokenId,
301                ),
302                pagination,
303            )
304            .build_sqlx(PostgresQueryBuilder);
305
306        let edges: Vec<UserRegistrationTokenLookup> = sqlx::query_as_with(&sql, arguments)
307            .traced()
308            .fetch_all(&mut *self.conn)
309            .await?;
310
311        let page = pagination
312            .process(edges)
313            .try_map(UserRegistrationToken::try_from)?;
314
315        Ok(page)
316    }
317
318    #[tracing::instrument(
319        name = "db.user_registration_token.count",
320        skip_all,
321        fields(
322            db.query.text,
323            user_registration_token.filter = ?filter,
324        ),
325        err,
326    )]
327    async fn count(&mut self, filter: UserRegistrationTokenFilter) -> Result<usize, Self::Error> {
328        let (sql, values) = Query::select()
329            .expr(
330                Expr::col((
331                    UserRegistrationTokens::Table,
332                    UserRegistrationTokens::UserRegistrationTokenId,
333                ))
334                .count(),
335            )
336            .from(UserRegistrationTokens::Table)
337            .apply_filter(filter)
338            .build_sqlx(PostgresQueryBuilder);
339
340        let count: i64 = sqlx::query_scalar_with(&sql, values)
341            .traced()
342            .fetch_one(&mut *self.conn)
343            .await?;
344
345        count
346            .try_into()
347            .map_err(DatabaseError::to_invalid_operation)
348    }
349
350    #[tracing::instrument(
351        name = "db.user_registration_token.lookup",
352        skip_all,
353        fields(
354            db.query.text,
355            user_registration_token.id = %id,
356        ),
357        err,
358    )]
359    async fn lookup(&mut self, id: Ulid) -> Result<Option<UserRegistrationToken>, Self::Error> {
360        let res = sqlx::query_as!(
361            UserRegistrationTokenLookup,
362            r#"
363                SELECT user_registration_token_id,
364                       token,
365                       usage_limit,
366                       times_used,
367                       created_at,
368                       last_used_at,
369                       expires_at,
370                       revoked_at
371                FROM user_registration_tokens
372                WHERE user_registration_token_id = $1
373            "#,
374            Uuid::from(id)
375        )
376        .traced()
377        .fetch_optional(&mut *self.conn)
378        .await?;
379
380        let Some(res) = res else {
381            return Ok(None);
382        };
383
384        Ok(Some(res.try_into()?))
385    }
386
387    #[tracing::instrument(
388        name = "db.user_registration_token.find_by_token",
389        skip_all,
390        fields(
391            db.query.text,
392            token = %token,
393        ),
394        err,
395    )]
396    async fn find_by_token(
397        &mut self,
398        token: &str,
399    ) -> Result<Option<UserRegistrationToken>, Self::Error> {
400        let res = sqlx::query_as!(
401            UserRegistrationTokenLookup,
402            r#"
403                SELECT user_registration_token_id,
404                       token,
405                       usage_limit,
406                       times_used,
407                       created_at,
408                       last_used_at,
409                       expires_at,
410                       revoked_at
411                FROM user_registration_tokens
412                WHERE token = $1
413            "#,
414            token
415        )
416        .traced()
417        .fetch_optional(&mut *self.conn)
418        .await?;
419
420        let Some(res) = res else {
421            return Ok(None);
422        };
423
424        Ok(Some(res.try_into()?))
425    }
426
427    #[tracing::instrument(
428        name = "db.user_registration_token.add",
429        skip_all,
430        fields(
431            db.query.text,
432            user_registration_token.token = %token,
433        ),
434        err,
435    )]
436    async fn add(
437        &mut self,
438        rng: &mut (dyn RngCore + Send),
439        clock: &dyn mas_data_model::Clock,
440        token: String,
441        usage_limit: Option<u32>,
442        expires_at: Option<DateTime<Utc>>,
443    ) -> Result<UserRegistrationToken, Self::Error> {
444        let created_at = clock.now();
445        let id = Ulid::from_datetime_with_rng(created_at, rng);
446
447        let usage_limit_i32 = usage_limit
448            .map(i32::try_from)
449            .transpose()
450            .map_err(DatabaseError::to_invalid_operation)?;
451
452        sqlx::query!(
453            r#"
454                INSERT INTO user_registration_tokens
455                    (user_registration_token_id, token, usage_limit, created_at, expires_at)
456                VALUES ($1, $2, $3, $4, $5)
457            "#,
458            Uuid::from(id),
459            &token,
460            usage_limit_i32,
461            created_at,
462            expires_at,
463        )
464        .traced()
465        .execute(&mut *self.conn)
466        .await?;
467
468        Ok(UserRegistrationToken {
469            id,
470            token,
471            usage_limit,
472            times_used: 0,
473            created_at,
474            last_used_at: None,
475            expires_at,
476            revoked_at: None,
477        })
478    }
479
480    #[tracing::instrument(
481        name = "db.user_registration_token.use_token",
482        skip_all,
483        fields(
484            db.query.text,
485            user_registration_token.id = %token.id,
486        ),
487        err,
488    )]
489    async fn use_token(
490        &mut self,
491        clock: &dyn Clock,
492        token: UserRegistrationToken,
493    ) -> Result<UserRegistrationToken, Self::Error> {
494        let now = clock.now();
495        let new_times_used = sqlx::query_scalar!(
496            r#"
497                UPDATE user_registration_tokens
498                SET times_used = times_used + 1,
499                    last_used_at = $2
500                WHERE user_registration_token_id = $1 AND revoked_at IS NULL
501                RETURNING times_used
502            "#,
503            Uuid::from(token.id),
504            now,
505        )
506        .traced()
507        .fetch_one(&mut *self.conn)
508        .await?;
509
510        let new_times_used = new_times_used
511            .try_into()
512            .map_err(DatabaseError::to_invalid_operation)?;
513
514        Ok(UserRegistrationToken {
515            times_used: new_times_used,
516            last_used_at: Some(now),
517            ..token
518        })
519    }
520
521    #[tracing::instrument(
522        name = "db.user_registration_token.revoke",
523        skip_all,
524        fields(
525            db.query.text,
526            user_registration_token.id = %token.id,
527        ),
528        err,
529    )]
530    async fn revoke(
531        &mut self,
532        clock: &dyn Clock,
533        mut token: UserRegistrationToken,
534    ) -> Result<UserRegistrationToken, Self::Error> {
535        let revoked_at = clock.now();
536        let res = sqlx::query!(
537            r#"
538                UPDATE user_registration_tokens
539                SET revoked_at = $2
540                WHERE user_registration_token_id = $1
541            "#,
542            Uuid::from(token.id),
543            revoked_at,
544        )
545        .traced()
546        .execute(&mut *self.conn)
547        .await?;
548
549        DatabaseError::ensure_affected_rows(&res, 1)?;
550
551        token.revoked_at = Some(revoked_at);
552
553        Ok(token)
554    }
555
556    #[tracing::instrument(
557        name = "db.user_registration_token.unrevoke",
558        skip_all,
559        fields(
560            db.query.text,
561            user_registration_token.id = %token.id,
562        ),
563        err,
564    )]
565    async fn unrevoke(
566        &mut self,
567        mut token: UserRegistrationToken,
568    ) -> Result<UserRegistrationToken, Self::Error> {
569        let res = sqlx::query!(
570            r#"
571                UPDATE user_registration_tokens
572                SET revoked_at = NULL
573                WHERE user_registration_token_id = $1
574            "#,
575            Uuid::from(token.id),
576        )
577        .traced()
578        .execute(&mut *self.conn)
579        .await?;
580
581        DatabaseError::ensure_affected_rows(&res, 1)?;
582
583        token.revoked_at = None;
584
585        Ok(token)
586    }
587
588    #[tracing::instrument(
589        name = "db.user_registration_token.set_expiry",
590        skip_all,
591        fields(
592            db.query.text,
593            user_registration_token.id = %token.id,
594        ),
595        err,
596    )]
597    async fn set_expiry(
598        &mut self,
599        mut token: UserRegistrationToken,
600        expires_at: Option<DateTime<Utc>>,
601    ) -> Result<UserRegistrationToken, Self::Error> {
602        let res = sqlx::query!(
603            r#"
604                UPDATE user_registration_tokens
605                SET expires_at = $2
606                WHERE user_registration_token_id = $1
607            "#,
608            Uuid::from(token.id),
609            expires_at,
610        )
611        .traced()
612        .execute(&mut *self.conn)
613        .await?;
614
615        DatabaseError::ensure_affected_rows(&res, 1)?;
616
617        token.expires_at = expires_at;
618
619        Ok(token)
620    }
621
622    #[tracing::instrument(
623        name = "db.user_registration_token.set_usage_limit",
624        skip_all,
625        fields(
626            db.query.text,
627            user_registration_token.id = %token.id,
628        ),
629        err,
630    )]
631    async fn set_usage_limit(
632        &mut self,
633        mut token: UserRegistrationToken,
634        usage_limit: Option<u32>,
635    ) -> Result<UserRegistrationToken, Self::Error> {
636        let usage_limit_i32 = usage_limit
637            .map(i32::try_from)
638            .transpose()
639            .map_err(DatabaseError::to_invalid_operation)?;
640
641        let res = sqlx::query!(
642            r#"
643                UPDATE user_registration_tokens
644                SET usage_limit = $2
645                WHERE user_registration_token_id = $1
646            "#,
647            Uuid::from(token.id),
648            usage_limit_i32,
649        )
650        .traced()
651        .execute(&mut *self.conn)
652        .await?;
653
654        DatabaseError::ensure_affected_rows(&res, 1)?;
655
656        token.usage_limit = usage_limit;
657
658        Ok(token)
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use chrono::Duration;
665    use mas_data_model::{Clock as _, clock::MockClock};
666    use mas_storage::{Pagination, user::UserRegistrationTokenFilter};
667    use rand::SeedableRng;
668    use rand_chacha::ChaChaRng;
669    use sqlx::PgPool;
670
671    use crate::PgRepository;
672
673    #[sqlx::test(migrator = "crate::MIGRATOR")]
674    async fn test_unrevoke(pool: PgPool) {
675        let mut rng = ChaChaRng::seed_from_u64(42);
676        let clock = MockClock::default();
677
678        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
679
680        // Create a token
681        let token = repo
682            .user_registration_token()
683            .add(&mut rng, &clock, "test_token".to_owned(), None, None)
684            .await
685            .unwrap();
686
687        // Revoke the token
688        let revoked_token = repo
689            .user_registration_token()
690            .revoke(&clock, token)
691            .await
692            .unwrap();
693
694        // Verify it's revoked
695        assert!(revoked_token.revoked_at.is_some());
696
697        // Unrevoke the token
698        let unrevoked_token = repo
699            .user_registration_token()
700            .unrevoke(revoked_token)
701            .await
702            .unwrap();
703
704        // Verify it's no longer revoked
705        assert!(unrevoked_token.revoked_at.is_none());
706
707        // Check that we can find it with the non-revoked filter
708        let non_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false);
709        let page = repo
710            .user_registration_token()
711            .list(non_revoked_filter, Pagination::first(10))
712            .await
713            .unwrap();
714
715        assert!(page.edges.iter().any(|t| t.node.id == unrevoked_token.id));
716    }
717
718    #[sqlx::test(migrator = "crate::MIGRATOR")]
719    async fn test_set_expiry(pool: PgPool) {
720        let mut rng = ChaChaRng::seed_from_u64(42);
721        let clock = MockClock::default();
722
723        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
724
725        // Create a token without expiry
726        let token = repo
727            .user_registration_token()
728            .add(&mut rng, &clock, "test_token_expiry".to_owned(), None, None)
729            .await
730            .unwrap();
731
732        // Verify it has no expiration
733        assert!(token.expires_at.is_none());
734
735        // Set an expiration
736        let future_time = clock.now() + Duration::days(30);
737        let updated_token = repo
738            .user_registration_token()
739            .set_expiry(token, Some(future_time))
740            .await
741            .unwrap();
742
743        // Verify expiration is set
744        assert_eq!(updated_token.expires_at, Some(future_time));
745
746        // Remove the expiration
747        let final_token = repo
748            .user_registration_token()
749            .set_expiry(updated_token, None)
750            .await
751            .unwrap();
752
753        // Verify expiration is removed
754        assert!(final_token.expires_at.is_none());
755    }
756
757    #[sqlx::test(migrator = "crate::MIGRATOR")]
758    async fn test_set_usage_limit(pool: PgPool) {
759        let mut rng = ChaChaRng::seed_from_u64(42);
760        let clock = MockClock::default();
761
762        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
763
764        // Create a token without usage limit
765        let token = repo
766            .user_registration_token()
767            .add(&mut rng, &clock, "test_token_limit".to_owned(), None, None)
768            .await
769            .unwrap();
770
771        // Verify it has no usage limit
772        assert!(token.usage_limit.is_none());
773
774        // Set a usage limit
775        let updated_token = repo
776            .user_registration_token()
777            .set_usage_limit(token, Some(5))
778            .await
779            .unwrap();
780
781        // Verify usage limit is set
782        assert_eq!(updated_token.usage_limit, Some(5));
783
784        // Change the usage limit
785        let changed_token = repo
786            .user_registration_token()
787            .set_usage_limit(updated_token, Some(10))
788            .await
789            .unwrap();
790
791        // Verify usage limit is changed
792        assert_eq!(changed_token.usage_limit, Some(10));
793
794        // Remove the usage limit
795        let final_token = repo
796            .user_registration_token()
797            .set_usage_limit(changed_token, None)
798            .await
799            .unwrap();
800
801        // Verify usage limit is removed
802        assert!(final_token.usage_limit.is_none());
803    }
804
805    #[sqlx::test(migrator = "crate::MIGRATOR")]
806    async fn test_list_and_count(pool: PgPool) {
807        let mut rng = ChaChaRng::seed_from_u64(42);
808        let clock = MockClock::default();
809
810        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
811
812        // Create different types of tokens
813        // 1. A regular token
814        let _token1 = repo
815            .user_registration_token()
816            .add(&mut rng, &clock, "token1".to_owned(), None, None)
817            .await
818            .unwrap();
819
820        // 2. A token that has been used
821        let token2 = repo
822            .user_registration_token()
823            .add(&mut rng, &clock, "token2".to_owned(), None, None)
824            .await
825            .unwrap();
826        let token2 = repo
827            .user_registration_token()
828            .use_token(&clock, token2)
829            .await
830            .unwrap();
831
832        // 3. A token that is expired
833        let past_time = clock.now() - Duration::days(1);
834        let token3 = repo
835            .user_registration_token()
836            .add(&mut rng, &clock, "token3".to_owned(), None, Some(past_time))
837            .await
838            .unwrap();
839
840        // 4. A token that is revoked
841        let token4 = repo
842            .user_registration_token()
843            .add(&mut rng, &clock, "token4".to_owned(), None, None)
844            .await
845            .unwrap();
846        let token4 = repo
847            .user_registration_token()
848            .revoke(&clock, token4)
849            .await
850            .unwrap();
851
852        // Test list with empty filter
853        let empty_filter = UserRegistrationTokenFilter::new(clock.now());
854        let page = repo
855            .user_registration_token()
856            .list(empty_filter, Pagination::first(10))
857            .await
858            .unwrap();
859        assert_eq!(page.edges.len(), 4);
860
861        // Test count with empty filter
862        let count = repo
863            .user_registration_token()
864            .count(empty_filter)
865            .await
866            .unwrap();
867        assert_eq!(count, 4);
868
869        // Test has_been_used filter
870        let used_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(true);
871        let page = repo
872            .user_registration_token()
873            .list(used_filter, Pagination::first(10))
874            .await
875            .unwrap();
876        assert_eq!(page.edges.len(), 1);
877        assert_eq!(page.edges[0].node.id, token2.id);
878
879        // Test unused filter
880        let unused_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(false);
881        let page = repo
882            .user_registration_token()
883            .list(unused_filter, Pagination::first(10))
884            .await
885            .unwrap();
886        assert_eq!(page.edges.len(), 3);
887
888        // Test is_expired filter
889        let expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(true);
890        let page = repo
891            .user_registration_token()
892            .list(expired_filter, Pagination::first(10))
893            .await
894            .unwrap();
895        assert_eq!(page.edges.len(), 1);
896        assert_eq!(page.edges[0].node.id, token3.id);
897
898        let not_expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(false);
899        let page = repo
900            .user_registration_token()
901            .list(not_expired_filter, Pagination::first(10))
902            .await
903            .unwrap();
904        assert_eq!(page.edges.len(), 3);
905
906        // Test is_revoked filter
907        let revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(true);
908        let page = repo
909            .user_registration_token()
910            .list(revoked_filter, Pagination::first(10))
911            .await
912            .unwrap();
913        assert_eq!(page.edges.len(), 1);
914        assert_eq!(page.edges[0].node.id, token4.id);
915
916        let not_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false);
917        let page = repo
918            .user_registration_token()
919            .list(not_revoked_filter, Pagination::first(10))
920            .await
921            .unwrap();
922        assert_eq!(page.edges.len(), 3);
923
924        // Test is_valid filter
925        let valid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(true);
926        let page = repo
927            .user_registration_token()
928            .list(valid_filter, Pagination::first(10))
929            .await
930            .unwrap();
931        assert_eq!(page.edges.len(), 2);
932
933        let invalid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(false);
934        let page = repo
935            .user_registration_token()
936            .list(invalid_filter, Pagination::first(10))
937            .await
938            .unwrap();
939        assert_eq!(page.edges.len(), 2);
940
941        // Test combined filters
942        let combined_filter = UserRegistrationTokenFilter::new(clock.now())
943            .with_been_used(false)
944            .with_revoked(true);
945        let page = repo
946            .user_registration_token()
947            .list(combined_filter, Pagination::first(10))
948            .await
949            .unwrap();
950        assert_eq!(page.edges.len(), 1);
951        assert_eq!(page.edges[0].node.id, token4.id);
952
953        // Test pagination
954        let page = repo
955            .user_registration_token()
956            .list(empty_filter, Pagination::first(2))
957            .await
958            .unwrap();
959        assert_eq!(page.edges.len(), 2);
960    }
961}