feat(core): add bounded key creation contract
Expose create_redis_value through the Tauri bridge, add NX-based string creation in redis-core, and surface duplicate-key handling through stable operator notices. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,8 +2,9 @@ use redis_core::{
|
|||||||
BackendBootstrap, BackendError, CommandExecutionResult, ConnectionTestResult,
|
BackendBootstrap, BackendError, CommandExecutionResult, ConnectionTestResult,
|
||||||
RedisCommandRequest, RedisConnectionRequest, RedisKeyBrowseRequest, RedisKeyBrowseResult,
|
RedisCommandRequest, RedisConnectionRequest, RedisKeyBrowseRequest, RedisKeyBrowseResult,
|
||||||
RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult,
|
RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult,
|
||||||
RedisValueReadRequest, RedisValueRecord, RedisValueWriteRequest, browse_keys, execute_command,
|
RedisValueCreateRequest, RedisValueCreateResult, RedisValueReadRequest, RedisValueRecord,
|
||||||
inspect_key, read_value, test_connection, update_key_ttl, write_value,
|
RedisValueWriteRequest, browse_keys, create_value, execute_command, inspect_key, read_value,
|
||||||
|
test_connection, update_key_ttl, write_value,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -45,6 +46,13 @@ fn write_redis_value(request: RedisValueWriteRequest) -> Result<RedisValueRecord
|
|||||||
write_value(request)
|
write_value(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_redis_value(
|
||||||
|
request: RedisValueCreateRequest,
|
||||||
|
) -> Result<RedisValueCreateResult, BackendError> {
|
||||||
|
create_value(request)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn update_redis_key_ttl(
|
fn update_redis_key_ttl(
|
||||||
request: RedisKeyTtlUpdateRequest,
|
request: RedisKeyTtlUpdateRequest,
|
||||||
@@ -62,6 +70,7 @@ fn main() {
|
|||||||
inspect_redis_key,
|
inspect_redis_key,
|
||||||
read_redis_value,
|
read_redis_value,
|
||||||
write_redis_value,
|
write_redis_value,
|
||||||
|
create_redis_value,
|
||||||
update_redis_key_ttl
|
update_redis_key_ttl
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -65,6 +65,28 @@ describe("operator notices", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps duplicate key creation into a stable neutral notice", () => {
|
||||||
|
expect(
|
||||||
|
toBackendErrorNotice(
|
||||||
|
{
|
||||||
|
code: "already_exists",
|
||||||
|
message: "Redis key creation does not overwrite an existing key.",
|
||||||
|
detail: "key `draft:key` already exists",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "key_create",
|
||||||
|
connectionName: "redis-local-fixture",
|
||||||
|
databaseLabel: "db0",
|
||||||
|
keyName: "draft:key",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual(
|
||||||
|
createNeutralNotice(
|
||||||
|
"Redis refused to proceed while creating draft:key on redis-local-fixture / db0 because the key already exists. Choose a new key name or inspect the existing key before retrying. Backend detail: Redis key creation does not overwrite an existing key. (key `draft:key` already exists).",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("describes command failures with command and scope context", () => {
|
it("describes command failures with command and scope context", () => {
|
||||||
expect(
|
expect(
|
||||||
toBackendErrorNotice(
|
toBackendErrorNotice(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type BackendErrorCode =
|
|||||||
| "invalid_connection_config"
|
| "invalid_connection_config"
|
||||||
| "unsupported_tls_mode"
|
| "unsupported_tls_mode"
|
||||||
| "unsupported_operation"
|
| "unsupported_operation"
|
||||||
|
| "already_exists"
|
||||||
| "connection_failed"
|
| "connection_failed"
|
||||||
| "authentication_failed"
|
| "authentication_failed"
|
||||||
| "command_failed"
|
| "command_failed"
|
||||||
@@ -21,6 +22,12 @@ export type BackendError = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type BackendErrorContext =
|
export type BackendErrorContext =
|
||||||
|
| {
|
||||||
|
operation: "key_create";
|
||||||
|
connectionName: string;
|
||||||
|
databaseLabel: string;
|
||||||
|
keyName: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
operation: "connection_test";
|
operation: "connection_test";
|
||||||
connectionName: string;
|
connectionName: string;
|
||||||
@@ -96,6 +103,7 @@ export function toBackendErrorNotice(
|
|||||||
const backendError = toBackendError(error);
|
const backendError = toBackendError(error);
|
||||||
const tone =
|
const tone =
|
||||||
backendError.code === "invalid_connection_config" ||
|
backendError.code === "invalid_connection_config" ||
|
||||||
|
backendError.code === "already_exists" ||
|
||||||
backendError.code === "unsupported_operation" ||
|
backendError.code === "unsupported_operation" ||
|
||||||
backendError.code === "unsupported_tls_mode"
|
backendError.code === "unsupported_tls_mode"
|
||||||
? "neutral"
|
? "neutral"
|
||||||
@@ -115,6 +123,7 @@ function normalizeBackendErrorCode(value: unknown): BackendErrorCode {
|
|||||||
switch (value) {
|
switch (value) {
|
||||||
case "invalid_connection_config":
|
case "invalid_connection_config":
|
||||||
case "unsupported_tls_mode":
|
case "unsupported_tls_mode":
|
||||||
|
case "already_exists":
|
||||||
case "unsupported_operation":
|
case "unsupported_operation":
|
||||||
case "connection_failed":
|
case "connection_failed":
|
||||||
case "authentication_failed":
|
case "authentication_failed":
|
||||||
@@ -134,6 +143,8 @@ function buildHeadline(error: BackendError, context: BackendErrorContext) {
|
|||||||
return `The request for ${target} was rejected before Redis was contacted.`;
|
return `The request for ${target} was rejected before Redis was contacted.`;
|
||||||
case "unsupported_tls_mode":
|
case "unsupported_tls_mode":
|
||||||
return `The request for ${target} requires TLS handling that this foundation build does not support yet.`;
|
return `The request for ${target} requires TLS handling that this foundation build does not support yet.`;
|
||||||
|
case "already_exists":
|
||||||
|
return `Redis refused to proceed while ${target} because the key already exists.`;
|
||||||
case "unsupported_operation":
|
case "unsupported_operation":
|
||||||
return `The current desktop contract does not allow ${target} yet.`;
|
return `The current desktop contract does not allow ${target} yet.`;
|
||||||
case "connection_failed":
|
case "connection_failed":
|
||||||
@@ -155,6 +166,8 @@ function buildHint(code: BackendErrorCode, context: BackendErrorContext) {
|
|||||||
return "Check host, port, database, search pattern, and key input before retrying.";
|
return "Check host, port, database, search pattern, and key input before retrying.";
|
||||||
case "unsupported_tls_mode":
|
case "unsupported_tls_mode":
|
||||||
return "Keep TLS disabled in this foundation build unless product scope explicitly adds transport controls.";
|
return "Keep TLS disabled in this foundation build unless product scope explicitly adds transport controls.";
|
||||||
|
case "already_exists":
|
||||||
|
return "Choose a new key name or inspect the existing key before retrying.";
|
||||||
case "unsupported_operation":
|
case "unsupported_operation":
|
||||||
return "Stay on the visible read-only path or wait for a backend contract change before retrying.";
|
return "Stay on the visible read-only path or wait for a backend contract change before retrying.";
|
||||||
case "connection_failed":
|
case "connection_failed":
|
||||||
@@ -181,6 +194,8 @@ function describeContext(context: BackendErrorContext) {
|
|||||||
switch (context.operation) {
|
switch (context.operation) {
|
||||||
case "connection_test":
|
case "connection_test":
|
||||||
return `testing ${context.connectionName} on ${context.databaseLabel}`;
|
return `testing ${context.connectionName} on ${context.databaseLabel}`;
|
||||||
|
case "key_create":
|
||||||
|
return `creating ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
|
||||||
case "key_browse":
|
case "key_browse":
|
||||||
return `loading keys from ${context.connectionName} / ${context.databaseLabel}`;
|
return `loading keys from ${context.connectionName} / ${context.databaseLabel}`;
|
||||||
case "value_read":
|
case "value_read":
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ pub struct RedisValueWriteRequest {
|
|||||||
pub value: RedisValueWriteInput,
|
pub value: RedisValueWriteInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct RedisValueCreateRequest {
|
||||||
|
pub connection: RedisConnectionRequest,
|
||||||
|
pub key: String,
|
||||||
|
pub value: RedisValueCreateInput,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ttl_millis: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct RedisKeyTtlUpdateRequest {
|
pub struct RedisKeyTtlUpdateRequest {
|
||||||
pub connection: RedisConnectionRequest,
|
pub connection: RedisConnectionRequest,
|
||||||
@@ -154,6 +163,11 @@ pub struct RedisKeyTtlUpdateResult {
|
|||||||
pub metadata: RedisKeyMetadata,
|
pub metadata: RedisKeyMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct RedisValueCreateResult {
|
||||||
|
pub record: RedisValueRecord,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct RedisFieldValueEntry {
|
pub struct RedisFieldValueEntry {
|
||||||
pub field: RedisValueContent,
|
pub field: RedisValueContent,
|
||||||
@@ -197,6 +211,12 @@ pub enum RedisValueWriteInput {
|
|||||||
String { value: String },
|
String { value: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum RedisValueCreateInput {
|
||||||
|
String { value: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RedisValueWriteCapability {
|
pub enum RedisValueWriteCapability {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use thiserror::Error;
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum BackendErrorCode {
|
pub enum BackendErrorCode {
|
||||||
InvalidConnectionConfig,
|
InvalidConnectionConfig,
|
||||||
|
AlreadyExists,
|
||||||
UnsupportedTlsMode,
|
UnsupportedTlsMode,
|
||||||
UnsupportedOperation,
|
UnsupportedOperation,
|
||||||
ConnectionFailed,
|
ConnectionFailed,
|
||||||
@@ -30,6 +31,10 @@ impl BackendError {
|
|||||||
Self::new(BackendErrorCode::UnsupportedTlsMode, message, None)
|
Self::new(BackendErrorCode::UnsupportedTlsMode, message, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn already_exists(message: impl Into<String>, detail: Option<String>) -> Self {
|
||||||
|
Self::new(BackendErrorCode::AlreadyExists, message, detail)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn connection_failed(message: impl Into<String>, detail: Option<String>) -> Self {
|
pub fn connection_failed(message: impl Into<String>, detail: Option<String>) -> Self {
|
||||||
Self::new(BackendErrorCode::ConnectionFailed, message, detail)
|
Self::new(BackendErrorCode::ConnectionFailed, message, detail)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ pub use connection::{
|
|||||||
RedisCommandRequest, RedisConnectionRequest, RedisFieldValueEntry, RedisKeyBrowseRequest,
|
RedisCommandRequest, RedisConnectionRequest, RedisFieldValueEntry, RedisKeyBrowseRequest,
|
||||||
RedisKeyBrowseResult, RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtl,
|
RedisKeyBrowseResult, RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtl,
|
||||||
RedisKeyTtlUpdate, RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult, RedisResponse,
|
RedisKeyTtlUpdate, RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult, RedisResponse,
|
||||||
RedisSortedSetEntry, RedisStreamEntry, RedisValueContent, RedisValueData,
|
RedisSortedSetEntry, RedisStreamEntry, RedisValueContent, RedisValueCreateInput,
|
||||||
RedisValueReadRequest, RedisValueRecord, RedisValueWriteCapability, RedisValueWriteInput,
|
RedisValueCreateRequest, RedisValueCreateResult, RedisValueData, RedisValueReadRequest,
|
||||||
RedisValueWriteRequest, TlsMode,
|
RedisValueRecord, RedisValueWriteCapability, RedisValueWriteInput, RedisValueWriteRequest,
|
||||||
|
TlsMode,
|
||||||
};
|
};
|
||||||
pub use error::{BackendError, BackendErrorCode};
|
pub use error::{BackendError, BackendErrorCode};
|
||||||
pub use service::{
|
pub use service::{
|
||||||
browse_keys, execute_command, inspect_key, read_value, test_connection, update_key_ttl,
|
browse_keys, create_value, execute_command, inspect_key, read_value, test_connection,
|
||||||
write_value,
|
update_key_ttl, write_value,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use crate::{
|
|||||||
RedisConnectionRequest, RedisFieldValueEntry, RedisKeyBrowseRequest, RedisKeyBrowseResult,
|
RedisConnectionRequest, RedisFieldValueEntry, RedisKeyBrowseRequest, RedisKeyBrowseResult,
|
||||||
RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtl, RedisKeyTtlUpdate,
|
RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtl, RedisKeyTtlUpdate,
|
||||||
RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult, RedisResponse, RedisSortedSetEntry,
|
RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult, RedisResponse, RedisSortedSetEntry,
|
||||||
RedisStreamEntry, RedisValueContent, RedisValueData, RedisValueReadRequest, RedisValueRecord,
|
RedisStreamEntry, RedisValueContent, RedisValueCreateInput, RedisValueCreateRequest,
|
||||||
|
RedisValueCreateResult, RedisValueData, RedisValueReadRequest, RedisValueRecord,
|
||||||
RedisValueWriteCapability, RedisValueWriteInput, RedisValueWriteRequest, TlsMode,
|
RedisValueWriteCapability, RedisValueWriteInput, RedisValueWriteRequest, TlsMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,6 +142,26 @@ pub fn write_value(request: RedisValueWriteRequest) -> Result<RedisValueRecord,
|
|||||||
read_value_record(&mut connection, database, &request.key)
|
read_value_record(&mut connection, database, &request.key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_value(
|
||||||
|
request: RedisValueCreateRequest,
|
||||||
|
) -> Result<RedisValueCreateResult, BackendError> {
|
||||||
|
validate_key_name(&request.key)?;
|
||||||
|
validate_optional_ttl_millis(request.ttl_millis)?;
|
||||||
|
|
||||||
|
let database = request.connection.target.database;
|
||||||
|
let mut connection = open_connection(&request.connection)?;
|
||||||
|
|
||||||
|
match &request.value {
|
||||||
|
RedisValueCreateInput::String { value } => {
|
||||||
|
create_string_value(&mut connection, &request.key, value, request.ttl_millis)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RedisValueCreateResult {
|
||||||
|
record: read_value_record(&mut connection, database, &request.key)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_key_ttl(
|
pub fn update_key_ttl(
|
||||||
request: RedisKeyTtlUpdateRequest,
|
request: RedisKeyTtlUpdateRequest,
|
||||||
) -> Result<RedisKeyTtlUpdateResult, BackendError> {
|
) -> Result<RedisKeyTtlUpdateResult, BackendError> {
|
||||||
@@ -490,6 +511,28 @@ fn write_string_value(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_string_value(
|
||||||
|
connection: &mut RedisConnection,
|
||||||
|
key_name: &str,
|
||||||
|
value: &str,
|
||||||
|
ttl_millis: Option<u64>,
|
||||||
|
) -> Result<(), BackendError> {
|
||||||
|
let mut command = vec![
|
||||||
|
"SET".to_string(),
|
||||||
|
key_name.to_string(),
|
||||||
|
value.to_string(),
|
||||||
|
"NX".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(ttl_millis) = ttl_millis {
|
||||||
|
command.push("PX".to_string());
|
||||||
|
command.push(ttl_millis.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = connection.execute_owned(command)?;
|
||||||
|
expect_set_create_applied(response, key_name)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_resp_string(value: RespValue) -> Result<String, BackendError> {
|
fn parse_resp_string(value: RespValue) -> Result<String, BackendError> {
|
||||||
match value {
|
match value {
|
||||||
RespValue::SimpleString(value) => Ok(value),
|
RespValue::SimpleString(value) => Ok(value),
|
||||||
@@ -708,6 +751,16 @@ fn validate_key_name(key: &str) -> Result<(), BackendError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_optional_ttl_millis(ttl_millis: Option<u64>) -> Result<(), BackendError> {
|
||||||
|
if matches!(ttl_millis, Some(0)) {
|
||||||
|
return Err(BackendError::invalid_connection_config(
|
||||||
|
"Redis key creation TTL must be greater than zero when provided.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn expect_ok(value: RespValue) -> Result<(), RespValue> {
|
fn expect_ok(value: RespValue) -> Result<(), RespValue> {
|
||||||
match value {
|
match value {
|
||||||
RespValue::SimpleString(value) if value == "OK" => Ok(()),
|
RespValue::SimpleString(value) if value == "OK" => Ok(()),
|
||||||
@@ -751,6 +804,24 @@ fn expect_set_write_applied(value: RespValue) -> Result<(), BackendError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn expect_set_create_applied(value: RespValue, key_name: &str) -> Result<(), BackendError> {
|
||||||
|
match value {
|
||||||
|
RespValue::SimpleString(value) if value == "OK" => Ok(()),
|
||||||
|
RespValue::BulkString(None) => Err(BackendError::already_exists(
|
||||||
|
"Redis key creation does not overwrite an existing key.",
|
||||||
|
Some(format!("key `{key_name}` already exists")),
|
||||||
|
)),
|
||||||
|
RespValue::Error(message) => Err(BackendError::command_failed(
|
||||||
|
"Redis string key creation failed.",
|
||||||
|
Some(message),
|
||||||
|
)),
|
||||||
|
other => Err(BackendError::command_failed(
|
||||||
|
"Redis string key creation returned an unexpected response.",
|
||||||
|
Some(format!("{other:?}")),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn map_auth_error(error: RespValue) -> BackendError {
|
fn map_auth_error(error: RespValue) -> BackendError {
|
||||||
match error {
|
match error {
|
||||||
RespValue::Error(message) => BackendError::authentication_failed(
|
RespValue::Error(message) => BackendError::authentication_failed(
|
||||||
@@ -1530,6 +1601,137 @@ mod tests {
|
|||||||
assert_eq!(error.code, BackendErrorCode::UnsupportedOperation);
|
assert_eq!(error.code, BackendErrorCode::UnsupportedOperation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_string_value_without_ttl() {
|
||||||
|
let server = MockRedisServer::spawn(vec![
|
||||||
|
ServerStep::expect(
|
||||||
|
["SET", "draft:key", "hello world", "NX"],
|
||||||
|
Response::simple("OK"),
|
||||||
|
),
|
||||||
|
ServerStep::expect(["TYPE", "draft:key"], Response::simple("string")),
|
||||||
|
ServerStep::expect(["PTTL", "draft:key"], Response::integer(-1)),
|
||||||
|
ServerStep::expect(["GET", "draft:key"], Response::bulk("hello world")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result = create_value(RedisValueCreateRequest {
|
||||||
|
connection: RedisConnectionRequest {
|
||||||
|
target: ConnectionTarget {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: server.port(),
|
||||||
|
database: 0,
|
||||||
|
username: None,
|
||||||
|
tls_mode: TlsMode::Disabled,
|
||||||
|
},
|
||||||
|
password: None,
|
||||||
|
},
|
||||||
|
key: "draft:key".to_string(),
|
||||||
|
value: RedisValueCreateInput::String {
|
||||||
|
value: "hello world".to_string(),
|
||||||
|
},
|
||||||
|
ttl_millis: None,
|
||||||
|
})
|
||||||
|
.expect("key create should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.record.metadata.name, "draft:key");
|
||||||
|
assert_eq!(result.record.metadata.ttl, RedisKeyTtl::Persistent);
|
||||||
|
assert_eq!(
|
||||||
|
result.record.write_capability,
|
||||||
|
RedisValueWriteCapability::ReplaceString
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.record.data,
|
||||||
|
RedisValueData::String {
|
||||||
|
value: RedisValueContent::String {
|
||||||
|
value: "hello world".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_string_value_with_ttl() {
|
||||||
|
let server = MockRedisServer::spawn(vec![
|
||||||
|
ServerStep::expect(
|
||||||
|
["SET", "draft:ttl", "expires soon", "NX", "PX", "60000"],
|
||||||
|
Response::simple("OK"),
|
||||||
|
),
|
||||||
|
ServerStep::expect(["TYPE", "draft:ttl"], Response::simple("string")),
|
||||||
|
ServerStep::expect(["PTTL", "draft:ttl"], Response::integer(59_000)),
|
||||||
|
ServerStep::expect(["GET", "draft:ttl"], Response::bulk("expires soon")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let result = create_value(RedisValueCreateRequest {
|
||||||
|
connection: RedisConnectionRequest {
|
||||||
|
target: ConnectionTarget {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: server.port(),
|
||||||
|
database: 0,
|
||||||
|
username: None,
|
||||||
|
tls_mode: TlsMode::Disabled,
|
||||||
|
},
|
||||||
|
password: None,
|
||||||
|
},
|
||||||
|
key: "draft:ttl".to_string(),
|
||||||
|
value: RedisValueCreateInput::String {
|
||||||
|
value: "expires soon".to_string(),
|
||||||
|
},
|
||||||
|
ttl_millis: Some(60_000),
|
||||||
|
})
|
||||||
|
.expect("ttl create should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.record.metadata.ttl,
|
||||||
|
RedisKeyTtl::ExpiresInMillis { value: 59_000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_duplicate_key_creation_with_stable_conflict_error() {
|
||||||
|
let server = MockRedisServer::spawn(vec![ServerStep::expect(
|
||||||
|
["SET", "draft:key", "hello world", "NX"],
|
||||||
|
Response::null_bulk(),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let error = create_value(RedisValueCreateRequest {
|
||||||
|
connection: RedisConnectionRequest {
|
||||||
|
target: ConnectionTarget {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: server.port(),
|
||||||
|
database: 0,
|
||||||
|
username: None,
|
||||||
|
tls_mode: TlsMode::Disabled,
|
||||||
|
},
|
||||||
|
password: None,
|
||||||
|
},
|
||||||
|
key: "draft:key".to_string(),
|
||||||
|
value: RedisValueCreateInput::String {
|
||||||
|
value: "hello world".to_string(),
|
||||||
|
},
|
||||||
|
ttl_millis: None,
|
||||||
|
})
|
||||||
|
.expect_err("duplicate create should fail");
|
||||||
|
|
||||||
|
assert_eq!(error.code, BackendErrorCode::AlreadyExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_zero_ttl_for_created_key_before_network_access() {
|
||||||
|
let error = create_value(RedisValueCreateRequest {
|
||||||
|
connection: RedisConnectionRequest {
|
||||||
|
target: ConnectionTarget::default(),
|
||||||
|
password: None,
|
||||||
|
},
|
||||||
|
key: "draft:key".to_string(),
|
||||||
|
value: RedisValueCreateInput::String {
|
||||||
|
value: "hello world".to_string(),
|
||||||
|
},
|
||||||
|
ttl_millis: Some(0),
|
||||||
|
})
|
||||||
|
.expect_err("zero ttl should fail");
|
||||||
|
|
||||||
|
assert_eq!(error.code, BackendErrorCode::InvalidConnectionConfig);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn updates_ttl_for_existing_keys() {
|
fn updates_ttl_for_existing_keys() {
|
||||||
let expiring_server = MockRedisServer::spawn(vec![
|
let expiring_server = MockRedisServer::spawn(vec![
|
||||||
@@ -1656,6 +1858,7 @@ mod tests {
|
|||||||
Error(String),
|
Error(String),
|
||||||
Integer(i64),
|
Integer(i64),
|
||||||
Bulk(Vec<u8>),
|
Bulk(Vec<u8>),
|
||||||
|
NullBulk,
|
||||||
Array(Vec<Response>),
|
Array(Vec<Response>),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1676,6 +1879,10 @@ mod tests {
|
|||||||
Self::Bulk(value.as_bytes().to_vec())
|
Self::Bulk(value.as_bytes().to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn null_bulk() -> Self {
|
||||||
|
Self::NullBulk
|
||||||
|
}
|
||||||
|
|
||||||
fn array<const N: usize>(items: [Response; N]) -> Self {
|
fn array<const N: usize>(items: [Response; N]) -> Self {
|
||||||
Self::Array(items.into())
|
Self::Array(items.into())
|
||||||
}
|
}
|
||||||
@@ -1691,6 +1898,7 @@ mod tests {
|
|||||||
encoded.extend(b"\r\n");
|
encoded.extend(b"\r\n");
|
||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
Self::NullBulk => b"$-1\r\n".to_vec(),
|
||||||
Self::Array(items) => {
|
Self::Array(items) => {
|
||||||
let mut encoded = format!("*{}\r\n", items.len()).into_bytes();
|
let mut encoded = format!("*{}\r\n", items.len()).into_bytes();
|
||||||
for item in items {
|
for item in items {
|
||||||
|
|||||||
Reference in New Issue
Block a user