Contract-suite scaffold: alkstore-contract-suite crate + ADR-022 (suite layout decision), engine dev-dep edges (ADR-017 §4.2 discharged, ADR-012 §2 mirror)

This commit is contained in:
glm-5.3-flash committed 2026-10-07 16:03:00 +00:00
1 parent eefee9ec1d
commit 621415cc47
15 files changed
+1028 -7

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
//! The suite's property rows — one named, independently-runnable
//! property per backlog row, version-stamped per the
//! [convention](crate::version_stamp).
//!
//! Assertion posture: rows panic with a label naming the entry point
//! and the broken expectation — a failing row is a test failure, which
//! is the suite's purpose as a test-side artifact (relaxing the
//! family-wide no-panic rule here only).
use serde_json::json;
use alkstore::{BoxedFuture, Error, RESERVED_PREFIX, Result, validate_shared_name};
use crate::factory::StoreFactory;
fn expect_invalid<T>(label: &str, out: alkstore::Result<T>) {
match out {
Err(Error::InvalidName { .. }) => {}
Err(other) => {
panic!("{label}: expected `InvalidName`, got {other:?} — entry-point validation broke")
}
Ok(_) => panic!("{label}: expected `InvalidName` rejection, got Ok"),
}
}
fn expect_reserved<T>(label: &str, out: alkstore::Result<T>) {
match out {
Err(Error::ReservedName { .. }) => {}
Err(other) => {
panic!("{label}: expected `ReservedName`, got {other:?} — entry-point validation broke")
}
Ok(_) => panic!("{label}: expected `ReservedName` rejection, got Ok"),
}
}
fn expect_ok<T>(label: &str, out: alkstore::Result<T>) {
if let Err(other) = out {
panic!("{label}: expected success (no validation error), got {other:?}");
}
}
/// **Exemplar row** — name validation at every entry point,
/// auto-commit and tx paths alike.
///
/// Every name-bearing entry point on the [`Store`](alkstore::Store)
/// surface — and every name-bearing `*_tx` twin on the
/// [`TxHandle`](alkstore::TxHandle) — rejects, before any engine round
/// trip: the empty (or whitespace-only) name with
/// [`Error::InvalidName`], and the reserved-prefix name
/// (`__alkstore_...`) with [`Error::ReservedName`], on the
/// shared-namespace kinds — channels, streams, queues, outboxes,
/// locks, schedule names, and `schedule()`'s queue argument.
/// Consumer-local identifiers take the non-empty rule only: `owner`
/// reserved-prefixed is legal, `owner` empty is not. The tx path is
/// exercised through `begin_tx` and `with_tx` (the provided wrapper):
/// validation must not be bypassable by the commit-atomic path.
///
/// Contract stamp: ADR-008 §4 (reserved namespace + entry-point
/// validation); ADR-021 §3 (the schedule queue argument); ADR-015 §2
/// (the keyed publish's non-empty-key rule).
///
/// Engine-independent, pure contract: runnable against any factory.
/// The happy-path spot-checks prove the rejections are the
/// validation's doing, not a store-wide failure — valid names
/// construct handles without a validation error, and a
/// reserved-prefixed *local* identifier is accepted.
pub async fn name_validation_rejects_empty_and_reserved(factory: &dyn StoreFactory) {
let store = factory.open().await.expect("factory opens a store");
// Auto-commit paths — empty names.
expect_invalid("notify(empty channel)", store.notify("", json!(null)).await);
expect_invalid("listen(empty channel)", store.listen("").await);
expect_invalid("stream(empty name)", store.stream("").await);
expect_invalid(
"queue(empty name)",
store.queue("", alkstore::QueueOpts::default()).await,
);
expect_invalid("outbox(empty name)", store.outbox("").await);
expect_invalid(
"try_lock(empty name)",
store.try_lock("", "worker", 60).await,
);
expect_invalid(
"try_lock(empty owner)",
store.try_lock("lock", "", 60).await,
);
expect_invalid(
"schedule(empty name)",
store
.schedule("", "@every 1s", "work", json!(null), Default::default())
.await,
);
expect_invalid(
"schedule(empty queue argument)",
store
.schedule("tick", "@every 1s", "", json!(null), Default::default())
.await,
);
// Auto-commit paths — reserved-prefix names.
expect_reserved(
"notify(reserved channel)",
store.notify(RESERVED_PREFIX, json!(null)).await,
);
expect_reserved(
"listen(reserved channel)",
store.listen(RESERVED_PREFIX).await,
);
expect_reserved("stream(reserved name)", store.stream(RESERVED_PREFIX).await);
expect_reserved(
"queue(reserved name)",
store
.queue(RESERVED_PREFIX, alkstore::QueueOpts::default())
.await,
);
expect_reserved("outbox(reserved name)", store.outbox(RESERVED_PREFIX).await);
expect_reserved(
"try_lock(reserved name)",
store.try_lock(RESERVED_PREFIX, "worker", 60).await,
);
expect_reserved(
"schedule(reserved name)",
store
.schedule(
RESERVED_PREFIX,
"@every 1s",
"work",
json!(null),
Default::default(),
)
.await,
);
expect_reserved(
"schedule(reserved queue argument)",
store
.schedule(
"tick",
"@every 1s",
RESERVED_PREFIX,
json!(null),
Default::default(),
)
.await,
);
// Happy-path spot-checks: valid names pass validation; a
// reserved-prefixed owner (consumer-local identifier) is legal.
expect_ok("stream(valid name)", store.stream("work").await);
expect_ok(
"queue(valid name)",
store.queue("work", alkstore::QueueOpts::default()).await,
);
expect_ok(
"try_lock(reserved-prefixed owner is legal)",
store.try_lock("lock", RESERVED_PREFIX, 60).await,
);
// Tx paths — the twins validate identically (begin_tx route).
let mut tx = store.begin_tx().await.expect("begin_tx opens");
expect_invalid(
"enqueue_tx(empty queue)",
tx.enqueue_tx("", Default::default(), json!(null)).await,
);
expect_reserved(
"enqueue_tx(reserved queue)",
tx.enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null))
.await,
);
expect_invalid(
"publish_tx(empty stream)",
tx.publish_tx("", json!(null)).await,
);
expect_reserved(
"publish_tx(reserved stream)",
tx.publish_tx(RESERVED_PREFIX, json!(null)).await,
);
expect_invalid(
"notify_tx(empty channel)",
tx.notify_tx("", json!(null)).await,
);
expect_reserved(
"notify_tx(reserved channel)",
tx.notify_tx(RESERVED_PREFIX, json!(null)).await,
);
expect_invalid(
"publish_with_key_tx(empty stream)",
tx.publish_with_key_tx("", None, json!(null)).await,
);
expect_reserved(
"publish_with_key_tx(reserved stream)",
tx.publish_with_key_tx(RESERVED_PREFIX, None, json!(null))
.await,
);
expect_invalid(
"publish_with_key_tx(empty key)",
tx.publish_with_key_tx("work", Some(String::new()), json!(null))
.await,
);
expect_invalid(
"publish_with_key_tx(whitespace key)",
tx.publish_with_key_tx("work", Some(" ".to_string()), json!(null))
.await,
);
expect_invalid(
"save_offset_tx(empty stream)",
tx.save_offset_tx("", "consumer", 0).await,
);
expect_reserved(
"save_offset_tx(reserved stream)",
tx.save_offset_tx(RESERVED_PREFIX, "consumer", 0).await,
);
expect_invalid(
"save_offset_tx(empty consumer)",
tx.save_offset_tx("work", "", 0).await,
);
expect_invalid("get_job_tx(empty queue)", tx.get_job_tx("", 1).await);
expect_reserved(
"get_job_tx(reserved queue)",
tx.get_job_tx(RESERVED_PREFIX, 1).await,
);
expect_invalid(
"get_offset_tx(empty stream)",
tx.get_offset_tx("", "consumer").await,
);
expect_reserved(
"get_offset_tx(reserved stream)",
tx.get_offset_tx(RESERVED_PREFIX, "consumer").await,
);
expect_invalid(
"read_since_tx(empty stream)",
tx.read_since_tx("", 0, 10).await,
);
expect_reserved(
"read_since_tx(reserved stream)",
tx.read_since_tx(RESERVED_PREFIX, 0, 10).await,
);
expect_invalid(
"read_from_consumer_tx(empty stream)",
tx.read_from_consumer_tx("", "consumer", 10).await,
);
expect_reserved(
"read_from_consumer_tx(reserved stream)",
tx.read_from_consumer_tx(RESERVED_PREFIX, "consumer", 10)
.await,
);
expect_invalid(
"read_from_consumer_tx(empty consumer)",
tx.read_from_consumer_tx("work", "", 10).await,
);
expect_invalid(
"outbox_enqueue_tx(empty outbox)",
tx.outbox_enqueue_tx("", Default::default(), json!(null))
.await,
);
expect_reserved(
"outbox_enqueue_tx(reserved outbox)",
tx.outbox_enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null))
.await,
);
// Tx path via `with_tx` (the provided wrapper): a validation error
// inside the closure propagates as the wrapper's `Err` (and the
// disposition rolls back) — the commit-atomic path cannot bypass
// validation either.
let result = store
.with_tx(Box::new(|tx| {
Box::pin(async move {
validate_shared_name("")?;
tx.notify_tx("work", json!(null)).await
}) as BoxedFuture<'_, Result<()>>
}))
.await;
expect_invalid("with_tx(inner empty-name rejection)", result);
factory.teardown().await.expect("factory teardown");
}