use tonic::metadata::MetadataValue;
use tonic::{Request, Status};
use crate::proto::udb::core::cache::services::v1 as cache_pb;
use crate::proto::udb::core::cache::services::v1::cache_service_server::CacheService;
use crate::proto::{ErrorDetail, ErrorKind};
use crate::runtime::executor_utils::ERROR_DETAIL_METADATA_KEY;
use super::CacheServiceImpl;
use super::config::{
DEFAULT_NAMESPACE_MAX_BYTES, MAX_SCAN_PAGE_LIMIT, META_SUFFIX, SWEEP_COMMAND, SWEEP_COUNT,
};
use super::errors::{redis_capability_status, require_field, validate_namespace};
use super::keys::{
bytes_counter_key, clamped_scan_count, data_key, data_match, effective_max_bytes, meta_key,
meta_match_all, namespace_match_all, parse_meta_key, reconcile_cursor_key, reconciled_sum,
would_exceed_budget,
};
use super::worker::{
invalidation_source_from_payload, invalidation_tenant_scope, namespace_for_source_table,
};
fn decode_detail(status: &Status) -> ErrorDetail {
let raw = status
.metadata()
.get_bin(ERROR_DETAIL_METADATA_KEY)
.expect("error-detail trailer present")
.to_bytes()
.expect("trailer decodes to bytes");
crate::runtime::executor_utils::decode_error_detail_from_raw(&raw)
}
#[test]
fn keys_are_isolated_per_tenant() {
let a = data_key("tenant-a", "sessions", "u1");
let b = data_key("tenant-b", "sessions", "u1");
assert_ne!(a, b);
assert_eq!(a, "udb:cache:tenant-a:sessions:k:u1");
assert!(a.starts_with("udb:cache:tenant-a:"));
assert!(b.starts_with("udb:cache:tenant-b:"));
let sweep_a = namespace_match_all("tenant-a", "sessions");
assert!(!b.starts_with(sweep_a.trim_end_matches('*')));
}
#[test]
fn cache_validation_statuses_carry_field_violations() {
let missing_namespace = validate_namespace(" ").expect_err("empty namespace must be rejected");
assert_eq!(missing_namespace.code(), tonic::Code::InvalidArgument);
assert_eq!(missing_namespace.message(), "namespace is required");
let detail = decode_detail(&missing_namespace);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "namespace");
assert_eq!(
detail.field_violations[0].description,
"must be a non-empty namespace"
);
let invalid_namespace =
validate_namespace("bad:namespace").expect_err("reserved separator rejected");
let detail = decode_detail(&invalid_namespace);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations[0].field, "namespace");
assert_eq!(
detail.field_violations[0].description,
"must not contain ':' or whitespace"
);
let missing_key = require_field("key", " ").expect_err("empty key must be rejected");
assert_eq!(missing_key.code(), tonic::Code::InvalidArgument);
assert_eq!(missing_key.message(), "key is required");
let detail = decode_detail(&missing_key);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "key");
assert_eq!(
detail.field_violations[0].description,
"must be a non-empty string"
);
}
#[tokio::test]
async fn delete_namespace_missing_confirmation_token_carries_field_violation() {
let svc = CacheServiceImpl::new();
let mut request = Request::new(cache_pb::DeleteNamespaceRequest {
tenant_id: "tenant-a".to_string(),
namespace: "sessions".to_string(),
confirmation_token: " ".to_string(),
..Default::default()
});
request
.metadata_mut()
.insert("x-tenant-id", MetadataValue::from_static("tenant-a"));
let err = svc
.delete_namespace(request)
.await
.expect_err("missing confirmation token must fail before backend access");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(
err.message(),
"DeleteNamespace flushes the whole namespace; confirmation_token is required"
);
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "confirmation_token");
assert_eq!(
detail.field_violations[0].description,
"must be present to flush a cache namespace"
);
}
#[test]
fn cache_missing_redis_capability_carries_typed_detail() {
let status = redis_capability_status(
"request_dispatch",
"redis_backend",
"cache service requires a configured Redis backend",
);
assert_eq!(status.code(), tonic::Code::FailedPrecondition);
assert_eq!(
status.message(),
"cache service requires a configured Redis backend"
);
let detail = decode_detail(&status);
assert_eq!(detail.kind, ErrorKind::Capability as i32);
assert_eq!(detail.backend, "cache");
assert_eq!(detail.operation, "request_dispatch");
assert_eq!(detail.capability_required, "redis_backend");
assert!(!detail.retryable);
}
#[test]
fn over_budget_is_rejected() {
assert!(would_exceed_budget(900, 200, 1000));
assert!(!would_exceed_budget(800, 200, 1000));
assert!(!would_exceed_budget(1000, 0, 1000));
assert!(!would_exceed_budget(1000, -10, 1000));
assert_eq!(effective_max_bytes(0), DEFAULT_NAMESPACE_MAX_BYTES);
assert_eq!(effective_max_bytes(2048), 2048);
}
#[test]
fn sweep_uses_scan_not_keys() {
assert_eq!(SWEEP_COMMAND, "SCAN");
assert_ne!(SWEEP_COMMAND, "KEYS");
let pattern = data_match("tenant-a", "sessions", "u");
assert_eq!(pattern, "udb:cache:tenant-a:sessions:k:u*");
assert!(pattern.ends_with('*'));
}
#[test]
fn reconciliation_sum_math() {
assert_eq!(reconciled_sum(&[]), 0);
assert_eq!(reconciled_sum(&[128, 64, 8]), 200);
assert_eq!(reconciled_sum(&[100, 0, 50]), 150);
assert_eq!(reconciled_sum(&[-16, 32]), 32);
assert_eq!(reconciled_sum(&[i64::MAX, 1]), i64::MAX);
}
#[test]
fn reconciliation_meta_key_round_trip() {
assert_eq!(
parse_meta_key(&meta_key("tenant-a", "sessions")),
Some(("tenant-a".to_string(), "sessions".to_string()))
);
assert_eq!(
parse_meta_key("udb:cache:ten:ant:sessions:__meta__"),
Some(("ten:ant".to_string(), "sessions".to_string()))
);
assert_eq!(parse_meta_key(&data_key("t", "ns", "k1")), None);
assert_eq!(parse_meta_key(&bytes_counter_key("t", "ns")), None);
assert_eq!(parse_meta_key(&reconcile_cursor_key()), None);
assert_eq!(parse_meta_key("udb:cache::__meta__"), None);
assert_eq!(parse_meta_key("udb:cache:only-one-segment:__meta__"), None);
assert_eq!(meta_match_all(), "udb:cache:*:__meta__");
assert!(meta_match_all().ends_with(META_SUFFIX));
}
#[test]
fn scan_limit_is_clamped() {
assert_eq!(clamped_scan_count(0), SWEEP_COUNT);
assert_eq!(clamped_scan_count(-1), SWEEP_COUNT);
assert_eq!(clamped_scan_count(10), 10);
assert_eq!(
clamped_scan_count(MAX_SCAN_PAGE_LIMIT as i32),
MAX_SCAN_PAGE_LIMIT
);
assert_eq!(clamped_scan_count(i32::MAX), MAX_SCAN_PAGE_LIMIT);
assert!(SWEEP_COUNT <= MAX_SCAN_PAGE_LIMIT);
}
#[test]
fn invalidation_scope_fails_closed() {
assert_eq!(invalidation_tenant_scope(&serde_json::json!({})), None);
assert_eq!(
invalidation_tenant_scope(&serde_json::json!({"tenant_id": " "})),
None
);
assert_eq!(
invalidation_tenant_scope(&serde_json::json!({"tenant_id": "tenant-a"})),
Some("tenant-a".to_string())
);
assert_eq!(
namespace_for_source_table("invoices"),
Some("invoices".to_string())
);
assert_eq!(namespace_for_source_table(" "), None);
}
#[test]
fn invalidation_source_uses_source_table_then_table_then_message_type() {
assert_eq!(
invalidation_source_from_payload(
&serde_json::json!({"source_table": "invoices", "message_type": "ignored"})
),
"invoices"
);
assert_eq!(
invalidation_source_from_payload(&serde_json::json!({"table_name": "orders"})),
"orders"
);
assert_eq!(
invalidation_source_from_payload(&serde_json::json!({"message_type": "billing.Invoice"})),
"billing.Invoice"
);
assert_eq!(invalidation_source_from_payload(&serde_json::json!({})), "");
}
#[tokio::test]
async fn get_rejects_cross_tenant_body() {
let svc = CacheServiceImpl::new(); let mut request = Request::new(cache_pb::GetRequest {
tenant_id: "tenant-b".to_string(),
namespace: "sessions".to_string(),
key: "u1".to_string(),
});
request
.metadata_mut()
.insert("x-tenant-id", MetadataValue::from_static("tenant-a"));
let err = svc
.get(request)
.await
.expect_err("cross-tenant body must be rejected");
assert_eq!(err.code(), tonic::Code::PermissionDenied);
}