@@ -62,6 +62,17 @@ async fn fixture_with_backend(
6262 vec![ ] ,
6363 vec![ FieldAnnotation :: Owner ] ,
6464 ) ,
65+ FieldDefinition :: with_annotations(
66+ FieldName :: new( "name_key" ) . unwrap( ) ,
67+ FieldType :: Text ( TextConstraints :: unconstrained( ) ) ,
68+ vec![ ] ,
69+ vec![
70+ FieldAnnotation :: Hidden ,
71+ FieldAnnotation :: Compute {
72+ expr: "string(size(owner)) + ':' + owner + title" . into( ) ,
73+ } ,
74+ ] ,
75+ ) ,
6576 FieldDefinition :: with_annotations(
6677 FieldName :: new( "restricted" ) . unwrap( ) ,
6778 FieldType :: Text ( TextConstraints :: unconstrained( ) ) ,
@@ -104,6 +115,15 @@ async fn fixture_with_backend(
104115 DynEntityStore :: create ( backend. as_ref ( ) , & entity)
105116 . await
106117 . unwrap ( ) ;
118+ let app = app_with_backend ( backend, schema, roles) . await ;
119+ ( app, format ! ( "/schemas/Note/entities/{}" , entity. id) )
120+ }
121+
122+ async fn app_with_backend (
123+ backend : Arc < dyn DynForgeBackend > ,
124+ schema : SchemaDefinition ,
125+ roles : & [ & str ] ,
126+ ) -> Router {
107127 let service = ServiceBuilder :: new ( )
108128 . with_config ( Config :: < SchemaForgeConfig > :: default ( ) )
109129 . with_actor :: < ForgeActor > ( )
@@ -142,7 +162,7 @@ async fn fixture_with_backend(
142162 username : None ,
143163 custom : HashMap :: new ( ) ,
144164 } ;
145- let app = forge_routes ( )
165+ forge_routes ( )
146166 . layer ( axum:: middleware:: from_fn (
147167 move |mut req : axum:: extract:: Request , next : axum:: middleware:: Next | {
148168 let caller = caller. clone ( ) ;
@@ -152,8 +172,7 @@ async fn fixture_with_backend(
152172 }
153173 } ,
154174 ) )
155- . with_state ( service. state ( ) . clone ( ) ) ;
156- ( app, format ! ( "/schemas/Note/entities/{}" , entity. id) )
175+ . with_state ( service. state ( ) . clone ( ) )
157176}
158177
159178async fn request (
@@ -329,6 +348,7 @@ fn assert_revision_conflict(result: &(StatusCode, axum::http::HeaderMap, serde_j
329348#[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
330349#[ ignore = "requires a scoped disposable PostgreSQL URL and SCHEMAFORGE_TEST_POSTGRES_DISPOSABLE=1" ]
331350async fn postgres_http_revisions_guard_updates_noops_races_and_deletes ( ) {
351+ use schema_forge_acton:: state:: DynSchemaBackend ;
332352 assert_eq ! (
333353 std:: env:: var( "SCHEMAFORGE_TEST_POSTGRES_DISPOSABLE" ) . as_deref( ) ,
334354 Ok ( "1" ) ,
@@ -624,6 +644,65 @@ async fn postgres_http_revisions_guard_updates_noops_races_and_deletes() {
624644 assert_eq ! ( & response_revision( & headers) , baseline) ;
625645 }
626646 }
647+ let schema = DynSchemaBackend :: load_schema_metadata ( backend. as_ref ( ) , & other. schema )
648+ . await
649+ . unwrap ( )
650+ . unwrap ( ) ;
651+ let admin_app = app_with_backend ( backend. clone ( ) , schema, & [ "platform_admin" ] ) . await ;
652+ for ( index, conditional) in [ false , true ] . into_iter ( ) . enumerate ( ) {
653+ for supplied_owner in [ None , Some ( "attempted-transfer" ) ] {
654+ let ( _, headers, _) =
655+ request ( & admin_app, & other_path, "GET" , None , serde_json:: json!( { } ) ) . await ;
656+ let baseline = response_revision ( & headers) ;
657+ let title = format ! ( "Admin {index} {}" , supplied_owner. unwrap_or( "omitted" ) ) ;
658+ let mut body = serde_json:: json!( { "title" : title} ) ;
659+ if let Some ( owner) = supplied_owner {
660+ body[ "owner" ] = serde_json:: json!( owner) ;
661+ }
662+ let ( status, headers, response) = request (
663+ & admin_app,
664+ & other_path,
665+ "PUT" ,
666+ conditional. then_some ( baseline. as_str ( ) ) ,
667+ body,
668+ )
669+ . await ;
670+ assert_eq ! ( status, StatusCode :: OK , "{response}" ) ;
671+ assert_eq ! ( response[ "fields" ] [ "owner" ] , "other" ) ;
672+ assert ! ( response[ "fields" ] . get( "name_key" ) . is_none( ) ) ;
673+ if conditional {
674+ assert_ne ! ( response_revision( & headers) , baseline) ;
675+ }
676+ let stored = DynEntityStore :: get ( backend. as_ref ( ) , & other. schema , & other. id )
677+ . await
678+ . unwrap ( ) ;
679+ assert_eq ! ( stored. fields[ "owner" ] , DynamicValue :: Text ( "other" . into( ) ) ) ;
680+ assert_eq ! (
681+ stored. fields[ "name_key" ] ,
682+ DynamicValue :: Text ( format!( "5:other{title}" ) )
683+ ) ;
684+ }
685+ }
686+ }
687+
688+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 2 ) ]
689+ async fn ordinary_put_computation_preserves_immutable_owner ( ) {
690+ for ( owner, roles) in [
691+ ( "editor" , vec ! [ "editor" ] ) ,
692+ ( "other" , vec ! [ "platform_admin" ] ) ,
693+ ] {
694+ let ( app, path) = fixture ( owner, & roles) . await ;
695+ for supplied_owner in [ None , Some ( "attempted-transfer" ) ] {
696+ let mut body = serde_json:: json!( { "title" : "Replacement" } ) ;
697+ if let Some ( value) = supplied_owner {
698+ body[ "owner" ] = serde_json:: json!( value) ;
699+ }
700+ let ( status, _, response) = request ( & app, & path, "PUT" , None , body) . await ;
701+ assert_eq ! ( status, StatusCode :: OK , "{response}" ) ;
702+ assert_eq ! ( response[ "fields" ] [ "owner" ] , owner) ;
703+ assert ! ( response[ "fields" ] . get( "name_key" ) . is_none( ) ) ;
704+ }
705+ }
627706}
628707
629708#[ tokio:: test( flavor = "multi_thread" , worker_threads = 2 ) ]
0 commit comments