阶段4验证埋点持久化与后台读取
补充 tracking draft、runtime input 和 outbox round-trip 验证 覆盖 SpacetimeDB mapper 与 metadata object 校验 补充账号态和 External API Key 后台 tracking readback 回归
This commit is contained in:
@@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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<String>| {
|
||||
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::<Value>(&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::<Value>(&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!([
|
||||
|
||||
@@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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!(
|
||||
|
||||
@@ -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::<serde_json::Value>(&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");
|
||||
|
||||
@@ -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::<Value>(&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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<serde_json::Value>(&mapped.metadata_json)
|
||||
.expect("mapped metadata should remain valid JSON"),
|
||||
serde_json::json!({
|
||||
"route": "/api/external/v1/editor/images/generations",
|
||||
"client": "agc",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user