backend/
user_state.rs

1use std::collections::HashMap;
2use std::str::FromStr;
3
4use autosurgeon::{Hydrate, Reconcile, Text, reconcile};
5pub use catcolab_document_types::current::DocumentType;
6use samod::DocumentId;
7use serde::Deserialize;
8use sqlx::PgPool;
9use tracing::{debug, error, info, warn};
10use ts_rs::TS;
11
12use crate::app::{AppError, AppState};
13use crate::autosurgeon_datetime::{datetime_millis, option_datetime_millis};
14
15/// Default name for documents without a name.
16pub const DEFAULT_DOC_NAME: &str = "untitled";
17
18/// User info for user state synchronization.
19///
20/// This is similar to [`crate::user::UserSummary`] but uses [`Text`] instead of [`String`]
21/// for compatibility with Automerge/Autosurgeon serialization.
22#[derive(Debug, Clone, Eq, PartialEq, Reconcile, Hydrate, TS)]
23#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
24pub struct UserInfo {
25    /// The user's chosen username, if set.
26    #[ts(as = "Option<String>")]
27    pub username: Option<Text>,
28    /// The user's display name, if set.
29    #[autosurgeon(rename = "displayName")]
30    #[ts(as = "Option<String>")]
31    pub display_name: Option<Text>,
32}
33
34/// A single permission entry for a document in user state.
35///
36/// Represents one user's (or the public "anyone") permission level on a document.
37#[cfg_attr(feature = "property-tests", derive(Eq, PartialEq))]
38#[derive(Debug, Clone, Deserialize, Reconcile, Hydrate, TS)]
39#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
40pub struct PermissionInfo {
41    /// The user ID this permission applies to, or `None` for the public "anyone" permission.
42    #[key]
43    pub user: Option<String>,
44    /// The permission level granted.
45    pub level: crate::auth::PermissionLevel,
46}
47
48/// A relationship between two documents.
49#[cfg_attr(feature = "property-tests", derive(Eq, PartialEq))]
50#[derive(Debug, Clone, Reconcile, Hydrate, TS)]
51#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
52pub struct RelationInfo {
53    /// The ref ID of the related document.
54    #[autosurgeon(rename = "refId")]
55    #[ts(type = "Uint8Array")]
56    pub ref_id: uuid::Uuid,
57    /// Type of relation to the referenced document.
58    #[autosurgeon(rename = "relationType")]
59    #[ts(rename = "relationType")]
60    pub relation_type: String,
61}
62
63/// Lightweight snapshot metadata for user state synchronization.
64#[cfg_attr(feature = "property-tests", derive(Eq, PartialEq))]
65#[derive(Debug, Clone, Deserialize, Reconcile, Hydrate, TS)]
66#[serde(rename_all = "camelCase")]
67#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
68pub struct SnapshotInfo {
69    /// The parent snapshot this was derived from, or `None` for the root snapshot.
70    pub parent: Option<i32>,
71    /// When this snapshot was created.
72    #[autosurgeon(rename = "createdAt", with = "datetime_millis")]
73    #[ts(type = "number")]
74    pub created_at: chrono::DateTime<chrono::Utc>,
75    /// Automerge change hashes identifying this snapshot's document state, hex-encoded.
76    pub heads: Vec<String>,
77}
78
79/// Document reference information for user state synchronization.
80///
81/// Contains lightweight metadata about a document that the user has access to.
82#[cfg_attr(feature = "property-tests", derive(Eq, PartialEq))]
83#[derive(Debug, Clone, Reconcile, Hydrate, TS)]
84#[ts(rename_all = "camelCase", export_to = "user_state.ts")]
85pub struct DocInfo {
86    /// The name of the document.
87    #[ts(as = "String")]
88    pub name: Text,
89    /// The type of the document.
90    #[autosurgeon(rename = "typeName")]
91    pub type_name: DocumentType,
92    /// The theory of the document, if it is a model.
93    pub theory: Option<String>,
94    /// All permissions on this document (users and public).
95    pub permissions: Vec<PermissionInfo>,
96    /// When this document was created.
97    #[autosurgeon(rename = "createdAt", with = "datetime_millis")]
98    #[ts(type = "number")]
99    pub created_at: chrono::DateTime<chrono::Utc>,
100    /// When this document was deleted, if applicable.
101    #[autosurgeon(rename = "deletedAt", with = "option_datetime_millis")]
102    #[ts(type = "number | null")]
103    pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
104    /// When the current snapshot pointer was last changed (snapshot created or undo/redo).
105    #[autosurgeon(rename = "currentSnapshotUpdatedAt", with = "datetime_millis")]
106    #[ts(type = "number")]
107    pub current_snapshot_updated_at: chrono::DateTime<chrono::Utc>,
108    /// The database ID of the current (active) snapshot.
109    #[autosurgeon(rename = "currentSnapshot")]
110    pub current_snapshot: i32,
111    /// All snapshots for this document, keyed by stringified snapshot ID.
112    pub snapshots: HashMap<String, SnapshotInfo>,
113    /// Outgoing relations from this document to other documents.
114    #[autosurgeon(rename = "dependsOn")]
115    pub depends_on: Vec<RelationInfo>,
116    /// Reverse relations: other documents that depend on this one.
117    ///
118    /// Computed from `depends_on` across all documents. Each entry identifies
119    /// the dependent document and the relation type.
120    #[autosurgeon(rename = "usedBy")]
121    pub used_by: Vec<RelationInfo>,
122}
123
124/// State associated with a user, synchronized via Automerge.
125#[cfg_attr(feature = "property-tests", derive(PartialEq, Eq))]
126#[derive(Debug, Clone, Reconcile, Hydrate, TS)]
127#[cfg_attr(not(test), ts(export, export_to = "user_state.ts"))]
128pub struct UserState {
129    /// The user's own profile information.
130    pub profile: UserInfo,
131    /// All users referenced in document permissions, keyed by user ID.
132    #[autosurgeon(rename = "knownUsers")]
133    #[ts(rename = "knownUsers")]
134    pub known_users: HashMap<String, UserInfo>,
135    /// The document refs accessible to the user, keyed by ref UUID string.
136    /// We cannot use the Uuid type here because Automerge requires the keys to have a `AsRef<str>` impl.
137    pub documents: HashMap<String, DocInfo>,
138}
139
140impl UserState {
141    /// Reconcile this `UserState` into an Automerge document, returning an `AppError` on failure.
142    pub fn reconcile_into(&self, doc: &mut automerge::Automerge) -> Result<(), AppError> {
143        doc.transact(|tx| reconcile(tx, self))
144            .map_err(|e| AppError::UserStateSync(format!("Failed to reconcile: {:?}", e)))?;
145        Ok(())
146    }
147
148    /// Creates a new empty UserState for the given user.
149    pub fn new() -> Self {
150        Self {
151            profile: UserInfo { username: None, display_name: None },
152            known_users: HashMap::new(),
153            documents: HashMap::new(),
154        }
155    }
156
157    /// Recomputes the `used_by` field of every [`DocInfo`] from the `depends_on` fields.
158    ///
159    /// A document appears in the `used_by` list of every document it depends on.
160    /// This should be called whenever the document map is mutated (initial load,
161    /// upsert, or revoke) so that the `used_by` vecs stay consistent.
162    pub fn recompute_used_by(&mut self) {
163        // Clear all existing used_by vecs.
164        for doc in self.documents.values_mut() {
165            doc.used_by.clear();
166        }
167
168        let pairs: Vec<(String, RelationInfo)> = self
169            .documents
170            .iter()
171            .flat_map(|(key, doc)| {
172                let child_uuid = uuid::Uuid::parse_str(key).ok()?;
173                Some(doc.depends_on.iter().map(move |rel| {
174                    (
175                        rel.ref_id.to_string(),
176                        RelationInfo {
177                            ref_id: child_uuid,
178                            relation_type: rel.relation_type.clone(),
179                        },
180                    )
181                }))
182            })
183            .flatten()
184            .collect();
185
186        for (target_key, relation) in pairs {
187            if let Some(target_doc) = self.documents.get_mut(&target_key) {
188                target_doc.used_by.push(relation);
189            }
190        }
191    }
192}
193
194impl Default for UserState {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200/// Extracts document relations from a JSON content tree.
201///
202/// Recursively walks the JSON tree and collects objects that have both `_id` and `type` keys,
203/// returning them as `RelationInfo` entries.
204pub fn extract_relations_from_json(value: &serde_json::Value) -> Vec<RelationInfo> {
205    let mut relations = Vec::new();
206    collect_relations(value, &mut relations);
207
208    // Deduplicate by (ref_id, relation_type)
209    relations.sort_by(|a, b| a.ref_id.cmp(&b.ref_id).then(a.relation_type.cmp(&b.relation_type)));
210    relations.dedup_by(|a, b| a.ref_id == b.ref_id && a.relation_type == b.relation_type);
211
212    relations
213}
214
215fn collect_relations(value: &serde_json::Value, out: &mut Vec<RelationInfo>) {
216    match value {
217        serde_json::Value::Object(map) => {
218            if let (Some(serde_json::Value::String(id)), Some(serde_json::Value::String(ty))) =
219                (map.get("_id"), map.get("type"))
220                && let Ok(ref_id) = uuid::Uuid::parse_str(id)
221            {
222                out.push(RelationInfo { ref_id, relation_type: ty.clone() });
223            }
224            for child in map.values() {
225                collect_relations(child, out);
226            }
227        }
228        serde_json::Value::Array(arr) => {
229            for child in arr {
230                collect_relations(child, out);
231            }
232        }
233        _ => {}
234    }
235}
236
237/// Reads user state from the database.
238pub async fn read_user_state_from_db(user_id: String, db: &PgPool) -> Result<UserState, AppError> {
239    debug!(user_id = %user_id, "Reading user state from database");
240
241    // Query documents the user has access to, excluding public documents.
242    // Deleted refs are included with their deleted_at timestamp so the
243    // frontend can filter them into a trash view.
244    // All permissions for each document are returned as a JSON array.
245    let results = sqlx::query!(
246        r#"
247        WITH
248            filtered_ids AS (
249                SELECT refs.id
250                FROM refs
251                WHERE
252                    -- filter by minimum permission level (read)
253                    get_max_permission($1, refs.id) >= 'read'::permission_level
254                    -- exclude public-only documents (user must have explicit permission)
255                    AND EXISTS (
256                        SELECT 1
257                        FROM permissions p_searcher
258                        WHERE
259                            p_searcher.object = refs.id
260                            AND p_searcher.subject = $1
261                    )
262            )
263        SELECT
264            refs.id AS "ref_id!",
265            snapshots.content->>'name' AS name,
266            snapshots.content->>'type' AS type_name,
267            snapshots.content->>'theory' AS theory,
268            refs.created AS "created_at!",
269            refs.deleted_at,
270            refs.current_snapshot_updated_at AS "current_snapshot_updated_at!",
271            snapshots.content AS "content!",
272            refs.current_snapshot AS "current_snapshot!",
273            COALESCE(
274                (SELECT json_agg(json_build_object(
275                    'user', p.subject,
276                    'level', INITCAP(p.level::text)
277                ) ORDER BY p.level DESC)
278                FROM permissions p
279                WHERE p.object = refs.id
280                ), '[]'::json
281            ) AS "permissions!: sqlx::types::Json<serde_json::Value>",
282            COALESCE(
283                (SELECT json_object_agg(
284                    s.id::text,
285                    json_build_object(
286                        'parent', s.parent,
287                        'createdAt', s.created_at,
288                        'heads', (SELECT array_agg(encode(h, 'hex')) FROM unnest(s.heads) AS h)
289                    )
290                )
291                FROM snapshots s
292                WHERE s.for_ref = refs.id
293                ), '{}'::json
294            ) AS "snapshots!: sqlx::types::Json<serde_json::Value>"
295        FROM filtered_ids
296        JOIN refs ON refs.id = filtered_ids.id
297        JOIN snapshots ON snapshots.id = refs.current_snapshot
298        ORDER BY refs.created DESC;
299        "#,
300        user_id,
301    )
302    .fetch_all(db)
303    .await?;
304
305    let mut documents: HashMap<String, DocInfo> = HashMap::new();
306    for row in results {
307        let key = row.ref_id.to_string();
308        let Some(type_str) = row.type_name.as_deref() else {
309            warn!(ref_id = %key, "Skipping document with no type");
310            continue;
311        };
312        let Ok(type_name) = type_str.parse::<DocumentType>() else {
313            warn!(ref_id = %key, type_name = %type_str, "Skipping document with unknown type");
314            continue;
315        };
316        let Ok(permissions) = serde_json::from_value::<Vec<PermissionInfo>>(row.permissions.0)
317        else {
318            error!(ref_id = %key, "Skipping document with invalid permissions JSON");
319            continue;
320        };
321        let Ok(snapshots) =
322            serde_json::from_value::<HashMap<String, SnapshotInfo>>(row.snapshots.0)
323        else {
324            error!(ref_id = %key, "Skipping document with invalid snapshots JSON");
325            continue;
326        };
327        let depends_on = extract_relations_from_json(&row.content);
328
329        let info = DocInfo {
330            name: Text::from(row.name.unwrap_or_else(|| DEFAULT_DOC_NAME.to_string())),
331            type_name,
332            theory: row.theory,
333            permissions,
334            created_at: row.created_at,
335            deleted_at: row.deleted_at,
336            current_snapshot_updated_at: row.current_snapshot_updated_at,
337            current_snapshot: row.current_snapshot,
338            snapshots,
339            depends_on,
340            used_by: Vec::new(),
341        };
342        documents.insert(key, info);
343    }
344
345    // Fetch user info for all users referenced in document permissions.
346    let user_ids: Vec<String> = documents
347        .values()
348        .flat_map(|doc| doc.permissions.iter().filter_map(|p| p.user.clone()))
349        .collect();
350
351    let known_users: HashMap<String, UserInfo> = if user_ids.is_empty() {
352        HashMap::new()
353    } else {
354        let user_rows = sqlx::query!(
355            r#"
356            SELECT id, username, display_name FROM users
357            WHERE id = ANY($1)
358            "#,
359            &user_ids,
360        )
361        .fetch_all(db)
362        .await?;
363
364        user_rows
365            .into_iter()
366            .map(|row| {
367                (
368                    row.id,
369                    UserInfo {
370                        username: row.username.map(Text::from),
371                        display_name: row.display_name.map(Text::from),
372                    },
373                )
374            })
375            .collect()
376    };
377
378    // Fetch the user's own profile info.
379    let profile_row = sqlx::query!(
380        r#"
381        SELECT username, display_name FROM users
382        WHERE id = $1
383        "#,
384        user_id,
385    )
386    .fetch_optional(db)
387    .await?;
388
389    let profile = match profile_row {
390        Some(row) => UserInfo {
391            username: row.username.map(Text::from),
392            display_name: row.display_name.map(Text::from),
393        },
394        None => UserInfo { username: None, display_name: None },
395    };
396
397    let mut user_state = UserState { profile, known_users, documents };
398    user_state.recompute_used_by();
399    Ok(user_state)
400}
401
402/// Gets the user state document ID for a given user from the database.
403pub async fn get_user_state_doc(state: &AppState, user_id: &str) -> Option<DocumentId> {
404    let (doc_id_str,): (String,) =
405        sqlx::query_as("SELECT state_doc_id FROM users WHERE id = $1 AND state_doc_id IS NOT NULL")
406            .bind(user_id)
407            .fetch_optional(&state.db)
408            .await
409            .ok()??;
410
411    DocumentId::from_str(&doc_id_str).ok()
412}
413
414/// Gets or creates the user state document for a given user.
415pub async fn get_or_create_user_state_doc(
416    state: &AppState,
417    user_id: &str,
418) -> Result<DocumentId, AppError> {
419    debug!(user_id = %user_id, "Getting or creating user state document");
420
421    {
422        let initialized = state.initialized_user_states.read().await;
423        if let Some(doc_id) = initialized.get(user_id) {
424            return Ok(doc_id.clone());
425        }
426    }
427
428    let user_state = read_user_state_from_db(user_id.to_string(), &state.db).await?;
429    let doc_id = initialize_user_state_doc(state, user_id, &user_state).await?;
430
431    let mut initialized = state.initialized_user_states.write().await;
432    initialized.insert(user_id.to_string(), doc_id.clone());
433    Ok(doc_id)
434}
435
436/// Converts a `UserState` into an Automerge document.
437pub fn user_state_to_automerge(state: &UserState) -> Result<automerge::Automerge, AppError> {
438    let mut doc = automerge::Automerge::new();
439    state.reconcile_into(&mut doc)?;
440    Ok(doc)
441}
442
443/// Initializes a user state document.
444pub async fn initialize_user_state_doc(
445    state: &AppState,
446    user_id: &str,
447    user_state: &UserState,
448) -> Result<DocumentId, AppError> {
449    let persisted_doc_id: Option<(String,)> =
450        sqlx::query_as("SELECT state_doc_id FROM users WHERE id = $1 AND state_doc_id IS NOT NULL")
451            .bind(user_id)
452            .fetch_optional(&state.db)
453            .await?;
454
455    // If we have a persisted ID, try to find the doc in the repo and re-use it.
456    if let Some((doc_id_str,)) = &persisted_doc_id
457        && let Ok(doc_id) = DocumentId::from_str(doc_id_str)
458    {
459        if let Ok(Some(doc_handle)) = state.repo.find(doc_id.clone()).await {
460            doc_handle.with_document(|doc| user_state.reconcile_into(doc))?;
461            debug!(
462                user_id = %user_id,
463                doc_id = %doc_id,
464                "Reconciled existing user state document from DB"
465            );
466            return Ok(doc_id);
467        }
468        debug!(
469            user_id = %user_id,
470            doc_id = %doc_id_str,
471            "Persisted state_doc_id not found in repo; creating new document"
472        );
473    }
474
475    // No existing doc — create a fresh one.
476    let doc = user_state_to_automerge(user_state)?;
477    let doc_handle = state.repo.create(doc).await?;
478    let doc_id = doc_handle.document_id();
479
480    sqlx::query("UPDATE users SET state_doc_id = $2 WHERE id = $1")
481        .bind(user_id)
482        .bind(doc_id.to_string())
483        .execute(&state.db)
484        .await?;
485
486    info!(user_id = %user_id, doc_id = %doc_id, "Initialized user state document");
487
488    Ok(doc_id.clone())
489}
490
491/// Arbitrary instances for property-based testing.
492#[cfg(feature = "property-tests")]
493pub mod arbitrary {
494    #![allow(dead_code)]
495    use super::*;
496    use crate::auth::PermissionLevel;
497    use autosurgeon::Text;
498    use chrono::{TimeZone, Utc};
499    use proptest::{arbitrary::Arbitrary, prelude::*};
500    use proptest_arbitrary_interop::arb;
501
502    /// Strategy that generates an arbitrary [`DocumentType`].
503    pub fn arb_document_type() -> BoxedStrategy<DocumentType> {
504        proptest::sample::select(&[
505            DocumentType::Model,
506            DocumentType::Diagram,
507            DocumentType::Analysis,
508        ])
509        .boxed()
510    }
511
512    impl Arbitrary for UserInfo {
513        type Parameters = ();
514        type Strategy = BoxedStrategy<Self>;
515
516        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
517            (any::<Option<String>>(), any::<Option<String>>())
518                .prop_map(|(username, display_name)| {
519                    // Filter out empty strings: the DB has a unique constraint on
520                    // username, and empty strings are not valid usernames/display
521                    // names in practice.
522                    let username = username.filter(|s| !s.is_empty());
523                    let display_name = display_name.filter(|s| !s.is_empty());
524                    UserInfo {
525                        username: username.map(Text::from),
526                        display_name: display_name.map(Text::from),
527                    }
528                })
529                .boxed()
530        }
531    }
532
533    impl Arbitrary for PermissionInfo {
534        type Parameters = ();
535        type Strategy = BoxedStrategy<Self>;
536
537        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
538            (
539                proptest::option::of(arb::<uuid::Uuid>().prop_map(|u| format!("test_{u}"))),
540                any::<PermissionLevel>(),
541            )
542                .prop_map(|(user, level)| PermissionInfo { user, level })
543                .boxed()
544        }
545    }
546
547    impl Arbitrary for DocInfo {
548        type Parameters = ();
549        type Strategy = BoxedStrategy<Self>;
550
551        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
552            (
553                any::<String>(),
554                arb_document_type(),
555                proptest::option::of(any::<String>()),
556                prop::collection::vec(any::<PermissionInfo>(), 0..5),
557                0i64..253402300799i64,
558                proptest::option::of(0i64..253402300799i64),
559                0i64..253402300799i64,
560            )
561                .prop_map(
562                    |(
563                        name,
564                        type_name,
565                        theory,
566                        permissions,
567                        seconds,
568                        deleted_seconds,
569                        updated_seconds,
570                    )| {
571                        DocInfo {
572                            name: Text::from(name),
573                            type_name,
574                            theory,
575                            permissions,
576                            created_at: Utc
577                                .timestamp_opt(seconds, 0)
578                                .single()
579                                .expect("valid timestamp"),
580                            deleted_at: deleted_seconds.map(|s| {
581                                Utc.timestamp_opt(s, 0).single().expect("valid timestamp")
582                            }),
583                            current_snapshot_updated_at: Utc
584                                .timestamp_opt(updated_seconds, 0)
585                                .single()
586                                .expect("valid timestamp"),
587                            current_snapshot: 1,
588                            snapshots: HashMap::new(),
589                            depends_on: Vec::new(),
590                            used_by: Vec::new(),
591                        }
592                    },
593                )
594                .boxed()
595        }
596    }
597
598    /// Generates a consistent user state doc info entry (key + DocInfo + users map entries) where:
599    /// - The user always has a permission on the document
600    /// - An owner (with 'Own' permission) is always present
601    /// - If the user's level is Own, they are the owner
602    /// - Additional permissions may be generated
603    /// - User info in permissions matches the user's profile (as the DB will fill it in)
604    fn doc_info_entry_with_permissions(
605        user_id: String,
606        user_profile: UserInfo,
607    ) -> impl Strategy<Value = (String, DocInfo, HashMap<String, UserInfo>)> {
608        (
609            any::<String>(),                             // name
610            arb_document_type(),                         // type_name
611            arb::<uuid::Uuid>(),                         // ref_id (used as map key)
612            any::<PermissionLevel>(),                    // user's permission_level
613            arb::<uuid::Uuid>(),                         // other owner id
614            any::<Option<String>>(),                     // other owner display_name
615            0i64..253402300799i64,                       // created_at seconds
616            proptest::option::of(0i64..253402300799i64), // deleted_at seconds
617            0i64..253402300799i64,                       // current_snapshot_updated_at seconds
618        )
619            .prop_map(
620                move |(
621                    name,
622                    type_name,
623                    ref_id,
624                    user_level,
625                    other_owner_uuid,
626                    other_owner_display_name,
627                    seconds,
628                    deleted_seconds,
629                    updated_seconds,
630                )| {
631                    let mut permissions = Vec::new();
632                    let mut users = HashMap::new();
633
634                    if user_level == PermissionLevel::Own {
635                        // User is the owner - use their full profile info as the DB will fill it in
636                        permissions.push(PermissionInfo {
637                            user: Some(user_id.clone()),
638                            level: PermissionLevel::Own,
639                        });
640                        users.insert(user_id.clone(), user_profile.clone());
641                    } else {
642                        // Someone else is the owner, user has a different permission
643                        let mut other_id = format!("test_{other_owner_uuid}");
644                        if other_id == user_id {
645                            other_id = format!("{}_other", other_id);
646                        }
647                        let other_owner_info = UserInfo {
648                            username: Some(Text::from(format!("owner_{other_owner_uuid}"))),
649                            display_name: other_owner_display_name
650                                .filter(|s| !s.is_empty())
651                                .map(Text::from),
652                        };
653                        users.insert(other_id.clone(), other_owner_info);
654                        permissions.push(PermissionInfo {
655                            user: Some(other_id),
656                            level: PermissionLevel::Own,
657                        });
658                        // User has non-owner permission - use their full profile info
659                        users.insert(user_id.clone(), user_profile.clone());
660                        permissions.push(PermissionInfo {
661                            user: Some(user_id.clone()),
662                            level: user_level,
663                        });
664                    }
665
666                    let key = ref_id.to_string();
667                    let info = DocInfo {
668                        name: Text::from(name),
669                        type_name,
670                        theory: None,
671                        permissions,
672                        created_at: Utc
673                            .timestamp_opt(seconds, 0)
674                            .single()
675                            .expect("valid timestamp"),
676                        deleted_at: deleted_seconds
677                            .map(|s| Utc.timestamp_opt(s, 0).single().expect("valid timestamp")),
678                        current_snapshot_updated_at: Utc
679                            .timestamp_opt(updated_seconds, 0)
680                            .single()
681                            .expect("valid timestamp"),
682                        current_snapshot: 1,
683                        snapshots: HashMap::new(),
684                        // We are not yet generating complete relationship trees, just independent
685                        // docs
686                        depends_on: Vec::new(),
687                        used_by: Vec::new(),
688                    };
689                    (key, info, users)
690                },
691            )
692    }
693
694    /// Generates a (user_id, UserState) pair where the UserState is consistent
695    /// with the user_id (i.e., owned documents have the user as owner, and user
696    /// info in permissions matches the profile as the database will fill it in).
697    pub fn arbitrary_user_state_with_id() -> impl Strategy<Value = (String, UserState)> {
698        (arb::<uuid::Uuid>(), any::<Option<String>>(), any::<Option<String>>()).prop_flat_map(
699            |(user_uuid, username, display_name)| {
700                let user_id = format!("test_user_{}", user_uuid);
701                let username = username.filter(|s| !s.is_empty());
702                let display_name = display_name.filter(|s| !s.is_empty());
703                let profile = UserInfo {
704                    username: username.map(Text::from),
705                    display_name: display_name.map(Text::from),
706                };
707                prop::collection::vec(
708                    doc_info_entry_with_permissions(user_id.clone(), profile.clone()),
709                    0..5,
710                )
711                .prop_map(move |entries| {
712                    let mut known_users = HashMap::new();
713                    let mut documents = HashMap::new();
714                    for (key, doc_info, entry_users) in entries {
715                        known_users.extend(entry_users);
716                        documents.insert(key, doc_info);
717                    }
718                    (
719                        user_id.clone(),
720                        UserState {
721                            profile: profile.clone(),
722                            known_users,
723                            documents,
724                        },
725                    )
726                })
727            },
728        )
729    }
730
731    impl Arbitrary for UserState {
732        type Parameters = ();
733        type Strategy = BoxedStrategy<Self>;
734
735        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
736            arbitrary_user_state_with_id().prop_map(|(_, state)| state).boxed()
737        }
738    }
739}
740
741#[cfg(test)]
742mod unit_tests {
743    use super::*;
744    use serde_json::json;
745
746    #[test]
747    fn extract_relations_from_nested_json() {
748        let content = json!({
749            "name": "My Model",
750            "type": "model",
751            "theory": "th_signed_category",
752            "content": {
753                "cells": [
754                    {
755                        "tag": "ob",
756                        "content": {
757                            "obType": {
758                                "_id": "019532d2-d6a3-7c7e-93c6-6c43419e4233",
759                                "type": "model"
760                            }
761                        }
762                    },
763                    {
764                        "tag": "morphism",
765                        "content": {
766                            "morType": {
767                                "_id": "019532d2-d6a3-7c7e-93c6-6c43419e4234",
768                                "type": "diagram"
769                            }
770                        }
771                    }
772                ]
773            }
774        });
775
776        let mut relations = extract_relations_from_json(&content);
777        relations.sort_by(|a, b| a.ref_id.cmp(&b.ref_id));
778
779        assert_eq!(relations.len(), 2);
780        assert_eq!(
781            relations[0].ref_id,
782            uuid::Uuid::parse_str("019532d2-d6a3-7c7e-93c6-6c43419e4233").unwrap()
783        );
784        assert_eq!(relations[0].relation_type, "model");
785        assert_eq!(
786            relations[1].ref_id,
787            uuid::Uuid::parse_str("019532d2-d6a3-7c7e-93c6-6c43419e4234").unwrap()
788        );
789        assert_eq!(relations[1].relation_type, "diagram");
790    }
791
792    #[test]
793    fn extract_relations_deduplicates() {
794        let content = json!({
795            "a": { "_id": "019532d2-d6a3-7c7e-93c6-6c43419e4233", "type": "model" },
796            "b": { "_id": "019532d2-d6a3-7c7e-93c6-6c43419e4233", "type": "model" }
797        });
798
799        let relations = extract_relations_from_json(&content);
800        assert_eq!(relations.len(), 1);
801    }
802
803    #[test]
804    fn extract_relations_empty_document() {
805        let content = json!({ "name": "Empty", "type": "model" });
806        // The top-level object has "type" but no "_id", so no relations.
807        let relations = extract_relations_from_json(&content);
808        assert!(relations.is_empty());
809    }
810}
811
812#[cfg(all(test, feature = "property-tests"))]
813mod tests {
814    use super::*;
815    use autosurgeon::hydrate;
816    use test_strategy::proptest;
817
818    use crate::app::AppError;
819
820    /// Converts an Automerge document to a `UserState`.
821    fn automerge_to_user_state(doc: &automerge::Automerge) -> Result<UserState, AppError> {
822        let state: UserState = hydrate(doc)
823            .map_err(|e| AppError::Invalid(format!("Failed to hydrate UserState: {}", e)))?;
824        Ok(state)
825    }
826
827    /// Tests that converting UserState to Automerge and back yields the same UserState.
828    #[proptest(cases = 16)]
829    fn user_state_automerge_roundtrip(input_state: UserState) {
830        let doc = user_state_to_automerge(&input_state).expect("Failed to convert to Automerge");
831        let output_state = automerge_to_user_state(&doc).expect("Failed to convert from Automerge");
832
833        proptest::prop_assert_eq!(input_state, output_state);
834    }
835}