Skip to main content

mostro_core/
user.rs

1//! Persistent user representation and reputation helpers.
2//!
3//! The [`User`] struct is the database-backed record Mostro keeps for every
4//! identity that has interacted with the system. It tracks rating aggregates,
5//! administrative flags and the last trade index used by the user, which is
6//! required so that new orders always carry a strictly increasing trade index.
7//!
8//! [`UserInfo`] is a lightweight view of the same data that can safely be
9//! shared with a counterpart during a trade without leaking internals.
10
11use chrono::Utc;
12
13use crate::reputation::ReputationImport;
14use serde::{Deserialize, Serialize, Serializer};
15#[cfg(feature = "sqlx")]
16use sqlx::FromRow;
17
18/// Seconds in a day, the granularity every published first-trade date uses.
19const SECONDS_PER_DAY: u64 = 86_400;
20
21/// Truncate a Unix timestamp to the start of its UTC day.
22///
23/// Every published first-trade date goes through this. The rounding is not
24/// cosmetic: the value travels on every order and every peer message of the
25/// same user, so a second-precision timestamp would be a fingerprint that ties
26/// their otherwise unlinkable trade pubkeys together. A day carries exactly
27/// the information the old `operating_days` count carried.
28///
29/// Timestamps before the epoch clamp to `0` rather than wrap.
30pub fn day_truncate(timestamp: i64) -> u64 {
31    truncate_since(timestamp.max(0) as u64)
32}
33
34/// [`day_truncate`] for a value that is already a `since`. Idempotent, so it
35/// is safe on every path a `since` takes out of the crate.
36pub(crate) fn truncate_since(since: u64) -> u64 {
37    since - since % SECONDS_PER_DAY
38}
39
40/// `serialize_with` for a `since` field: the field is public, so rounding at
41/// the serialization boundary is the only place that covers every producer.
42pub(crate) fn serialize_since<S: Serializer>(
43    since: &Option<u64>,
44    serializer: S,
45) -> Result<S::Ok, S::Error> {
46    since.map(truncate_since).serialize(serializer)
47}
48
49/// Public snapshot of a user's reputation shared with peers during a trade.
50///
51/// Unlike [`User`], `UserInfo` contains only the values a counterpart needs
52/// to decide whether to trade: aggregated rating, number of reviews and how
53/// many days the user has been operating on Mostro.
54#[derive(Debug, Default, Deserialize, Serialize, Clone)]
55
56pub struct UserInfo {
57    /// Aggregated rating value for the user (see [`crate::rating::Rating`]).
58    pub rating: f64,
59    /// Total number of ratings received.
60    pub reviews: i64,
61    /// Number of days since the user account was created.
62    ///
63    /// Superseded by [`UserInfo::since`], which carries the same information
64    /// as a date instead of a count derived when the message was built. Kept
65    /// for one deprecation window so old and new peers interoperate.
66    pub operating_days: u64,
67    /// Unix timestamp of the user's first trade, truncated to the start of its
68    /// UTC day, or `None` from a peer that does not send it yet.
69    ///
70    /// Clients compute the age at display time, so it does not go stale the
71    /// way `operating_days` does. Skipped when absent, so a `UserInfo` that
72    /// never sets it serializes exactly as it did before this field existed.
73    /// Always serialized truncated to its UTC day, however it was set.
74    #[serde(
75        default,
76        skip_serializing_if = "Option::is_none",
77        serialize_with = "serialize_since"
78    )]
79    pub since: Option<u64>,
80}
81
82/// Database representation of a Mostro user.
83///
84/// This is the canonical row stored on the Mostro node. It tracks identity
85/// data (`pubkey`), administrative role flags, the last trade index used by
86/// the user and rating aggregates used to compute reputation.
87#[cfg_attr(feature = "sqlx", derive(FromRow))]
88#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
89pub struct User {
90    /// Master public key of the user, hex encoded.
91    pub pubkey: String,
92    /// `1` when the user has admin privileges, `0` otherwise. Stored as
93    /// `i64` to match the underlying SQLite representation.
94    pub is_admin: i64,
95    /// Optional password used to authenticate privileged admin actions.
96    pub admin_password: Option<String>,
97    /// `1` when the user is a dispute solver, `0` otherwise.
98    pub is_solver: i64,
99    /// `1` when the user is banned from the platform, `0` otherwise.
100    pub is_banned: i64,
101    /// Free-form category bucket. Reserved for future segmentation.
102    pub category: i64,
103    /// Last trade index used by this user. When a user creates a new order
104    /// (or takes one) the incoming trade index must be strictly greater than
105    /// this value, or the request is rejected.
106    pub last_trade_index: i64,
107    /// Total number of ratings the user has received.
108    pub total_reviews: i64,
109    /// Weighted rating average computed from all received ratings.
110    pub total_rating: f64,
111    /// Most recent rating received, in the `MIN_RATING..=MAX_RATING` range.
112    pub last_rating: i64,
113    /// Highest rating ever received.
114    pub max_rating: i64,
115    /// Lowest rating ever received.
116    pub min_rating: i64,
117    /// Unix timestamp (seconds) the user's age is shown from: when the
118    /// record was created, moved back by an imported reputation's earlier
119    /// first-trade date.
120    pub created_at: i64,
121    /// Ratings received through imported reputation, never natively.
122    /// Internal only. See [`crate::reputation`].
123    #[cfg_attr(feature = "sqlx", sqlx(default))]
124    #[serde(default)]
125    pub seeded_reviews: i64,
126    /// Sum of `rating × reviews` over every imported reputation. Internal
127    /// only.
128    #[cfg_attr(feature = "sqlx", sqlx(default))]
129    #[serde(default)]
130    pub seeded_rating_sum: f64,
131    /// Sum of the raw ratings received natively, with no first-vote
132    /// damping, so the native average is exact. Internal only.
133    ///
134    /// A row that predates the column must be backfilled as
135    /// `total_rating * total_reviews` before it takes a new rating: the
136    /// default `0.0` would make the sum count only post-migration ratings.
137    /// Rows from before any import have no seeded reviews, so that product is
138    /// their whole native history, damped by the first vote.
139    #[cfg_attr(feature = "sqlx", sqlx(default))]
140    #[serde(default)]
141    pub native_rating_sum: f64,
142    /// When the record was created, which an import never moves: what
143    /// `created_at` goes back to when an import is reversed. Read it through
144    /// [`User::native_created_at`].
145    ///
146    /// An `Option` on purpose: a database that predates the column reads it
147    /// as `None`, and the accessor falls back to `created_at`; a plain `i64`
148    /// would default to `0` and place every such user in 1970.
149    #[cfg_attr(feature = "sqlx", sqlx(default))]
150    #[serde(default)]
151    pub native_created_at: Option<i64>,
152    /// The identity this account's reputation is bound to, as an issuer:
153    /// the only identity an export may name without a rebind authorisation.
154    #[cfg_attr(feature = "sqlx", sqlx(default))]
155    #[serde(default)]
156    pub reputation_exported_to: Option<String>,
157    /// UTC day start of the last export. Day precision, so the record cannot
158    /// time-correlate an export with an import elsewhere.
159    #[cfg_attr(feature = "sqlx", sqlx(default))]
160    #[serde(default)]
161    pub reputation_exported_at: Option<i64>,
162}
163
164impl User {
165    /// Create a new [`User`] with fresh rating aggregates.
166    ///
167    /// `trade_index` becomes the user's `last_trade_index`. The `created_at`
168    /// timestamp is set to the current system time.
169    pub fn new(
170        pubkey: String,
171        is_admin: i64,
172        is_solver: i64,
173        is_banned: i64,
174        category: i64,
175        trade_index: i64,
176    ) -> Self {
177        let created_at = Utc::now().timestamp();
178        Self {
179            pubkey,
180            is_admin,
181            admin_password: None,
182            is_solver,
183            is_banned,
184            category,
185            last_trade_index: trade_index,
186            total_reviews: 0,
187            total_rating: 0.0,
188            last_rating: 0,
189            max_rating: 0,
190            min_rating: 0,
191            created_at,
192            native_created_at: Some(created_at),
193            ..Self::default()
194        }
195    }
196
197    /// Merge an imported reputation into this record (section 6 of the
198    /// reputation portability plan).
199    ///
200    /// Ratings received add up, the average is weighted by each origin's
201    /// review count, and the age is the earliest date, never a sum.
202    /// `min_rating`, `max_rating` and `last_rating` are set from the
203    /// imported average only where still `0`. The import is also counted in
204    /// `seeded_reviews` and `seeded_rating_sum`, so it is never exported as
205    /// native reputation and can be reversed exactly; `native_rating_sum`
206    /// and the native date are left alone. A record that predates the
207    /// `native_created_at` column gets it pinned to its current
208    /// `created_at` before that moves, so a reversal can restore it.
209    pub fn apply_reputation_import(&mut self, import: &ReputationImport) {
210        let reviews = i64::from(import.reviews);
211        let rating = import.rating();
212        let rounded = rating.round() as i64;
213        self.native_created_at = Some(self.native_created_at());
214        self.total_rating = (self.total_rating * self.total_reviews as f64
215            + rating * reviews as f64)
216            / (self.total_reviews + reviews) as f64;
217        self.total_reviews += reviews;
218        self.created_at = self.created_at.min(import.since as i64);
219        for extreme in [
220            &mut self.min_rating,
221            &mut self.max_rating,
222            &mut self.last_rating,
223        ] {
224            if *extreme == 0 {
225                *extreme = rounded;
226            }
227        }
228        self.seeded_reviews += reviews;
229        self.seeded_rating_sum += rating * reviews as f64;
230    }
231
232    /// Reverse an import merged by [`User::apply_reputation_import`], as an
233    /// operator does after an issuer key is compromised.
234    ///
235    /// `remaining_since` is the earliest `since` among the imports that
236    /// stay; the displayed date goes back to the earliest of it and the
237    /// native date. Native ratings received in between are kept exactly,
238    /// because the running average is linear in each contribution. The
239    /// rating extrema are reset to `0` when no rating remains and are
240    /// otherwise left as they are: they cannot be recomputed without a
241    /// per-rating history.
242    pub fn revert_reputation_import(
243        &mut self,
244        import: &ReputationImport,
245        remaining_since: Option<i64>,
246    ) {
247        let reviews = i64::from(import.reviews);
248        let rating = import.rating();
249        let remaining = (self.total_reviews - reviews).max(0);
250        self.total_rating = if remaining == 0 {
251            0.0
252        } else {
253            (self.total_rating * self.total_reviews as f64 - rating * reviews as f64)
254                / remaining as f64
255        };
256        self.total_reviews = remaining;
257        self.seeded_reviews = (self.seeded_reviews - reviews).max(0);
258        self.seeded_rating_sum -= rating * reviews as f64;
259        let native = self.native_created_at();
260        self.created_at = remaining_since.map_or(native, |since| since.min(native));
261        if remaining == 0 {
262            self.min_rating = 0;
263            self.max_rating = 0;
264            self.last_rating = 0;
265        }
266    }
267
268    /// The reputation this record earned on this instance alone: ratings
269    /// received natively and their exact average (`0.0` with none). This is
270    /// what an export carries, so an imported reputation never travels on.
271    ///
272    /// For a record that predates `native_rating_sum`, the migration
273    /// backfills it from the displayed average, which the first vote damps;
274    /// such a record exports the average its counterparties already see.
275    pub fn native_stats(&self) -> (i64, f64) {
276        let reviews = (self.total_reviews - self.seeded_reviews).max(0);
277        let rating = if reviews == 0 {
278            0.0
279        } else {
280            self.native_rating_sum / reviews as f64
281        };
282        (reviews, rating)
283    }
284
285    /// When the record was created, which no import ever moves.
286    ///
287    /// Falls back to `created_at` for a record whose database predates the
288    /// `native_created_at` column, never to `0`.
289    pub fn native_created_at(&self) -> i64 {
290        self.native_created_at.unwrap_or(self.created_at)
291    }
292
293    /// Record a new rating for the user and refresh the aggregates.
294    ///
295    /// The first vote is weighted by `1/2` so that a single review cannot
296    /// anchor a perfect or disastrous reputation. Subsequent votes update
297    /// `total_rating` with an incremental running-average formula.
298    /// `min_rating` and `max_rating` are tightened as new extremes arrive.
299    ///
300    /// `native_rating_sum` accumulates the raw rating, undamped, so the
301    /// average of the ratings received natively stays exact for a
302    /// reputation export.
303    ///
304    /// # Example
305    ///
306    /// ```
307    /// use mostro_core::user::User;
308    ///
309    /// let mut user = User::new("pubkey".into(), 0, 0, 0, 0, 0);
310    /// user.update_rating(5);
311    /// assert_eq!(user.total_reviews, 1);
312    /// assert_eq!(user.max_rating, 5);
313    /// ```
314    pub fn update_rating(&mut self, rating: u8) {
315        // Update user reputation
316        // increment first
317        self.total_reviews += 1;
318        let old_rating = self.total_rating;
319        // recompute new rating
320        if self.total_reviews <= 1 {
321            // New logic with weight 1/2 for first vote.
322            let first_rating = rating as f64;
323            self.total_rating = first_rating / 2.0;
324            self.max_rating = rating.into();
325            self.min_rating = rating.into();
326        } else {
327            self.total_rating =
328                old_rating + ((rating as f64) - old_rating) / (self.total_reviews as f64);
329            if self.max_rating < rating.into() {
330                self.max_rating = rating.into();
331            }
332            if self.min_rating > rating.into() {
333                self.min_rating = rating.into();
334            }
335        }
336        // Store last rating
337        self.last_rating = rating.into();
338        // The raw sum, undamped, keeps the native average exact for export.
339        self.native_rating_sum += f64::from(rating);
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    mod import {
348        use super::*;
349        use serde_json::Value;
350
351        const VECTORS: &str = include_str!("../tests/vectors/reputation_v1.json");
352
353        fn import(reviews: u32, rating_hundredths: u16, since: u64) -> ReputationImport {
354            ReputationImport {
355                reviews,
356                rating_hundredths,
357                since,
358            }
359        }
360
361        fn row(v: &Value) -> User {
362            User {
363                total_reviews: v["total_reviews"].as_i64().unwrap(),
364                total_rating: v["total_rating"].as_f64().unwrap(),
365                created_at: v["created_at"].as_i64().unwrap(),
366                native_created_at: Some(v["native_created_at"].as_i64().unwrap()),
367                min_rating: v["min_rating"].as_i64().unwrap(),
368                max_rating: v["max_rating"].as_i64().unwrap(),
369                last_rating: v["last_rating"].as_i64().unwrap(),
370                seeded_reviews: v["seeded_reviews"].as_i64().unwrap(),
371                seeded_rating_sum: v["seeded_rating_sum"].as_f64().unwrap(),
372                native_rating_sum: v["native_rating_sum"].as_f64().unwrap(),
373                ..User::default()
374            }
375        }
376
377        fn assert_rows_match(actual: &User, expected: &User, tolerance: f64, what: &str) {
378            let close = |a: f64, b: f64| (a - b).abs() <= tolerance;
379            assert!(
380                close(actual.total_rating, expected.total_rating),
381                "{what}: total_rating {} != {}",
382                actual.total_rating,
383                expected.total_rating
384            );
385            assert!(
386                close(actual.seeded_rating_sum, expected.seeded_rating_sum),
387                "{what}: seeded_rating_sum"
388            );
389            assert!(
390                close(actual.native_rating_sum, expected.native_rating_sum),
391                "{what}: native_rating_sum"
392            );
393            let exact = |u: &User| {
394                (
395                    u.total_reviews,
396                    u.created_at,
397                    u.native_created_at,
398                    u.min_rating,
399                    u.max_rating,
400                    u.last_rating,
401                    u.seeded_reviews,
402                )
403            };
404            assert_eq!(exact(actual), exact(expected), "{what}");
405        }
406
407        #[test]
408        fn the_merge_vectors_apply_and_reverse() {
409            let v: Value = serde_json::from_str(VECTORS).unwrap();
410            let tolerance = v["merge"]["tolerance"].as_f64().unwrap();
411            for case in v["merge"]["cases"].as_array().unwrap() {
412                let name = case["name"].as_str().unwrap();
413                let figures = &case["import"];
414                let rating = crate::reputation::rating_hundredths(
415                    figures["rating"].as_str().unwrap().parse().unwrap(),
416                )
417                .unwrap();
418                let imported = import(
419                    figures["reviews"].as_u64().unwrap() as u32,
420                    rating,
421                    figures["since"].as_u64().unwrap(),
422                );
423                let mut user = row(&case["before"]);
424                user.apply_reputation_import(&imported);
425                assert_rows_match(&user, &row(&case["after"]), tolerance, name);
426                user.revert_reputation_import(&imported, None);
427                assert_rows_match(&user, &row(&case["reverted"]), tolerance, name);
428            }
429        }
430
431        #[test]
432        fn imports_from_several_issuers_add_up_and_keep_the_earliest_date() {
433            let mut user = User {
434                created_at: 1_790_000_000,
435                ..User::new("p".into(), 0, 0, 0, 0, 0)
436            };
437            user.native_created_at = Some(user.created_at);
438            user.apply_reputation_import(&import(10, 400, 1_700_006_400));
439            user.apply_reputation_import(&import(30, 500, 1_690_070_400));
440            assert_eq!(user.total_reviews, 40);
441            assert!((user.total_rating - 4.75).abs() < 1e-12);
442            assert_eq!(user.created_at, 1_690_070_400);
443            assert_eq!(user.seeded_reviews, 40);
444            assert_eq!(user.native_stats(), (0, 0.0));
445
446            // Reversing the older one leaves the other's date.
447            user.revert_reputation_import(&import(30, 500, 1_690_070_400), Some(1_700_006_400));
448            assert_eq!(user.total_reviews, 10);
449            assert!((user.total_rating - 4.0).abs() < 1e-12);
450            assert_eq!(user.created_at, 1_700_006_400);
451        }
452
453        #[test]
454        fn native_ratings_before_and_after_an_import_stay_exportable_as_native() {
455            let mut user = User::new("p".into(), 0, 0, 0, 0, 0);
456            user.update_rating(5);
457            user.update_rating(3);
458            user.apply_reputation_import(&import(100, 487, 1_696_204_800));
459            user.update_rating(1);
460            assert_eq!(user.native_stats(), (3, 3.0));
461            assert_eq!(user.total_reviews, 103);
462            assert_eq!(user.min_rating, 1);
463
464            // The native rating that arrived after the import survives its reversal.
465            let before_revert = user.total_rating * 103.0 - 487.0;
466            user.revert_reputation_import(&import(100, 487, 1_696_204_800), None);
467            assert_eq!(user.total_reviews, 3);
468            assert!((user.total_rating * 3.0 - before_revert).abs() < 1e-9);
469            assert_eq!(user.native_stats(), (3, 3.0));
470        }
471
472        #[test]
473        fn a_legacy_record_without_a_native_date_gets_it_pinned_before_the_import_moves_it() {
474            let mut user = User {
475                created_at: 1_780_000_000,
476                native_created_at: None,
477                ..User::default()
478            };
479            user.apply_reputation_import(&import(5, 450, 1_696_204_800));
480            assert_eq!(user.native_created_at, Some(1_780_000_000));
481            user.revert_reputation_import(&import(5, 450, 1_696_204_800), None);
482            assert_eq!(user.created_at, 1_780_000_000);
483        }
484
485        /// Deterministic generator for the property tests, so a failure
486        /// reproduces without a seed.
487        struct Lcg(u64);
488
489        impl Lcg {
490            fn next(&mut self, bound: u64) -> u64 {
491                self.0 = self
492                    .0
493                    .wrapping_mul(6_364_136_223_846_793_005)
494                    .wrapping_add(1_442_695_040_888_963_407);
495                (self.0 >> 33) % bound
496            }
497
498            fn import(&mut self) -> ReputationImport {
499                import(
500                    5 + self.next(500) as u32,
501                    100 + self.next(401) as u16,
502                    1_577_836_800 + self.next(2_000) * 86_400,
503                )
504            }
505
506            fn user(&mut self) -> User {
507                let mut user = User::new("p".into(), 0, 0, 0, 0, 0);
508                user.created_at = 1_700_000_000 + self.next(100_000_000) as i64;
509                user.native_created_at = Some(user.created_at);
510                for _ in 0..self.next(20) {
511                    user.update_rating(1 + self.next(5) as u8);
512                }
513                user
514            }
515        }
516
517        #[test]
518        fn an_import_never_lowers_reviews_nor_moves_dates_forward_nor_touches_native_state() {
519            let mut rng = Lcg(7);
520            for _ in 0..2_000 {
521                let mut user = rng.user();
522                let before = user.clone();
523                user.apply_reputation_import(&rng.import());
524                assert!(user.total_reviews > before.total_reviews);
525                assert!(user.created_at <= before.created_at);
526                assert_eq!(user.native_created_at, before.native_created_at);
527                assert_eq!(user.native_rating_sum, before.native_rating_sum);
528                assert_eq!(user.native_stats().0, before.native_stats().0);
529                assert!((1.0..=5.0).contains(&user.total_rating));
530            }
531        }
532
533        #[test]
534        fn apply_then_revert_restores_the_row() {
535            let mut rng = Lcg(11);
536            for _ in 0..2_000 {
537                let mut user = rng.user();
538                let before = user.clone();
539                let imported = rng.import();
540                user.apply_reputation_import(&imported);
541                user.revert_reputation_import(&imported, None);
542                assert_rows_match(&user, &before, 1e-9, "apply then revert");
543            }
544        }
545
546        #[test]
547        fn a_chain_a_b_c_exports_from_b_what_b_earned_natively() {
548            let mut rng = Lcg(13);
549            for _ in 0..500 {
550                let mut on_b = rng.user();
551                let native_before = on_b.native_stats();
552                on_b.apply_reputation_import(&rng.import());
553                for _ in 0..rng.next(5) {
554                    on_b.apply_reputation_import(&rng.import());
555                }
556                let (reviews, rating) = on_b.native_stats();
557                assert_eq!(reviews, native_before.0);
558                assert!((rating - native_before.1).abs() < 1e-12);
559            }
560        }
561    }
562
563    #[test]
564    fn a_first_native_rating_is_summed_raw_although_the_average_is_damped() {
565        let mut user = User::new("pubkey".into(), 0, 0, 0, 0, 0);
566        user.update_rating(5);
567        assert_eq!(user.total_rating, 2.5);
568        assert_eq!(user.native_rating_sum, 5.0);
569    }
570
571    #[test]
572    fn native_ratings_sum_to_their_plain_total() {
573        let mut user = User::new("pubkey".into(), 0, 0, 0, 0, 0);
574        for rating in [5, 3, 4, 1, 5] {
575            user.update_rating(rating);
576        }
577        assert_eq!(user.native_rating_sum, 18.0);
578        assert_eq!(user.native_rating_sum / user.total_reviews as f64, 3.6);
579    }
580
581    #[test]
582    fn a_new_user_records_its_creation_as_the_native_date() {
583        let user = User::new("pubkey".into(), 0, 0, 0, 0, 0);
584        assert_eq!(user.native_created_at, Some(user.created_at));
585        assert_eq!(user.native_created_at(), user.created_at);
586        assert_eq!((user.seeded_reviews, user.seeded_rating_sum), (0, 0.0));
587        assert_eq!(user.native_rating_sum, 0.0);
588        assert_eq!(user.reputation_exported_to, None);
589        assert_eq!(user.reputation_exported_at, None);
590    }
591
592    #[test]
593    fn a_record_without_a_native_date_falls_back_to_created_at_never_zero() {
594        let user = User {
595            created_at: 1_700_000_000,
596            native_created_at: None,
597            ..User::default()
598        };
599        assert_eq!(user.native_created_at(), 1_700_000_000);
600    }
601
602    #[test]
603    fn a_serialised_user_from_before_the_new_fields_still_deserialises() {
604        let json = r#"{"pubkey":"p","is_admin":0,"admin_password":null,"is_solver":0,
605            "is_banned":0,"category":0,"last_trade_index":3,"total_reviews":2,
606            "total_rating":4.5,"last_rating":5,"max_rating":5,"min_rating":4,
607            "created_at":1700000000}"#;
608        let user: User = serde_json::from_str(json).unwrap();
609        assert_eq!(user.native_created_at(), 1_700_000_000);
610        assert_eq!(user.seeded_reviews, 0);
611    }
612
613    /// A daemon whose database predates the migration still reads its rows
614    /// with `SELECT *`, and the native date of such a row is its
615    /// `created_at`, not 1970.
616    #[cfg(feature = "sqlx")]
617    #[tokio::test]
618    async fn a_row_without_the_new_columns_loads_with_their_defaults() {
619        let pool = sqlx::sqlite::SqlitePoolOptions::new()
620            .max_connections(1)
621            .connect(":memory:")
622            .await
623            .unwrap();
624        sqlx::query(
625            "CREATE TABLE users (pubkey char(64) primary key, is_admin integer not null default 0, \
626             admin_password char(64), is_solver integer not null default 0, \
627             is_banned integer not null default 0, category integer not null default 0, \
628             last_trade_index integer not null default 0, total_reviews integer not null default 0, \
629             total_rating real not null default 0.0, last_rating integer not null default 0, \
630             max_rating integer not null default 0, min_rating integer not null default 0, \
631             created_at integer not null)",
632        )
633        .execute(&pool)
634        .await
635        .unwrap();
636        sqlx::query(
637            "INSERT INTO users (pubkey, total_reviews, total_rating, created_at) \
638             VALUES ('p', 2, 4.5, 1700000000)",
639        )
640        .execute(&pool)
641        .await
642        .unwrap();
643        let user: User = sqlx::query_as("SELECT * FROM users")
644            .fetch_one(&pool)
645            .await
646            .unwrap();
647        assert_eq!(user.native_created_at, None);
648        assert_eq!(user.native_created_at(), 1_700_000_000);
649        assert_eq!((user.seeded_reviews, user.native_rating_sum), (0, 0.0));
650        assert_eq!(user.reputation_exported_to, None);
651    }
652
653    /// 2026-01-01T00:00:00Z, and the same instant plus most of a day.
654    const DAY_START: i64 = 1_767_225_600;
655    const LATE_IN_DAY: i64 = DAY_START + 86_399;
656
657    #[test]
658    fn day_truncate_lands_on_the_start_of_the_utc_day() {
659        // Arrange / Act / Assert — anywhere inside a day maps to its start, so
660        // the value cannot change while the user is mid-session.
661        assert_eq!(day_truncate(DAY_START), DAY_START as u64);
662        assert_eq!(day_truncate(LATE_IN_DAY), DAY_START as u64);
663        assert_eq!(
664            day_truncate(DAY_START + 86_400),
665            (DAY_START + 86_400) as u64
666        );
667    }
668
669    #[test]
670    fn day_truncate_clamps_a_pre_epoch_timestamp_instead_of_wrapping() {
671        // Arrange / Act / Assert — a negative cast to u64 would produce a date
672        // far in the future, which reads as a user who has not started trading.
673        assert_eq!(day_truncate(-1), 0);
674        assert_eq!(day_truncate(0), 0);
675    }
676
677    #[test]
678    fn user_info_omits_since_when_unset() {
679        // Arrange
680        let info = UserInfo {
681            rating: 4.5,
682            reviews: 10,
683            operating_days: 30,
684            since: None,
685        };
686
687        // Act
688        let json = serde_json::to_string(&info).expect("serializes");
689
690        // Assert — a peer that has not adopted the field must see the exact
691        // payload it saw before.
692        assert!(!json.contains("since"), "{json}");
693    }
694
695    #[test]
696    fn user_info_round_trips_with_since() {
697        // Arrange
698        let info = UserInfo {
699            rating: 4.5,
700            reviews: 10,
701            operating_days: 30,
702            since: Some(DAY_START as u64),
703        };
704
705        // Act
706        let parsed: UserInfo =
707            serde_json::from_str(&serde_json::to_string(&info).expect("serializes"))
708                .expect("parses");
709
710        // Assert
711        assert_eq!(parsed.since, Some(DAY_START as u64));
712        assert_eq!(parsed.operating_days, 30);
713    }
714
715    #[test]
716    fn user_info_truncates_since_on_serialization() {
717        // Arrange — built directly, bypassing any helper that would round it.
718        let info = UserInfo {
719            rating: 4.5,
720            reviews: 10,
721            operating_days: 30,
722            since: Some(LATE_IN_DAY as u64),
723        };
724
725        // Act
726        let json = serde_json::to_string(&info).expect("serializes");
727
728        // Assert — the peer sees the day, never the second.
729        assert!(json.contains(&format!("\"since\":{DAY_START}")), "{json}");
730    }
731
732    #[test]
733    fn user_info_from_a_peer_without_since_parses() {
734        // Arrange — what every daemon sends today.
735        let json = r#"{"rating":4.5,"reviews":10,"operating_days":30}"#;
736
737        // Act
738        let parsed: UserInfo = serde_json::from_str(json).expect("parses");
739
740        // Assert
741        assert_eq!(parsed.since, None);
742        assert_eq!(parsed.operating_days, 30);
743    }
744
745    #[test]
746    fn first_vote_is_weighted_by_half() {
747        let mut user = User::default();
748
749        user.update_rating(5);
750
751        assert_eq!(user.total_reviews, 1);
752        assert_eq!(user.total_rating, 2.5);
753        assert_eq!(user.last_rating, 5);
754        assert_eq!(user.max_rating, 5);
755        assert_eq!(user.min_rating, 5);
756    }
757
758    #[test]
759    fn second_vote_is_folded_into_the_average() {
760        let mut user = User::default();
761        user.update_rating(5);
762
763        user.update_rating(1);
764
765        // First vote weighted 1/2 -> 2.5, then incremental average with the
766        // new vote: 2.5 + (1 - 2.5) / 2 = 1.75
767        assert_eq!(user.total_reviews, 2);
768        assert!((user.total_rating - 1.75).abs() < 1e-9);
769        assert_eq!(user.last_rating, 1);
770    }
771
772    #[test]
773    fn low_vote_lowers_a_high_average() {
774        let mut user = User::default();
775        for _ in 0..10 {
776            user.update_rating(5);
777        }
778        let farmed_average = user.total_rating;
779        assert!((farmed_average - 4.75).abs() < 1e-9);
780
781        user.update_rating(1);
782
783        // Correct running average: (2.5 + 9 * 5 + 1) / 11 = 48.5 / 11
784        let expected = 48.5 / 11.0;
785        assert!(
786            user.total_rating < farmed_average,
787            "a 1-star review must lower the average, got {} (was {})",
788            user.total_rating,
789            farmed_average
790        );
791        assert!((user.total_rating - expected).abs() < 1e-9);
792        assert_eq!(user.last_rating, 1);
793        assert_eq!(user.min_rating, 1);
794        assert_eq!(user.max_rating, 5);
795    }
796
797    #[test]
798    fn high_vote_raises_a_low_average() {
799        let mut user = User::default();
800        user.update_rating(1);
801        user.update_rating(1);
802        let low_average = user.total_rating;
803
804        user.update_rating(5);
805
806        assert!(
807            user.total_rating > low_average,
808            "a 5-star review must raise the average, got {} (was {})",
809            user.total_rating,
810            low_average
811        );
812        // (0.5 + 1 + 5) / 3
813        assert!((user.total_rating - 6.5 / 3.0).abs() < 1e-9);
814    }
815
816    #[test]
817    fn min_and_max_track_extremes() {
818        let mut user = User::default();
819        user.update_rating(3);
820        user.update_rating(5);
821        user.update_rating(1);
822
823        assert_eq!(user.max_rating, 5);
824        assert_eq!(user.min_rating, 1);
825    }
826}