huskies: merge 1111 bug Test isolation: init_for_test() and ensure_content_store() are once-per-thread, not once-per-test, polluting CRDT state across tests
This commit is contained in:
@@ -122,15 +122,10 @@ pub(super) fn get_crdt() -> Option<&'static Mutex<CrdtState>> {
|
|||||||
/// This avoids the async SQLite setup from `init()`. Ops are sent to a
|
/// This avoids the async SQLite setup from `init()`. Ops are sent to a
|
||||||
/// channel whose receiver is leaked (so nothing is persisted, but the channel
|
/// channel whose receiver is leaked (so nothing is persisted, but the channel
|
||||||
/// stays open and `apply_and_persist` succeeds silently).
|
/// stays open and `apply_and_persist` succeeds silently).
|
||||||
/// Safe to call multiple times — subsequent calls are no-ops (thread-local).
|
/// Always resets all thread-local state so each call produces a clean slate —
|
||||||
|
/// no cross-test pollution when two tests share the same thread.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn init_for_test() {
|
pub fn init_for_test() {
|
||||||
// Initialise thread-local CRDT for test isolation.
|
|
||||||
// Only creates a new CRDT if one isn't set yet on this thread;
|
|
||||||
// subsequent calls are no-ops (matching the old OnceLock semantics
|
|
||||||
// while keeping each thread isolated).
|
|
||||||
CRDT_STATE_TL.with(|lock| {
|
|
||||||
if lock.get().is_none() {
|
|
||||||
let keypair = make_keypair();
|
let keypair = make_keypair();
|
||||||
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
let crdt = BaseCrdt::<PipelineDoc>::new(&keypair);
|
||||||
let (persist_tx, rx) = mpsc::unbounded_channel();
|
let (persist_tx, rx) = mpsc::unbounded_channel();
|
||||||
@@ -138,7 +133,7 @@ pub fn init_for_test() {
|
|||||||
// can then send without error, preventing [crdt_persist] WARNs
|
// can then send without error, preventing [crdt_persist] WARNs
|
||||||
// from racing with other tests that watch the global log buffer.
|
// from racing with other tests that watch the global log buffer.
|
||||||
std::mem::forget(rx);
|
std::mem::forget(rx);
|
||||||
let state = CrdtState {
|
let fresh = CrdtState {
|
||||||
crdt,
|
crdt,
|
||||||
keypair,
|
keypair,
|
||||||
index: HashMap::new(),
|
index: HashMap::new(),
|
||||||
@@ -153,18 +148,31 @@ pub fn init_for_test() {
|
|||||||
lamport_floor: 0,
|
lamport_floor: 0,
|
||||||
tombstones: HashSet::new(),
|
tombstones: HashSet::new(),
|
||||||
};
|
};
|
||||||
let _ = lock.set(Mutex::new(state));
|
CRDT_STATE_TL.with(|lock| {
|
||||||
|
if let Some(mutex) = lock.get() {
|
||||||
|
// Already set on this thread — replace contents so the second
|
||||||
|
// (and subsequent) test on the same thread starts clean.
|
||||||
|
*mutex.lock().unwrap() = fresh;
|
||||||
|
} else {
|
||||||
|
let _ = lock.set(Mutex::new(fresh));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let _ = statics::CRDT_EVENT_TX.get_or_init(|| broadcast::channel::<CrdtEvent>(256).0);
|
let _ = statics::CRDT_EVENT_TX.get_or_init(|| broadcast::channel::<CrdtEvent>(256).0);
|
||||||
let _ = statics::SYNC_TX.get_or_init(|| broadcast::channel::<SignedOp>(1024).0);
|
let _ = statics::SYNC_TX.get_or_init(|| broadcast::channel::<SignedOp>(1024).0);
|
||||||
// Per-thread op journal + vector clock — keeps parallel tests' writes
|
// Per-thread op journal + vector clock — always cleared so a second test
|
||||||
// from corrupting each other's view of ALL_OPS (notably, one thread's
|
// on the same thread cannot see ops written by the first.
|
||||||
// `apply_compaction` could otherwise prune another thread's ops).
|
|
||||||
statics::ALL_OPS_TL.with(|lock| {
|
statics::ALL_OPS_TL.with(|lock| {
|
||||||
|
if let Some(mutex) = lock.get() {
|
||||||
|
mutex.lock().unwrap().clear();
|
||||||
|
} else {
|
||||||
let _ = lock.set(Mutex::new(Vec::new()));
|
let _ = lock.set(Mutex::new(Vec::new()));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
statics::VECTOR_CLOCK_TL.with(|lock| {
|
statics::VECTOR_CLOCK_TL.with(|lock| {
|
||||||
|
if let Some(mutex) = lock.get() {
|
||||||
|
mutex.lock().unwrap().clear();
|
||||||
|
} else {
|
||||||
let _ = lock.set(Mutex::new(VectorClock::new()));
|
let _ = lock.set(Mutex::new(VectorClock::new()));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,9 @@ pub fn delete_content(key: ContentKey<'_>) {
|
|||||||
|
|
||||||
/// Ensure the in-memory content store is initialised.
|
/// Ensure the in-memory content store is initialised.
|
||||||
///
|
///
|
||||||
/// Safe to call multiple times — the `OnceLock` is set at most once.
|
/// In non-test builds: init-once via `OnceLock` (safe to call multiple times).
|
||||||
|
/// In test builds: always resets `CONTENT_STORE_TL` to an empty `HashMap` so
|
||||||
|
/// each test on the same thread starts with a clean store.
|
||||||
pub fn ensure_content_store() {
|
pub fn ensure_content_store() {
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
{
|
{
|
||||||
@@ -175,7 +177,11 @@ pub fn ensure_content_store() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
{
|
{
|
||||||
CONTENT_STORE_TL.with(|lock| {
|
CONTENT_STORE_TL.with(|lock| {
|
||||||
if lock.get().is_none() {
|
if let Some(mutex) = lock.get() {
|
||||||
|
// Already initialised on this thread — reset to empty so the
|
||||||
|
// next test does not see content written by a previous test.
|
||||||
|
mutex.lock().unwrap().clear();
|
||||||
|
} else {
|
||||||
let _ = lock.set(Mutex::new(HashMap::new()));
|
let _ = lock.set(Mutex::new(HashMap::new()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -203,6 +209,41 @@ pub(super) fn init_content_store(map: HashMap<String, String>) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Regression: two sequential `ensure_content_store()` + write + read cycles
|
||||||
|
/// in the same test body must not see each other's content. Before the fix,
|
||||||
|
/// `ensure_content_store()` was a no-op on the second call (OnceLock gating),
|
||||||
|
/// so the second cycle could read items written in the first cycle.
|
||||||
|
#[test]
|
||||||
|
fn sequential_ensure_content_store_resets_state() {
|
||||||
|
// ── Cycle 1 ──────────────────────────────────────────────────────────
|
||||||
|
ensure_content_store();
|
||||||
|
write_content(ContentKey::Story("1111_cycle1"), "cycle-one body");
|
||||||
|
assert_eq!(
|
||||||
|
read_content(ContentKey::Story("1111_cycle1")).as_deref(),
|
||||||
|
Some("cycle-one body"),
|
||||||
|
"cycle 1: item must be readable after write"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Cycle 2: reset, write a different item ────────────────────────────
|
||||||
|
ensure_content_store();
|
||||||
|
// Cycle-1 item must no longer be visible.
|
||||||
|
assert!(
|
||||||
|
read_content(ContentKey::Story("1111_cycle1")).is_none(),
|
||||||
|
"cycle 2: store must be empty; cycle-1 content must not bleed through"
|
||||||
|
);
|
||||||
|
write_content(ContentKey::Story("1111_cycle2"), "cycle-two body");
|
||||||
|
assert_eq!(
|
||||||
|
read_content(ContentKey::Story("1111_cycle2")).as_deref(),
|
||||||
|
Some("cycle-two body"),
|
||||||
|
"cycle 2: own item must be readable"
|
||||||
|
);
|
||||||
|
// And cycle-1 key must still be absent.
|
||||||
|
assert!(
|
||||||
|
read_content(ContentKey::Story("1111_cycle1")).is_none(),
|
||||||
|
"cycle 2: cycle-1 content must remain absent after cycle-2 write"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// AC 2 regression: writing under `ContentKey::Story` is not visible under
|
/// AC 2 regression: writing under `ContentKey::Story` is not visible under
|
||||||
/// `ContentKey::GateOutput` (and vice versa). The typed key namespace, not
|
/// `ContentKey::GateOutput` (and vice versa). The typed key namespace, not
|
||||||
/// runtime substring matching, enforces the separation.
|
/// runtime substring matching, enforces the separation.
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str, meta:
|
|||||||
.and_then(|d| serde_json::to_string(d).ok());
|
.and_then(|d| serde_json::to_string(d).ok());
|
||||||
|
|
||||||
// Update in-memory content store.
|
// Update in-memory content store.
|
||||||
|
// In test builds, the caller (test setup) is responsible for calling
|
||||||
|
// ensure_content_store() once before writing — calling it here would
|
||||||
|
// reset the store on every write, losing items from prior writes in the
|
||||||
|
// same test. In production, the lazy-init call is safe because nothing
|
||||||
|
// resets the store between writes.
|
||||||
|
#[cfg(not(test))]
|
||||||
ensure_content_store();
|
ensure_content_store();
|
||||||
write_content(ContentKey::Story(story_id), content);
|
write_content(ContentKey::Story(story_id), content);
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ mod tests {
|
|||||||
use crate::http::test_helpers::test_ctx;
|
use crate::http::test_helpers::test_ctx;
|
||||||
|
|
||||||
fn setup_git_repo_in(dir: &std::path::Path) {
|
fn setup_git_repo_in(dir: &std::path::Path) {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
std::process::Command::new("git")
|
std::process::Command::new("git")
|
||||||
.args(["init"])
|
.args(["init"])
|
||||||
.current_dir(dir)
|
.current_dir(dir)
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_refactor_accepts_single_criterion() {
|
fn tool_create_refactor_accepts_single_criterion() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_refactor(
|
let result = tool_create_refactor(
|
||||||
@@ -146,6 +147,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_refactor_accepts_mixed_junk_and_real_acceptance_criteria() {
|
fn tool_create_refactor_accepts_mixed_junk_and_real_acceptance_criteria() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_refactor(
|
let result = tool_create_refactor(
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_spike_creates_file() {
|
fn tool_create_spike_creates_file() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
|
|
||||||
@@ -147,6 +148,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_spike_creates_file_without_description() {
|
fn tool_create_spike_creates_file_without_description() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
|
|
||||||
@@ -202,6 +204,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_spike_accepts_single_criterion() {
|
fn tool_create_spike_accepts_single_criterion() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_spike(
|
let result = tool_create_spike(
|
||||||
@@ -233,6 +236,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_spike_accepts_mixed_junk_and_real_acceptance_criteria() {
|
fn tool_create_spike_accepts_mixed_junk_and_real_acceptance_criteria() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_spike(
|
let result = tool_create_spike(
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_accepts_single_criterion() {
|
fn tool_create_story_accepts_single_criterion() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_story(
|
let result = tool_create_story(
|
||||||
@@ -283,6 +284,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_accepts_mixed_junk_and_real_acceptance_criteria() {
|
fn tool_create_story_accepts_mixed_junk_and_real_acceptance_criteria() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
let result = tool_create_story(
|
let result = tool_create_story(
|
||||||
@@ -299,6 +301,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_description_is_written_to_file() {
|
fn tool_create_story_description_is_written_to_file() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
|
|
||||||
@@ -368,6 +371,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_html_sanitised_in_name() {
|
fn tool_create_story_html_sanitised_in_name() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
// HTML in name is sanitised (not rejected)
|
// HTML in name is sanitised (not rejected)
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_create_story_and_list_upcoming() {
|
fn tool_create_story_and_list_upcoming() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
// No git repo needed: spike 61 — create_story just writes the file;
|
// No git repo needed: spike 61 — create_story just writes the file;
|
||||||
// the filesystem watcher handles the commit asynchronously.
|
// the filesystem watcher handles the commit asynchronously.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use super::spike::create_spike_file;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
fn setup_git_repo(root: &std::path::Path) {
|
fn setup_git_repo(root: &std::path::Path) {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
std::process::Command::new("git")
|
std::process::Command::new("git")
|
||||||
.args(["init"])
|
.args(["init"])
|
||||||
.current_dir(root)
|
.current_dir(root)
|
||||||
@@ -166,6 +167,7 @@ fn extract_bug_name_from_content_parses_heading() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_bug_file_writes_correct_content() {
|
fn create_bug_file_writes_correct_content() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
setup_git_repo(tmp.path());
|
||||||
|
|
||||||
@@ -257,6 +259,7 @@ fn create_bug_file_rejects_empty_acceptance_criteria() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_spike_file_writes_correct_content() {
|
fn create_spike_file_writes_correct_content() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
let spike_id = create_spike_file(
|
let spike_id = create_spike_file(
|
||||||
@@ -294,6 +297,7 @@ fn create_spike_file_writes_correct_content() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_spike_file_uses_description_when_provided() {
|
fn create_spike_file_uses_description_when_provided() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let description = "What is the best approach for watching filesystem events?";
|
let description = "What is the best approach for watching filesystem events?";
|
||||||
|
|
||||||
@@ -319,6 +323,7 @@ fn create_spike_file_uses_description_when_provided() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_spike_file_uses_placeholder_when_no_description() {
|
fn create_spike_file_uses_placeholder_when_no_description() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let spike_id = create_spike_file(
|
let spike_id = create_spike_file(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
@@ -350,6 +355,7 @@ fn create_spike_file_rejects_empty_name() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
|
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let name = "Spike: compare \"fast\" vs slow encoders";
|
let name = "Spike: compare \"fast\" vs slow encoders";
|
||||||
let result = create_spike_file(
|
let result = create_spike_file(
|
||||||
@@ -423,6 +429,7 @@ fn create_bug_file_with_depends_on_persists_to_crdt() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_bug_file_without_depends_on_omits_field() {
|
fn create_bug_file_without_depends_on_omits_field() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
setup_git_repo(tmp.path());
|
||||||
|
|
||||||
@@ -474,6 +481,7 @@ fn create_refactor_file_with_depends_on_persists_to_crdt() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_refactor_file_without_depends_on_omits_field() {
|
fn create_refactor_file_without_depends_on_omits_field() {
|
||||||
|
crate::db::ensure_content_store();
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
setup_git_repo(tmp.path());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user