阶段4验证埋点持久化与后台读取
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

补充 tracking draft、runtime input 和 outbox round-trip 验证

覆盖 SpacetimeDB mapper 与 metadata object 校验

补充账号态和 External API Key 后台 tracking readback 回归
This commit is contained in:
2026-09-02 10:17:59 +00:00
parent 01159a2698
commit c75c61521d
5 changed files with 372 additions and 5 deletions
+161 -2
View File
@@ -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!([
+71 -3
View File
@@ -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",
})
);
}
}