diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 36fb7ffaa..38e0acc45 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -4394,7 +4394,7 @@ mod tests { http::{Method, StatusCode}, response::IntoResponse, }; - use serde_json::json; + use serde_json::{Value, json}; use shared_contracts::admin::{ AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery, @@ -4407,7 +4407,11 @@ mod tests { SpacetimeClientStage, }; - use crate::{config::AppConfig, state::AppState}; + use crate::{ + config::AppConfig, + state::AppState, + tracking::{TrackingEventDraft, build_tracking_event_input}, + }; fn test_admin_editor_showcase_asset_record(owner_user_id: &str) -> EditorShowcaseAssetRecord { EditorShowcaseAssetRecord { @@ -5976,6 +5980,161 @@ mod tests { assert_eq!(entries[0].module_key.as_deref(), Some("profile")); } + #[test] + fn parse_admin_tracking_events_sql_response_preserves_agc_metadata_and_owner() { + let payload = json!([ + { + "rows": [ + [ + "account-event-225", + "editor_projects_view", + "user", + "user-225", + 20600, + {"some": "user-225"}, + {"some": "user-225"}, + null, + "editor", + "{\"route\":\"/api/editor/projects\",\"method\":\"GET\",\"status\":200,\"operation\":\"listEditorProjects\",\"client\":\"agc\"}", + "2026-09-02T00:00:00Z" + ], + [ + "external-event-225", + "editor_image_generation_submit", + "user", + "owner-225", + 20600, + null, + {"some": "owner-225"}, + null, + "editor", + "{\"route\":\"/api/external/v1/editor/images/generations\",\"method\":\"POST\",\"status\":202,\"operation\":\"generateExternalEditorImage\",\"client\":\"agc\"}", + "2026-09-02T00:00:01Z" + ] + ] + } + ]); + + let entries = parse_admin_tracking_events_sql_response(payload) + .expect("AGC tracking rows should be readable by the admin parser"); + + assert_eq!(entries.len(), 2); + let account = entries + .iter() + .find(|entry| entry.event_id == "account-event-225") + .expect("account event should be present"); + let account_metadata = serde_json::from_str::(&account.metadata_json) + .expect("account metadata should remain valid JSON"); + assert_eq!(account_metadata["client"], "agc"); + assert_eq!(account_metadata["route"], "/api/editor/projects"); + assert_eq!(account.user_id.as_deref(), Some("user-225")); + assert_eq!(account.owner_user_id.as_deref(), Some("user-225")); + + let external = entries + .iter() + .find(|entry| entry.event_id == "external-event-225") + .expect("External API Key event should be present"); + let external_metadata = serde_json::from_str::(&external.metadata_json) + .expect("External API Key metadata should remain valid JSON"); + assert_eq!(external_metadata["client"], "agc"); + assert_eq!( + external_metadata["route"], + "/api/external/v1/editor/images/generations" + ); + assert_eq!(external.user_id, None); + assert_eq!(external.owner_user_id.as_deref(), Some("owner-225")); + } + + #[test] + fn tracking_inputs_round_trip_to_admin_readback_for_both_subjects() { + let mut account_draft = TrackingEventDraft::new("editor_projects_view", "editor"); + account_draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + account_draft.scope_id = "user-225".to_string(); + account_draft.user_id = Some("user-225".to_string()); + account_draft.owner_user_id = Some("user-225".to_string()); + account_draft.metadata = json!({ + "route": "/api/editor/projects", + "method": "GET", + "status": 200, + "operation": "listEditorProjects", + "client": "agc", + }); + + let mut external_draft = + TrackingEventDraft::new("editor_image_generation_submit", "editor"); + external_draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + external_draft.scope_id = "owner-225".to_string(); + external_draft.owner_user_id = Some("owner-225".to_string()); + external_draft.metadata = json!({ + "route": "/api/external/v1/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc", + }); + + let inputs = [ + build_tracking_event_input(account_draft), + build_tracking_event_input(external_draft), + ]; + let row_for_input = |input: &module_runtime::RuntimeTrackingEventInput, + occurred_at: &str| { + let optional_column = |value: &Option| { + value + .as_ref() + .map(|value| json!({"some": value})) + .unwrap_or(Value::Null) + }; + json!([ + input.event_id.clone(), + input.event_key.clone(), + "user", + input.scope_id.clone(), + 20600, + optional_column(&input.user_id), + optional_column(&input.owner_user_id), + optional_column(&input.profile_id), + optional_column(&input.module_key), + input.metadata_json.clone(), + occurred_at, + ]) + }; + let payload = json!([{ + "rows": [ + row_for_input(&inputs[0], "2026-09-02T00:00:00Z"), + row_for_input(&inputs[1], "2026-09-02T00:00:01Z"), + ] + }]); + + let entries = parse_admin_tracking_events_sql_response(payload) + .expect("tracking inputs should remain readable by admin readback"); + assert_eq!(entries.len(), 2); + + let account = entries + .iter() + .find(|entry| entry.event_key == "editor_projects_view") + .expect("account input should be readable"); + let account_metadata = serde_json::from_str::(&account.metadata_json) + .expect("account metadata should remain valid JSON"); + assert_eq!(account_metadata["client"], "agc"); + assert_eq!(account.user_id.as_deref(), Some("user-225")); + assert_eq!(account.owner_user_id.as_deref(), Some("user-225")); + + let external = entries + .iter() + .find(|entry| entry.event_key == "editor_image_generation_submit") + .expect("External API Key input should be readable"); + let external_metadata = serde_json::from_str::(&external.metadata_json) + .expect("External API Key metadata should remain valid JSON"); + assert_eq!(external_metadata["client"], "agc"); + assert_eq!( + external_metadata["route"], + "/api/external/v1/editor/images/generations" + ); + assert_eq!(external.user_id, None); + assert_eq!(external.owner_user_id.as_deref(), Some("owner-225")); + } + #[test] fn parse_admin_tracking_events_sql_response_normalizes_sats_values() { let payload = json!([ diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 892961c1a..b7d48a6fe 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -1176,9 +1176,10 @@ mod tests { }; use super::{ - TrackingClientMarker, build_route_tracking_metadata, is_route_tracking_excluded, - normalize_route_path, resolve_route_tracking_spec, resolve_tracking_client_marker, - resolve_tracking_identity, resolve_tracking_scope_id, route_spec, + TrackingClientMarker, TrackingEventDraft, build_route_tracking_metadata, + build_tracking_event_input, is_route_tracking_excluded, normalize_route_path, + resolve_route_tracking_spec, resolve_tracking_client_marker, resolve_tracking_identity, + resolve_tracking_scope_id, route_spec, }; fn build_test_authenticated(user_id: &str) -> AuthenticatedAccessToken { @@ -1329,6 +1330,73 @@ mod tests { assert!(unmarked.get("client").is_none()); } + #[test] + fn tracking_event_input_preserves_agc_metadata_and_subject_fields() { + let cases = [ + (Some("user-225"), Some("user-225"), "user-225", "account"), + (None, Some("owner-225"), "owner-225", "external-key"), + ]; + + for (user_id, owner_user_id, scope_id, label) in cases { + let mut draft = TrackingEventDraft::new("editor_image_generation_submit", "editor"); + draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + draft.scope_id = scope_id.to_string(); + draft.user_id = user_id.map(str::to_string); + draft.owner_user_id = owner_user_id.map(str::to_string); + draft.metadata = serde_json::json!({ + "route": "/api/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc", + }); + + let input = build_tracking_event_input(draft); + let metadata = serde_json::from_str::(&input.metadata_json) + .unwrap_or_else(|error| panic!("{label} metadata should remain JSON: {error}")); + + assert!( + metadata.is_object(), + "{label} metadata must remain an object" + ); + assert_eq!(metadata["client"], "agc", "{label}"); + assert_eq!( + metadata["route"], "/api/editor/images/generations", + "{label}" + ); + assert_eq!(metadata["status"], 202, "{label}"); + assert_eq!(input.user_id.as_deref(), user_id); + assert_eq!(input.owner_user_id.as_deref(), owner_user_id); + assert_eq!(input.scope_id, scope_id); + assert_eq!(input.event_key, "editor_image_generation_submit"); + assert_eq!(input.module_key.as_deref(), Some("editor")); + } + } + + #[test] + fn tracking_event_input_without_marker_keeps_metadata_object_without_client() { + let mut draft = TrackingEventDraft::new("editor_projects_view", "editor"); + draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + draft.scope_id = "user-225".to_string(); + draft.user_id = Some("user-225".to_string()); + draft.owner_user_id = Some("user-225".to_string()); + draft.metadata = serde_json::json!({ + "route": "/api/editor/projects", + "method": "GET", + "status": 200, + "operation": "listEditorProjects", + }); + + let input = build_tracking_event_input(draft); + let metadata = serde_json::from_str::(&input.metadata_json) + .expect("unmarked metadata should remain valid JSON"); + + assert!(metadata.is_object()); + assert!(metadata.get("client").is_none()); + assert_eq!(metadata["route"], "/api/editor/projects"); + assert_eq!(metadata["status"], 200); + } + #[test] fn route_normalization_preserves_static_segments_and_replaces_ids() { assert_eq!( diff --git a/server-rs/crates/api-server/src/tracking_outbox.rs b/server-rs/crates/api-server/src/tracking_outbox.rs index 272f08df4..390d71011 100644 --- a/server-rs/crates/api-server/src/tracking_outbox.rs +++ b/server-rs/crates/api-server/src/tracking_outbox.rs @@ -589,6 +589,52 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn enqueue_round_trips_agc_metadata_and_tracking_identity() { + let dir = test_dir("agc-round-trip"); + let outbox = test_outbox(dir.clone(), 500, 1024 * 1024); + let event = RuntimeTrackingEventInput { + event_id: "agc-event-225".to_string(), + event_key: "editor_image_generation_submit".to_string(), + scope_kind: module_runtime::RuntimeTrackingScopeKind::User, + scope_id: "owner-225".to_string(), + user_id: None, + owner_user_id: Some("owner-225".to_string()), + profile_id: None, + module_key: Some("editor".to_string()), + metadata_json: serde_json::json!({ + "route": "/api/external/v1/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc", + }) + .to_string(), + occurred_at_micros: 1_756_800_000_000_000, + }; + + assert!(matches!( + outbox.enqueue(event.clone()).await.unwrap(), + TrackingOutboxEnqueueOutcome::Enqueued + )); + + let events = read_outbox_events(&dir.join(ACTIVE_FILE_NAME)) + .await + .expect("outbox record should deserialize"); + assert_eq!(events, vec![event]); + let metadata = serde_json::from_str::(&events[0].metadata_json) + .expect("outbox metadata should remain valid JSON"); + assert_eq!(metadata["client"], "agc"); + assert_eq!( + metadata["route"], + "/api/external/v1/editor/images/generations" + ); + assert_eq!(events[0].owner_user_id.as_deref(), Some("owner-225")); + assert_eq!(events[0].user_id, None); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn enqueue_drops_when_outbox_exceeds_max_bytes() { let dir = test_dir("max-bytes"); diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 0fc648db8..138bb2cf3 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -1643,4 +1643,55 @@ mod tests { }) ); } + + #[test] + fn tracking_input_normalizes_and_preserves_agc_metadata_and_owner() { + let input = build_runtime_tracking_event_input( + "event-225".to_string(), + "editor_image_generation_submit".to_string(), + RuntimeTrackingScopeKind::User, + "owner-225".to_string(), + None, + Some("owner-225".to_string()), + None, + Some("editor".to_string()), + r#" { "route": "/api/external/v1/editor/images/generations", "client": "agc" } "# + .to_string(), + 1_756_800_000_000_000, + ) + .expect("tracking input should accept object metadata"); + + assert_eq!(input.event_id, "event-225"); + assert_eq!(input.scope_id, "owner-225"); + assert_eq!(input.owner_user_id.as_deref(), Some("owner-225")); + assert_eq!(input.user_id, None); + assert_eq!(input.module_key.as_deref(), Some("editor")); + assert_eq!( + serde_json::from_str::(&input.metadata_json) + .expect("normalized metadata should parse"), + serde_json::json!({ + "route": "/api/external/v1/editor/images/generations", + "client": "agc", + }) + ); + } + + #[test] + fn tracking_input_rejects_non_object_metadata() { + let error = build_runtime_tracking_event_input( + "event-225-invalid".to_string(), + "editor_projects_view".to_string(), + RuntimeTrackingScopeKind::User, + "user-225".to_string(), + Some("user-225".to_string()), + Some("user-225".to_string()), + None, + Some("editor".to_string()), + r#"["not-an-object"]"#.to_string(), + 1_756_800_000_000_000, + ) + .expect_err("tracking metadata must remain an object"); + + assert_eq!(error, RuntimeProfileFieldError::InvalidInviteCodeMetadata); + } } diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime.rs index 5c39b0de0..97ab82524 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime.rs @@ -180,3 +180,46 @@ pub(crate) fn map_runtime_tracking_scope_kind_back( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracking_input_mapper_preserves_agc_metadata_and_external_owner() { + let input = module_runtime::RuntimeTrackingEventInput { + event_id: "external-event-225".to_string(), + event_key: "editor_image_generation_submit".to_string(), + scope_kind: DomainRuntimeTrackingScopeKind::User, + scope_id: "owner-225".to_string(), + user_id: None, + owner_user_id: Some("owner-225".to_string()), + profile_id: None, + module_key: Some("editor".to_string()), + metadata_json: serde_json::json!({ + "route": "/api/external/v1/editor/images/generations", + "client": "agc", + }) + .to_string(), + occurred_at_micros: 1_756_800_000_000_000, + }; + + let mapped: RuntimeTrackingEventInput = input.into(); + + assert_eq!(mapped.event_id, "external-event-225"); + assert_eq!(mapped.event_key, "editor_image_generation_submit"); + assert_eq!(mapped.scope_kind, RuntimeTrackingScopeKind::User); + assert_eq!(mapped.scope_id, "owner-225"); + assert_eq!(mapped.user_id, None); + assert_eq!(mapped.owner_user_id.as_deref(), Some("owner-225")); + assert_eq!(mapped.module_key.as_deref(), Some("editor")); + assert_eq!( + serde_json::from_str::(&mapped.metadata_json) + .expect("mapped metadata should remain valid JSON"), + serde_json::json!({ + "route": "/api/external/v1/editor/images/generations", + "client": "agc", + }) + ); + } +}