myxce/src/lib/testsuite/test_raft.c

7561 lines
277 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unity/unity.h>
#include "../raft/raft.h"
/* ---------- stub callbacks ---------- */
static int stub_send_vote_request(void *ctx, int peer_idx, uint64_t term)
{
(void)ctx; (void)peer_idx; (void)term;
return 0;
}
static void stub_become_leader(void *ctx, uint64_t term)
{
(void)ctx; (void)term;
}
static void stub_become_follower(void *ctx, uint64_t term, int leader_idx)
{
(void)ctx; (void)term; (void)leader_idx;
}
static int stub_persist(void *ctx, uint64_t term, int voted_for)
{
(void)ctx; (void)term; (void)voted_for;
return 0;
}
static void stub_schedule_timeout(void *ctx, uint32_t delay_ms)
{
(void)ctx; (void)delay_ms;
}
static void stub_cancel_timeout(void *ctx)
{
(void)ctx;
}
static void stub_start_election_after_delay(void *ctx, uint32_t delay_ms)
{
(void)ctx; (void)delay_ms;
}
/* ---- log-replication callback stubs (raft-log-replication) ---- */
static int stub_persist_entry(void *ctx, uint64_t index, uint64_t term,
const void *entry, size_t len)
{
(void)ctx; (void)index; (void)term; (void)entry; (void)len;
return 0;
}
static uint64_t stub_get_entry_term(void *ctx, uint64_t index)
{
(void)ctx; (void)index;
return 0;
}
static int stub_send_append_entries(void *ctx, int peer_idx,
uint64_t prev_log_index,
uint64_t prev_log_term,
uint64_t leader_commit, int n_entries)
{
(void)ctx; (void)peer_idx; (void)prev_log_index; (void)prev_log_term;
(void)leader_commit; (void)n_entries;
return 0;
}
static int stub_truncate_from(void *ctx, uint64_t index)
{
(void)ctx; (void)index;
return 0;
}
/* ---- commit-advance callback stub (raft-commit-safety) ---- */
static void stub_commit_advanced(void *ctx, uint64_t commit_index)
{
(void)ctx; (void)commit_index;
}
/* Build a fully-populated callbacks struct */
static raft_callbacks_t make_full_cbs(void)
{
raft_callbacks_t cbs;
memset(&cbs, 0, sizeof(cbs));
cbs.on_send_vote_request = stub_send_vote_request;
cbs.on_become_leader = stub_become_leader;
cbs.on_become_follower = stub_become_follower;
cbs.on_persist = stub_persist;
cbs.on_schedule_timeout = stub_schedule_timeout;
cbs.on_cancel_timeout = stub_cancel_timeout;
cbs.on_start_election_after_delay = stub_start_election_after_delay;
cbs.on_persist_entry = stub_persist_entry;
cbs.on_get_entry_term = stub_get_entry_term;
cbs.on_send_append_entries = stub_send_append_entries;
cbs.on_truncate_from = stub_truncate_from;
cbs.on_commit_advanced = stub_commit_advanced;
/* on_vote_eligible and on_trace left NULL (optional) */
return cbs;
}
/* ---------- mock infrastructure for raft_start_election tests ---------- */
/* Per-test counters — reset by each test before calling start_election */
typedef struct {
int send_vote_request_count;
int last_send_peer_idx;
uint64_t last_send_term;
/* track each peer index sent (up to 8 for test purposes) */
int sent_peers[8];
int schedule_timeout_count;
uint32_t last_schedule_delay;
int persist_count;
uint64_t last_persist_term;
int last_persist_voted_for;
int persist_return_value; /* configurable: 0=success, non-zero=fail */
int become_leader_count;
uint64_t last_become_leader_term;
/* on_vote_eligible mock */
int vote_eligible_return_value; /* configurable: 1=grant, 0=deny; default 1 */
int vote_eligible_call_count;
/* on_get_entry_term mock (raft-commit-safety freshness, task 4.1/5.1).
* The voter's local last-log term is returned for index == get_entry_term_log_len;
* any other index returns 0 (absent). Default 0 keeps empty-log scenarios at the
* pre-existing local_last_log_term == 0. */
uint64_t local_last_log_term; /* term reported at index == get_entry_term_log_len */
uint64_t get_entry_term_log_len; /* the index treated as the local last log */
/* on_trace mock */
int trace_call_count;
char last_trace_event[64];
/* on_become_follower mock */
int become_follower_count;
uint64_t last_become_follower_term;
int last_become_follower_leader_idx;
/* on_cancel_timeout mock */
int cancel_timeout_count;
/* on_start_election_after_delay mock (task 3.5) */
int start_election_after_delay_count;
uint32_t last_start_election_delay;
/* on_send_pre_vote_request mock (task 2.2) */
int send_pre_vote_request_count;
int last_pre_vote_peer_idx;
uint64_t last_pre_vote_term;
/* per-peer prospective term recorded by index (up to 8) */
uint64_t pre_vote_sent_term[8]; /* term sent to peer i; 0 = not sent */
int pre_vote_sent_peers[8]; /* ordered list of peer indices sent to */
} mock_cbs_state_t;
static mock_cbs_state_t g_mock;
static void reset_mock(void)
{
memset(&g_mock, 0, sizeof(g_mock));
/* Defaults: persist succeeds, vote_eligible grants */
g_mock.persist_return_value = 0;
g_mock.vote_eligible_return_value = 1;
}
static int mock_send_vote_request(void *ctx, int peer_idx, uint64_t term)
{
(void)ctx;
if (g_mock.send_vote_request_count < 8)
g_mock.sent_peers[g_mock.send_vote_request_count] = peer_idx;
g_mock.last_send_peer_idx = peer_idx;
g_mock.last_send_term = term;
g_mock.send_vote_request_count++;
return 0;
}
static void mock_become_leader(void *ctx, uint64_t term)
{
(void)ctx;
g_mock.become_leader_count++;
g_mock.last_become_leader_term = term;
}
static void mock_become_follower(void *ctx, uint64_t term, int leader_idx)
{
(void)ctx;
g_mock.become_follower_count++;
g_mock.last_become_follower_term = term;
g_mock.last_become_follower_leader_idx = leader_idx;
}
static void mock_cancel_timeout(void *ctx)
{
(void)ctx;
g_mock.cancel_timeout_count++;
}
static void mock_start_election_after_delay(void *ctx, uint32_t delay_ms)
{
(void)ctx;
g_mock.start_election_after_delay_count++;
g_mock.last_start_election_delay = delay_ms;
}
static int mock_send_pre_vote_request(void *ctx, int peer_idx,
uint64_t prospective_term)
{
(void)ctx;
if (g_mock.send_pre_vote_request_count < 8)
g_mock.pre_vote_sent_peers[g_mock.send_pre_vote_request_count] = peer_idx;
if (peer_idx >= 0 && peer_idx < 8)
g_mock.pre_vote_sent_term[peer_idx] = prospective_term;
g_mock.last_pre_vote_peer_idx = peer_idx;
g_mock.last_pre_vote_term = prospective_term;
g_mock.send_pre_vote_request_count++;
return 0;
}
static int mock_persist(void *ctx, uint64_t term, int voted_for)
{
(void)ctx;
g_mock.persist_count++;
g_mock.last_persist_term = term;
g_mock.last_persist_voted_for = voted_for;
return g_mock.persist_return_value;
}
static void mock_schedule_timeout(void *ctx, uint32_t delay_ms)
{
(void)ctx;
g_mock.schedule_timeout_count++;
g_mock.last_schedule_delay = delay_ms;
}
static int mock_vote_eligible(void *ctx, int from_idx, uint64_t term)
{
(void)ctx; (void)from_idx; (void)term;
g_mock.vote_eligible_call_count++;
return g_mock.vote_eligible_return_value;
}
/* Configurable local-log term reporter for freshness tests (task 4.1/5.1).
* Returns g_mock.local_last_log_term for the configured last-log index, 0
* otherwise. Defaults (both 0) reproduce the previous always-0 behaviour, so
* empty-log call sites are unaffected. */
static uint64_t mock_get_entry_term(void *ctx, uint64_t index)
{
(void)ctx;
if (g_mock.get_entry_term_log_len != 0 &&
index == g_mock.get_entry_term_log_len)
return g_mock.local_last_log_term;
return 0;
}
static void mock_trace(void *ctx, const char *event, const char *detail)
{
(void)ctx; (void)detail;
g_mock.trace_call_count++;
if (event) {
strncpy(g_mock.last_trace_event, event,
sizeof(g_mock.last_trace_event) - 1);
g_mock.last_trace_event[sizeof(g_mock.last_trace_event) - 1] = '\0';
}
}
/* Build callbacks that record invocations in g_mock */
static raft_callbacks_t make_mock_cbs(void)
{
raft_callbacks_t cbs;
memset(&cbs, 0, sizeof(cbs));
cbs.on_send_vote_request = mock_send_vote_request;
cbs.on_become_leader = mock_become_leader;
cbs.on_become_follower = mock_become_follower;
cbs.on_persist = mock_persist;
cbs.on_schedule_timeout = mock_schedule_timeout;
cbs.on_cancel_timeout = mock_cancel_timeout;
cbs.on_start_election_after_delay = mock_start_election_after_delay;
cbs.on_send_pre_vote_request = mock_send_pre_vote_request;
/* Log-replication callbacks: stubs (task 2.1 only needs them non-NULL for
* raft_init validation; behaviour is exercised by later tasks). */
cbs.on_persist_entry = stub_persist_entry;
cbs.on_get_entry_term = stub_get_entry_term;
cbs.on_send_append_entries = stub_send_append_entries;
cbs.on_truncate_from = stub_truncate_from;
cbs.on_commit_advanced = stub_commit_advanced;
/* on_vote_eligible and on_trace left NULL — tests that need them set explicitly */
return cbs;
}
/* ---------- helpers ---------- */
/* Initialize a raft_state_t with mock callbacks; n_peers and self_idx configurable */
static int init_node(raft_state_t *s, raft_callbacks_t *cbs, int n_peers, int self_idx)
{
raft_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = n_peers;
cfg.self_idx = self_idx;
cfg.election_timeout_ms = 1000;
cfg.cbs = cbs;
return raft_init(s, &cfg);
}
/* ---------- raft_init tests ---------- */
/* raft_init with NULL required callback returns -EINVAL and does not modify s */
static void test_raft_init_null_send_vote_request_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
/* canary: fill s with a non-zero sentinel so we can verify it's untouched */
memset(&s, 0xAB, sizeof(s));
cbs.on_send_vote_request = NULL; /* knock out a required callback */
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.cbs = &cbs;
cfg.ctx = NULL;
int rc = raft_init(&s, &cfg);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
/* s must be completely unchanged (canary check — spot-check first byte) */
{
unsigned char buf[sizeof(s)];
memset(buf, 0xAB, sizeof(buf));
TEST_ASSERT_EQUAL_MEMORY(buf, &s, sizeof(s));
}
}
static void test_raft_init_null_become_leader_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_become_leader = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
static void test_raft_init_null_become_follower_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_become_follower = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
static void test_raft_init_null_persist_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_persist = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
static void test_raft_init_null_schedule_timeout_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_schedule_timeout = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
static void test_raft_init_null_cancel_timeout_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_cancel_timeout = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
static void test_raft_init_null_start_election_after_delay_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_start_election_after_delay = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
/* ---- log-replication required callbacks (Req 7.1–7.4): each NULL → -EINVAL ---- */
static void test_raft_init_null_persist_entry_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_persist_entry = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
static void test_raft_init_null_get_entry_term_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_get_entry_term = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
static void test_raft_init_null_send_append_entries_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_send_append_entries = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
static void test_raft_init_null_truncate_from_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_truncate_from = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
/* All four log callbacks present → succeeds; initial_log_len restored to log_len (Req 1.1) */
static void test_raft_init_log_callbacks_present_succeeds(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.initial_log_len = 5; /* WAL length restored from disk */
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
TEST_ASSERT_EQUAL_UINT64(5, s.log_len);
}
/* ---- commit-advance required callback + commit_index restore (raft-commit-safety
* task 2.1, Req 2.5, 1.1) ---- */
/* on_commit_advanced NULL → -EINVAL (Req 2.5), like the other required callbacks */
static void test_raft_init_null_commit_advanced_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_commit_advanced = NULL; /* knock out the new required callback */
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
}
/* on_commit_advanced present → succeeds (Req 2.5) */
static void test_raft_init_commit_advanced_present_succeeds(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3; cfg.self_idx = 0; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
}
/* commit_index restored from initial_commit_index at init (Req 1.1) */
static void test_raft_init_restores_commit_index(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0xAB, sizeof(s)); /* poison: prove init writes the field */
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.initial_commit_index = 7; /* committed LSN restored from disk */
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
TEST_ASSERT_EQUAL_UINT64(7, s.commit_index);
}
/* default commit_index is 0 on first boot (Req 1.1, default 0) */
static void test_raft_init_commit_index_default_zero(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0xAB, sizeof(s)); /* poison */
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.cbs = &cbs; /* initial_commit_index left 0 */
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index);
}
/* n_peers even (>1) → -EINVAL: even-sized clusters have no fault-tolerance
* advantage over the next-smaller odd cluster; reject to prevent misconfiguration. */
static void test_raft_init_even_peers_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0xAB, sizeof(s));
/* n_peers=2: even, >1 → must fail */
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 2;
cfg.self_idx = 0;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
/* n_peers=4: even, >1 → must fail */
cfg.n_peers = 4;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
/* n_peers=1: single-node is always valid (odd by convention) */
cfg.n_peers = 1;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
/* n_peers=3: odd, >1 → must succeed */
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
/* s must NOT be unchanged after valid inits above */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
}
/* n_peers > RAFT_MAX_PEERS → -EINVAL: the fixed-size next_index[]/match_index[]/
* pre_vote_replies[] arrays are indexed by peer, so an oversize cluster must be
* rejected before any leader-progress/pre-vote loop can OOB-write. */
static void test_raft_init_oversize_peers_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0xAB, sizeof(s));
/* n_peers = RAFT_MAX_PEERS + 1 → must fail (would OOB next_index[]) */
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = RAFT_MAX_PEERS + 1;
cfg.self_idx = 0;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
/* s must be untouched on rejection (canary) */
{
unsigned char buf[sizeof(s)];
memset(buf, 0xAB, sizeof(buf));
TEST_ASSERT_EQUAL_MEMORY(buf, &s, sizeof(s));
}
/* n_peers = RAFT_MAX_PEERS (odd? RAFT_MAX_PEERS=8 is even → also rejected by
* the even-peers rule, so use the largest valid odd size at the bound) */
cfg.n_peers = RAFT_MAX_PEERS - 1; /* 7: odd, within bound → must succeed */
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
}
/* n_peers == 0 → -EINVAL */
static void test_raft_init_zero_peers_returns_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0xAB, sizeof(s));
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 0;
cfg.self_idx = 0;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(-EINVAL, raft_init(&s, &cfg));
/* s must be unchanged */
unsigned char buf[sizeof(s)];
memset(buf, 0xAB, sizeof(buf));
TEST_ASSERT_EQUAL_MEMORY(buf, &s, sizeof(s));
}
/* Successful init: check all fields */
static void test_raft_init_success_fields(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 1;
cfg.election_timeout_ms = 1500;
cfg.my_priority = 2;
cfg.initial_term = 7;
cfg.initial_voted_for = -1;
cfg.cbs = &cbs;
cfg.ctx = (void *)0xDEADBEEF;
int rc = raft_init(&s, &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(3, s.n_peers);
TEST_ASSERT_EQUAL_INT(1, s.self_idx);
TEST_ASSERT_EQUAL_UINT32(1500, s.election_timeout_ms);
TEST_ASSERT_EQUAL_INT(2, s.my_priority);
TEST_ASSERT_EQUAL_UINT64(7, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(0, s.votes_granted);
TEST_ASSERT_EQUAL_INT(0, s.replies_received);
TEST_ASSERT_EQUAL_INT(0, s.election_decided);
/* cbs/ctx must be stored in raft_state_t for the state machine to fire callbacks */
TEST_ASSERT_EQUAL_PTR(&cbs, s.cbs);
TEST_ASSERT_EQUAL_PTR((void *)0xDEADBEEF, s.ctx);
}
/* election_timeout_ms == 0 → default 2000 */
static void test_raft_init_default_election_timeout(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.election_timeout_ms = 0; /* trigger default */
cfg.cbs = &cbs;
int rc = raft_init(&s, &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT32(2000, s.election_timeout_ms);
}
/* optional callbacks (on_vote_eligible, on_trace) may be NULL — must succeed */
static void test_raft_init_optional_callbacks_null_succeeds(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
cbs.on_vote_eligible = NULL;
cbs.on_trace = NULL;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
}
/* ---------- raft_start_election tests (task 3.2) ---------- */
/*
* test_start_election_from_follower_3node
*
* 3-node cluster (self=0). raft_start_election transitions to CANDIDATE,
* increments term, records self-vote, fires on_persist(new_term, self_idx),
* fires on_send_vote_request for each peer != self_idx (peers 1 and 2),
* fires on_schedule_timeout once, returns RAFT_OK.
*/
static void test_start_election_from_follower_3node(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_UINT64(0, s.term);
int rc = raft_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
TEST_ASSERT_EQUAL_UINT64(1, s.term);
TEST_ASSERT_EQUAL_INT(0, s.self_idx);
TEST_ASSERT_EQUAL_INT(0, s.voted_for); /* voted_for == self_idx */
TEST_ASSERT_EQUAL_UINT64(1, s.voted_for_term); /* voted_for_term == new term */
TEST_ASSERT_EQUAL_INT(1, s.votes_granted); /* self-vote counted */
TEST_ASSERT_EQUAL_INT(0, s.replies_received);
TEST_ASSERT_EQUAL_INT(0, s.election_decided);
/* on_persist must fire once with (new_term=1, self_idx=0) */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_persist_term);
TEST_ASSERT_EQUAL_INT(0, g_mock.last_persist_voted_for);
/* on_send_vote_request must fire for peers 1 and 2 (not self=0) */
TEST_ASSERT_EQUAL_INT(2, g_mock.send_vote_request_count);
/* Both sends must use the new term */
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_send_term);
/* Verify neither sent peer is self_idx=0 */
{
int i;
for (i = 0; i < g_mock.send_vote_request_count; i++) {
TEST_ASSERT_NOT_EQUAL(0, g_mock.sent_peers[i]);
}
}
/* on_schedule_timeout must fire exactly once */
TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count);
TEST_ASSERT_EQUAL_UINT32(1000, g_mock.last_schedule_delay);
/* on_become_leader must NOT fire (multi-node path) */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
}
/*
* test_start_election_single_node
*
* 1-node cluster (self=0). raft_start_election shortcircuits to LEADER,
* fires on_persist then on_become_leader, returns RAFT_ELECTED_IMMEDIATELY.
* No on_send_vote_request or on_schedule_timeout calls.
*/
static void test_start_election_single_node(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 1, 0));
int rc = raft_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_ELECTED_IMMEDIATELY, rc);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_UINT64(1, s.term);
TEST_ASSERT_EQUAL_INT(0, s.voted_for); /* self-vote */
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_persist fires once with (term=1, self_idx=0) */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_persist_term);
TEST_ASSERT_EQUAL_INT(0, g_mock.last_persist_voted_for);
/* on_become_leader fires once with term=1 */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_become_leader_term);
/* on_send_vote_request must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count);
/* on_schedule_timeout must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count);
}
/*
* test_start_election_already_candidate
*
* Call raft_start_election once (become CANDIDATE), then call again.
* Second call must return -EALREADY and leave state completely unchanged.
*/
static void test_start_election_already_candidate(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 1));
/* First election — becomes CANDIDATE */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
uint64_t term_after_first = s.term;
/* Snapshot the state before the second call */
int saved_voted_for = s.voted_for;
int saved_votes_granted = s.votes_granted;
int saved_replies_recvd = s.replies_received;
int saved_election_dec = s.election_decided;
reset_mock();
/* Second call must be rejected */
int rc = raft_start_election(&s);
TEST_ASSERT_EQUAL_INT(-EALREADY, rc);
/* State must be unchanged */
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
TEST_ASSERT_EQUAL_UINT64(term_after_first, s.term);
TEST_ASSERT_EQUAL_INT(saved_voted_for, s.voted_for);
TEST_ASSERT_EQUAL_INT(saved_votes_granted, s.votes_granted);
TEST_ASSERT_EQUAL_INT(saved_replies_recvd, s.replies_received);
TEST_ASSERT_EQUAL_INT(saved_election_dec, s.election_decided);
/* No callbacks must have fired on the second call */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
}
/*
* test_start_election_already_leader
*
* Manually set role=RAFT_LEADER, then call raft_start_election.
* Must return -EALREADY without modifying any state or firing any callback.
*/
static void test_start_election_already_leader(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 2));
/* Manually elevate to LEADER */
s.role = RAFT_LEADER;
s.term = 5;
int rc = raft_start_election(&s);
TEST_ASSERT_EQUAL_INT(-EALREADY, rc);
/* All state must be unchanged */
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
/* No callbacks must fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
}
/* ---------- raft_on_vote_request tests (task 3.3) ---------- */
/*
* Helper: init a 3-node node with mock callbacks (including trace).
* term, role, voted_for can be set after init.
*/
static int init_vote_node(raft_state_t *s, raft_callbacks_t *cbs,
int n_peers, int self_idx, uint64_t initial_term)
{
raft_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = n_peers;
cfg.self_idx = self_idx;
cfg.election_timeout_ms = 1000;
cfg.initial_term = initial_term;
cfg.initial_voted_for = -1;
cfg.cbs = cbs;
return raft_init(s, &cfg);
}
/*
* test_vote_req_stale_term
*
* Node at term=5, receives request with term=3.
* Expected: RAFT_DENY, *reply_term=5, state unchanged,
* on_trace fired with "vote_denied_stale_term".
*/
static void test_vote_req_stale_term(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
/* Confirm starting state */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
int rc = raft_on_vote_request(&s, 1, 3, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* State must be unchanged */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
/* on_trace must fire with "vote_denied_stale_term" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_denied_stale_term", g_mock.last_trace_event);
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_vote_req_higher_term_updates_state
*
* Node at term=2, receives request with term=5 (higher).
* Expected: s->term updated to 5, voted_for cleared to -1, role=FOLLOWER.
* Since voted_for is unset and persist succeeds: RAFT_GRANT.
* reply_term = 5.
*/
static void test_vote_req_higher_term_updates_state(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 2));
/* Simulate node was CANDIDATE in term=2 */
s.role = RAFT_CANDIDATE;
s.voted_for = 0; /* voted for self */
s.voted_for_term = 2;
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
/* Term upgrade must happen: term updated, role stepped down */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
/* Then grant because voted_for is cleared and persist succeeds */
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* on_persist must fire once */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
TEST_ASSERT_EQUAL_UINT64(5, g_mock.last_persist_term);
TEST_ASSERT_EQUAL_INT(1, g_mock.last_persist_voted_for);
/* voted_for set on grant */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
}
/*
* test_vote_req_same_term_already_voted_different
*
* Node at term=5, voted_for=1, voted_for_term=5.
* Receives request from idx=2 at term=5.
* Expected: RAFT_DENY, "vote_denied_already_voted" trace.
*/
static void test_vote_req_same_term_already_voted_different(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.voted_for = 1;
s.voted_for_term = 5;
int rc = raft_on_vote_request(&s, 2, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* State: voted_for still 1 (not 2), term unchanged */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
/* on_trace fires with already_voted */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_denied_already_voted", g_mock.last_trace_event);
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_vote_req_same_term_idempotent_same_candidate
*
* Node at term=5, voted_for=1, voted_for_term=5.
* Receives request from idx=1 at term=5 (same candidate, same term).
* Expected: idempotent re-grant → RAFT_GRANT, voted_for still 1.
*/
static void test_vote_req_same_term_idempotent_same_candidate(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.voted_for = 1;
s.voted_for_term = 5;
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for still 1 */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
/* on_persist must fire once for the re-grant */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
}
/*
* test_vote_req_leader_denies
*
* Node is RAFT_LEADER at term=5, receives request at term=5 (same term, no upgrade).
* Expected: RAFT_DENY ("vote_denied_role" trace), role stays LEADER.
*/
static void test_vote_req_leader_denies(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.role = RAFT_LEADER;
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* Role gate fires: still LEADER (term==request term, no upgrade) */
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
/* on_trace fires with "vote_denied_role" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_denied_role", g_mock.last_trace_event);
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_vote_req_on_vote_eligible_denies
*
* on_vote_eligible returns 0 → RAFT_DENY, voted_for unchanged,
* "vote_denied_freshness" trace fires.
*/
static void test_vote_req_on_vote_eligible_denies(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_trace = mock_trace;
reset_mock();
g_mock.vote_eligible_return_value = 0; /* deny */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for must be unchanged */
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
/* on_vote_eligible must have been called */
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
/* on_trace fires with "vote_denied_freshness" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_denied_freshness", g_mock.last_trace_event);
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_vote_req_on_vote_eligible_grants
*
* on_vote_eligible returns 1 and persist succeeds → RAFT_GRANT, voted_for set.
*/
static void test_vote_req_on_vote_eligible_grants(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_trace = mock_trace;
reset_mock();
g_mock.vote_eligible_return_value = 1; /* grant */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for must be set */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
/* on_vote_eligible called once */
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
/* on_persist called once */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
/* on_trace fires "vote_granted" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("vote_granted", g_mock.last_trace_event);
}
/*
* test_vote_req_on_vote_eligible_null
*
* on_vote_eligible is NULL → treated as eligible, grant if persist ok.
*/
static void test_vote_req_on_vote_eligible_null(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
/* on_vote_eligible left NULL in make_mock_cbs */
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for must be set */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
/* on_persist called once */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
}
/*
* test_vote_req_persist_fails
*
* on_persist returns non-zero → RAFT_DENY, voted_for unchanged.
*/
static void test_vote_req_persist_fails(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
g_mock.persist_return_value = -1; /* fail */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for must NOT be updated */
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
/* on_persist called once (it ran, but failed) */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
}
/*
* test_vote_req_reply_term_always_set
*
* Verify that *reply_term == s->term on every return path by testing
* several scenarios and checking reply_term each time.
*/
static void test_vote_req_reply_term_always_set(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term;
/* Path 1: stale term deny */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 7));
reply_term = 0;
raft_on_vote_request(&s, 1, 3, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal current term=7 */
/* Path 2: term upgrade + grant */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 3));
reply_term = 0;
raft_on_vote_request(&s, 1, 7, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal upgraded term=7 */
/* Path 3: already voted for different — deny */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.voted_for = 1; s.voted_for_term = 5;
reply_term = 0;
raft_on_vote_request(&s, 2, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal 5 */
/* Path 4: persist fails — deny */
g_mock.persist_return_value = -1;
reset_mock();
g_mock.persist_return_value = -1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
reply_term = 0;
raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_UINT64(s.term, reply_term); /* must equal 5 */
}
/* ---------- raft_on_vote_reply tests (task 3.4) ---------- */
/*
* test_vote_reply_higher_term_steps_down
*
* Node is CANDIDATE at term=2. Receives reply with term=5 (higher).
* Expected: RAFT_BECAME_FOLLOWER, role=FOLLOWER, term=5, election_decided=1,
* on_become_follower fired with (5, -1), on_cancel_timeout fired once.
*/
static void test_vote_reply_higher_term_steps_down(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
/* Manually put node into CANDIDATE state at term=2 */
s.role = RAFT_CANDIDATE;
s.term = 2;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
int rc = raft_on_vote_reply(&s, 1, 0, 5);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_follower must fire once with (term=5, leader_idx=-1) */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_UINT64(5, g_mock.last_become_follower_term);
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_become_follower_leader_idx);
/* on_cancel_timeout must fire once */
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
/* on_become_leader must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
}
/*
* test_vote_reply_noop_after_decided
*
* election_decided=1 already set. Call raft_on_vote_reply.
* Expected: RAFT_PENDING, no callbacks fire.
*/
static void test_vote_reply_noop_after_decided(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.role = RAFT_CANDIDATE;
s.term = 2;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 1; /* already decided */
int rc = raft_on_vote_reply(&s, 1, 1, 2);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc);
/* No callbacks must fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
}
/*
* test_vote_reply_quorum_3node_1grant
*
* 3-node cluster (quorum=2). Node is CANDIDATE with votes_granted=1 (self-vote).
* One peer grant arrives → votes_granted=2 >= quorum=2.
* Expected: RAFT_BECAME_LEADER, role=LEADER, election_decided=1,
* on_become_leader fired, on_cancel_timeout fired.
*/
static void test_vote_reply_quorum_3node_1grant(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
/* Simulate state after raft_start_election: CANDIDATE, self-vote counted */
s.role = RAFT_CANDIDATE;
s.term = 1;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
/* One peer grants */
int rc = raft_on_vote_reply(&s, 1, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
TEST_ASSERT_EQUAL_INT(2, s.votes_granted);
/* on_become_leader must fire once with the current term */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_become_leader_term);
/* on_cancel_timeout must fire once */
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
/* on_become_follower must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_vote_reply_no_quorum_all_replied_3node
*
* 3-node cluster. Node starts election (votes_granted=1, replies_received=0).
* Both peers deny. After second deny: replies_received=2 == n_peers-1=2,
* votes_granted=1 < quorum=2 → RAFT_BECAME_FOLLOWER.
*/
static void test_vote_reply_no_quorum_all_replied_3node(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.role = RAFT_CANDIDATE;
s.term = 1;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
/* First deny: replies_received=1, not yet all replied */
int rc1 = raft_on_vote_reply(&s, 1, 0, 1);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc1);
TEST_ASSERT_EQUAL_INT(1, s.replies_received);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
/* Second deny: replies_received=2 == n_peers-1=2 → step down */
int rc2 = raft_on_vote_reply(&s, 2, 0, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc2);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_follower must fire once */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_become_follower_term);
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_become_follower_leader_idx);
/* on_become_leader must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
/* on_cancel_timeout must NOT fire for all-replied-no-quorum (only term-upgrade and quorum paths cancel) */
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
}
/*
* test_vote_reply_quorum_5node
*
* 5-node cluster (quorum=3). Node starts election (votes_granted=1).
* Two grants arrive → votes_granted=3 = quorum=3 → RAFT_BECAME_LEADER.
*/
static void test_vote_reply_quorum_5node(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 5, 0));
s.role = RAFT_CANDIDATE;
s.term = 1;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
/* First grant: votes_granted=2, quorum=3 → still pending */
int rc1 = raft_on_vote_reply(&s, 1, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc1);
TEST_ASSERT_EQUAL_INT(2, s.votes_granted);
/* Second grant: votes_granted=3 = quorum → leader */
int rc2 = raft_on_vote_reply(&s, 2, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc2);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
TEST_ASSERT_EQUAL_INT(3, s.votes_granted);
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_vote_reply_pending_waiting
*
* 3-node cluster. Node starts election (votes_granted=1, replies_received=0).
* One deny arrives: replies_received=1, n_peers-1=2 → not all replied yet.
* Expected: RAFT_PENDING, no role change, no callbacks.
*/
static void test_vote_reply_pending_waiting(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.role = RAFT_CANDIDATE;
s.term = 1;
s.votes_granted = 1;
s.replies_received = 0;
s.election_decided = 0;
/* One deny: replies_received=1, n_peers-1=2 → still waiting */
int rc = raft_on_vote_reply(&s, 1, 0, 1);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
TEST_ASSERT_EQUAL_INT(0, s.election_decided);
TEST_ASSERT_EQUAL_INT(1, s.replies_received);
TEST_ASSERT_EQUAL_INT(1, s.votes_granted); /* unchanged */
/* No role-change callbacks */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.cancel_timeout_count);
}
/* ---------- leader progress init on becoming leader (task 2.2) ---------- */
/* Init a node with a non-zero restored log length (initial_log_len). */
static int init_node_log_len(raft_state_t *s, raft_callbacks_t *cbs,
int n_peers, int self_idx, uint64_t initial_log_len)
{
raft_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = n_peers;
cfg.self_idx = self_idx;
cfg.election_timeout_ms = 1000;
cfg.initial_log_len = initial_log_len;
cfg.cbs = cbs;
return raft_init(s, &cfg);
}
/*
* test_leader_progress_init_single_node (raft.tla BecomeLeader, Req 3.1)
*
* Single-node immediate-win path (raft_start_election → RAFT_ELECTED_IMMEDIATELY).
* Non-zero restored log (log_len=4): on becoming leader, next_index[j] must equal
* log_len+1 (=5) and match_index[j] must equal 0 for EVERY peer j in [0, n_peers).
*/
static void test_leader_progress_init_single_node(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 1, 0, 4));
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
int rc = raft_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_ELECTED_IMMEDIATELY, rc);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
for (int j = 0; j < s.n_peers; j++) {
TEST_ASSERT_EQUAL_UINT64(s.log_len + 1, s.next_index[j]);
TEST_ASSERT_EQUAL_UINT64(0, s.match_index[j]);
}
}
/*
* test_leader_progress_init_multi_node (raft.tla BecomeLeader, Req 3.1)
*
* Multi-node become-leader path (raft_on_vote_reply → RAFT_BECAME_LEADER).
* Non-zero restored log (log_len=7): on reaching quorum, next_index[j] must equal
* log_len+1 (=8) and match_index[j] must equal 0 for EVERY peer j in [0, n_peers).
*/
static void test_leader_progress_init_multi_node(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 7));
TEST_ASSERT_EQUAL_UINT64(7, s.log_len);
/* Drive to CANDIDATE then reach quorum with one peer grant. */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
int rc = raft_on_vote_reply(&s, 1, 1, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
for (int j = 0; j < s.n_peers; j++) {
TEST_ASSERT_EQUAL_UINT64(s.log_len + 1, s.next_index[j]);
TEST_ASSERT_EQUAL_UINT64(0, s.match_index[j]);
}
}
/* ---------- raft_log_append tests (task 3.1/3.2, raft.tla ClientRequest 246) ---------- */
/* Spy on_persist_entry: records every call's (index, term, bytes, len) and a
* call count. A separate fail-mode variant returns a configurable non-zero rc
* to exercise the persist-failure path (no log mutation, error propagated). */
typedef struct {
int call_count;
uint64_t last_index;
uint64_t last_term;
size_t last_len;
unsigned char last_bytes[RAFT_SIM_ENTRY_LEN];
int return_value; /* 0 = success; non-zero = persist failure */
} persist_entry_spy_t;
static persist_entry_spy_t g_pe_spy;
static void reset_pe_spy(void)
{
memset(&g_pe_spy, 0, sizeof(g_pe_spy));
g_pe_spy.return_value = 0;
}
static int spy_persist_entry(void *ctx, uint64_t index, uint64_t term,
const void *entry, size_t len)
{
(void)ctx;
g_pe_spy.call_count++;
g_pe_spy.last_index = index;
g_pe_spy.last_term = term;
g_pe_spy.last_len = len;
size_t n = len < sizeof(g_pe_spy.last_bytes) ? len : sizeof(g_pe_spy.last_bytes);
if (entry && n)
memcpy(g_pe_spy.last_bytes, entry, n);
return g_pe_spy.return_value;
}
/* Drive a node to LEADER via the multi-node quorum path (start_election →
* one peer grant), leaving log_len at the restored value. */
static void drive_to_leader(raft_state_t *s)
{
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s->role);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, raft_on_vote_reply(s, 1, 1, s->term));
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s->role);
}
/*
* test_log_append_leader_assigns_index_stamps_term (Req 2.1, 2.3, 2.4, 2.5)
*
* LEADER raft_log_append must:
* - assign index log_len+1 (1-based, == WAL LSN)
* - stamp the entry with s->term (currentTerm), not a zero placeholder
* - call on_persist_entry EXACTLY ONCE with (index, term, entry, len)
* - return that index via out_index
* - return RAFT_OK and advance log_len by exactly one
*/
static void test_log_append_leader_assigns_index_stamps_term(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_persist_entry = spy_persist_entry;
reset_mock();
reset_pe_spy();
/* restored log_len = 4 so the first append lands at index 5 */
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
drive_to_leader(&s);
uint64_t term_at_append = s.term;
const unsigned char payload[] = { 0xDE, 0xAD, 0xBE, 0xEF };
uint64_t out_index = 0;
int rc = raft_log_append(&s, payload, sizeof(payload), &out_index);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* assigned index = log_len(before)+1 = 5, returned via out_index */
TEST_ASSERT_EQUAL_UINT64(5, out_index);
/* log_len advanced by exactly one */
TEST_ASSERT_EQUAL_UINT64(5, s.log_len);
/* on_persist_entry called exactly once with (index=5, term=currentTerm, bytes, len) */
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(5, g_pe_spy.last_index);
TEST_ASSERT_EQUAL_UINT64(term_at_append, g_pe_spy.last_term);
TEST_ASSERT_TRUE(term_at_append != 0); /* not a zero placeholder (Req 2.4) */
TEST_ASSERT_EQUAL_UINT64(sizeof(payload), g_pe_spy.last_len);
TEST_ASSERT_EQUAL_UINT8_ARRAY(payload, g_pe_spy.last_bytes, sizeof(payload));
}
/*
* test_log_append_follower_rejected (Req 2.2)
*
* FOLLOWER raft_log_append must return -EPERM with NO log change and NO callback.
*/
static void test_log_append_follower_rejected(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_persist_entry = spy_persist_entry;
reset_mock();
reset_pe_spy();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
/* raft_init leaves the node RAFT_FOLLOWER */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
uint64_t out_index = 12345; /* sentinel: must remain untouched */
const unsigned char payload[] = { 0x01 };
int rc = raft_log_append(&s, payload, sizeof(payload), &out_index);
TEST_ASSERT_EQUAL_INT(-EPERM, rc);
TEST_ASSERT_EQUAL_UINT64(4, s.log_len); /* no log change */
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no callback */
}
/*
* test_log_append_candidate_rejected (Req 2.2)
*
* CANDIDATE raft_log_append must also return -EPERM (only RAFT_LEADER may append),
* with NO log change and NO callback.
*/
static void test_log_append_candidate_rejected(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_persist_entry = spy_persist_entry;
reset_mock();
reset_pe_spy();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
uint64_t out_index = 0;
const unsigned char payload[] = { 0x02 };
int rc = raft_log_append(&s, payload, sizeof(payload), &out_index);
TEST_ASSERT_EQUAL_INT(-EPERM, rc);
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count);
}
/*
* test_log_append_persist_failure_propagates (Req 2.3, 2.5)
*
* When on_persist_entry returns non-zero, raft_log_append must:
* - propagate that exact non-zero return value
* - NOT advance log_len (no phantom entry — persist-before-act)
*/
static void test_log_append_persist_failure_propagates(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_persist_entry = spy_persist_entry;
reset_mock();
reset_pe_spy();
g_pe_spy.return_value = -EIO; /* persist fails */
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
drive_to_leader(&s);
uint64_t out_index = 0;
const unsigned char payload[] = { 0x03, 0x04 };
int rc = raft_log_append(&s, payload, sizeof(payload), &out_index);
/* error propagated verbatim */
TEST_ASSERT_EQUAL_INT(-EIO, rc);
/* persist was attempted (persist-before-act) but log_len did NOT advance */
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
}
/* ---------- raft_send_append_entries tests (task 4.1/4.2, raft.tla AppendEntries 204) ---------- */
/* Spy on_send_append_entries: records the last call's arguments and a call
* count, so emission of (peer_idx, prev_log_index, prev_log_term, leader_commit,
* n_entries) can be verified exactly. */
typedef struct {
int call_count;
int last_peer_idx;
uint64_t last_prev_log_index;
uint64_t last_prev_log_term;
uint64_t last_leader_commit;
int last_n_entries;
} send_ae_spy_t;
static send_ae_spy_t g_ae_spy;
static void reset_ae_spy(void)
{
memset(&g_ae_spy, 0, sizeof(g_ae_spy));
}
static int spy_send_append_entries(void *ctx, int peer_idx,
uint64_t prev_log_index,
uint64_t prev_log_term,
uint64_t leader_commit, int n_entries)
{
(void)ctx;
g_ae_spy.call_count++;
g_ae_spy.last_peer_idx = peer_idx;
g_ae_spy.last_prev_log_index = prev_log_index;
g_ae_spy.last_prev_log_term = prev_log_term;
g_ae_spy.last_leader_commit = leader_commit;
g_ae_spy.last_n_entries = n_entries;
return 0;
}
/* Spy on_get_entry_term: returns a configurable term per 1-based index so the
* prev_log_term computation in the emit helper can be verified. An index past
* the populated range (or index 0) returns 0 = "absent". */
typedef struct {
int call_count;
uint64_t last_index;
uint64_t term_at[RAFT_SIM_MAX_LOG + 1]; /* 1-based: term_at[i] = term of entry i */
} get_term_spy_t;
static get_term_spy_t g_gt_spy;
static void reset_gt_spy(void)
{
memset(&g_gt_spy, 0, sizeof(g_gt_spy));
}
static uint64_t spy_get_entry_term(void *ctx, uint64_t index)
{
(void)ctx;
g_gt_spy.call_count++;
g_gt_spy.last_index = index;
if (index >= 1 && index <= RAFT_SIM_MAX_LOG)
return g_gt_spy.term_at[index];
return 0;
}
/*
* test_send_ae_prev_index_zero_heartbeat (Req 3.2, 3.3, 3.4)
*
* next_index[j] == 1 (so prev_log_index == 0) AND next_index[j] > log_len
* (empty log, log_len == 0) → heartbeat: prev_log_index == 0, prev_log_term == 0
* (NOT queried / treated as absent), n_entries == 0, leader_commit passed opaque.
*/
static void test_send_ae_prev_index_zero_heartbeat(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_send_append_entries = spy_send_append_entries;
cbs.on_get_entry_term = spy_get_entry_term;
reset_mock();
reset_ae_spy();
reset_gt_spy();
/* empty restored log → log_len = 0, next_index[j] becomes 1 on leader */
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
drive_to_leader(&s);
TEST_ASSERT_EQUAL_UINT64(0, s.log_len);
TEST_ASSERT_EQUAL_UINT64(1, s.next_index[1]); /* log_len+1 */
int rc = raft_send_append_entries(&s, 1, /*leader_commit=*/0);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.call_count);
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.last_peer_idx);
TEST_ASSERT_EQUAL_UINT64(0, g_ae_spy.last_prev_log_index);
TEST_ASSERT_EQUAL_UINT64(0, g_ae_spy.last_prev_log_term); /* absent when prevIdx==0 */
TEST_ASSERT_EQUAL_UINT64(0, g_ae_spy.last_leader_commit);
TEST_ASSERT_EQUAL_INT(0, g_ae_spy.last_n_entries); /* heartbeat */
/* prev_log_index == 0 → on_get_entry_term must NOT be queried for it */
TEST_ASSERT_EQUAL_INT(0, g_gt_spy.call_count);
}
/*
* test_send_ae_heartbeat_when_next_past_log (Req 3.2, 3.3, 3.4)
*
* next_index[j] > log_len with prev_log_index > 0 → heartbeat (n_entries == 0).
* prev_log_index = next_index[j]-1; prev_log_term queried via on_get_entry_term.
* leader_commit passed through opaquely.
*/
static void test_send_ae_heartbeat_when_next_past_log(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_send_append_entries = spy_send_append_entries;
cbs.on_get_entry_term = spy_get_entry_term;
reset_mock();
reset_ae_spy();
reset_gt_spy();
/* restored log_len = 4; on becoming leader next_index[j] = 5 (= log_len+1),
* so next_index[j] > log_len → heartbeat, prev_log_index = 4 (> 0). */
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
drive_to_leader(&s);
uint64_t leader_term = s.term;
g_gt_spy.term_at[4] = leader_term; /* term of entry at prev_log_index = 4 */
int rc = raft_send_append_entries(&s, 1, /*leader_commit=*/42);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.call_count);
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.last_peer_idx);
TEST_ASSERT_EQUAL_UINT64(4, g_ae_spy.last_prev_log_index); /* next_index-1 */
TEST_ASSERT_EQUAL_UINT64(leader_term, g_ae_spy.last_prev_log_term);
TEST_ASSERT_EQUAL_UINT64(42, g_ae_spy.last_leader_commit);
TEST_ASSERT_EQUAL_INT(0, g_ae_spy.last_n_entries); /* heartbeat */
/* prev_log_index = 4 (> 0) → on_get_entry_term queried for index 4 */
TEST_ASSERT_EQUAL_UINT64(4, g_gt_spy.last_index);
TEST_ASSERT_TRUE(g_gt_spy.call_count >= 1);
}
/*
* test_send_ae_one_entry_when_next_within_log (Req 3.2, 3.3, 3.4)
*
* next_index[j] <= log_len → one entry (n_entries == 1).
* prev_log_index = next_index[j]-1; prev_log_term queried via on_get_entry_term.
*/
static void test_send_ae_one_entry_when_next_within_log(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_send_append_entries = spy_send_append_entries;
cbs.on_get_entry_term = spy_get_entry_term;
reset_mock();
reset_ae_spy();
reset_gt_spy();
/* restored log_len = 5; on becoming leader next_index[j] = 6. Walk one peer
* back to next_index = 3 (<= log_len) so one entry (index 3) must be sent;
* prev_log_index = 2 (> 0). */
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
drive_to_leader(&s);
uint64_t leader_term = s.term;
s.next_index[1] = 3; /* construct the within-log case */
g_gt_spy.term_at[2] = leader_term; /* term of entry at prev_log_index = 2 */
int rc = raft_send_append_entries(&s, 1, /*leader_commit=*/7);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.call_count);
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.last_peer_idx);
TEST_ASSERT_EQUAL_UINT64(2, g_ae_spy.last_prev_log_index); /* next_index-1 */
TEST_ASSERT_EQUAL_UINT64(leader_term, g_ae_spy.last_prev_log_term);
TEST_ASSERT_EQUAL_UINT64(7, g_ae_spy.last_leader_commit);
TEST_ASSERT_EQUAL_INT(1, g_ae_spy.last_n_entries); /* one entry */
TEST_ASSERT_EQUAL_UINT64(2, g_gt_spy.last_index);
}
/*
* test_send_ae_non_leader_emits_nothing (Req 3.5)
*
* A non-leader (FOLLOWER) raft_send_append_entries must NOT call
* on_send_append_entries at all.
*/
static void test_send_ae_non_leader_emits_nothing(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_send_append_entries = spy_send_append_entries;
cbs.on_get_entry_term = spy_get_entry_term;
reset_mock();
reset_ae_spy();
reset_gt_spy();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 4));
/* raft_init leaves the node RAFT_FOLLOWER */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
int rc = raft_send_append_entries(&s, 1, /*leader_commit=*/9);
/* No emission whatsoever; the helper is a no-op off the leader. */
TEST_ASSERT_EQUAL_INT(0, g_ae_spy.call_count);
/* Header contract: returns RAFT_OK on every path, including non-leader. */
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
}
/* ---------- raft_on_append_entries_request tests
* (tasks 5.1+5.2 UpdateTerm-first / 6.1 logOk+reject+candidate-step-down /
* 6.2+6.3 accept already-have/conflict-truncate/append;
* raft.tla HandleAppendEntriesRequest 327 + UpdateTerm 406) ----------
*
* Reuses the existing spy_persist_entry/g_pe_spy, spy_get_entry_term/g_gt_spy,
* and mock_persist/mock_become_follower (g_mock). Adds a truncate spy.
*/
/* Spy on_truncate_from: records each call's index and a call count. */
typedef struct {
int call_count;
uint64_t last_index;
} truncate_spy_t;
static truncate_spy_t g_tr_spy;
static void reset_tr_spy(void)
{
memset(&g_tr_spy, 0, sizeof(g_tr_spy));
}
static int spy_truncate_from(void *ctx, uint64_t index)
{
(void)ctx;
g_tr_spy.call_count++;
g_tr_spy.last_index = index;
return 0;
}
/* Build callbacks wired to ALL the log-replication spies + g_mock for the
* shared election-side callbacks (on_persist, on_become_follower). This lets
* the AppendEntries-request tests observe term adoption (mock_persist /
* mock_become_follower) and log mutation (pe/gt/tr spies) together. */
static raft_callbacks_t make_ae_req_cbs(void)
{
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_persist_entry = spy_persist_entry;
cbs.on_get_entry_term = spy_get_entry_term;
cbs.on_truncate_from = spy_truncate_from;
return cbs;
}
static void reset_ae_req_spies(void)
{
reset_mock();
reset_pe_spy();
reset_gt_spy();
reset_tr_spy();
}
/*
* test_ae_req_follower_adopts_higher_term (task 5.1/5.2, Req 6.1, 6.2, 6.5 — G5)
*
* A FOLLOWER at term T receiving an AppendEntries at term T+1 must adopt T+1,
* clear voted_for (=-1), and persist (T+1, -1) via on_persist BEFORE the rest of
* the RPC — even when the local log would FAIL logOk (prev_log_index/term that
* mismatch). Term adoption happens regardless of logOk. reply_term carries the
* adopted term. (on_become_follower NOT required for a non-leader adopter.)
*/
static void test_ae_req_follower_adopts_higher_term(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
/* restored log_len = 2; node starts FOLLOWER at term 5, voted_for=1 */
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 2));
s.term = 5;
s.role = RAFT_FOLLOWER;
s.voted_for = 1;
s.voted_for_term = 5;
/* logOk WILL fail: prev_log_index=2 expects term 9, but local entry 2 is term 5 */
g_gt_spy.term_at[2] = 5;
raft_append_result_t out;
memset(&out, 0xEE, sizeof(out));
const unsigned char e[] = { 0x7 };
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/6,
/*prev_log_index=*/2, /*prev_log_term=*/9, /*leader_commit=*/0,
e, sizeof(e), /*entry_term=*/6, /*entries_len=*/1, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* term adopted even though logOk fails */
TEST_ASSERT_EQUAL_UINT64(6, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
/* persisted (term=6, voted_for=-1) at least once */
TEST_ASSERT_GREATER_OR_EQUAL_INT(1, g_mock.persist_count);
TEST_ASSERT_EQUAL_UINT64(6, g_mock.last_persist_term);
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_persist_voted_for);
/* reply carries the adopted term; the RPC is still rejected (logOk failed) */
TEST_ASSERT_EQUAL_UINT64(6, out.reply_term);
TEST_ASSERT_EQUAL_INT(0, out.success);
TEST_ASSERT_EQUAL_UINT64(0, out.match_index);
}
/*
* test_ae_req_leader_adopts_higher_term_steps_down (task 5.1/5.2, Req 6.1, 6.3)
*
* A LEADER at term T receiving an AppendEntries at term T+1 must adopt T+1,
* clear vote, step down to FOLLOWER, persist (T+1,-1), AND fire on_become_follower
* with (T+1, leader_idx). Applies before logOk.
*/
static void test_ae_req_leader_adopts_higher_term_steps_down(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
/* drive to LEADER at term 1, then a higher-term AE arrives */
drive_to_leader(&s);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
int bf_before = g_mock.become_follower_count;
uint64_t higher = s.term + 1;
raft_append_result_t out;
memset(&out, 0, sizeof(out));
/* heartbeat (entries_len=0, prev_log_index=0 → logOk trivially true) */
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/2, higher,
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(higher, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
/* on_become_follower fired once for the step-down with (higher, leader_idx=2) */
TEST_ASSERT_EQUAL_INT(bf_before + 1, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_UINT64(higher, g_mock.last_become_follower_term);
TEST_ASSERT_EQUAL_INT(2, g_mock.last_become_follower_leader_idx);
/* persisted the adopted term */
TEST_ASSERT_GREATER_OR_EQUAL_INT(1, g_mock.persist_count);
TEST_ASSERT_EQUAL_UINT64(higher, g_mock.last_persist_term);
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_persist_voted_for);
/* the higher-term heartbeat is accepted (logOk trivially true) */
TEST_ASSERT_EQUAL_INT(1, out.success);
TEST_ASSERT_EQUAL_UINT64(higher, out.reply_term);
}
/*
* test_ae_req_heartbeat_adopts_higher_term (task 5.1/5.2, Req 6.1, 6.4 — G5)
*
* A 0-entry heartbeat at a strictly-higher term still triggers UpdateTerm
* (adoption applies on EVERY RPC incl. heartbeat), even when the heartbeat's
* prev_log_index would fail logOk on the local log.
*/
static void test_ae_req_heartbeat_adopts_higher_term(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 1));
s.term = 3;
s.role = RAFT_FOLLOWER;
/* logOk fails: prev_log_index=1 expects term 8 but local entry 1 is term 3 */
g_gt_spy.term_at[1] = 3;
raft_append_result_t out;
memset(&out, 0, sizeof(out));
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/7,
/*prev_log_index=*/1, /*prev_log_term=*/8, /*leader_commit=*/0,
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* adopted despite logOk failure */
TEST_ASSERT_EQUAL_UINT64(7, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_GREATER_OR_EQUAL_INT(1, g_mock.persist_count);
TEST_ASSERT_EQUAL_UINT64(7, g_mock.last_persist_term);
/* heartbeat with a failing logOk is rejected, but at the adopted term */
TEST_ASSERT_EQUAL_INT(0, out.success);
TEST_ASSERT_EQUAL_UINT64(7, out.reply_term);
TEST_ASSERT_EQUAL_UINT64(0, out.match_index);
}
/*
* test_ae_req_equal_term_no_term_change (Req 6.5)
*
* On an EQUAL-term AppendEntries, the handler must NOT change term or voted_for,
* and must NOT persist a term adoption.
*/
static void test_ae_req_equal_term_no_term_change(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
s.term = 4;
s.role = RAFT_FOLLOWER;
s.voted_for = 2;
s.voted_for_term = 4;
int persist_before = g_mock.persist_count;
raft_append_result_t out;
memset(&out, 0, sizeof(out));
/* empty log, prev_log_index=0 → logOk true → append branch (heartbeat) */
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/4,
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(4, s.term);
TEST_ASSERT_EQUAL_INT(2, s.voted_for); /* untouched */
TEST_ASSERT_EQUAL_UINT64(4, s.voted_for_term);
/* no on_persist for term adoption on equal term */
TEST_ASSERT_EQUAL_INT(persist_before, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(1, out.success); /* equal-term heartbeat accepted */
}
/*
* test_ae_req_stale_term_rejected (task 6.1, Req 4.2)
*
* term < currentTerm → reject (success=0, match_index=0, reply_term=currentTerm),
* no term change, no log mutation.
*/
static void test_ae_req_stale_term_rejected(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 2));
s.term = 9;
s.role = RAFT_FOLLOWER;
raft_append_result_t out;
memset(&out, 0, sizeof(out));
const unsigned char e[] = { 0x1 };
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/3,
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
e, sizeof(e), /*entry_term=*/3, /*entries_len=*/1, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(0, out.success);
TEST_ASSERT_EQUAL_UINT64(0, out.match_index);
TEST_ASSERT_EQUAL_UINT64(9, out.reply_term); /* current term */
TEST_ASSERT_EQUAL_UINT64(9, s.term); /* unchanged */
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no log write */
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate */
}
/*
* test_ae_req_logok_prev_zero_accepts_append (task 6.1/6.3, Req 4.1, 4.6)
*
* prev_log_index == 0 → logOk true unconditionally; the local log ends at 0
* (empty) so the one incoming entry is appended via on_persist_entry at index 1;
* success, match_index = prev_log_index + entries_len = 1; log_len grows to 1.
*/
static void test_ae_req_logok_prev_zero_accepts_append(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
s.term = 2;
s.role = RAFT_FOLLOWER;
raft_append_result_t out;
memset(&out, 0, sizeof(out));
const unsigned char e[] = { 0xAA, 0xBB };
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/2,
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
e, sizeof(e), /*entry_term=*/2, /*entries_len=*/1, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, out.success);
TEST_ASSERT_EQUAL_UINT64(1, out.match_index); /* prev(0) + entries_len(1) */
/* appended at index 1 with the incoming entry term */
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(1, g_pe_spy.last_index);
TEST_ASSERT_EQUAL_UINT64(2, g_pe_spy.last_term);
TEST_ASSERT_EQUAL_UINT8_ARRAY(e, g_pe_spy.last_bytes, sizeof(e));
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* append, no truncate */
TEST_ASSERT_EQUAL_UINT64(1, s.log_len); /* log extended */
}
/*
* test_ae_req_logok_match_accepts (task 6.1/6.3, Req 4.1)
*
* prev_log_index > 0 and on_get_entry_term(prev_log_index) == prev_log_term →
* logOk true. Append at index prev+1 (local log ends at prev) → success,
* match_index = prev + entries_len.
*/
static void test_ae_req_logok_match_accepts(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 3));
s.term = 4;
s.role = RAFT_FOLLOWER;
/* local entry 3 has term 4 == prev_log_term ⇒ logOk true; index 4 absent */
g_gt_spy.term_at[3] = 4;
/* g_gt_spy.term_at[4] == 0 → absent → append branch */
raft_append_result_t out;
memset(&out, 0, sizeof(out));
const unsigned char e[] = { 0xC0 };
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/4,
/*prev_log_index=*/3, /*prev_log_term=*/4, /*leader_commit=*/0,
e, sizeof(e), /*entry_term=*/4, /*entries_len=*/1, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, out.success);
TEST_ASSERT_EQUAL_UINT64(4, out.match_index); /* prev(3) + 1 */
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(4, g_pe_spy.last_index);
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
}
/*
* test_ae_req_logok_mismatch_rejected (task 6.1, Req 4.1, 4.2 — G2)
*
* prev_log_index > 0 and on_get_entry_term(prev_log_index) != prev_log_term →
* logOk FALSE → follower at equal term rejects (success=0, match_index=0),
* with no log mutation.
*/
static void test_ae_req_logok_mismatch_rejected(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 3));
s.term = 4;
s.role = RAFT_FOLLOWER;
/* local entry 3 has term 2, but prev_log_term says 4 → logOk false */
g_gt_spy.term_at[3] = 2;
raft_append_result_t out;
memset(&out, 0, sizeof(out));
const unsigned char e[] = { 0xD0 };
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/4,
/*prev_log_index=*/3, /*prev_log_term=*/4, /*leader_commit=*/0,
e, sizeof(e), /*entry_term=*/4, /*entries_len=*/1, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(0, out.success);
TEST_ASSERT_EQUAL_UINT64(0, out.match_index);
TEST_ASSERT_EQUAL_UINT64(4, out.reply_term);
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count);
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(3, s.log_len); /* unchanged */
}
/*
* test_ae_req_candidate_equal_term_steps_down (task 6.1, Req 4.3)
*
* A CANDIDATE at EQUAL term receiving a logOk AppendEntries transitions to
* FOLLOWER (return-to-follower branch) and processes the accept.
*/
static void test_ae_req_candidate_equal_term_steps_down(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
/* become CANDIDATE at term 1 */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
uint64_t cand_term = s.term;
raft_append_result_t out;
memset(&out, 0, sizeof(out));
/* equal-term heartbeat, prev_log_index=0 → logOk true */
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, cand_term,
/*prev_log_index=*/0, /*prev_log_term=*/0, /*leader_commit=*/0,
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* stepped down */
TEST_ASSERT_EQUAL_UINT64(cand_term, s.term); /* no term change (equal) */
TEST_ASSERT_EQUAL_INT(1, out.success); /* heartbeat accepted */
}
/*
* test_ae_req_candidate_equal_term_steps_down_then_rejects (task 6.1, Req 4.2/4.3)
*
* A CANDIDATE at EQUAL term receiving an AppendEntries whose prev entry does
* NOT match (logOk false) must still step down to FOLLOWER (step 3, before
* logOk) AND then reject (step 5). This pins the step ordering: a mutation that
* moved candidate step-down after the logOk reject would leave the node a
* CANDIDATE here and be caught.
*/
static void test_ae_req_candidate_equal_term_steps_down_then_rejects(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
uint64_t cand_term = s.term;
/* prev_log_index=3 within log, but local term there (4) != prev_log_term (9)
* → logOk false → reject, yet the candidate must have stepped down first. */
g_gt_spy.term_at[3] = 4;
raft_append_result_t out;
memset(&out, 0, sizeof(out));
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, cand_term,
/*prev_log_index=*/3, /*prev_log_term=*/9, /*leader_commit=*/0,
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* stepped down BEFORE logOk */
TEST_ASSERT_EQUAL_UINT64(cand_term, s.term); /* equal term, no change */
TEST_ASSERT_EQUAL_INT(0, out.success); /* logOk failed → reject */
TEST_ASSERT_EQUAL_UINT64(0, out.match_index); /* reject → match_index 0 */
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no persist on reject */
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate on reject */
}
/*
* test_ae_req_already_have_heartbeat (task 6.2, Req 4.4)
*
* entries_len == 0 (heartbeat) with logOk → already-satisfied: success,
* match_index = prev + 0 (= prev), NO log rewrite, NO truncate.
*/
static void test_ae_req_already_have_heartbeat(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
s.term = 6;
s.role = RAFT_FOLLOWER;
g_gt_spy.term_at[5] = 6; /* prev_log_index=5 matches prev_log_term=6 */
raft_append_result_t out;
memset(&out, 0, sizeof(out));
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/6,
/*prev_log_index=*/5, /*prev_log_term=*/6, /*leader_commit=*/0,
NULL, 0, /*entry_term=*/0, /*entries_len=*/0, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, out.success);
TEST_ASSERT_EQUAL_UINT64(5, out.match_index); /* prev(5) + entries_len(0) */
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* no rewrite */
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate */
TEST_ASSERT_EQUAL_UINT64(5, s.log_len); /* heartbeat must NOT shrink log */
}
/*
* test_ae_req_already_have_matching_term (task 6.2, Req 4.4)
*
* entries_len == 1 but the local entry at prev+1 already has the SAME term as the
* incoming entry → already-satisfied: success, match_index = prev + entries_len,
* NO log rewrite, NO truncate.
*/
static void test_ae_req_already_have_matching_term(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
s.term = 6;
s.role = RAFT_FOLLOWER;
g_gt_spy.term_at[3] = 6; /* prev_log_index=3 matches prev_log_term=6 (logOk) */
g_gt_spy.term_at[4] = 6; /* local entry at index 4 already term 6 == entry_term */
raft_append_result_t out;
memset(&out, 0, sizeof(out));
const unsigned char e[] = { 0x55 };
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/6,
/*prev_log_index=*/3, /*prev_log_term=*/6, /*leader_commit=*/0,
e, sizeof(e), /*entry_term=*/6, /*entries_len=*/1, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, out.success);
TEST_ASSERT_EQUAL_UINT64(4, out.match_index); /* prev(3) + 1 */
TEST_ASSERT_EQUAL_INT(0, g_pe_spy.call_count); /* already have it — no rewrite */
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(5, s.log_len); /* must NOT shrink */
}
/*
* test_ae_req_conflict_truncate_then_persist (task 6.2/6.3, Req 4.5 — G3)
*
* logOk holds, but the local entry at index = prev+1 exists with a DIFFERENT term
* than the incoming entry → on_truncate_from(index) THEN on_persist_entry(index,
* entry_term, entry); success, match_index = index (= prev + entries_len).
* The truncate MUST be immediately followed by the re-persist of the leader entry.
*/
static void test_ae_req_conflict_truncate_then_persist(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
s.term = 7;
s.role = RAFT_FOLLOWER;
g_gt_spy.term_at[3] = 7; /* prev_log_index=3, prev_log_term=7 → logOk */
g_gt_spy.term_at[4] = 4; /* local entry 4 has term 4 (divergent) */
raft_append_result_t out;
memset(&out, 0, sizeof(out));
const unsigned char e[] = { 0x99, 0xAA };
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/7,
/*prev_log_index=*/3, /*prev_log_term=*/7, /*leader_commit=*/0,
e, sizeof(e), /*entry_term=*/7, /*entries_len=*/1, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, out.success);
TEST_ASSERT_EQUAL_UINT64(4, out.match_index); /* index = prev+1 */
/* truncate at the conflicting index, then re-persist the leader's entry there */
TEST_ASSERT_EQUAL_INT(1, g_tr_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(4, g_tr_spy.last_index);
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(4, g_pe_spy.last_index);
TEST_ASSERT_EQUAL_UINT64(7, g_pe_spy.last_term); /* the leader's entry_term */
TEST_ASSERT_EQUAL_UINT8_ARRAY(e, g_pe_spy.last_bytes, sizeof(e));
/* log_len is at least prev + entries_len after a conflict-and-append */
TEST_ASSERT_EQUAL_UINT64(4, s.log_len);
}
/*
* test_ae_req_append_extends_log (task 6.3, Req 4.6)
*
* logOk holds and the local log ends exactly at prev_log_index (no entry at
* prev+1) → append via on_persist_entry; success; match_index = prev + entries_len;
* log_len extended to prev + entries_len.
*/
static void test_ae_req_append_extends_log(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 2));
s.term = 5;
s.role = RAFT_FOLLOWER;
g_gt_spy.term_at[2] = 5; /* prev_log_index=2, prev_log_term=5 → logOk */
/* index 3 absent (term_at[3]==0), log_len==2 == prev_log_index → append */
raft_append_result_t out;
memset(&out, 0, sizeof(out));
const unsigned char e[] = { 0x11, 0x22, 0x33 };
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/5,
/*prev_log_index=*/2, /*prev_log_term=*/5, /*leader_commit=*/0,
e, sizeof(e), /*entry_term=*/5, /*entries_len=*/1, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, out.success);
TEST_ASSERT_EQUAL_UINT64(3, out.match_index);
TEST_ASSERT_EQUAL_INT(0, g_tr_spy.call_count); /* no truncate on plain append */
TEST_ASSERT_EQUAL_INT(1, g_pe_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(3, g_pe_spy.last_index);
TEST_ASSERT_EQUAL_UINT64(5, g_pe_spy.last_term);
TEST_ASSERT_EQUAL_UINT64(3, s.log_len); /* extended */
}
/*
* test_ae_req_leader_commit_not_acted (Req 4.x / R3.4 — commit opaque)
*
* A large leader_commit must NOT advance any commit index or otherwise leak into
* library state — the handler only fills *out and mutates the log per the accept
* branch. (No commit field exists in raft_state_t; this asserts the accept path
* is unaffected by leader_commit value.)
*/
static void test_ae_req_leader_commit_not_acted(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_ae_req_cbs();
reset_ae_req_spies();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
s.term = 1;
s.role = RAFT_FOLLOWER;
raft_append_result_t out;
memset(&out, 0, sizeof(out));
const unsigned char e[] = { 0x7E };
int rc = raft_on_append_entries_request(&s, /*leader_idx=*/1, /*term=*/1,
/*prev_log_index=*/0, /*prev_log_term=*/0,
/*leader_commit=*/999999, e, sizeof(e), /*entry_term=*/1,
/*entries_len=*/1, &out);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, out.success);
TEST_ASSERT_EQUAL_UINT64(1, out.match_index); /* unaffected by leader_commit */
TEST_ASSERT_EQUAL_UINT64(1, s.log_len);
}
/* ---------- raft_on_append_entries_reply tests (task 7.1/7.2,
* raft.tla HandleAppendEntriesResponse 393) ---------- */
/*
* test_ae_reply_success_advances_indices (Req 5.1, 5.2)
*
* A success reply at the leader's current term must:
* - set next_index[peer] = match_index + 1
* - set match_index[peer] = match_index
* - return RAFT_OK
* It must NOT advance any commit index (no commit field exists; not asserted here).
*/
static void test_ae_reply_success_advances_indices(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
/* restored log_len = 5; after drive_to_leader next_index[peer]=6, match_index[peer]=0 */
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
drive_to_leader(&s);
/* peer 1 acknowledged up through index 4 */
int rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/1,
/*match_index=*/4, /*reply_term=*/s.term);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(5, s.next_index[1]); /* match_index + 1 */
TEST_ASSERT_EQUAL_UINT64(4, s.match_index[1]); /* == match_index */
}
/*
* test_ae_reply_failure_decrements_next_index_floor (Req 5.1, 5.3)
*
* A failure reply at the leader's current term must:
* - set next_index[peer] = Max(next_index[peer] - 1, 1) (decrement, floored at 1)
* - leave match_index[peer] unchanged
* - stay at 1 when next_index[peer] is already 1 (verify the floor)
* - return RAFT_OK
*/
static void test_ae_reply_failure_decrements_next_index_floor(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
/* restored log_len = 5 → next_index[peer] = 6 after drive_to_leader */
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
drive_to_leader(&s);
/* seed a non-zero match_index to prove failure leaves it untouched */
s.match_index[1] = 3;
/* first failure: 6 → 5 */
int rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/0,
/*match_index=*/0, /*reply_term=*/s.term);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(5, s.next_index[1]);
TEST_ASSERT_EQUAL_UINT64(3, s.match_index[1]); /* unchanged */
/* drive next_index down to the floor */
s.next_index[1] = 1;
rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/0,
/*match_index=*/0, /*reply_term=*/s.term);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(1, s.next_index[1]); /* floored at 1, not 0 */
TEST_ASSERT_EQUAL_UINT64(3, s.match_index[1]); /* still unchanged */
}
/*
* test_ae_reply_stale_term_ignored (Req 5.4, raft.tla 415)
*
* A reply whose reply_term != s->term is a stale-term drop: neither next_index
* nor match_index for the peer may change. Returns RAFT_OK.
*/
static void test_ae_reply_stale_term_ignored(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 5));
drive_to_leader(&s);
/* snapshot current per-peer indices (next_index[1]=6, match_index[1]=0) */
uint64_t next_before = s.next_index[1];
uint64_t match_before = s.match_index[1];
/* stale success reply (older term) must be ignored */
int rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/1,
/*match_index=*/9,
/*reply_term=*/s.term - 1);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(next_before, s.next_index[1]);
TEST_ASSERT_EQUAL_UINT64(match_before, s.match_index[1]);
/* stale failure reply (newer term, e.g. from a future leader) must also be ignored */
rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/0,
/*match_index=*/0,
/*reply_term=*/s.term + 1);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(next_before, s.next_index[1]);
TEST_ASSERT_EQUAL_UINT64(match_before, s.match_index[1]);
}
/* ---------- raft_advance_commit tests (task 3.1/3.2/3.3, raft.tla
* AdvanceCommitIndex 259/270 — current-term Figure-8 rule) ---------- */
/* Spy on_commit_advanced: records the last reported commit_index and a fire
* counter so tests can assert the strict-advance / fire-once semantics (the
* library fires this ONLY on a strict commit_index advance). */
typedef struct {
int call_count;
uint64_t last_commit_index;
} commit_advanced_spy_t;
static commit_advanced_spy_t g_ca_spy;
static void reset_ca_spy(void)
{
memset(&g_ca_spy, 0, sizeof(g_ca_spy));
}
static void spy_commit_advanced(void *ctx, uint64_t commit_index)
{
(void)ctx;
g_ca_spy.call_count++;
g_ca_spy.last_commit_index = commit_index;
}
/*
* test_advance_commit_advances_current_term (Req 1.2-1.7, 2.2, 2.3)
*
* Leader, term=2, log_len=3. A quorum (self + peer 1 of a 3-node cluster) agrees
* through index 3, and the entry at index 3 is from the CURRENT term (2). The
* commit index must advance to 3 and on_commit_advanced(3) must fire exactly once.
*/
static void test_advance_commit_advances_current_term(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_get_entry_term = spy_get_entry_term;
cbs.on_commit_advanced = spy_commit_advanced;
reset_mock();
reset_gt_spy();
reset_ca_spy();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
/* Hand-set leader state directly (do not exercise the election path). */
s.role = RAFT_LEADER;
s.term = 2;
s.log_len = 3;
/* every entry 1..3 is from the current term 2 */
g_gt_spy.term_at[1] = 2;
g_gt_spy.term_at[2] = 2;
g_gt_spy.term_at[3] = 2;
/* peer 1 has replicated through index 3 (self counts as log_len=3) → quorum (2 of 3) */
s.match_index[1] = 3;
s.match_index[2] = 0;
int rc = raft_advance_commit(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(3, s.commit_index);
TEST_ASSERT_EQUAL_INT(1, g_ca_spy.call_count);
TEST_ASSERT_EQUAL_UINT64(3, g_ca_spy.last_commit_index);
}
/*
* test_advance_commit_figure8_hold_previous_term (Req 1.2-1.7, 2.2, 2.3)
*
* Figure-8 safety: a quorum agrees through index 2, but the entry at index 2 is
* from a PREVIOUS term (1) while the leader's current term is 3, and no
* current-term entry sits above it that also has quorum. The commit index must
* NOT advance and on_commit_advanced must NOT fire.
*/
static void test_advance_commit_figure8_hold_previous_term(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_get_entry_term = spy_get_entry_term;
cbs.on_commit_advanced = spy_commit_advanced;
reset_mock();
reset_gt_spy();
reset_ca_spy();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
s.role = RAFT_LEADER;
s.term = 3;
s.log_len = 3;
/* entries 1,2 are from a previous term (1); entry 3 (current term) is NOT
* yet replicated to a quorum. */
g_gt_spy.term_at[1] = 1;
g_gt_spy.term_at[2] = 1;
g_gt_spy.term_at[3] = 3;
/* peer 1 agrees only through index 2 (a previous-term entry); self has 3. */
s.match_index[1] = 2;
s.match_index[2] = 0;
int rc = raft_advance_commit(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index); /* held — not advanced over prev-term */
TEST_ASSERT_EQUAL_INT(0, g_ca_spy.call_count);
}
/*
* test_advance_commit_no_quorum (Req 1.2-1.7, 2.2, 2.3)
*
* No index reaches quorum (a 3-node cluster where no peer has replicated any
* entry; only self agrees). agreeIndexes is empty → commit_index unchanged, no
* callback.
*/
static void test_advance_commit_no_quorum(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_get_entry_term = spy_get_entry_term;
cbs.on_commit_advanced = spy_commit_advanced;
reset_mock();
reset_gt_spy();
reset_ca_spy();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
s.role = RAFT_LEADER;
s.term = 2;
s.log_len = 3;
g_gt_spy.term_at[1] = 2;
g_gt_spy.term_at[2] = 2;
g_gt_spy.term_at[3] = 2;
/* no peer has replicated anything → only self agrees (1 of 3 — not quorum) */
s.match_index[1] = 0;
s.match_index[2] = 0;
int rc = raft_advance_commit(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index);
TEST_ASSERT_EQUAL_INT(0, g_ca_spy.call_count);
}
/*
* test_advance_commit_monotonic (Req 1.2-1.7, 2.2, 2.3)
*
* A later call that computes a SMALLER Max(agreeIndexes) must never lower
* commit_index. First advance to 3, then drop a peer's match_index so the newly
* computed Max is 1 (< 3): commit_index stays 3 and no second callback fires.
*/
static void test_advance_commit_monotonic(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_get_entry_term = spy_get_entry_term;
cbs.on_commit_advanced = spy_commit_advanced;
reset_mock();
reset_gt_spy();
reset_ca_spy();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
s.role = RAFT_LEADER;
s.term = 2;
s.log_len = 3;
g_gt_spy.term_at[1] = 2;
g_gt_spy.term_at[2] = 2;
g_gt_spy.term_at[3] = 2;
s.match_index[1] = 3;
s.match_index[2] = 0;
/* first advance → commit_index = 3 */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_advance_commit(&s));
TEST_ASSERT_EQUAL_UINT64(3, s.commit_index);
TEST_ASSERT_EQUAL_INT(1, g_ca_spy.call_count);
/* now the peer regresses to index 1; new Max(agreeIndexes) = 1 < 3 */
s.match_index[1] = 1;
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_advance_commit(&s));
TEST_ASSERT_EQUAL_UINT64(3, s.commit_index); /* never lowered */
TEST_ASSERT_EQUAL_INT(1, g_ca_spy.call_count); /* no second fire */
}
/*
* test_advance_commit_non_leader_eperm (Req 1.2-1.7, 2.2, 2.3)
*
* Off the leader, raft_advance_commit returns -EPERM, makes no change to
* commit_index, and fires no callback.
*/
static void test_advance_commit_non_leader_eperm(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_get_entry_term = spy_get_entry_term;
cbs.on_commit_advanced = spy_commit_advanced;
reset_mock();
reset_gt_spy();
reset_ca_spy();
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 0));
s.role = RAFT_FOLLOWER;
s.term = 2;
s.log_len = 3;
g_gt_spy.term_at[1] = 2;
g_gt_spy.term_at[2] = 2;
g_gt_spy.term_at[3] = 2;
s.match_index[1] = 3;
s.match_index[2] = 3;
int rc = raft_advance_commit(&s);
TEST_ASSERT_EQUAL_INT(-EPERM, rc);
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index);
TEST_ASSERT_EQUAL_INT(0, g_ca_spy.call_count);
}
/*
* test_ae_reply_does_not_advance_commit (task 3.3, Req 2.6)
*
* Commit-advance is SEPARATE from the AppendEntries-reply handler. A
* raft_on_append_entries_reply that updates match_index[] to a committable quorum
* must NOT, by itself, change commit_index or fire on_commit_advanced. Only a
* subsequent raft_advance_commit advances the commit index.
*
* NOTE: raft_on_append_entries_reply is owned upstream (raft-log-replication) and
* is NOT modified by this spec; this test only asserts that contract holds.
*/
static void test_ae_reply_does_not_advance_commit(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_get_entry_term = spy_get_entry_term;
cbs.on_commit_advanced = spy_commit_advanced;
reset_mock();
reset_gt_spy();
reset_ca_spy();
/* restored log_len = 3 → after drive_to_leader next_index[peer]=4 */
TEST_ASSERT_EQUAL_INT(0, init_node_log_len(&s, &cbs, 3, 0, 3));
drive_to_leader(&s);
/* all 3 entries are from the leader's current term → would be committable */
g_gt_spy.term_at[1] = s.term;
g_gt_spy.term_at[2] = s.term;
g_gt_spy.term_at[3] = s.term;
uint64_t commit_before = s.commit_index;
/* a success reply moving peer 1 to a committable quorum index */
int rc = raft_on_append_entries_reply(&s, /*peer_idx=*/1, /*success=*/1,
/*match_index=*/3, /*reply_term=*/s.term);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(3, s.match_index[1]); /* match recorded */
/* the reply ALONE must not move commit or fire the callback */
TEST_ASSERT_EQUAL_UINT64(commit_before, s.commit_index);
TEST_ASSERT_EQUAL_INT(0, g_ca_spy.call_count);
/* only the explicit commit-advance step advances commit_index */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_advance_commit(&s));
TEST_ASSERT_EQUAL_UINT64(3, s.commit_index);
TEST_ASSERT_EQUAL_INT(1, g_ca_spy.call_count);
}
/* ---------- raft_on_election_timeout tests (task 3.5) ---------- */
/*
* test_election_timeout_undecided
*
* Node is CANDIDATE, election_decided=0.
* raft_on_election_timeout must:
* - set role=FOLLOWER
* - set election_decided=1
* - fire on_become_follower once
* - fire on_cancel_timeout once
* - trace "election_timeout"
*/
static void test_election_timeout_undecided(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.role = RAFT_CANDIDATE;
s.term = 2;
s.election_decided = 0;
int become_follower_before = g_mock.become_follower_count;
raft_on_election_timeout(&s);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_follower fired once (with current term, leader_idx=-1) */
TEST_ASSERT_EQUAL_INT(become_follower_before + 1, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_UINT64(2, g_mock.last_become_follower_term);
TEST_ASSERT_EQUAL_INT(-1, g_mock.last_become_follower_leader_idx);
/* on_cancel_timeout fired once */
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
/* trace fired with "election_timeout" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("election_timeout", g_mock.last_trace_event);
}
/*
* test_election_timeout_already_decided
*
* election_decided=1 already set (election outcome already fired).
* raft_on_election_timeout must:
* - fire on_cancel_timeout once (stale timer cleanup, Req 6.3)
* - NOT fire an additional on_become_follower
* - trace "election_timeout_noop"
*/
static void test_election_timeout_already_decided(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.role = RAFT_CANDIDATE;
s.term = 2;
s.election_decided = 1; /* already decided */
/* Snapshot become_follower_count before the call */
int become_follower_before = g_mock.become_follower_count;
raft_on_election_timeout(&s);
/* on_cancel_timeout must fire once (stale timer cleanup, Req 6.3) */
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
/* on_become_follower must NOT have fired an additional time */
TEST_ASSERT_EQUAL_INT(become_follower_before, g_mock.become_follower_count);
/* trace fired with "election_timeout_noop" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("election_timeout_noop", g_mock.last_trace_event);
}
/* ---------- raft_on_leader_lost tests (task 3.5) ---------- */
/*
* test_leader_lost_clears_voted_for
*
* Node had voted_for=1. raft_on_leader_lost must:
* - set voted_for=-1
* - set role=FOLLOWER
* - fire on_start_election_after_delay with delay = my_priority * election_timeout_ms
* - trace "leader_lost"
*/
static void test_leader_lost_clears_voted_for(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
raft_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.election_timeout_ms = 500;
cfg.my_priority = 2;
cfg.initial_voted_for = -1;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
s.voted_for = 1;
s.role = RAFT_FOLLOWER;
raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
/* on_start_election_after_delay fired once with delay = priority * timeout */
TEST_ASSERT_EQUAL_INT(1, g_mock.start_election_after_delay_count);
TEST_ASSERT_EQUAL_UINT32(2 * 500, g_mock.last_start_election_delay);
/* trace fired with "leader_lost" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("leader_lost", g_mock.last_trace_event);
}
/* ---------- raft_check_and_start_election tests (task 3.5) ---------- */
/*
* test_check_and_start_voted_for_other
*
* voted_for=1, self_idx=0 (voted for a different candidate).
* Must return RAFT_SKIP_ELECTION, role stays FOLLOWER, no election started.
* Trace "jitter_skip" fires.
*/
static void test_check_and_start_voted_for_other(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.voted_for = 1; /* voted for peer 1, not self (0) */
s.role = RAFT_FOLLOWER;
int rc = raft_check_and_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, rc);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
/* No election RPCs must have fired */
TEST_ASSERT_EQUAL_INT(0, g_mock.send_vote_request_count);
/* trace fired with "jitter_skip" */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.trace_call_count);
TEST_ASSERT_EQUAL_STRING("jitter_skip", g_mock.last_trace_event);
}
/*
* test_check_and_start_not_voted
*
* voted_for==-1 (no vote cast yet).
* Must call raft_start_election internally and return RAFT_ELECTION_STARTED.
* role becomes CANDIDATE (3-node cluster).
*/
static void test_check_and_start_not_voted(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.voted_for = -1;
s.role = RAFT_FOLLOWER;
int rc = raft_check_and_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
/* raft_start_election must have fired vote requests */
TEST_ASSERT_GREATER_THAN_INT(0, g_mock.send_vote_request_count);
}
/*
* test_check_and_start_voted_for_self
*
* voted_for==self_idx (we previously voted for ourselves — this means
* we already started an election, so start again to re-enter candidacy).
* Must return RAFT_ELECTION_STARTED, not RAFT_SKIP_ELECTION.
*/
static void test_check_and_start_voted_for_self(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.voted_for = 0; /* voted for self (self_idx=0) */
s.role = RAFT_FOLLOWER;
int rc = raft_check_and_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc);
}
/* ---------- raft_snapshot tests ---------- */
static void test_raft_snapshot_returns_current_state(void)
{
raft_state_t s;
raft_snapshot_t snap;
raft_callbacks_t cbs = make_full_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.initial_term = 5;
cfg.initial_voted_for = 1;
cfg.cbs = &cbs;
int rc = raft_init(&s, &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
/* manually set a few fields to distinguish from zero defaults */
s.votes_granted = 2;
s.election_decided = 1;
s.voted_for_term = 5;
memset(&snap, 0xFF, sizeof(snap)); /* canary */
raft_snapshot(&s, &snap);
TEST_ASSERT_EQUAL_UINT64(5, snap.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, snap.role);
TEST_ASSERT_EQUAL_INT(1, snap.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, snap.voted_for_term);
TEST_ASSERT_EQUAL_INT(2, snap.votes_granted);
TEST_ASSERT_EQUAL_INT(1, snap.election_decided);
}
/* ---------- simulator tests (task 4.1) ---------- */
#ifdef XCE_TESTING
/*
* test_sim_conformance_types_compile (raft-sim-conformance task 1.1)
*
* Structural / compile-reference test for the conformance harness data types
* added to the XCE_TESTING section of raft.h. This task is non-behavioral: it
* only declares new types and struct fields. The test proves the new types are
* usable (construct a raft_sim_msg_t / raft_sim_log_entry_t / raft_sim_action_t
* and read fields back) and that raft_sim_cluster_t / raft_sim_node_t carry the
* new fixed arrays (sizeof grows to include bag[]/trace[]/applied[]).
*/
static void test_sim_conformance_types_compile(void)
{
/* Bag message round-trips its fields. */
raft_sim_msg_t m;
memset(&m, 0, sizeof(m));
m.kind = RAFT_SIM_MSG_AE_REQ;
m.src = 1;
m.dst = 2;
m.term = 7;
m.entry_term = 7;
m.entry_value = 42;
m.entries_len = 1;
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_AE_REQ, m.kind);
TEST_ASSERT_EQUAL_UINT64(42, m.entry_value);
TEST_ASSERT_EQUAL_INT(1, m.entries_len);
/* Lightweight (term, value) log entry. */
raft_sim_log_entry_t e = { .term = 3, .value = 99 };
TEST_ASSERT_EQUAL_UINT64(3, e.term);
TEST_ASSERT_EQUAL_UINT64(99, e.value);
/* Replayable action record over the Next-action enum. */
raft_sim_action_t a;
memset(&a, 0, sizeof(a));
a.kind = RAFT_SIM_ACT_APPEND_ENTRIES;
a.actor = 0;
a.msg_index = -1;
a.term = 7;
a.index = 5;
TEST_ASSERT_EQUAL_INT(RAFT_SIM_ACT_APPEND_ENTRIES, a.kind);
TEST_ASSERT_EQUAL_UINT64(5, a.index);
/* New node + cluster fields exist and are addressable. */
raft_sim_node_t n;
memset(&n, 0, sizeof(n));
n.applied[0] = 11;
n.persisted_term = 4;
n.persisted_voted_for = 2;
TEST_ASSERT_EQUAL_UINT64(11, n.applied[0]);
TEST_ASSERT_EQUAL_INT(2, n.persisted_voted_for);
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
c.seed = 0xDEADBEEFULL;
c.prng = c.seed;
c.bag_count = 0;
c.trace_len = 0;
c.bag[0] = m;
c.trace[0] = a;
TEST_ASSERT_EQUAL_UINT64(0xDEADBEEFULL, c.seed);
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_AE_REQ, c.bag[0].kind);
TEST_ASSERT_EQUAL_INT(RAFT_SIM_ACT_APPEND_ENTRIES, c.trace[0].kind);
/* sizeof grows to include the new fixed arrays (bounds are positive). */
TEST_ASSERT_TRUE(RAFT_SIM_MAX_MSGS > 0);
TEST_ASSERT_TRUE(RAFT_SIM_MAX_STEPS > 0);
TEST_ASSERT_TRUE(sizeof(c.bag) == sizeof(raft_sim_msg_t) * RAFT_SIM_MAX_MSGS);
TEST_ASSERT_TRUE(sizeof(c.trace) == sizeof(raft_sim_action_t) * RAFT_SIM_MAX_STEPS);
}
/*
* test_sim_init_3node
*
* raft_sim_init(c, 3, 2000) returns 0.
* All 3 nodes have: role=FOLLOWER, term=0, voted_for=-1.
* leader_count=0, pending_count=0.
*/
static void test_sim_init_3node(void)
{
raft_sim_cluster_t c;
memset(&c, 0xFF, sizeof(c)); /* canary — force all fields to non-zero */
int rc = raft_sim_init(&c, 3, 2000);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(3, c.n);
TEST_ASSERT_EQUAL_INT(0, c.leader_count);
TEST_ASSERT_EQUAL_INT(0, c.pending_count);
for (int i = 0; i < 3; i++) {
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[i].state.role);
TEST_ASSERT_EQUAL_UINT64(0, c.nodes[i].state.term);
TEST_ASSERT_EQUAL_INT(-1, c.nodes[i].state.voted_for);
TEST_ASSERT_EQUAL_INT(i, c.nodes[i].idx);
TEST_ASSERT_EQUAL_INT(0, c.nodes[i].partition);
}
}
/*
* test_sim_trace_buf
*
* After raft_sim_init, calling raft_start_election on node 0 (which has
* on_trace wired) must append at least one event to node 0's trace_buf.
*/
static void test_sim_trace_buf(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
int rc = raft_sim_init(&c, 3, 2000);
TEST_ASSERT_EQUAL_INT(0, rc);
/* trace_buf must start empty */
TEST_ASSERT_EQUAL_INT(0, c.nodes[0].trace_len);
/* Start election on node 0 — on_trace wired in raft_sim_init will write to trace_buf */
raft_start_election(&c.nodes[0].state);
/* At least one trace event must have been written */
TEST_ASSERT_GREATER_THAN_INT(0, c.nodes[0].trace_len);
}
/*
* test_sim_3node_election_one_leader (task 4.2)
*
* Node 0 starts the election alone (priority=0, highest priority).
* Nodes 1 and 2 have not yet voted, so they grant node 0's request.
* After delivering pending RPCs, node 0 reaches quorum and becomes LEADER.
*
* Steps:
* 1. raft_sim_init(c, 3, 2000)
* 2. raft_start_election only for node 0 → buffers 2 vote-request RPCs
* 3. raft_sim_deliver_pending(c) → nodes 1 and 2 grant; node 0 wins
* 4. Assert c->leader_count == 1 (exactly one LEADER, no split brain)
* 5. Assert node 0 is RAFT_LEADER, nodes 1 and 2 are RAFT_FOLLOWER
*/
static void test_sim_3node_election_one_leader(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
int rc = raft_sim_init(&c, 3, 2000);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Only node 0 starts an election (highest priority) */
raft_start_election(&c.nodes[0].state);
/* 2 RPCs buffered (to nodes 1 and 2) */
TEST_ASSERT_EQUAL_INT(2, c.pending_count);
/* Deliver: nodes 1 and 2 have not voted yet → both grant node 0 */
raft_sim_deliver_pending(&c);
/* Exactly one leader must exist */
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
/* Node 0 must be the leader */
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Nodes 1 and 2 are followers */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[2].state.role);
}
/*
* test_sim_start_all_elections_no_winner (task 4.2)
*
* All 3 nodes start elections simultaneously at the same term.
* Each node votes for itself; all peer requests are denied (already voted).
* No node reaches quorum — leader_count remains 0.
*
* This demonstrates that simultaneous same-term elections produce no winner
* and require a timeout + retry cycle to resolve.
*/
static void test_sim_start_all_elections_no_winner(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
int rc = raft_sim_init(&c, 3, 2000);
TEST_ASSERT_EQUAL_INT(0, rc);
/* All nodes start elections at term 1 — each votes for itself */
raft_sim_start_all_elections(&c);
/* 6 RPCs buffered: each of 3 nodes sends to 2 peers */
TEST_ASSERT_EQUAL_INT(6, c.pending_count);
/* Deliver: every node has voted_for=self, so all peer requests are denied */
raft_sim_deliver_pending(&c);
/* No leader — all denied each other */
TEST_ASSERT_EQUAL_INT(0, c.leader_count);
/* All nodes have stepped back to FOLLOWER after all-replied-no-quorum */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[0].state.role);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[2].state.role);
}
/*
* test_sim_partition_no_quorum (task 4.2)
*
* Node 2 is isolated before it starts an election.
* Its vote-request RPCs are dropped during delivery (partition flag set).
* Node 2 receives no grants and remains CANDIDATE; leader_count stays 0.
*
* Steps:
* 1. raft_sim_init(c, 3, 2000)
* 2. raft_sim_partition(c, 2, 1) — isolate node 2
* 3. raft_start_election(&c->nodes[2]) — node 2 starts election
* 4. raft_sim_deliver_pending(c) — RPCs to/from node 2 are dropped
* 5. Assert node 2 is still CANDIDATE (no quorum reached)
* 6. Assert c->leader_count == 0
*/
static void test_sim_partition_no_quorum(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
int rc = raft_sim_init(&c, 3, 2000);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Isolate node 2 */
raft_sim_partition(&c, 2, 1);
TEST_ASSERT_EQUAL_INT(1, c.nodes[2].partition);
/* Node 2 starts an election — becomes CANDIDATE and buffers RPCs */
raft_start_election(&c.nodes[2].state);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[2].state.role);
/* Deliver: node 2 is partitioned → all its RPCs are dropped */
raft_sim_deliver_pending(&c);
/* Node 2 never received any grants — still CANDIDATE */
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[2].state.role);
/* No leader elected */
TEST_ASSERT_EQUAL_INT(0, c.leader_count);
}
/*
* test_sim_fire_timeout_steps_down (task 4.2)
*
* Node 0 starts an election (becomes CANDIDATE).
* raft_sim_fire_timeout fires the election timeout for node 0.
* Node 0 must step down to FOLLOWER.
*/
static void test_sim_fire_timeout_steps_down(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Start election — node 0 becomes CANDIDATE */
raft_start_election(&c.nodes[0].state);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[0].state.role);
/* Fire timeout — no quorum was reached */
raft_sim_fire_timeout(&c, 0);
/* Node 0 must step down */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[0].state.role);
}
/*
* test_sim_deliver_append_entries_replicates (task 8.2)
*
* Delivery smoke test for raft_sim_deliver_append_entries: a leader appends one
* entry and replicates it to a connected follower; a partitioned follower
* receives nothing.
*
* Steps:
* 1. raft_sim_init(c, 3, 2000); elect node 0 leader.
* 2. Partition node 2 (so it must not receive the entry).
* 3. Leader (node 0) raft_log_append one entry → lands in node 0's log[0].
* 4. raft_send_append_entries to node 1 (connected) and node 2 (partitioned)
* → buffers two AEs.
* 5. raft_sim_deliver_append_entries(c) routes them.
* 6. Assert node 1's in-memory log now holds the entry (log_count >= 1, term set).
* 7. Assert node 2 (partitioned) received nothing (log_count == 0).
*/
static void test_sim_deliver_append_entries_replicates(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Elect node 0 leader. */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Isolate node 2 — it must receive nothing. */
raft_sim_partition(&c, 2, 1);
/* Leader appends one client entry; persisted into node 0's in-memory log. */
unsigned char payload[4] = { 0xDE, 0xAD, 0xBE, 0xEF };
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
TEST_ASSERT_EQUAL_UINT64(1, out_index);
TEST_ASSERT_EQUAL_INT(1, c.nodes[0].log_count);
/* Send AppendEntries to connected (1) and partitioned (2) followers. */
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_send_append_entries(&c.nodes[0].state, 2, /*leader_commit=*/0));
/* Deliver: node 1 receives + replies; node 2 is dropped (partitioned). */
raft_sim_deliver_append_entries(&c);
/* Connected follower replicated the entry — its term must equal the LEADER'S
* PERSISTED entry term (node 0's log[0]), the authoritative send-time term,
* not the leader's live state term (which only coincides here). */
TEST_ASSERT_EQUAL_INT(1, c.nodes[1].log_count);
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[0].term, c.nodes[1].log[0].term);
/* Partitioned follower received nothing. */
TEST_ASSERT_EQUAL_INT(0, c.nodes[2].log_count);
}
/*
* test_sim_check_log_matching_pass_on_converged (task 8.3)
*
* Log Matching helper must return PASS (1) when two nodes hold identical logs.
* Build a 3-node cluster, elect node 0, append two entries, replicate both to
* node 1; assert raft_sim_check_log_matching == 1 and node 1's prefix matches
* node 0's byte-for-byte.
*/
static void test_sim_check_log_matching_pass_on_converged(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Append two client entries on the leader and replicate each to node 1.
* One AppendEntries carries at most one entry, so replicate index by index. */
unsigned char p1[3] = { 0x01, 0x02, 0x03 };
unsigned char p2[3] = { 0x04, 0x05, 0x06 };
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, p1, sizeof(p1), &out_index));
TEST_ASSERT_EQUAL_UINT64(1, out_index);
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
raft_sim_deliver_append_entries(&c);
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, p2, sizeof(p2), &out_index));
TEST_ASSERT_EQUAL_UINT64(2, out_index);
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
raft_sim_deliver_append_entries(&c);
/* Node 1 holds the leader's prefix byte-for-byte. */
TEST_ASSERT_EQUAL_INT(2, c.nodes[0].log_count);
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count);
/* Log Matching holds across the cluster. */
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
}
/*
* test_sim_check_log_matching_fail_on_divergence (task 8.3)
*
* Log Matching helper must return FAIL (0) when two nodes share the same
* (index, term) at some i but their prefixes differ. Hand-craft node 0 and
* node 1 so that index 2 carries the SAME term but index 1 differs in bytes —
* a Log Matching violation (equal (i,term) but non-identical prefix through i).
*/
static void test_sim_check_log_matching_fail_on_divergence(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Hand-craft node 0: index1=(term 5, "AAA"), index2=(term 7, "XXX"). */
c.nodes[0].log[0].term = 5;
c.nodes[0].log[0].bytes[0] = 'A';
c.nodes[0].log[0].bytes[1] = 'A';
c.nodes[0].log[0].bytes[2] = 'A';
c.nodes[0].log[0].len = 3;
c.nodes[0].log[1].term = 7;
c.nodes[0].log[1].bytes[0] = 'X';
c.nodes[0].log[1].bytes[1] = 'X';
c.nodes[0].log[1].bytes[2] = 'X';
c.nodes[0].log[1].len = 3;
c.nodes[0].log_count = 2;
/* Hand-craft node 1: SAME (index2, term 7) but a DIFFERENT index1 prefix
* (term 5 but bytes "BBB"). This must be flagged: equal (i=2, term=7) yet
* the prefix through 2 is not byte-identical. */
c.nodes[1].log[0].term = 5;
c.nodes[1].log[0].bytes[0] = 'B';
c.nodes[1].log[0].bytes[1] = 'B';
c.nodes[1].log[0].bytes[2] = 'B';
c.nodes[1].log[0].len = 3;
c.nodes[1].log[1].term = 7;
c.nodes[1].log[1].bytes[0] = 'X';
c.nodes[1].log[1].bytes[1] = 'X';
c.nodes[1].log[1].bytes[2] = 'X';
c.nodes[1].log[1].len = 3;
c.nodes[1].log_count = 2;
/* Same (index2, term7) with divergent index1 ⇒ Log Matching violated. */
TEST_ASSERT_EQUAL_INT(0, raft_sim_check_log_matching(&c));
}
/*
* test_sim_3_2_log_matching_shared_idx_term_requires_identical_prefix (task 3.2)
*
* Spec R3.2 (Log Matching): "for any two nodes whose logs contain an entry at
* the same index with the same term, all entries at and before that index are
* identical (same term and value) across those two logs". This test exercises
* raft_sim_check_log_matching over hand-built clusters in BOTH directions, with
* the shared (idx, term) at a LATER index (3) and the divergence at an EARLIER
* index (1) — a distinct shape from the task-8.3 tests (which share index 2 and
* diverge at index 1) so the spec's R3.2 traceability is explicit.
*
* It is a pure checker unit test: roles/terms/bytes are set directly (no
* protocol drive) per Req 3.6 (observable per-node log state only). The checker
* pre-exists (raft-log-replication); this test confirms — and guards — that it
* correctly flags an earlier-prefix divergence and passes identical prefixes.
*/
static void test_sim_3_2_log_matching_shared_idx_term_requires_identical_prefix(void)
{
/* ---- Direction A: shared (idx=3, term=9), differ at earlier idx=1 ⇒ FAIL.
* Both nodes carry the SAME (term, value) at indices 2 and 3, and the SAME
* term at index 1, but index-1 VALUE differs. Because index 3 is shared with
* the same term, the prefix 1..3 must be identical — index 1 breaks it. */
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* node 0: idx1=(t3,"AAA") idx2=(t6,"MMM") idx3=(t9,"ZZZ") */
c.nodes[0].log[0].term = 3; memcpy(c.nodes[0].log[0].bytes, "AAA", 3); c.nodes[0].log[0].len = 3;
c.nodes[0].log[1].term = 6; memcpy(c.nodes[0].log[1].bytes, "MMM", 3); c.nodes[0].log[1].len = 3;
c.nodes[0].log[2].term = 9; memcpy(c.nodes[0].log[2].bytes, "ZZZ", 3); c.nodes[0].log[2].len = 3;
c.nodes[0].log_count = 3;
/* node 1: SAME (idx3,t9) and (idx2,t6) but idx1 term matches with a
* DIFFERENT value ("CCC") — earlier-prefix divergence under a shared tip. */
c.nodes[1].log[0].term = 3; memcpy(c.nodes[1].log[0].bytes, "CCC", 3); c.nodes[1].log[0].len = 3;
c.nodes[1].log[1].term = 6; memcpy(c.nodes[1].log[1].bytes, "MMM", 3); c.nodes[1].log[1].len = 3;
c.nodes[1].log[2].term = 9; memcpy(c.nodes[1].log[2].bytes, "ZZZ", 3); c.nodes[1].log[2].len = 3;
c.nodes[1].log_count = 3;
TEST_ASSERT_EQUAL_INT(0, raft_sim_check_log_matching(&c));
}
/* ---- Direction B: identical prefixes ⇒ PASS. node 1 is a strict prefix of
* node 0 (idx 1..2 byte-identical); node 2 holds the full identical prefix. */
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
c.nodes[0].log[0].term = 3; memcpy(c.nodes[0].log[0].bytes, "AAA", 3); c.nodes[0].log[0].len = 3;
c.nodes[0].log[1].term = 6; memcpy(c.nodes[0].log[1].bytes, "MMM", 3); c.nodes[0].log[1].len = 3;
c.nodes[0].log[2].term = 9; memcpy(c.nodes[0].log[2].bytes, "ZZZ", 3); c.nodes[0].log[2].len = 3;
c.nodes[0].log_count = 3;
/* node 1: shorter but byte-identical prefix (idx 1..2). */
c.nodes[1].log[0].term = 3; memcpy(c.nodes[1].log[0].bytes, "AAA", 3); c.nodes[1].log[0].len = 3;
c.nodes[1].log[1].term = 6; memcpy(c.nodes[1].log[1].bytes, "MMM", 3); c.nodes[1].log[1].len = 3;
c.nodes[1].log_count = 2;
/* node 2: full identical prefix (idx 1..3). */
c.nodes[2].log[0].term = 3; memcpy(c.nodes[2].log[0].bytes, "AAA", 3); c.nodes[2].log[0].len = 3;
c.nodes[2].log[1].term = 6; memcpy(c.nodes[2].log[1].bytes, "MMM", 3); c.nodes[2].log[1].len = 3;
c.nodes[2].log[2].term = 9; memcpy(c.nodes[2].log[2].bytes, "ZZZ", 3); c.nodes[2].log[2].len = 3;
c.nodes[2].log_count = 3;
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
}
}
/*
* test_sim_check_election_safety_fail_on_two_leaders (task 3.1)
*
* Election Safety helper must return FAIL (0) when two distinct nodes hold the
* LEADER role at the SAME term. Electing two leaders at one term is impossible
* through the protocol, so hand-set role/term directly on two nodes (this is a
* checker unit test over a hand-built cluster). Both node 0 and node 1 are
* LEADER at term 7 ⇒ violation.
*/
static void test_sim_check_election_safety_fail_on_two_leaders(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Two LEADERs at the SAME term ⇒ Election Safety violated. */
c.nodes[0].state.role = RAFT_LEADER;
c.nodes[0].state.term = 7;
c.nodes[1].state.role = RAFT_LEADER;
c.nodes[1].state.term = 7;
c.nodes[2].state.role = RAFT_FOLLOWER;
c.nodes[2].state.term = 7;
TEST_ASSERT_EQUAL_INT(0, raft_sim_check_election_safety(&c));
}
/*
* test_sim_check_election_safety_pass_on_single_leader (task 3.1)
*
* Election Safety helper must return PASS (1) when there is at most one LEADER
* per term. Two scenarios both hold:
* (a) one LEADER at term T, others FOLLOWER;
* (b) two LEADERs at DIFFERENT terms (a leader and a stale ex-leader that has
* not yet stepped down) — distinct terms never collide.
*/
static void test_sim_check_election_safety_pass_on_single_leader(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* (a) exactly one LEADER at term 7, the rest FOLLOWER ⇒ holds. */
c.nodes[0].state.role = RAFT_LEADER;
c.nodes[0].state.term = 7;
c.nodes[1].state.role = RAFT_FOLLOWER;
c.nodes[1].state.term = 7;
c.nodes[2].state.role = RAFT_FOLLOWER;
c.nodes[2].state.term = 7;
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_election_safety(&c));
/* (b) two LEADERs but at DIFFERENT terms ⇒ still holds (no same-term pair). */
c.nodes[1].state.role = RAFT_LEADER;
c.nodes[1].state.term = 6;
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_election_safety(&c));
}
/* ---------- Group: Simulator invariant tests (tasks 9.1-9.4) ----------
*
* These are observation-based invariant tests. They drive the COMPLETE
* machinery (raft_log_append / raft_send_append_entries /
* raft_sim_deliver_append_entries / raft_on_append_entries_reply) and assert
* Raft Log Matching (raft_sim_check_log_matching) plus the divergent-suffix
* truncation, drop/dup/reorder convergence, and chaotic-churn (bug-851)
* properties from requirements 8.1, 8.3, 8.4, 8.5, 8.6.
*
* Each test carries INTERMEDIATE assertions that would FAIL if convergence
* did NOT actually happen (e.g. follower log_count, exact entry terms,
* nextIndex back-off), so a trivially-passing run is impossible: the
* end-to-end raft_sim_check_log_matching == 1 is only reachable after the
* replicate/catch-up/truncate/term-adoption work is genuinely observed.
*/
/* Replicate the leader's single entry at the leader's CURRENT next_index for
* `peer` and route it synchronously. Returns the follower's resulting reply via
* the sim's reply path (already applied to the leader's per-peer progress). */
static void sim_replicate_one(raft_sim_cluster_t *c, int leader, int peer,
uint64_t leader_commit)
{
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_send_append_entries(&c->nodes[leader].state, peer, leader_commit));
raft_sim_deliver_append_entries(c);
}
/*
* test_sim_9_1_replication_and_partition_catch_up (task 9.1; Req 8.1, 8.3, 8.4)
*
* Leader appends 3 entries, replicates to a quorum, asserts Log Matching.
* Then isolates a follower across MORE appends, heals it, and drives catch-up:
* the leader's nextIndex for the lagging follower walks BACK (failure replies
* decrement next_index), the follower catches up entry-by-entry, and Log
* Matching holds end-to-end.
*
* Load-bearing intermediate assertions:
* - after the initial 3-entry replication, node 1 AND node 2 hold log_count 3
* (if delivery were skipped these would be 0 — the assertion is real).
* - after isolation + 2 more appends, node 2 is STILL at log_count 3 (proves
* the partition genuinely blocked replication) while leader is at 5.
* - the first post-heal AppendEntries (prev_log_index=4) is REJECTED, forcing
* next_index[2] to decrement (catch-up back-off is exercised, not bypassed).
* - node 2 converges to log_count 5 and Log Matching holds.
*/
static void test_sim_9_1_replication_and_partition_catch_up(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Elect node 0 leader. */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Append 3 entries and replicate each to BOTH followers (a quorum). */
for (int e = 0; e < 3; e++) {
unsigned char payload[2] = { (unsigned char)('A' + e), 0x00 };
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
TEST_ASSERT_EQUAL_UINT64((uint64_t)(e + 1), out_index);
sim_replicate_one(&c, 0, 1, 0);
sim_replicate_one(&c, 0, 2, 0);
}
/* Intermediate: both followers replicated all 3 (delivery actually happened). */
TEST_ASSERT_EQUAL_INT(3, c.nodes[0].log_count);
TEST_ASSERT_EQUAL_INT(3, c.nodes[1].log_count);
TEST_ASSERT_EQUAL_INT(3, c.nodes[2].log_count);
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
/* After replicating 1..3 to node 2, the leader believes next_index[2] == 4. */
TEST_ASSERT_EQUAL_UINT64(4, c.nodes[0].state.next_index[2]);
/* Isolate node 2; append 2 MORE entries and replicate only to node 1.
* Sends to node 2 are dropped (partitioned), so next_index[2] stays 4. */
raft_sim_partition(&c, 2, 1);
for (int e = 3; e < 5; e++) {
unsigned char payload[2] = { (unsigned char)('A' + e), 0x00 };
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
TEST_ASSERT_EQUAL_UINT64((uint64_t)(e + 1), out_index);
sim_replicate_one(&c, 0, 1, 0);
/* Attempt to send to node 2 as well — dropped because partitioned. */
sim_replicate_one(&c, 0, 2, 0);
}
/* Intermediate: the partition genuinely blocked node 2 (still 3, not 5). */
TEST_ASSERT_EQUAL_INT(5, c.nodes[0].log_count);
TEST_ASSERT_EQUAL_INT(5, c.nodes[1].log_count);
TEST_ASSERT_EQUAL_INT(3, c.nodes[2].log_count);
/* Simulate node 2 having LOST entries 2..3 while away (e.g. a crash that
* rolled back its tail to 1) so its real log no longer matches the leader's
* stale next_index[2] == 4. This forces the AppendEntries consistency check
* to walk next_index[2] BACK until prev_log_index matches node 2's tail. */
raft_sim_partition(&c, 2, 0);
c.nodes[2].cbs.on_truncate_from(&c.nodes[2], 2); /* drop indices 2..end */
TEST_ASSERT_EQUAL_INT(1, c.nodes[2].log_count);
uint64_t ni_before = c.nodes[0].state.next_index[2];
/* The leader still believes node 2 is at index 3 (next_index 4), past its
* real tail (1) — so the first AE prev_log_index = 3 must be rejected. */
TEST_ASSERT_TRUE(ni_before > (uint64_t)(c.nodes[2].log_count + 1));
/* One round: rejected (prev_log_index beyond node 2's log) ⇒ next_index-- . */
sim_replicate_one(&c, 0, 2, 0);
TEST_ASSERT_TRUE(c.nodes[0].state.next_index[2] < ni_before);
/* Continue driving rounds until node 2 catches up (bounded loop). Each round
* either backs off (reject) or appends one entry (accept), so it converges. */
int rounds = 0;
while (c.nodes[2].log_count < c.nodes[0].log_count && rounds < 64) {
sim_replicate_one(&c, 0, 2, 0);
rounds++;
}
/* Converged: node 2 holds the full prefix; Log Matching holds end-to-end. */
TEST_ASSERT_EQUAL_INT(5, c.nodes[2].log_count);
for (int i = 0; i < 5; i++)
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[i].term, c.nodes[2].log[i].term);
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
}
/*
* test_sim_9_2_divergent_suffix_truncation (task 9.2; Req 8.5, closes G3)
*
* Give a follower a manufactured divergent tail (same indices, DIFFERENT term),
* replicate from a higher-term leader, and assert the follower truncates the
* divergent suffix (on_truncate_from via the conflict branch) and converges to
* the leader's prefix. The follower's post-converge prefix must equal the
* leader's byte-for-byte; Log Matching holds.
*
* Load-bearing intermediate assertions:
* - pre-replication, node 1 holds the WRONG (term-2) entries at indices 1..3,
* differing from node 0's term-3 entries (so a no-op delivery would FAIL
* the final byte-equality / Log Matching check).
* - the conflict at index 1 triggers a truncation back-off, and node 1's
* final terms equal node 0's (proves the divergent suffix was deleted, not
* merely overwritten in place across a mismatching prefix).
*/
static void test_sim_9_2_divergent_suffix_truncation(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Elect node 0 leader, then advance its term to 3 (strictly higher than the
* follower's manufactured divergent term 2). raft_log_append stamps each new
* entry with s->term, so setting the term before appending makes the leader's
* entries carry term 3. next_index/match_index were initialized at
* become_leader and are term-independent, so they remain valid. */
raft_start_election(&c.nodes[0].state); /* term -> 1 */
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
c.nodes[0].state.term = 3;
uint64_t leader_term = c.nodes[0].state.term;
TEST_ASSERT_TRUE(leader_term >= 3);
unsigned char lp[3][3] = {
{ 'L', '0', 0 }, { 'L', '1', 0 }, { 'L', '2', 0 } };
for (int e = 0; e < 3; e++) {
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, lp[e], 2, &out_index));
TEST_ASSERT_EQUAL_UINT64((uint64_t)(e + 1), out_index);
}
TEST_ASSERT_EQUAL_INT(3, c.nodes[0].log_count);
for (int i = 0; i < 3; i++)
TEST_ASSERT_EQUAL_UINT64(leader_term, c.nodes[0].log[i].term);
/* Manufacture node 1's DIVERGENT tail: same indices 1..3, term 2, "X" bytes.
* Persist directly into the sim log (same indices, lower/different term). */
for (int i = 0; i < 3; i++) {
c.nodes[1].log[i].term = 2;
c.nodes[1].log[i].bytes[0] = 'X';
c.nodes[1].log[i].bytes[1] = (unsigned char)('0' + i);
c.nodes[1].log[i].len = 2;
}
c.nodes[1].log_count = 3;
/* Make node 1 a follower whose term is at most the leader's (so it accepts). */
raft_on_append_entries_request(&c.nodes[1].state, 0, leader_term,
0, 0, 0, NULL, 0, 0, 0, &(raft_append_result_t){0});
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
/* Intermediate: node 1 currently DIVERGES from node 0 at every index. */
TEST_ASSERT_TRUE(c.nodes[1].log[0].term != c.nodes[0].log[0].term);
/* Replicate from the higher-term leader. next_index[1] starts at Len(log)+1
* = 4; the leader walks back, the conflict branch truncates the divergent
* suffix, then appends the leader's entries. Drive bounded rounds. */
int rounds = 0;
while (rounds < 64) {
sim_replicate_one(&c, 0, 1, 0);
rounds++;
if (c.nodes[1].log_count == c.nodes[0].log_count) {
int all_match = 1;
for (int i = 0; i < c.nodes[0].log_count; i++)
if (c.nodes[1].log[i].term != c.nodes[0].log[i].term)
all_match = 0;
if (all_match)
break;
}
}
/* Converged: node 1's prefix now EQUALS node 0's byte-for-byte (the divergent
* term-2 suffix was truncated and replaced with the leader's term-N prefix). */
TEST_ASSERT_EQUAL_INT(3, c.nodes[1].log_count);
for (int i = 0; i < 3; i++) {
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[i].term, c.nodes[1].log[i].term);
TEST_ASSERT_EQUAL_size_t(c.nodes[0].log[i].len, c.nodes[1].log[i].len);
TEST_ASSERT_EQUAL_MEMORY(c.nodes[0].log[i].bytes, c.nodes[1].log[i].bytes,
c.nodes[0].log[i].len);
}
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
}
/*
* test_sim_9_3_drop_dup_reorder_convergence (task 9.3; Req 8.3)
*
* Drop, duplicate, and reorder AppendEntries in the sim queue and assert
* idempotent convergence: a duplicated AE is a no-op via the already-have
* branch, a dropped AE is recovered by a later round, and reordering does not
* corrupt the log. Log Matching holds after the faults clear.
*
* Load-bearing intermediate assertions:
* - after the deliberately-DROPPED round, node 1 is still missing the entry
* (proves the drop was real, not silently delivered).
* - after delivering a DUPLICATE of an already-applied AE, node 1's log_count
* does NOT grow (idempotent already-have branch).
* - final Log Matching holds and node 1 holds the full prefix.
*/
static void test_sim_9_3_drop_dup_reorder_convergence(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Append 3 entries on the leader. */
for (int e = 0; e < 3; e++) {
unsigned char payload[2] = { (unsigned char)('a' + e), 0x00 };
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
TEST_ASSERT_EQUAL_UINT64((uint64_t)(e + 1), out_index);
}
TEST_ASSERT_EQUAL_INT(3, c.nodes[0].log_count);
/* DROP: buffer an AE for entry 1 to node 1, then clear the queue WITHOUT
* delivering (simulate a dropped message). */
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
TEST_ASSERT_EQUAL_INT(1, c.ae_pending_count);
c.ae_pending_count = 0; /* DROP the buffered AE */
/* Intermediate: the drop was real — node 1 received nothing. */
TEST_ASSERT_EQUAL_INT(0, c.nodes[1].log_count);
/* Recover the dropped entry via a fresh round. */
sim_replicate_one(&c, 0, 1, 0);
TEST_ASSERT_EQUAL_INT(1, c.nodes[1].log_count);
/* DUPLICATE: rewind the leader's belief so it re-sends the ALREADY-PRESENT
* entry 1 (next_index[1] = 1 ⇒ prev_log_index = 0, entry index 1). Buffer it
* twice and deliver both; both deliveries hit the already-have branch (the
* incoming entry matches node 1's local entry 1), so the log must NOT grow. */
c.nodes[0].state.next_index[1] = 1;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
TEST_ASSERT_TRUE(c.ae_pending_count >= 1);
/* Manually duplicate the just-buffered AE (re-sends entry 1). */
if (c.ae_pending_count < RAFT_SIM_MAX_AE) {
c.ae_pending[c.ae_pending_count] = c.ae_pending[c.ae_pending_count - 1];
c.ae_pending_count++;
}
raft_sim_deliver_append_entries(&c);
/* Idempotent: the duplicated already-have AE did not grow the log. */
TEST_ASSERT_EQUAL_INT(1, c.nodes[1].log_count);
/* REORDER (genuine out-of-order delivery): node 1 currently holds only
* entry 1. Buffer the AE for entry 2 (prev_log_index=1) and the AE for
* entry 3 (prev_log_index=2), then SWAP them so entry 3 is delivered FIRST.
* The entry-3 AE references a prev (index 2) node 1 does not yet have, so
* logOk fails (prev_log_index > log_len) ⇒ it is rejected without creating a
* gap; the entry-2 AE then appends normally. This proves reordered AEs cannot
* insert out-of-order (Req 8.3 reorder). */
c.ae_pending_count = 0; /* start the reorder round with a clean queue */
c.nodes[0].state.next_index[1] = 2; /* prev=1 → entry 2 */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_send_append_entries(&c.nodes[0].state, 1, 0));
c.nodes[0].state.next_index[1] = 3; /* prev=2 → entry 3 */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_send_append_entries(&c.nodes[0].state, 1, 0));
TEST_ASSERT_EQUAL_INT(2, c.ae_pending_count);
/* Swap so the entry-3 AE is delivered before the entry-2 AE. */
{
raft_sim_ae_t tmp = c.ae_pending[0];
c.ae_pending[0] = c.ae_pending[1];
c.ae_pending[1] = tmp;
}
raft_sim_deliver_append_entries(&c);
/* The out-of-order entry-3 AE was rejected (no gap); only entry 2 appended. */
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count);
/* Now deliver remaining rounds (entry 3) under continued churn; bounded loop
* drives to convergence regardless of transient reject/back-off. */
int rounds = 0;
while (c.nodes[1].log_count < c.nodes[0].log_count && rounds < 64) {
sim_replicate_one(&c, 0, 1, 0);
rounds++;
}
/* Converged despite drop + dup + back-off; Log Matching holds. */
TEST_ASSERT_EQUAL_INT(3, c.nodes[1].log_count);
for (int i = 0; i < 3; i++)
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[i].term, c.nodes[1].log[i].term);
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
}
/*
* test_sim_9_4_chaotic_churn_g5_regression (task 9.4; Req 8.6 — bug-851)
*
* A stale-term node (term lagging) rejoins under a higher-term leader; assert it
* adopts the higher term from the AE/heartbeat path (UpdateTerm on AppendEntries,
* G5, task 5/6) and does NOT re-elect at a reused term. Log Matching holds across
* the churn. THIS is the bug-851 regression: silent committed-prefix divergence
* from two leaders at a reused term. It relies on UpdateTerm-on-AE and the
* SEND-time leader term in delivery (task 8.2).
*
* Load-bearing intermediate assertions:
* - pre-rejoin, the stale node's term is STRICTLY LESS than the leader's
* (so a no-op heartbeat would leave them divergent and the assertion real).
* - after the FIRST heartbeat, the rejoining node's term EQUALS the leader's
* (term adopted from the AE path) AND its role is FOLLOWER (stepped down).
* - no SECOND leader appears at the reused term (leader_count unchanged after
* the stale node is denied a re-election at its own old term).
*/
static void test_sim_9_4_chaotic_churn_g5_regression(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Elect node 0 leader and advance its term to model accumulated leadership
* churn — node 0 now leads at a high term while a partitioned node 2 lags. */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
c.nodes[0].state.term = 5;
uint64_t leader_term = c.nodes[0].state.term;
TEST_ASSERT_TRUE(leader_term >= 3);
/* Leader has a couple of entries. */
for (int e = 0; e < 2; e++) {
unsigned char payload[2] = { (unsigned char)('K' + e), 0x00 };
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
}
/* Node 2 is a STALE-TERM rejoiner: force its term low (1) and role follower
* (as if it was partitioned away during the churn and never advanced). */
c.nodes[2].state.term = 1;
c.nodes[2].state.role = RAFT_FOLLOWER;
c.nodes[2].state.voted_for = -1;
/* Intermediate: stale node's term is strictly below the leader's. */
TEST_ASSERT_TRUE(c.nodes[2].state.term < leader_term);
int leader_count_before = c.leader_count;
/* Send a HEARTBEAT (n_entries handled by send AE: next_index[2] = Len(log)+1
* so prev_log_index points past node 2's empty log; this is the AE/heartbeat
* path). UpdateTerm must fire on this AE: node 2 adopts leader_term. */
sim_replicate_one(&c, 0, 2, 0);
/* G5: node 2 adopted the higher term from the AppendEntries/heartbeat path
* and stepped down to follower — NOT only from a vote RPC. */
TEST_ASSERT_EQUAL_UINT64(leader_term, c.nodes[2].state.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[2].state.role);
/* UpdateTerm cleared the stale node's vote state: voted_for reset to -1 so it
* cannot double-vote in the new term, and any vote it casts now belongs to
* leader_term (>= the term it would have reused). This is the concrete state
* that prevents the reused-term double-leader of bug-851. */
TEST_ASSERT_EQUAL_INT(-1, c.nodes[2].state.voted_for);
/* No second leader was created during the rejoin/heartbeat delivery. */
TEST_ASSERT_EQUAL_INT(leader_count_before, c.leader_count);
/* The original leader is still the sole leader. */
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Drive catch-up so node 2 converges; Log Matching holds across the churn. */
int rounds = 0;
while (c.nodes[2].log_count < c.nodes[0].log_count && rounds < 64) {
sim_replicate_one(&c, 0, 2, 0);
rounds++;
}
TEST_ASSERT_EQUAL_INT(c.nodes[0].log_count, c.nodes[2].log_count);
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
/* Active check (after convergence, so it cannot disturb catch-up): if the
* rejoined node now starts an election it campaigns at a term STRICTLY ABOVE
* leader_term — never the stale term it lagged at — so it can never resurrect
* a committed-prefix divergence by reusing an old term (bug-851). */
raft_start_election(&c.nodes[2].state);
TEST_ASSERT_TRUE(c.nodes[2].state.term > leader_term);
}
/*
* test_sim_9_5_node_restart_resumes_replication (task 9.x; Req 8.3 — node restart
* while replication proceeds)
*
* A follower restarts MID-replication: its volatile consensus state is lost but
* its durable log (the in-memory log[], modelling the WAL) and persisted
* (term, voted_for) survive. Replication then resumes and converges with Log
* Matching intact. This closes the "node restart" sub-clause of Req 8.3 that the
* drop/dup/reorder test (9.3) did not chain.
*
* Load-bearing assertions:
* - immediately after restart the follower STILL holds its partial log
* (log_count preserved, NOT wiped to 0) and is back to FOLLOWER at the same
* durable term — proving the restart cleared volatile state while keeping the
* durable log;
* - replication resumes from the partial prefix and the follower converges to
* the leader's full log with Log Matching holding.
*/
static void test_sim_9_5_node_restart_resumes_replication(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Elect node 0 leader. */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Leader appends 3 entries. */
for (int e = 0; e < 3; e++) {
unsigned char payload[2] = { (unsigned char)('p' + e), 0x00 };
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
}
TEST_ASSERT_EQUAL_INT(3, c.nodes[0].log_count);
/* Replicate PARTIALLY to node 1 (entries 1 and 2), leaving entry 3 pending. */
sim_replicate_one(&c, 0, 1, 0);
sim_replicate_one(&c, 0, 1, 0);
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count);
uint64_t durable_term = c.nodes[1].state.term;
/* RESTART node 1 mid-replication. */
TEST_ASSERT_EQUAL_INT(0, raft_sim_restart_node(&c, 1));
/* Durable log survived the restart (NOT wiped); volatile state reset to a
* fresh FOLLOWER at the restored term and log length. */
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
TEST_ASSERT_EQUAL_UINT64(durable_term, c.nodes[1].state.term);
TEST_ASSERT_EQUAL_UINT64(2, c.nodes[1].state.log_len); /* restored from log_count */
/* Resume replication; bounded loop drives the rebooted follower to converge. */
int rounds = 0;
while (c.nodes[1].log_count < c.nodes[0].log_count && rounds < 64) {
sim_replicate_one(&c, 0, 1, 0);
rounds++;
}
/* Converged across the restart; Log Matching holds. */
TEST_ASSERT_EQUAL_INT(3, c.nodes[1].log_count);
for (int i = 0; i < 3; i++)
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[i].term, c.nodes[1].log[i].term);
TEST_ASSERT_EQUAL_INT(1, raft_sim_check_log_matching(&c));
}
/* ---------- Group: Simulator commit-safety invariant tests (tasks 7.1-7.4) ----------
*
* These observation-based invariant tests drive the COMMIT-SAFETY machinery
* through the simulator: raft_advance_commit (via raft_sim_advance_commit, the
* Figure-8 current-term rule), the on_commit_advanced trigger mirror (per-node
* commit_index / commit_advanced_count), Leader Completeness across forced
* leader changes, and the (lastLogTerm, lastLogIndex) lexicographic freshness
* gate on both the binding-vote and PreVote paths.
*
* Each test carries a LOAD-BEARING intermediate assertion that would fail if the
* corresponding behavior regressed (a Figure-8 hold that did not actually hold,
* a strict advance that did not fire on_commit_advanced exactly once, a freshness
* outcome that flipped, or a future leader missing a committed entry), so a
* trivially-passing run is impossible.
*/
/* Stamp a leader's sim log entry directly: set log[index-1].term and grow
* log_count / state.log_len so on_get_entry_term (sim_get_entry_term, which reads
* node->log) and the agreeIndexes scan (bounded by state.log_len) both see it.
* Used to construct a controlled Figure-8 prefix without driving replication. */
static void sim_set_log_entry(raft_sim_node_t *node, uint64_t index, uint64_t term)
{
TEST_ASSERT_TRUE(index >= 1 && index <= RAFT_SIM_MAX_LOG);
raft_sim_entry_t *slot = &node->log[index - 1];
slot->term = term;
slot->bytes[0] = (unsigned char)('0' + (index % 10));
slot->len = 1;
if ((int)index > node->log_count)
node->log_count = (int)index;
if (index > node->state.log_len)
node->state.log_len = index;
}
/*
* test_sim_7_1_figure8_commit_refusal (task 7.1; Req 6.2, 6.3)
*
* Figure-8 commit refusal (G4) driven through the simulator's
* raft_sim_advance_commit. A PREVIOUS-term (T1) entry at index 2 is replicated to
* a genuine quorum, but NO current-term (T3) entry above it has quorum yet:
* raft_advance_commit must HOLD commit_index at 0 (the current-term rule refuses
* to commit a prior-term entry by replica-count alone). THEN a CURRENT-term entry
* at index 3 reaches quorum: raft_advance_commit must advance commit_index to 3,
* indirectly committing the prior-term entry at index 2 as well.
*
* Load-bearing assertions:
* - the held phase has a REAL quorum on the prior-term entry (match_index[1]=2
* in a 3-node cluster ⇒ {self, peer1} agree through 2), so ONLY the
* current-term gate can be holding commit_index at 0. If the gate regressed
* to term-blind commit, the held assertion (commit_index == 0) would fail.
* - the released phase asserts commit_index advances over BOTH indices (3),
* and on_commit_advanced fired exactly once on that strict advance.
*/
static void test_sim_7_1_figure8_commit_refusal(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
raft_sim_node_t *L = &c.nodes[0];
/* Model a leader at term 3 carrying a committed-prefix shape from a churned
* past: entries 1,2 are PREVIOUS-term (T1) entries, replicated to a quorum,
* but the leader's current term is 3 with no T3 entry above them yet. */
L->state.role = RAFT_LEADER;
L->state.term = 3;
sim_set_log_entry(L, 1, 1); /* prior-term entry */
sim_set_log_entry(L, 2, 1); /* prior-term entry — the Figure-8 entry */
TEST_ASSERT_EQUAL_UINT64(2, L->state.log_len);
/* Genuine quorum on the prior-term entry: in a 3-node cluster {self} U {peer1}
* agree through index 2 (peer 1 match_index = 2). Max(agreeIndexes) = 2, but
* log[2].term (1) != currentTerm (3) ⇒ the current-term rule must HOLD. */
L->state.match_index[1] = 2;
L->state.match_index[2] = 0;
raft_sim_advance_commit(&c, 0);
/* HELD: a real quorum exists on index 2, so only the current-term gate can be
* keeping commit_index at 0. on_commit_advanced must NOT have fired. */
TEST_ASSERT_EQUAL_UINT64(0, L->state.commit_index);
TEST_ASSERT_EQUAL_UINT64(0, L->commit_index);
TEST_ASSERT_EQUAL_INT(0, L->commit_advanced_count);
/* Now a CURRENT-term (T3) entry lands at index 3 and reaches quorum. */
sim_set_log_entry(L, 3, 3);
TEST_ASSERT_EQUAL_UINT64(3, L->state.log_len);
L->state.match_index[1] = 3; /* {self, peer1} now agree through 3 */
raft_sim_advance_commit(&c, 0);
/* RELEASED: committing the current-term entry at 3 indirectly commits the
* prior-term entries below it — commit_index advances over BOTH (to 3) and
* on_commit_advanced fired exactly once on the strict advance. */
TEST_ASSERT_EQUAL_UINT64(3, L->state.commit_index);
TEST_ASSERT_EQUAL_UINT64(3, L->commit_index);
TEST_ASSERT_EQUAL_INT(1, L->commit_advanced_count);
}
/*
* test_sim_7_2_leader_completeness_across_changes (task 7.2; Req 6.4)
*
* Leader Completeness across forced leader changes: an entry committed under one
* leader must be present in the log of EVERY node that subsequently becomes
* leader. Node 0 leads, replicates entries to a quorum (nodes 0 and 1) and
* commits them. Node 0 then "fails" (partitioned away). The freshness gate plus
* a real election force a NEW leader — and because only nodes holding the
* committed prefix can win a quorum, the new leader necessarily holds it.
*
* Load-bearing assertions:
* - node 2 (which never received the committed entries) is DENIED the vote by
* the freshness gate (its last-log tuple is staler than the up-to-date
* voters), so it cannot become a leader missing the committed prefix — if the
* freshness gate regressed, node 2 could win and the completeness assertion
* would be violated.
* - the node that DOES become the new leader holds every committed entry
* (term-for-term equal to the original committing leader's prefix).
*/
static void test_sim_7_2_leader_completeness_across_changes(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Elect node 0 leader at term 1. */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Replicate 2 entries to the quorum {node0, node1}; node 2 is isolated so it
* NEVER receives them (it will become the stale, completeness-violating
* candidate). */
raft_sim_partition(&c, 2, 1);
for (int e = 0; e < 2; e++) {
unsigned char payload[2] = { (unsigned char)('C' + e), 0x00 };
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, payload, sizeof(payload), &out_index));
sim_replicate_one(&c, 0, 1, 0);
}
/* Commit the current-term entries (quorum {self, peer1} through index 2). */
raft_sim_advance_commit(&c, 0);
uint64_t committed = c.nodes[0].state.commit_index;
TEST_ASSERT_EQUAL_UINT64(2, committed);
TEST_ASSERT_EQUAL_INT(2, c.nodes[1].log_count); /* the committed prefix is on node 1 */
/* Snapshot the committing leader's committed prefix for the completeness check. */
raft_sim_entry_t committed_prefix[2];
for (uint64_t i = 1; i <= committed; i++)
committed_prefix[i - 1] = c.nodes[0].log[i - 1];
/* FORCE A LEADER CHANGE: node 0 dies (partitioned away). Heal node 2 so it is
* back on the network — it is the up-to-date node 1's only peer now. */
raft_sim_partition(&c, 0, 1);
raft_sim_partition(&c, 2, 0);
/* The stale node 2 (empty log) campaigns first. Its RequestVote advertises an
* empty last-log tuple; the up-to-date node 1 (last-log term 1, index 2) must
* DENY it on the (lastLogTerm, lastLogIndex) freshness gate. */
raft_start_election(&c.nodes[2].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_NOT_EQUAL(RAFT_LEADER, c.nodes[2].state.role);
/* Load-bearing: node 1 refused node 2 (no quorum for the stale candidate). */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
/* Now the up-to-date node 1 campaigns. Node 2 (no committed prefix, staler
* tuple) GRANTS the fresher candidate, so node 1 — which holds the committed
* prefix — wins and becomes the new leader. */
raft_start_election(&c.nodes[1].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[1].state.role);
/* LEADER COMPLETENESS: the node that became leader holds EVERY committed
* entry, term-for-term, from the original committing leader. */
TEST_ASSERT_TRUE(c.nodes[1].log_count >= (int)committed);
for (uint64_t i = 1; i <= committed; i++) {
TEST_ASSERT_EQUAL_UINT64(committed_prefix[i - 1].term,
c.nodes[1].log[i - 1].term);
TEST_ASSERT_EQUAL_size_t(committed_prefix[i - 1].len,
c.nodes[1].log[i - 1].len);
TEST_ASSERT_EQUAL_MEMORY(committed_prefix[i - 1].bytes,
c.nodes[1].log[i - 1].bytes,
committed_prefix[i - 1].len);
}
}
/*
* test_sim_7_3_freshness_gate_lexicographic (task 7.3; Req 6.5)
*
* Exercise the (last_log_term, last_log_index) lexicographic freshness gate on
* BOTH the binding-vote path (raft_on_vote_request) and the side-effect-free
* PreVote path (raft_on_pre_vote_request) with mismatched tuples:
* - stale-term-but-HIGHER-index → DENIED (term dominates index)
* - fresher-term-but-LOWER-index → GRANTED (term dominates index)
* - tie-term decided by index → GRANTED iff cand index >= voter index
* The voter holds (last_log_term 5, last_log_index 4).
*
* Load-bearing: the stale-term-higher-index DENY and the fresher-term-lower-index
* GRANT are the exact pair that a pure LSN comparison (the pre-spec G6 bug) would
* get BACKWARDS — so these assertions fail if the gate regressed to index-only.
*/
static void test_sim_7_3_freshness_gate_lexicographic(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Voter is node 1 at term 5 with last-log (term 5, index 4). The sim's
* on_get_entry_term reads node->log, so stamp index 4 with term 5. */
raft_sim_node_t *V = &c.nodes[1];
V->state.term = 5;
V->state.role = RAFT_FOLLOWER;
V->state.voted_for = -1;
sim_set_log_entry(V, 1, 5);
sim_set_log_entry(V, 2, 5);
sim_set_log_entry(V, 3, 5);
sim_set_log_entry(V, 4, 5);
TEST_ASSERT_EQUAL_UINT64(4, V->state.log_len);
uint64_t reply_term = 0;
/* --- Binding-vote path (raft_on_vote_request) --- */
/* Stale last-log TERM (4) but HIGHER index (10): term dominates ⇒ DENY. */
int rc = raft_on_vote_request(&V->state, 0, 5, /*cand_lt=*/4, /*cand_li=*/10,
&reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_INT(-1, V->state.voted_for); /* not granted */
/* Fresher last-log TERM (6) but LOWER index (2): term dominates ⇒ GRANT. */
rc = raft_on_vote_request(&V->state, 0, 6, /*cand_lt=*/6, /*cand_li=*/2,
&reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
/* Re-init the voter for the tie-term cases (fresh, unvoted, same log). */
V->state.term = 5;
V->state.role = RAFT_FOLLOWER;
V->state.voted_for = -1;
V->state.voted_for_term = 0;
/* Tie TERM (5), index EQUAL to voter's (4): index >= voter ⇒ GRANT. */
rc = raft_on_vote_request(&V->state, 0, 5, /*cand_lt=*/5, /*cand_li=*/4,
&reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
V->state.term = 5;
V->state.role = RAFT_FOLLOWER;
V->state.voted_for = -1;
V->state.voted_for_term = 0;
/* Tie TERM (5), index LOWER than voter's (3 < 4) ⇒ DENY. */
rc = raft_on_vote_request(&V->state, 0, 5, /*cand_lt=*/5, /*cand_li=*/3,
&reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
/* --- PreVote path (raft_on_pre_vote_request) — same lexicographic predicate,
* side-effect free. Reset the voter to a clean (term 5, last-log 5@4). --- */
V->state.term = 5;
V->state.role = RAFT_FOLLOWER;
V->state.voted_for = -1;
V->state.voted_for_term = 0;
/* Stale term (4), higher index (10) ⇒ DENY (and no mutation). */
TEST_ASSERT_EQUAL_INT(RAFT_DENY,
raft_on_pre_vote_request(&V->state, 0, 5, /*cand_lt=*/4, /*cand_li=*/10));
/* Fresher term (6), lower index (2) ⇒ GRANT. */
TEST_ASSERT_EQUAL_INT(RAFT_GRANT,
raft_on_pre_vote_request(&V->state, 0, 6, /*cand_lt=*/6, /*cand_li=*/2));
/* Tie term (5), equal index (4) ⇒ GRANT; tie term, lower index (3) ⇒ DENY. */
TEST_ASSERT_EQUAL_INT(RAFT_GRANT,
raft_on_pre_vote_request(&V->state, 0, 5, /*cand_lt=*/5, /*cand_li=*/4));
TEST_ASSERT_EQUAL_INT(RAFT_DENY,
raft_on_pre_vote_request(&V->state, 0, 5, /*cand_lt=*/5, /*cand_li=*/3));
/* PreVote was side-effect-free across all four probes. */
TEST_ASSERT_EQUAL_UINT64(5, V->state.term);
TEST_ASSERT_EQUAL_INT(-1, V->state.voted_for);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, V->state.role);
}
/*
* test_sim_7_4_on_commit_advanced_trigger_semantics (task 7.4; Req 6.6, 2.2, 2.3)
*
* Trigger semantics for on_commit_advanced via the per-node commit_advanced_count
* mirror. The callback must fire EXACTLY ONCE on a strict advance and must NOT
* fire while the Figure-8 current-term rule HOLDS the commit index, nor when the
* node is not the leader. The fire-count must equal the number of strict advances.
*
* Phases (one leader node, deterministic match_index/term control):
* 1. HELD: prior-term entries with quorum, no current-term entry above ⇒ NO fire.
* 2. ADVANCE #1: a current-term entry reaches quorum ⇒ EXACTLY ONE fire.
* 3. NO-OP RE-DRIVE: same state re-driven ⇒ no additional fire (not a strict advance).
* 4. ADVANCE #2: another current-term entry reaches quorum ⇒ ONE more fire (count 2).
* 5. NOT LEADER: as a follower, raft_advance_commit is -EPERM ⇒ no fire.
*
* Load-bearing: the held phase (real quorum, no fire) is distinguished from the
* advance phase (one fire), and the no-op re-drive proves the count tracks STRICT
* advances — not raft_advance_commit invocations. If the held gate or the
* strict-advance firing regressed, the per-phase count assertions fail.
*/
static void test_sim_7_4_on_commit_advanced_trigger_semantics(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
raft_sim_node_t *L = &c.nodes[0];
L->state.role = RAFT_LEADER;
L->state.term = 4;
/* Phase 1 — HELD: two prior-term (T2) entries replicated to a quorum, no
* current-term entry above them. */
sim_set_log_entry(L, 1, 2);
sim_set_log_entry(L, 2, 2);
L->state.match_index[1] = 2; /* genuine quorum {self, peer1} through 2 */
L->state.match_index[2] = 0;
raft_sim_advance_commit(&c, 0);
TEST_ASSERT_EQUAL_UINT64(0, L->state.commit_index);
TEST_ASSERT_EQUAL_INT(0, L->commit_advanced_count); /* held — no fire */
/* Phase 2 — ADVANCE #1: a current-term (T4) entry at index 3 reaches quorum. */
sim_set_log_entry(L, 3, 4);
L->state.match_index[1] = 3;
raft_sim_advance_commit(&c, 0);
TEST_ASSERT_EQUAL_UINT64(3, L->state.commit_index);
TEST_ASSERT_EQUAL_INT(1, L->commit_advanced_count); /* exactly one fire */
/* Phase 3 — NO-OP RE-DRIVE: nothing changed; Max(agreeIndexes) is unchanged so
* the commit index does not strictly advance ⇒ NO additional fire. */
raft_sim_advance_commit(&c, 0);
TEST_ASSERT_EQUAL_UINT64(3, L->state.commit_index);
TEST_ASSERT_EQUAL_INT(1, L->commit_advanced_count); /* still one — count tracks advances */
/* Phase 4 — ADVANCE #2: another current-term (T4) entry at index 4 reaches
* quorum ⇒ ONE more strict advance (count 2). */
sim_set_log_entry(L, 4, 4);
L->state.match_index[1] = 4;
raft_sim_advance_commit(&c, 0);
TEST_ASSERT_EQUAL_UINT64(4, L->state.commit_index);
TEST_ASSERT_EQUAL_INT(2, L->commit_advanced_count); /* fire count == strict advances */
/* Phase 5 — NOT LEADER: a follower's raft_advance_commit is -EPERM; even with a
* quorum that would otherwise commit a current-term entry at 5, NO fire occurs
* and commit_index is untouched. */
sim_set_log_entry(L, 5, 4);
L->state.match_index[1] = 5;
L->state.role = RAFT_FOLLOWER;
raft_sim_advance_commit(&c, 0);
TEST_ASSERT_EQUAL_UINT64(4, L->state.commit_index); /* unchanged off-leader */
TEST_ASSERT_EQUAL_INT(2, L->commit_advanced_count); /* no fire off-leader */
}
/*
* test_sim_conformance_init_append_readback (raft-sim-conformance task 1.2; Req 1.1, 1.2, 2.1)
*
* Observable completion for the conformance init + in-memory adapter callbacks:
* - raft_sim_conformance_init(&c, n, timeout, seed) wires the six conformance
* adapter callbacks (log persist/get-term/truncate + conformance commit +
* send-to-bag) and seeds the deterministic PRNG from `seed`.
* - Drive node 0 to LEADER through the published election entry points
* (start_election on node 0, deliver the pending vote requests so peers grant).
* - raft_log_append a known value on the leader; the entry must be readable back
* two ways: directly via node0.log[0] (term + decoded value) AND through the
* wired on_get_entry_term callback (returns the entry's term, not 0).
* - Determinism smoke: two conformance inits with the SAME seed leave c->prng in
* the same state (R1.2: every nondeterministic choice flows from the seed).
*/
static void test_sim_conformance_init_append_readback(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
/* Single-node conformance cluster: raft_start_election short-circuits node 0
* straight to LEADER (no peers to canvass), so the leader is forced without
* depending on bag delivery (task 2.1). The PRNG seed is still asserted. */
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&c, 1, 2000, 0xC0FFEEULL));
/* PRNG seeded from the run key. */
TEST_ASSERT_EQUAL_UINT64(0xC0FFEEULL, c.seed);
/* Node 0 starts as a follower, no leader yet. */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[0].state.role);
TEST_ASSERT_EQUAL_INT(0, c.leader_count);
/* Force node 0 to LEADER via the published election entry point. */
raft_start_election(&c.nodes[0].state);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
/* Append a known client value on the leader. The first 8 bytes decode to the
* applied value; the conformance adapter stores it in node0.log[0]. */
uint64_t value = 0x1122334455667788ULL;
uint64_t out_index = 0;
int rc = raft_log_append(&c.nodes[0].state, &value, sizeof(value), &out_index);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_UINT64(1, out_index);
/* Read back directly via the in-memory log: index 1 -> log[0]. */
raft_sim_node_t *L = &c.nodes[0];
TEST_ASSERT_EQUAL_INT(1, L->log_count);
TEST_ASSERT_EQUAL_UINT64(L->state.term, L->log[0].term);
uint64_t decoded = 0;
memcpy(&decoded, L->log[0].bytes, sizeof(decoded));
TEST_ASSERT_EQUAL_UINT64(value, decoded);
/* Read back the entry's term through the wired on_get_entry_term callback. */
uint64_t got_term = L->cbs.on_get_entry_term(L, 1);
TEST_ASSERT_EQUAL_UINT64(L->state.term, got_term);
TEST_ASSERT_NOT_EQUAL(0, got_term);
/* Determinism smoke: same seed -> identical PRNG state after init. */
raft_sim_cluster_t c2;
memset(&c2, 0, sizeof(c2));
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&c2, 1, 2000, 0xC0FFEEULL));
TEST_ASSERT_EQUAL_UINT64(c.prng, c2.prng); /* same seed -> same start state */
/* The seeded generator advances identically for the same seed: the first few
* draws match, and the post-draw state stays in lockstep (R1.2 determinism). */
raft_sim_cluster_t g1, g2;
memset(&g1, 0, sizeof(g1));
memset(&g2, 0, sizeof(g2));
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&g1, 1, 2000, 0x5EEDULL));
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&g2, 1, 2000, 0x5EEDULL));
for (int k = 0; k < 4; k++)
TEST_ASSERT_EQUAL_UINT64(raft_sim_prng_next(&g1), raft_sim_prng_next(&g2));
TEST_ASSERT_EQUAL_UINT64(g1.prng, g2.prng);
}
/*
* test_sim_conformance_bag_deliver_routes_rpcs (raft-sim-conformance task 2.1; Req 2.2, 2.4)
*
* Observable completion for raft_sim_bag_push / raft_sim_bag_deliver(i):
* - A pushed VOTE_REQ delivered by index invokes raft_on_vote_request on dst and
* pushes a VOTE_RSP back onto the bag (src/dst swapped, granted=result). The
* request is consumed (swap-with-last removal); the reply is the surviving member.
* - A pushed AE_REQ delivered by index invokes raft_on_append_entries_request on
* dst (replicating the single entry) and pushes an AE_RSP back (src/dst swapped,
* success=result). The dst's in-memory log grows (handler ran).
* - A request to a PARTITIONED dst is NOT delivered: no reply is pushed, the dst
* state is unchanged, and the request stays in the bag (mirrors a dropped edge).
*/
static void test_sim_conformance_bag_deliver_routes_rpcs(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
/* 3-node conformance cluster; force node 0 to LEADER so it owns a real log
* tail and per-peer progress for a genuine AppendEntries. */
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&c, 3, 2000, 0xBA61ULL));
raft_start_election(&c.nodes[0].state); /* sends vote reqs to the bag */
/* Drain the election vote traffic via direct delivery so node 0 wins, leaving
* the bag empty for the targeted assertions below. */
for (int g = 0; g < 64 && c.bag_count > 0; g++)
raft_sim_bag_deliver(&c, 0); /* always deliver head */
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* ---- VOTE_REQ routing ---- */
c.bag_count = 0;
raft_sim_msg_t vreq;
memset(&vreq, 0, sizeof(vreq));
vreq.kind = RAFT_SIM_MSG_VOTE_REQ;
vreq.src = 1; /* candidate */
vreq.dst = 2; /* voter */
vreq.term = c.nodes[1].state.term + 5; /* fresh higher term -> grant */
vreq.last_log_term = 0; /* empty-log candidate vs empty voter */
vreq.last_log_index = 0;
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&c, &vreq));
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_deliver(&c, 0));
/* Request consumed, exactly one VOTE_RSP now in the bag, src/dst swapped. */
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_VOTE_RSP, c.bag[0].kind);
TEST_ASSERT_EQUAL_INT(2, c.bag[0].src); /* voter replies */
TEST_ASSERT_EQUAL_INT(1, c.bag[0].dst); /* back to candidate */
TEST_ASSERT_EQUAL_INT(1, c.bag[0].granted);
/* Handler ran on dst: node 2 adopted the higher term and recorded the vote. */
TEST_ASSERT_EQUAL_UINT64(vreq.term, c.nodes[2].state.term);
TEST_ASSERT_EQUAL_INT(1, c.nodes[2].state.voted_for);
/* ---- AE_REQ routing ---- */
c.bag_count = 0;
/* Leader appends one client value, then sends AppendEntries to peer 1 — the
* conformance send callback pushes a real AE_REQ onto the bag. */
uint64_t value = 0xABCDEF12u;
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, &value, sizeof(value), &out_index));
TEST_ASSERT_EQUAL_UINT64(1, out_index);
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_send_append_entries(&c.nodes[0].state, 1, /*leader_commit=*/0));
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_AE_REQ, c.bag[0].kind);
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_deliver(&c, 0));
/* AE request consumed, one AE_RSP remains, src/dst swapped, success. */
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_AE_RSP, c.bag[0].kind);
TEST_ASSERT_EQUAL_INT(1, c.bag[0].src); /* follower replies */
TEST_ASSERT_EQUAL_INT(0, c.bag[0].dst); /* back to leader */
TEST_ASSERT_EQUAL_INT(1, c.bag[0].success);
/* Handler ran on dst: node 1 replicated the single entry. */
TEST_ASSERT_EQUAL_INT(1, c.nodes[1].log_count);
TEST_ASSERT_EQUAL_UINT64(c.nodes[0].log[0].term, c.nodes[1].log[0].term);
/* ---- partitioned dst is NOT delivered ---- */
c.bag_count = 0;
raft_sim_partition(&c, 2, 1); /* isolate the voter */
int term2_before = (int)c.nodes[2].state.term;
int votedfor2_before = c.nodes[2].state.voted_for;
raft_sim_msg_t vreq2;
memset(&vreq2, 0, sizeof(vreq2));
vreq2.kind = RAFT_SIM_MSG_VOTE_REQ;
vreq2.src = 1;
vreq2.dst = 2; /* partitioned */
vreq2.term = c.nodes[2].state.term + 9;
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&c, &vreq2));
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
/* Delivery to a partitioned endpoint is a no-op: distinct non-zero status,
* the request stays in the bag, no reply pushed, dst state unchanged. */
TEST_ASSERT_NOT_EQUAL(0, raft_sim_bag_deliver(&c, 0));
TEST_ASSERT_EQUAL_INT(1, c.bag_count); /* request not consumed */
TEST_ASSERT_EQUAL_INT(RAFT_SIM_MSG_VOTE_REQ, c.bag[0].kind); /* still the request */
TEST_ASSERT_EQUAL_INT(term2_before, (int)c.nodes[2].state.term);
TEST_ASSERT_EQUAL_INT(votedfor2_before, c.nodes[2].state.voted_for);
}
/* ----- task 2.2: Drop / Duplicate / Reorder mutate ONLY the bag ----- */
/* Observable per-node snapshot for the R2.5 "node state UNCHANGED" assertion.
* Captures exactly the non-message vars raft.tla:443/448 keep UNCHANGED across
* DuplicateMessage/DropMessage: serverVars (term, role, votedFor) + logVars
* (the log entries and length). memcmp-comparable (memset to zero first). */
typedef struct {
uint64_t term;
raft_role_t role;
int voted_for;
int log_count;
uint64_t commit_index;
raft_sim_entry_t log[RAFT_SIM_MAX_LOG];
} sim_node_state_snapshot_t;
static void sim_snapshot_node(const raft_sim_node_t *node,
sim_node_state_snapshot_t *out)
{
memset(out, 0, sizeof(*out));
out->term = node->state.term;
out->role = node->state.role;
out->voted_for = node->state.voted_for;
out->log_count = node->log_count;
out->commit_index = node->commit_index;
for (int i = 0; i < node->log_count && i < RAFT_SIM_MAX_LOG; i++)
out->log[i] = node->log[i];
}
/*
* test_sim_2_2_drop_duplicate_preserve_node_state (task 2.2; Req 2.5)
*
* R2.5 / raft.tla:443/448: DropMessage and DuplicateMessage change bag
* multiplicity ONLY — every node's (term, role, votedFor, log) is byte-identical
* before and after. The bag operations never touch any node:
* - raft_sim_bag_drop(c, i): bag_count -= 1, node snapshots identical.
* - raft_sim_bag_duplicate(c, j): bag_count += 1, the duplicate's content equals
* the original, node snapshots still identical.
* Bounds are fail-loud (return < 0) and leave the bag untouched.
*/
static void test_sim_2_2_drop_duplicate_preserve_node_state(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
/* 3-node conformance cluster; give nodes some real, distinct state so the
* "UNCHANGED" assertion has teeth (non-trivial term/role/votedFor/log). */
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&c, 3, 2000, 0xD0D0ULL));
raft_start_election(&c.nodes[0].state); /* node 0 -> candidate */
for (int g = 0; g < 64 && c.bag_count > 0; g++)
raft_sim_bag_deliver(&c, 0); /* node 0 wins -> LEADER */
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
uint64_t value = 0x1122334455667788ULL;
uint64_t out_index = 0;
TEST_ASSERT_EQUAL_INT(RAFT_OK,
raft_log_append(&c.nodes[0].state, &value, sizeof(value), &out_index));
/* Seed the bag directly with two UNDELIVERED messages (never routed, so no
* node was touched by them). */
c.bag_count = 0;
raft_sim_msg_t m0;
memset(&m0, 0, sizeof(m0));
m0.kind = RAFT_SIM_MSG_VOTE_REQ;
m0.src = 1; m0.dst = 2; m0.term = 7;
raft_sim_msg_t m1;
memset(&m1, 0, sizeof(m1));
m1.kind = RAFT_SIM_MSG_AE_REQ;
m1.src = 0; m1.dst = 1; m1.term = 3; m1.entries_len = 1;
m1.entry_term = 3; m1.entry_value = 0xFEEDFACEULL;
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&c, &m0));
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&c, &m1));
TEST_ASSERT_EQUAL_INT(2, c.bag_count);
/* Snapshot every node BEFORE the bag mutations. */
sim_node_state_snapshot_t before[RAFT_SIM_MAX_NODES];
for (int i = 0; i < c.n; i++)
sim_snapshot_node(&c.nodes[i], &before[i]);
/* ---- DROP ---- discard the AE_REQ at index 1; multiplicity drops by 1. */
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_drop(&c, 1));
TEST_ASSERT_EQUAL_INT(1, c.bag_count);
for (int i = 0; i < c.n; i++) {
sim_node_state_snapshot_t after;
sim_snapshot_node(&c.nodes[i], &after);
TEST_ASSERT_EQUAL_MEMORY(&before[i], &after, sizeof(after));
}
/* ---- DUPLICATE ---- re-enqueue a copy of the surviving message (index 0);
* multiplicity rises by 1, the duplicate content equals the original. */
raft_sim_msg_t orig = c.bag[0];
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_duplicate(&c, 0));
TEST_ASSERT_EQUAL_INT(2, c.bag_count);
TEST_ASSERT_EQUAL_MEMORY(&orig, &c.bag[0], sizeof(orig)); /* original intact */
TEST_ASSERT_EQUAL_MEMORY(&orig, &c.bag[1], sizeof(orig)); /* copy at tail */
for (int i = 0; i < c.n; i++) {
sim_node_state_snapshot_t after;
sim_snapshot_node(&c.nodes[i], &after);
TEST_ASSERT_EQUAL_MEMORY(&before[i], &after, sizeof(after));
}
/* ---- bounds: fail-loud, bag untouched ---- */
int saved_count = c.bag_count;
TEST_ASSERT_TRUE(raft_sim_bag_drop(&c, -1) < 0);
TEST_ASSERT_TRUE(raft_sim_bag_drop(&c, c.bag_count) < 0);
TEST_ASSERT_TRUE(raft_sim_bag_duplicate(&c, -1) < 0);
TEST_ASSERT_TRUE(raft_sim_bag_duplicate(&c, c.bag_count) < 0);
TEST_ASSERT_EQUAL_INT(saved_count, c.bag_count);
}
/*
* test_sim_2_2_reorder_prng_deterministic (task 2.2; Req 2.5)
*
* Reorder: raft_sim_bag_pick(c) returns a PRNG-chosen in-flight index so delivery
* order is PRNG-determined, not FIFO. Same seed => same pick sequence (replay), and
* picking is a pure read: it mutates no node's (term, role, votedFor, log).
*/
static void test_sim_2_2_reorder_prng_deterministic(void)
{
raft_sim_cluster_t a, b;
memset(&a, 0, sizeof(a));
memset(&b, 0, sizeof(b));
/* Two clusters, identical seed: pick sequences must match byte-for-byte. */
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&a, 3, 2000, 0xAB1EULL));
TEST_ASSERT_EQUAL_INT(0, raft_sim_conformance_init(&b, 3, 2000, 0xAB1EULL));
/* Push >=2 distinct messages onto each (same content) so a pick can vary. */
for (int k = 0; k < 4; k++) {
raft_sim_msg_t m;
memset(&m, 0, sizeof(m));
m.kind = RAFT_SIM_MSG_VOTE_REQ;
m.src = 0; m.dst = 1; m.term = (uint64_t)(k + 1);
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&a, &m));
TEST_ASSERT_EQUAL_INT(0, raft_sim_bag_push(&b, &m));
}
TEST_ASSERT_EQUAL_INT(4, a.bag_count);
/* Snapshot node state before any picks (picking must not mutate nodes). */
sim_node_state_snapshot_t before[RAFT_SIM_MAX_NODES];
for (int i = 0; i < a.n; i++)
sim_snapshot_node(&a.nodes[i], &before[i]);
for (int step = 0; step < 16; step++) {
int pa = raft_sim_bag_pick(&a);
int pb = raft_sim_bag_pick(&b);
TEST_ASSERT_EQUAL_INT(pa, pb); /* deterministic replay */
TEST_ASSERT_TRUE(pa >= 0 && pa < a.bag_count); /* names a live member */
}
for (int i = 0; i < a.n; i++) {
sim_node_state_snapshot_t after;
sim_snapshot_node(&a.nodes[i], &after);
TEST_ASSERT_EQUAL_MEMORY(&before[i], &after, sizeof(after));
}
/* Empty bag: pick returns < 0 (nothing to deliver). */
a.bag_count = 0;
TEST_ASSERT_TRUE(raft_sim_bag_pick(&a) < 0);
}
/* ---------- Group A: Basic Election Correctness (task 5.1) ---------- */
/*
* test_proto_A1_single_node_self_elects
*
* 1-node cluster: raft_start_election returns RAFT_ELECTED_IMMEDIATELY.
* role==LEADER, leader_count==1, no on_send_vote_request calls (no pending RPCs).
* on_become_leader fired once.
*/
static void test_proto_A1_single_node_self_elects(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 1, 2000));
int rc = raft_start_election(&c.nodes[0].state);
/* Single-node shortcircuit */
TEST_ASSERT_EQUAL_INT(RAFT_ELECTED_IMMEDIATELY, rc);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* on_become_leader fired once → leader_count incremented */
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
/* No pending RPCs — no on_send_vote_request calls */
TEST_ASSERT_EQUAL_INT(0, c.pending_count);
}
/*
* test_proto_A2_3node_one_leader
*
* 3-node cluster: node 0 (highest priority, idx=0) wins the election.
* After deliver_pending: leader_count==1, node 0 is LEADER, others FOLLOWER.
*/
static void test_proto_A2_3node_one_leader(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Only node 0 starts — it will ask nodes 1 and 2 for votes */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
/* Exactly one leader */
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[1].state.role);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, c.nodes[2].state.role);
}
/*
* test_proto_A3_late_election_denied
*
* Node 0 wins at term=1. Node 1 already voted_for=0 at term=1.
* Send a vote request from idx=2 to node 1 at term=1 directly.
* Node 1 must DENY (already voted this term), leader_count stays 1.
*/
static void test_proto_A3_late_election_denied(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Node 0 wins at term=1 */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
/* Node 1 granted node 0 at term=1: voted_for=0, voted_for_term=1 */
TEST_ASSERT_EQUAL_INT(0, c.nodes[1].state.voted_for);
TEST_ASSERT_EQUAL_UINT64(1, c.nodes[1].state.voted_for_term);
/* Now node 2 tries to request a vote from node 1 at the same term=1 */
uint64_t reply_term = 0;
int rc = raft_on_vote_request(&c.nodes[1].state, 2, 1, 0, 0, &reply_term);
/* Node 1 must deny — already voted for node 0 this term */
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
/* leader_count still 1 (no new leader elected) */
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
}
/*
* test_proto_A4_5node_quorum_3
*
* 5-node cluster: node 0 starts the election.
* Quorum=3 (self + 2 peer grants). After deliver, node 0 is LEADER.
*/
static void test_proto_A4_5node_quorum_3(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 5, 2000));
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Self vote + 4 peer grants → election_decided=1 after first quorum reached */
TEST_ASSERT_GREATER_OR_EQUAL(3, c.nodes[0].state.votes_granted);
}
/*
* test_proto_A5_leader_denies_vote_request
*
* Node 0 is LEADER at term=1. Receives a vote request from idx=1 at term=1.
* The role gate must deny (LEADER, same term → no step-down).
* Node 0 stays LEADER.
*/
static void test_proto_A5_leader_denies_vote_request(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Make node 0 the leader */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
uint64_t term_before = c.nodes[0].state.term; /* =1 */
int lc_before = c.leader_count;
/* Send a vote request to the leader at the same term */
uint64_t reply_term = 0;
int rc = raft_on_vote_request(&c.nodes[0].state, 1, term_before, 0, 0, &reply_term);
/* Must be denied */
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
/* Node 0 remains LEADER */
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* No new leader elected */
TEST_ASSERT_EQUAL_INT(lc_before, c.leader_count);
}
/* ---------- Group C: on_vote_eligible policy callback (task 5.2) ---------- */
/*
* test_proto_C1_eligible_denies
*
* on_vote_eligible returns 0 → RAFT_DENY, voted_for unchanged (-1),
* vote_eligible_call_count == 1.
*/
static void test_proto_C1_eligible_denies(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
reset_mock();
g_mock.vote_eligible_return_value = 0; /* deny */
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.term = 1;
s.voted_for = -1;
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
}
/*
* test_proto_C2_eligible_grants
*
* on_vote_eligible returns 1 → RAFT_GRANT, voted_for==1,
* vote_eligible_call_count == 1.
*/
static void test_proto_C2_eligible_grants(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
reset_mock();
g_mock.vote_eligible_return_value = 1; /* grant */
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.term = 1;
s.voted_for = -1;
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
}
/*
* test_proto_C3_eligible_null_grants
*
* on_vote_eligible is NULL (default make_mock_cbs) → treated as eligible,
* grant succeeds (RAFT_GRANT).
*/
static void test_proto_C3_eligible_null_grants(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
/* on_vote_eligible left NULL in make_mock_cbs */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.term = 1;
s.voted_for = -1;
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
/* vote_eligible not called (NULL) */
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
}
/*
* test_proto_C4_stale_term_no_eligible_call
*
* Node at term=5, receives request at term=3 (stale).
* The stale-term check must return DENY before the policy gate is reached.
* vote_eligible_call_count must be 0.
*/
static void test_proto_C4_stale_term_no_eligible_call(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.term = 5;
s.voted_for = -1;
int rc = raft_on_vote_request(&s, 1, 3, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
}
/*
* test_proto_C5_already_voted_no_eligible_call
*
* voted_for=1, voted_for_term=3 at term=3.
* Receives request from idx=2 at term=3.
* The dup-vote check must fire before the policy gate.
* vote_eligible_call_count must be 0.
*/
static void test_proto_C5_already_voted_no_eligible_call(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.term = 3;
s.voted_for = 1;
s.voted_for_term = 3;
int rc = raft_on_vote_request(&s, 2, 3, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
}
/* ---------- Group H: Persistence and restart (task 5.2) ---------- */
/*
* test_proto_H1_init_with_persisted_state
*
* raft_config_t with initial_term=5, initial_voted_for=1.
* After raft_init: s.term==5, s.voted_for==1, s.role==RAFT_FOLLOWER.
*/
static void test_proto_H1_init_with_persisted_state(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.election_timeout_ms = 1000;
cfg.initial_term = 5;
cfg.initial_voted_for = 1;
cfg.cbs = &cbs;
int rc = raft_init(&s, &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
/* The restored vote belongs to the restored term — without this, the
* duplicate-vote check cannot match after restart (see H6). */
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
}
/*
* test_proto_H2_init_first_boot
*
* initial_term=0, initial_voted_for=-1 (first boot).
* After raft_init: s.term==0, s.voted_for==-1, s.role==RAFT_FOLLOWER.
*/
static void test_proto_H2_init_first_boot(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.election_timeout_ms = 1000;
cfg.initial_term = 0;
cfg.initial_voted_for = -1;
cfg.cbs = &cbs;
int rc = raft_init(&s, &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT64(0, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
}
/*
* test_proto_H6_restored_vote_blocks_second_vote
*
* Election Safety across restart: a node that voted for candidate 1 in
* term 5, then restarted (raft_init with initial_term=5, initial_voted_for=1),
* must DENY a vote request from a DIFFERENT candidate (idx=2) in the same
* term 5. Without restoring voted_for_term alongside voted_for, the
* duplicate-vote check cannot match the restored term and the node would
* grant a second vote in term 5 → two leaders in one term.
*/
static void test_proto_H6_restored_vote_blocks_second_vote(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
reset_mock();
raft_config_t cfg;
memset(&s, 0, sizeof(s));
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.election_timeout_ms = 1000;
cfg.initial_term = 5;
cfg.initial_voted_for = 1; /* persisted vote for candidate 1, term 5 */
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
/* Different candidate (idx=2) requests a vote in the SAME term 5 */
int rc = raft_on_vote_request(&s, 2, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* The restored vote must be untouched */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
/* Same candidate (idx=1) retransmitting must still be re-granted */
rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
}
/*
* test_proto_H3_persist_before_vote_recorded
*
* Verify that on_persist fires BEFORE voted_for is written to raft_state_t.
* A custom persist callback captures s->voted_for at call time; it must
* still be -1 (unset) when persist is invoked, and set to the candidate
* after raft_on_vote_request returns RAFT_GRANT.
*/
static raft_state_t *h3_state_ptr = NULL;
static int h3_voted_for_snapshot = 999; /* sentinel; updated by h3_persist */
static int h3_persist(void *ctx, uint64_t term, int voted_for)
{
(void)ctx; (void)term; (void)voted_for;
/* Capture voted_for from the live state object at persist call time */
if (h3_state_ptr)
h3_voted_for_snapshot = h3_state_ptr->voted_for;
g_mock.persist_count++;
return 0;
}
static void test_proto_H3_persist_before_vote_recorded(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
reset_mock();
h3_state_ptr = &s;
h3_voted_for_snapshot = 999; /* reset sentinel */
cbs.on_persist = h3_persist;
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.term = 1;
s.voted_for = -1;
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
/* Must grant */
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
/* on_persist must have fired once */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
/* At persist call time, voted_for must still have been -1 (not yet written) */
TEST_ASSERT_EQUAL_INT(-1, h3_voted_for_snapshot);
/* After the call returns, voted_for must be set to from_idx=1 */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
h3_state_ptr = NULL;
}
/*
* test_proto_H4_persist_fail_denies
*
* g_mock.persist_return_value = -1.
* raft_on_vote_request must return RAFT_DENY and leave voted_for==-1.
*/
static void test_proto_H4_persist_fail_denies(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
reset_mock();
g_mock.persist_return_value = -1;
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.term = 1;
s.voted_for = -1;
int rc = raft_on_vote_request(&s, 1, 1, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
}
/*
* test_proto_H5_start_election_self_vote_persisted
*
* raft_start_election(s) for a 3-node cluster.
* on_persist must fire with (new_term=1, self_idx=0).
* g_mock.last_persist_voted_for == s.self_idx (0).
* g_mock.last_persist_term == 1.
*/
static void test_proto_H5_start_election_self_vote_persisted(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
int rc = raft_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* on_persist fired with (term=1, self_idx=0) for the self-vote */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
TEST_ASSERT_EQUAL_UINT64(1, g_mock.last_persist_term);
TEST_ASSERT_EQUAL_INT(s.self_idx, g_mock.last_persist_voted_for);
TEST_ASSERT_EQUAL_INT(0, g_mock.last_persist_voted_for);
}
/*
* test_proto_Hnull_required_callback_einval
*
* raft_init with NULL on_become_follower → returns -EINVAL.
* State remains untouched (canary check).
*/
static void test_proto_Hnull_required_callback_einval(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
raft_config_t cfg;
memset(&s, 0xAB, sizeof(s));
cbs.on_become_follower = NULL; /* knock out a required callback */
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.cbs = &cbs;
int rc = raft_init(&s, &cfg);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
/* s must be completely unchanged (canary check) */
{
unsigned char buf[sizeof(s)];
memset(buf, 0xAB, sizeof(buf));
TEST_ASSERT_EQUAL_MEMORY(buf, &s, sizeof(s));
}
}
/* ---------- Group B: voted_for Invariant (task 5.1) ---------- */
/*
* test_proto_B1_one_vote_per_term
*
* Node 2 receives two RPCs in the same term — one from node 0, one from node 1.
* Both RPCs are buffered (nodes 0 and 1 both start elections simultaneously).
* After delivery node 2 grants only the first requester; the second is denied.
* voted_for is set to exactly one candidate.
*/
static void test_proto_B1_one_vote_per_term(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Both node 0 and node 1 start elections — they each send to all peers */
raft_start_election(&c.nodes[0].state);
raft_start_election(&c.nodes[1].state);
/* 4 RPCs buffered: node0→1, node0→2, node1→0, node1→2 */
TEST_ASSERT_EQUAL_INT(4, c.pending_count);
/* Deliver all */
raft_sim_deliver_pending(&c);
/* Node 2 must have voted for exactly one candidate */
TEST_ASSERT_NOT_EQUAL(-1, c.nodes[2].state.voted_for);
/* voted_for must be 0 or 1 (one of the two candidates that requested) */
int vf = c.nodes[2].state.voted_for;
TEST_ASSERT_TRUE(vf == 0 || vf == 1);
}
/*
* test_proto_B2_higher_term_updates_state
*
* Single-node at term=2, role=FOLLOWER, voted_for=1 (old vote).
* Receives vote request at term=5 (higher).
* Expected: node.term==5, voted_for updated (grant because on_vote_eligible is NULL).
*/
static void test_proto_B2_higher_term_updates_state(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 1, 2));
/* Simulate old vote from a previous term */
s.voted_for = 1; /* node voted for itself in an earlier election */
s.voted_for_term = 1; /* that vote was in term 1 */
int rc = raft_on_vote_request(&s, 0, 5, 0, 0, &reply_term);
/* Term must be upgraded to 5 */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
/* After term upgrade, voted_for is cleared to -1 and then re-evaluated.
* With on_vote_eligible=NULL and persist succeeding, must grant. */
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
/* voted_for must now be set to from_idx=0 */
TEST_ASSERT_EQUAL_INT(0, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
}
/*
* test_proto_B3_stale_term_denied
*
* Node at term=5. Receives request at term=3 (stale).
* Must return RAFT_DENY, reply_term==5, node.term==5 (unchanged).
*/
static void test_proto_B3_stale_term_denied(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 0, 3, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
TEST_ASSERT_EQUAL_UINT64(5, s.term); /* term unchanged */
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_proto_B4_different_candidate_denied
*
* voted_for=1, voted_for_term=3 at term=3.
* Receives request from idx=2 at term=3.
* Must DENY — already voted for 1 this term.
*/
static void test_proto_B4_different_candidate_denied(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 3));
s.voted_for = 1;
s.voted_for_term = 3;
int rc = raft_on_vote_request(&s, 2, 3, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(3, reply_term);
/* voted_for must still be 1 — unchanged */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(3, s.voted_for_term);
/* on_persist must NOT fire */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_proto_B5_idempotent_same_candidate
*
* voted_for=1, voted_for_term=3 at term=3.
* Receives request from idx=1 at term=3 again (same candidate, same term).
* Must GRANT (idempotent re-grant).
*/
static void test_proto_B5_idempotent_same_candidate(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 3));
s.voted_for = 1;
s.voted_for_term = 3;
int rc = raft_on_vote_request(&s, 1, 3, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(3, reply_term);
/* voted_for must still be 1 */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(3, s.voted_for_term);
/* on_persist fires once for the re-grant */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
}
/* ---------- Group D: Quorum and Result (task 5.3) ---------- */
/*
* test_proto_D1_quorum_1grant_of_2_needed
*
* 3-node cluster, node 0 starts election (votes_granted=1, n_peers=3, quorum=2).
* Feed 1 grant reply from peer 1: votes_granted becomes 2 == quorum.
* Expected: RAFT_BECAME_LEADER, role==RAFT_LEADER, election_decided==1,
* on_become_leader fired exactly once.
*/
static void test_proto_D1_quorum_1grant_of_2_needed(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
/* Start election: votes_granted=1 (self), role=CANDIDATE */
int start_rc = raft_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, start_rc);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
TEST_ASSERT_EQUAL_INT(1, s.votes_granted);
TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count);
/* Feed 1 grant from peer 1: votes_granted=2 == quorum=2 */
int rc = raft_on_vote_reply(&s, 1, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_leader must have fired exactly once */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
}
/*
* test_proto_D2_both_peers_deny_becomes_follower
*
* 3-node cluster, node 0 starts election (votes_granted=1, replies_received=0).
* Feed 2 deny replies from peers 1 and 2.
* After second deny: replies_received==2 == n_peers-1=2, votes_granted=1 < quorum=2.
* Expected: RAFT_BECAME_FOLLOWER, role==RAFT_FOLLOWER, election_decided==1,
* on_become_follower fired.
*/
static void test_proto_D2_both_peers_deny_becomes_follower(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
/* Start election */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
TEST_ASSERT_EQUAL_INT(0, s.replies_received);
/* First deny from peer 1: replies_received=1, still pending */
int rc1 = raft_on_vote_reply(&s, 1, 0, 1);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc1);
TEST_ASSERT_EQUAL_INT(1, s.replies_received);
TEST_ASSERT_EQUAL_INT(0, s.election_decided);
/* Second deny from peer 2: replies_received=2 == n_peers-1=2 → step down */
int rc2 = raft_on_vote_reply(&s, 2, 0, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc2);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_follower must have fired once */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
}
/*
* test_proto_D3_second_vote_reply_noop
*
* 3-node cluster, start election.
* Feed 1 grant → RAFT_BECAME_LEADER (election_decided=1).
* Feed a second grant from peer 2 → RAFT_PENDING (noop, election_decided guard).
* Expected: become_leader_count still 1 (on_become_leader fired exactly once).
*/
static void test_proto_D3_second_vote_reply_noop(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
/* Start election */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
/* First grant: wins election */
int rc1 = raft_on_vote_reply(&s, 1, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc1);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
/* Second grant from peer 2: election already decided → noop */
int rc2 = raft_on_vote_reply(&s, 2, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_PENDING, rc2);
/* on_become_leader must NOT have fired again */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count);
}
/*
* test_proto_D4_reply_higher_term_steps_down
*
* 3-node cluster, start election (term=1).
* Feed reply with term=3 (granted=0) → higher term → step down.
* Expected: RAFT_BECAME_FOLLOWER, role==RAFT_FOLLOWER, s.term==3,
* election_decided==1, on_become_follower fired.
*/
static void test_proto_D4_reply_higher_term_steps_down(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
/* Start election: term becomes 1 */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(1, (int)s.term);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
/* Reply with higher term=3, not granted */
int rc = raft_on_vote_reply(&s, 1, 0, 3);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_FOLLOWER, rc);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_UINT64(3, s.term);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_follower must have fired once */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
}
/* ---------- Group E: Election Timeout (task 5.3) ---------- */
/*
* test_proto_E1_timeout_before_quorum
*
* 3-node cluster, start election (on_schedule_timeout fires → schedule_timeout_count==1).
* Call raft_on_election_timeout before any replies.
* Expected: role==RAFT_FOLLOWER, election_decided==1, on_become_follower fired,
* on_cancel_timeout fired.
*/
static void test_proto_E1_timeout_before_quorum(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
/* Start election: schedule_timeout fires */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count);
/* Fire timeout before any replies */
raft_on_election_timeout(&s);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* on_become_follower must have fired */
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count);
/* on_cancel_timeout must have fired */
TEST_ASSERT_EQUAL_INT(1, g_mock.cancel_timeout_count);
}
/*
* test_proto_E2_timeout_after_quorum_noop
*
* 3-node cluster, start election, feed 1 grant → RAFT_BECAME_LEADER.
* Call raft_on_election_timeout.
* Expected: role still RAFT_LEADER (already decided, timeout is noop),
* on_cancel_timeout fired (stale timer cleanup),
* on_become_follower NOT additionally fired.
*/
static void test_proto_E2_timeout_after_quorum_noop(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
/* Start election */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
/* Feed 1 grant → LEADER */
int rc = raft_on_vote_reply(&s, 1, 1, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rc);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* Snapshot counters before stale timeout delivery */
int follower_before = g_mock.become_follower_count;
int cancel_before = g_mock.cancel_timeout_count;
/* Fire timeout: already decided → fires on_cancel_timeout, no other effects (Req 6.3) */
raft_on_election_timeout(&s);
/* Role must still be LEADER */
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
/* on_cancel_timeout fires exactly once for the stale delivery (Req 6.3) */
TEST_ASSERT_EQUAL_INT(cancel_before + 1, g_mock.cancel_timeout_count);
/* on_become_follower must NOT have fired additionally */
TEST_ASSERT_EQUAL_INT(follower_before, g_mock.become_follower_count);
}
/*
* test_proto_E3_new_election_after_timeout
*
* 3-node cluster, start election (term=1).
* Timeout fires → becomes FOLLOWER (election_decided=1).
* Start new election: raft_start_election again.
* Expected: term==2 (incremented), on_schedule_timeout fired again
* (schedule_timeout_count==2 total).
*/
static void test_proto_E3_new_election_after_timeout(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
/* First election: term=1 */
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_UINT64(1, s.term);
TEST_ASSERT_EQUAL_INT(1, g_mock.schedule_timeout_count);
/* Timeout: step down to FOLLOWER */
raft_on_election_timeout(&s);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(1, s.election_decided);
/* Start new election: election_decided=1 means old state decided — need to
* reset election state so raft_start_election allows another attempt.
* Per spec: a new election restarts the state machine; reset election_decided. */
s.election_decided = 0;
s.role = RAFT_FOLLOWER;
int rc2 = raft_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc2);
/* Term must have incremented to 2 */
TEST_ASSERT_EQUAL_UINT64(2, s.term);
/* on_schedule_timeout must have fired again (total 2) */
TEST_ASSERT_EQUAL_INT(2, g_mock.schedule_timeout_count);
}
/* ---------- Group F: RDHUP Failover (task 5.4) ---------- */
/*
* test_proto_F1_leader_lost_priority_delay
*
* Verify that on_start_election_after_delay fires with delay = my_priority * election_timeout_ms.
* Node 1 (priority=1, timeout=1000) → delay=1000ms.
* Node 2 (priority=2, timeout=1000) → delay=2000ms.
*/
static void test_proto_F1_leader_lost_priority_delay(void)
{
/* --- Node 1: priority=1, election_timeout_ms=1000 → expected delay=1000 --- */
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
raft_config_t cfg;
reset_mock();
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 1;
cfg.my_priority = 1;
cfg.election_timeout_ms = 1000;
cfg.initial_voted_for = -1;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(1, g_mock.start_election_after_delay_count);
TEST_ASSERT_EQUAL_UINT32(1000, g_mock.last_start_election_delay);
}
/* --- Node 2: priority=2, election_timeout_ms=1000 → expected delay=2000 --- */
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
raft_config_t cfg;
reset_mock();
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 2;
cfg.my_priority = 2;
cfg.election_timeout_ms = 1000;
cfg.initial_voted_for = -1;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(1, g_mock.start_election_after_delay_count);
TEST_ASSERT_EQUAL_UINT32(2000, g_mock.last_start_election_delay);
}
}
/*
* test_proto_F2_voted_during_delay_skip
*
* Node calls raft_on_leader_lost (voted_for=-1).
* During the jitter delay, a vote request arrives from node 0 at term=1.
* Node grants the vote (voted_for=0).
* Then raft_check_and_start_election is called.
* Since voted_for==0 != self_idx==1, must return RAFT_SKIP_ELECTION.
*/
static void test_proto_F2_voted_during_delay_skip(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
raft_config_t cfg;
uint64_t reply_term = 0;
reset_mock();
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 1;
cfg.my_priority = 1;
cfg.election_timeout_ms = 1000;
cfg.initial_voted_for = -1;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
/* Leader lost: clears voted_for to -1, schedules delayed election */
raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
/* During the delay, a vote request arrives from node 0 at term=1 */
int rc = raft_on_vote_request(&s, 0, 1, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_INT(0, s.voted_for);
/* Timer fires: check-and-start sees voted_for=0 != self_idx=1 → skip */
int check_rc = raft_check_and_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, check_rc);
/* Role must stay FOLLOWER — no election started */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
}
/*
* test_proto_F3_no_vote_during_delay_starts_election
*
* Node 0 (priority=0, self_idx=0) calls raft_on_leader_lost.
* No vote arrives during the delay (voted_for stays -1).
* raft_check_and_start_election must return RAFT_ELECTION_STARTED and role==CANDIDATE.
*/
static void test_proto_F3_no_vote_during_delay_starts_election(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
raft_config_t cfg;
reset_mock();
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.my_priority = 0;
cfg.election_timeout_ms = 1000;
cfg.initial_voted_for = -1;
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
/* Leader lost: voted_for cleared to -1 */
raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
/* No vote arrived during the delay */
int rc = raft_check_and_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
}
/*
* test_proto_F4_simultaneous_rdhup_one_leader
*
* Nodes 1 and 2 both receive RDHUP (leader/node 0 lost).
* Both call raft_on_leader_lost → voted_for=-1 for both.
* Node 1 (lower idx, lower priority) calls check_and_start first → ELECTION_STARTED.
* Node 1 sends vote requests; deliver them to node 2.
* Node 2 grants node 1 (voted_for=1).
* Node 2 then calls check_and_start → RAFT_SKIP_ELECTION (voted_for=1 != self_idx=2).
* Result: leader_count==1.
*/
static void test_proto_F4_simultaneous_rdhup_one_leader(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Simulate: node 0 was the leader, now lost. Nodes 1 and 2 see RDHUP. */
raft_on_leader_lost(&c.nodes[1].state);
raft_on_leader_lost(&c.nodes[2].state);
/* Both have voted_for=-1 now */
TEST_ASSERT_EQUAL_INT(-1, c.nodes[1].state.voted_for);
TEST_ASSERT_EQUAL_INT(-1, c.nodes[2].state.voted_for);
/* Node 1 fires its delayed timer first (lower priority = smaller delay) */
int rc1 = raft_check_and_start_election(&c.nodes[1].state);
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED, rc1);
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[1].state.role);
/* Deliver node 1's vote requests to the cluster (node 2 gets one, node 0 is gone) */
/* Only deliver the RPC intended for node 2; skip node 0 (it's gone/isolated) */
raft_sim_partition(&c, 0, 1); /* node 0 is isolated (lost leader) */
raft_sim_deliver_pending(&c);
/* Node 2 received node 1's vote request and granted it */
TEST_ASSERT_EQUAL_INT(1, c.nodes[2].state.voted_for);
/* Node 2's timer fires: check_and_start sees voted_for=1 != self_idx=2 → skip */
int rc2 = raft_check_and_start_election(&c.nodes[2].state);
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, rc2);
/* Exactly one leader (node 1) elected */
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
}
/* ---------- Group G: Split-Brain Prevention (task 5.4) ---------- */
/*
* test_proto_G1_no_split_brain_100_rounds
*
* Run 100 independent election rounds on a fresh 3-node cluster each time.
* Node 0 always starts first. After delivering pending RPCs, leader_count must
* equal exactly 1 (no split brain, no starvation).
*/
static void test_proto_G1_no_split_brain_100_rounds(void)
{
int total_leaders = 0;
for (int round = 0; round < 100; round++) {
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Node 0 always starts first (highest priority, idx=0) */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
/* Each round must produce exactly one leader — no split brain */
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
total_leaders += c.leader_count;
}
/* Across 100 rounds, exactly 100 leaders must have been elected */
TEST_ASSERT_EQUAL_INT(100, total_leaders);
}
/*
* test_proto_G2_partition_minority_no_quorum
*
* 3-node cluster. Node 2 is isolated (partition of 1).
* Nodes 0+1 form the majority partition (size=2 = quorum).
* Node 0 starts election in the majority partition → wins (leader_count=1).
* Node 2 tries to start election in isolation → RPCs dropped → cannot win.
* leader_count remains 1.
*/
static void test_proto_G2_partition_minority_no_quorum(void)
{
raft_sim_cluster_t c;
memset(&c, 0, sizeof(c));
TEST_ASSERT_EQUAL_INT(0, raft_sim_init(&c, 3, 2000));
/* Isolate node 2 (minority partition of 1) */
raft_sim_partition(&c, 2, 1);
/* Node 0 starts election in the majority partition {0, 1} */
raft_start_election(&c.nodes[0].state);
raft_sim_deliver_pending(&c);
/* Nodes 0+1 have quorum=2; node 0 must be LEADER */
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, c.nodes[0].state.role);
/* Node 2 attempts to start an election in isolation */
raft_start_election(&c.nodes[2].state);
raft_sim_deliver_pending(&c); /* RPCs to/from node 2 are dropped */
/* Node 2 cannot win — still CANDIDATE (no grants received) */
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, c.nodes[2].state.role);
/* leader_count still 1 — no split brain */
TEST_ASSERT_EQUAL_INT(1, c.leader_count);
}
#endif /* XCE_TESTING */
/* ---------- (lastLogTerm,lastLogIndex) vote freshness tests (task 4.1) ----------
*
* raft.tla HandleRequestVoteRequest logOk (285), closes G6. The library now
* computes logOk itself from the candidate's advertised (cand_last_log_term,
* cand_last_log_index) against its own (on_get_entry_term(log_len), log_len),
* BEFORE the application gate. */
/* Configure the voter's local log: last index = log_len, last term = last_term. */
static void set_voter_log(raft_state_t *s, uint64_t log_len, uint64_t last_term)
{
s->log_len = log_len;
g_mock.get_entry_term_log_len = log_len;
g_mock.local_last_log_term = last_term;
}
/*
* test_vote_req_freshness_stale_term_denied_higher_index
* Voter log = (term 5, index 3). Candidate advertises a STALE last-log term (4)
* but a HIGHER index (10). logOk false -> deny, even though the app gate grants.
* (Req 3.1, 3.3, 3.4.)
*/
static void test_vote_req_freshness_stale_term_denied_higher_index(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_get_entry_term = mock_get_entry_term;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
set_voter_log(&s, 3, 5);
int rc = raft_on_vote_request(&s, 1, 5, 4, 10, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
}
/*
* test_vote_req_freshness_fresher_term_granted_smaller_index
* Voter log = (term 5, index 10). Candidate (term 6 fresher, index 2 smaller).
* logOk = (6 > 5) -> grant. (Req 3.1, 3.6.)
*/
static void test_vote_req_freshness_fresher_term_granted_smaller_index(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_get_entry_term = mock_get_entry_term;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
set_voter_log(&s, 10, 5);
int rc = raft_on_vote_request(&s, 1, 5, 6, 2, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
}
/*
* test_vote_req_freshness_tie_term_index_boundary
* Voter log = (term 5, index 4). On a term TIE, grant requires cand index >=
* voter log_len. index 3 (<4) -> deny; index 4 (==4) -> grant. (Req 3.1.)
*/
static void test_vote_req_freshness_tie_term_index_boundary(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_get_entry_term = mock_get_entry_term;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
set_voter_log(&s, 4, 5);
int rc = raft_on_vote_request(&s, 1, 5, 5, 3, &reply_term); /* idx 3 < 4 */
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
set_voter_log(&s, 4, 5);
rc = raft_on_vote_request(&s, 1, 5, 5, 4, &reply_term); /* idx 4 == 4 */
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
}
/*
* test_vote_req_freshness_empty_voter_log_term_zero
* Empty voter log (log_len 0): local last-log term 0. Candidate (0,0) satisfies
* logOk = (0>0)||(0==0 && 0>=0) = true -> grant. (Req 3.1.)
*/
static void test_vote_req_freshness_empty_voter_log_term_zero(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_get_entry_term = mock_get_entry_term;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count);
}
/*
* test_vote_req_freshness_logok_precedes_app_gate
* !logOk denies even when on_vote_eligible WOULD grant, and the app gate is not
* consulted (logOk strictly precedes it). (Req 3.4.)
*/
static void test_vote_req_freshness_logok_precedes_app_gate(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_get_entry_term = mock_get_entry_term;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
set_voter_log(&s, 8, 7);
int rc = raft_on_vote_request(&s, 1, 5, 7, 1, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/* ---------- (lastLogTerm,lastLogIndex) PreVote freshness tests (task 5.1) ----------
*
* The PreVote path applies the IDENTICAL logOk, and mutates NOTHING. (Req 4.1-4.5.) */
/*
* test_pre_vote_req_freshness_stale_tuple_denied
* Voter (term 5, index 3). PreVote tuple (term 4 stale, index 10) -> deny, no
* mutation, no persist. (Req 4.1, 4.3.)
*/
static void test_pre_vote_req_freshness_stale_tuple_denied(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_get_entry_term = mock_get_entry_term;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
set_voter_log(&s, 3, 5);
int rc = raft_on_pre_vote_request(&s, 1, 5, 4, 10);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_UINT64(0, s.commit_index);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_pre_vote_req_freshness_fresher_tuple_granted
* Voter (term 5, index 10). PreVote (term 6 fresher, index 2) -> grant, still
* side-effect-free. (Req 4.1.)
*/
static void test_pre_vote_req_freshness_fresher_tuple_granted(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_get_entry_term = mock_get_entry_term;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
set_voter_log(&s, 10, 5);
int rc = raft_on_pre_vote_request(&s, 1, 5, 6, 2);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
}
/*
* test_pre_vote_req_freshness_no_mutation_grant_and_deny
* Across BOTH a grant and a deny, no change to term/voted_for/voted_for_term/
* role/commit_index and no on_persist. (Req 4.4.)
*/
static void test_pre_vote_req_freshness_no_mutation_grant_and_deny(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_get_entry_term = mock_get_entry_term;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.commit_index = 2;
set_voter_log(&s, 4, 5);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, raft_on_pre_vote_request(&s, 1, 5, 5, 9));
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_UINT64(2, s.commit_index);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.commit_index = 2;
set_voter_log(&s, 4, 5);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, raft_on_pre_vote_request(&s, 1, 5, 5, 1));
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_UINT64(2, s.commit_index);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_pre_vote_req_freshness_stale_term_still_denied
* The pre-existing stale-term deny stays in force even with a fresh tuple:
* cand_term < s.term denies. (Req 4.5.)
*/
static void test_pre_vote_req_freshness_stale_term_still_denied(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_get_entry_term = mock_get_entry_term;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
set_voter_log(&s, 3, 5);
int rc = raft_on_pre_vote_request(&s, 1, 4, 99, 99);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/* ---------- raft_on_pre_vote_request tests (task 2.1) ---------- */
/*
* raft_on_pre_vote_request is the voter-side PreVote decision. It mirrors
* raft_on_vote_request's Step 1 (stale-term deny) and Step 5 (on_vote_eligible
* gate) but strips ALL persistent mutation: it must never touch term,
* voted_for, voted_for_term, role, and must never call on_persist. Freshness is
* applied by the dispatcher BEFORE this call (same contract as the real vote).
*/
/*
* test_pre_vote_req_grant_higher_term_eligible
*
* Node at term=5 (FOLLOWER, no vote), receives PreVote for cand_term=6 with the
* eligibility callback granting (return 1). Expected: grant (1), and term /
* voted_for / voted_for_term / role all unchanged, on_persist NOT called.
*/
static void test_pre_vote_req_grant_higher_term_eligible(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
cbs.on_vote_eligible = mock_vote_eligible;
reset_mock();
g_mock.vote_eligible_return_value = 1; /* eligibility grants */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_pre_vote_request(&s, 1, 6, 0, 0);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
/* PURITY: nothing persistent may change on a granted PreVote */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
/* eligibility gate was consulted */
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
}
/*
* test_pre_vote_req_grant_equal_term_eligible
*
* Equal-term candidate (cand_term == s->term) must NOT be denied by the
* stale-term gate (deny is only for cand_term < term), and is granted when
* eligibility allows. Purity invariant still holds.
*/
static void test_pre_vote_req_grant_equal_term_eligible(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
cbs.on_vote_eligible = mock_vote_eligible;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_pre_vote_request(&s, 1, 5, 0, 0); /* equal term */
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_pre_vote_req_deny_stale_term
*
* Node at term=5, receives PreVote for cand_term=4 (< term). Expected: deny (0)
* WITHOUT consulting on_vote_eligible, no term-adoption, no persist.
*/
static void test_pre_vote_req_deny_stale_term(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
cbs.on_vote_eligible = mock_vote_eligible;
reset_mock();
g_mock.vote_eligible_return_value = 1; /* would grant if consulted */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_pre_vote_request(&s, 1, 4, 0, 0); /* stale */
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
/* Stale-term denial short-circuits BEFORE the eligibility gate. */
TEST_ASSERT_EQUAL_INT(0, g_mock.vote_eligible_call_count);
/* PURITY: term must NOT be adopted, no persist. */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_pre_vote_req_deny_eligibility_rejects
*
* Fresh, higher-term candidate but the eligibility callback rejects (returns 0)
* — e.g. primary-deny / fresh-heartbeat stickiness / catch-up. Expected: deny,
* with the eligibility gate consulted, and the purity invariant preserved.
*/
static void test_pre_vote_req_deny_eligibility_rejects(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
cbs.on_vote_eligible = mock_vote_eligible;
reset_mock();
g_mock.vote_eligible_return_value = 0; /* eligibility denies */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_pre_vote_request(&s, 1, 6, 0, 0);
TEST_ASSERT_EQUAL_INT(RAFT_DENY, rc);
TEST_ASSERT_EQUAL_INT(1, g_mock.vote_eligible_call_count);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_pre_vote_req_null_eligible_grants
*
* When on_vote_eligible is NULL, a fresh higher-or-equal-term candidate is
* granted (all Raft-eligible candidates granted), with no mutation/persist.
*/
static void test_pre_vote_req_null_eligible_grants(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
/* on_vote_eligible deliberately left NULL */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_on_pre_vote_request(&s, 1, 6, 0, 0);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_pre_vote_req_grant_preserves_existing_vote (Req 2.4)
*
* A node that has already voted for someone in the current term may still GRANT
* a PreVote (PreVote does not consume / change the recorded vote). Confirms the
* existing voted_for / voted_for_term are untouched by a granted PreVote.
*/
static void test_pre_vote_req_grant_preserves_existing_vote(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
cbs.on_vote_eligible = mock_vote_eligible;
reset_mock();
g_mock.vote_eligible_return_value = 1;
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
/* Node already cast its real vote for peer 1 in term 5. */
s.voted_for = 1;
s.voted_for_term = 5;
/* A different candidate (peer 2) probes a PreVote for term 6. */
int rc = raft_on_pre_vote_request(&s, 2, 6, 0, 0);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
/* The recorded real vote must be untouched (no consume, no overwrite). */
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/* ---------- raft_begin_pre_vote tests (task 2.2) ---------- */
/*
* raft_begin_pre_vote is the candidate-side PreVote start. It is the pre-image
* of raft_start_election: it sends a probe to each peer and tallies a self-grant,
* but STRIPS the term++/persist/role-change/timeout. Per Req 1.2/1.3/6.2 it must
* NOT modify s->term, s->voted_for, s->voted_for_term, s->role, and must NOT call
* on_persist. The prospective term (term+1) is a LOCAL value only.
*/
/*
* test_begin_pre_vote_uses_prospective_term_not_persisted
*
* 3-node FOLLOWER at term=5. raft_begin_pre_vote must send PreVote requests
* carrying prospective_term = term+1 = 6 to each peer, while s->term itself
* stays 5 and on_persist never fires.
*/
static void test_begin_pre_vote_uses_prospective_term_not_persisted(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
int rc = raft_begin_pre_vote(&s);
/* Round started (multi-node, not single) */
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* Each peer probe carried prospective term = term+1 = 6 */
TEST_ASSERT_EQUAL_UINT64(6, g_mock.last_pre_vote_term);
/* PURITY: persistent term is unchanged (still 5) and never persisted */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_begin_pre_vote_sends_once_per_non_self_peer
*
* 3-node FOLLOWER, self=1. The send-pre-vote callback must fire exactly once
* for each peer != self (peers 0 and 2), never for self.
*/
static void test_begin_pre_vote_sends_once_per_non_self_peer(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 1, 5));
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* Exactly n_peers-1 = 2 sends */
TEST_ASSERT_EQUAL_INT(2, g_mock.send_pre_vote_request_count);
/* Self (idx 1) must NOT be probed; peers 0 and 2 must each be probed once. */
TEST_ASSERT_EQUAL_UINT64(0, g_mock.pre_vote_sent_term[1]); /* self untouched */
TEST_ASSERT_EQUAL_UINT64(6, g_mock.pre_vote_sent_term[0]);
TEST_ASSERT_EQUAL_UINT64(6, g_mock.pre_vote_sent_term[2]);
/* None of the ordered sent peers is self_idx=1 */
for (int i = 0; i < g_mock.send_pre_vote_request_count; i++)
TEST_ASSERT_NOT_EQUAL(1, g_mock.pre_vote_sent_peers[i]);
}
/*
* test_begin_pre_vote_self_grant_and_in_progress
*
* After begin, the PreVote tally must show in_progress=1 and a single self-grant
* (pre_votes_granted=1). No real election timeout is scheduled here (the
* controller arms the round timeout separately).
*/
static void test_begin_pre_vote_self_grant_and_in_progress(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self-grant only */
/* No election-timeout scheduled by begin_pre_vote (controller owns it). */
TEST_ASSERT_EQUAL_INT(0, g_mock.schedule_timeout_count);
}
/*
* test_begin_pre_vote_purity_no_persistent_mutation
*
* Byte-for-byte: term, voted_for, voted_for_term, role unchanged; no persist,
* no role transition to CANDIDATE, no become_leader/become_follower.
*/
static void test_begin_pre_vote_purity_no_persistent_mutation(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
/* Node already cast a real vote for peer 2 in term 5 — must survive. */
s.voted_for = 2;
s.voted_for_term = 5;
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
/* PURITY: every persistent field untouched */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(2, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* stays FOLLOWER, not CANDIDATE */
/* No persistence / no leadership-transition side effects */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_begin_pre_vote_single_node_wins
*
* 1-node cluster: no peers, the self-grant is already a majority, so the round
* is won immediately. Returns RAFT_PRE_VOTE_WON, sends nothing, and still
* mutates no persistent state.
*/
static void test_begin_pre_vote_single_node_wins(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 1, 0, 5));
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc);
/* No peer probes for a single-node cluster */
TEST_ASSERT_EQUAL_INT(0, g_mock.send_pre_vote_request_count);
/* Self-grant recorded as a majority */
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted);
/* PURITY even on the WON short-circuit */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
}
/*
* test_begin_pre_vote_null_send_callback_suppressed
*
* If on_send_pre_vote_request is NULL, begin must still run (the caller owns
* transport) without crashing, recording the self-grant, and emitting no sends.
*/
static void test_begin_pre_vote_null_send_callback_suppressed(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
cbs.on_send_pre_vote_request = NULL; /* suppressed */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
int rc = raft_begin_pre_vote(&s);
TEST_ASSERT_EQUAL_INT(RAFT_OK, rc);
TEST_ASSERT_EQUAL_INT(0, g_mock.send_pre_vote_request_count);
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
}
/* ---------- raft_on_pre_vote_reply tally (task 2.3) ---------- */
/*
* Helper: arm a pre-vote round on an n-node cluster at the given term.
* Mirrors what raft_begin_pre_vote sets up (self-grant, in-progress), so the
* tally tests can feed replies without depending on the send transport.
*/
static int arm_pre_vote_round(raft_state_t *s, raft_callbacks_t *cbs,
int n_peers, int self_idx, uint64_t term)
{
int rc = init_vote_node(s, cbs, n_peers, self_idx, term);
if (rc != 0)
return rc;
/* Suppress sends; we drive replies directly. */
raft_begin_pre_vote(s);
return 0;
}
/*
* test_pre_vote_reply_won_at_quorum
*
* 3-node cluster (quorum=2), self-grant already counted. One peer grants →
* the round is WON. Tally lives on the raft state; in-progress is cleared.
*/
static void test_pre_vote_reply_won_at_quorum(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
/* begin sent to peers; clear send counter so it does not confuse asserts. */
int rc = raft_on_pre_vote_reply(&s, 1, 1 /* granted */);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc);
TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* self + peer 1 */
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress); /* round closed */
/* CRITICAL: a win must NEVER fire leadership callbacks. */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_pre_vote_reply_lost_when_all_reply_no_quorum
*
* 3-node cluster. Both peers deny. After all non-self peers reply without
* quorum, the round is LOST.
*/
static void test_pre_vote_reply_lost_when_all_reply_no_quorum(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
int rc1 = raft_on_pre_vote_reply(&s, 1, 0 /* deny */);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc1); /* one peer still out */
int rc2 = raft_on_pre_vote_reply(&s, 2, 0 /* deny */);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_LOST, rc2); /* all replied, no quorum */
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self only */
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_pre_vote_reply_pending_when_undecided
*
* 3-node cluster. One peer denies, the other has not replied yet → PENDING
* (quorum still reachable).
*/
static void test_pre_vote_reply_pending_when_undecided(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
int rc = raft_on_pre_vote_reply(&s, 1, 0 /* deny */);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc);
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self only */
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress); /* still open */
}
/*
* test_pre_vote_reply_duplicate_ignored
*
* A duplicate reply from the same peer must not double-count. A grant from
* peer 1, then a second grant from peer 1, leaves the tally at 2 (self + one
* grant). In a 5-node cluster (quorum=3) that keeps the round PENDING.
*/
static void test_pre_vote_reply_duplicate_ignored(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 5, 0, 5));
int rc1 = raft_on_pre_vote_reply(&s, 1, 1 /* grant */);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc1);
TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* self + peer 1 */
/* Duplicate grant from peer 1 — must be ignored. */
int rc2 = raft_on_pre_vote_reply(&s, 1, 1 /* grant again */);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc2);
TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* unchanged: no double-count */
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
}
/*
* test_pre_vote_reply_won_purity_state_unchanged
*
* On WON: term, voted_for, voted_for_term, role are all unchanged and no
* persist / leadership callback fires.
*/
static void test_pre_vote_reply_won_purity_state_unchanged(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
/* A real vote cast earlier in this term must survive the tally. */
s.voted_for = 2;
s.voted_for_term = 5;
int rc = raft_on_pre_vote_reply(&s, 1, 1 /* grant */);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(2, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_pre_vote_reply_lost_purity_state_unchanged
*
* On LOST: term / voted_for / role unchanged; no leadership callback.
*/
static void test_pre_vote_reply_lost_purity_state_unchanged(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 7));
s.voted_for = 1;
s.voted_for_term = 7;
raft_on_pre_vote_reply(&s, 1, 0 /* deny */);
int rc = raft_on_pre_vote_reply(&s, 2, 0 /* deny */);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_LOST, rc);
TEST_ASSERT_EQUAL_UINT64(7, s.term);
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(7, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_pre_vote_reply_out_of_range_peer_ignored
*
* A peer_idx outside [0, n_peers) (or >= RAFT_MAX_PEERS) is ignored: no tally
* change, round stays in progress, returns PENDING.
*/
static void test_pre_vote_reply_out_of_range_peer_ignored(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
int rc_neg = raft_on_pre_vote_reply(&s, -1, 1 /* grant */);
int rc_over = raft_on_pre_vote_reply(&s, 3, 1 /* grant */); /* == n_peers */
int rc_max = raft_on_pre_vote_reply(&s, RAFT_MAX_PEERS, 1);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc_neg);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc_over);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING, rc_max);
TEST_ASSERT_EQUAL_INT(1, s.pre_votes_granted); /* self only, no spurious count */
TEST_ASSERT_EQUAL_INT(1, s.pre_vote_in_progress);
}
/*
* test_pre_vote_reply_5node_won_at_three
*
* 5-node cluster (quorum=3): self + two granting peers → WON.
*/
static void test_pre_vote_reply_5node_won_at_three(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 5, 0, 5));
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING,
raft_on_pre_vote_reply(&s, 1, 1));
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON,
raft_on_pre_vote_reply(&s, 2, 1));
TEST_ASSERT_EQUAL_INT(3, s.pre_votes_granted);
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/* ====================================================================== *
* Task 6.1 — Raft safety-invariant assertions for the PreVote API
*
* The per-function tests above (tasks 2.1/2.2/2.3) check individual cases.
* The tests below assert the SAFETY INVARIANTS as *properties* that hold
* across the ENTIRE PreVote API, mechanically backing the RN-1 TLA+/
* verification-checklist consistency argument recorded in
* .kiro/specs/ha-prevote/research.md (§"RN-1 resolved").
*
* ---------------------------------------------------------------------- *
* RN-1 CONSISTENCY ARGUMENT (mechanically evidenced below)
* ---------------------------------------------------------------------- *
* Claim: adding the PreVote phase preserves every Raft safety invariant
* the repository's `raft/raft.tla` reference and the controller
* verification checklist require — Election Safety (at most one leader per
* term), Leader Completeness, term monotonicity, and a monotonic commit
* index — so the system stays consistent after the change.
*
* Proof structure (purity ⇒ no new transitions):
*
* 1. The three PreVote functions — raft_begin_pre_vote,
* raft_on_pre_vote_request, raft_on_pre_vote_reply — are PURE with
* respect to the persistent Raft state. Across an arbitrary sequence of
* begin / request / reply calls (mixed grant+deny, duplicates,
* out-of-range peers, and both WON and LOST terminal outcomes), none of
* them ever:
* - changes s->term, s->voted_for, s->voted_for_term, or s->role, or
* - invokes on_persist, or
* - invokes on_become_leader / on_become_follower.
* (Asserted by test_invariant_prevote_sequence_pure_no_persist and
* test_invariant_prevote_won_grants_no_leadership.)
*
* 2. Because the PreVote path mutates no term, it cannot violate term
* monotonicity: the only writer of s->term remains raft_start_election
* (real term bump) and raft_on_vote_request Step 2 (adopt a strictly
* higher term) — both UNCHANGED by this feature. A PreVote can only
* *gate* whether raft_start_election is later called; it never advances
* a term itself. (Asserted by the term-constant checks below.)
*
* 3. Because the PreVote path creates no leader (no on_become_leader, no
* role := RAFT_LEADER), it adds no new way to satisfy the antecedent of
* Election Safety. The sole leadership-creating transitions remain
* raft_start_election's single-node short-circuit and
* raft_on_vote_reply's quorum branch — again UNCHANGED. A PreVote round
* reaching RAFT_PRE_VOTE_WON returns a STATUS only; the node stays
* RAFT_FOLLOWER until the separate, unchanged real-election path runs.
* (Asserted by test_invariant_prevote_won_grants_no_leadership.)
*
* 4. The legitimate higher-term-adopt failover path (the bug-339/bug-340
* fix) lives entirely in raft_on_vote_request / raft_on_vote_reply,
* which this feature does not touch. PreVote sits strictly *before* that
* path and, by (1), leaves its inputs (term/voted_for/role) untouched.
* test_invariant_real_vote_higher_term_adopt_unchanged demonstrates the
* real-vote path still adopts a higher term and fires become_follower /
* become_leader exactly as before, even after a full PreVote round has
* run on the same node — so PreVote did not alter the legitimate
* failover semantics bug-339/bug-340 depend on.
*
* Conclusion: PreVote introduces no transition that writes a term or
* creates a leader. Every term-writing / leadership-creating transition in
* the model is byte-for-byte the pre-PreVote transition. Therefore the set
* of reachable (term, leader) configurations is unchanged, and the TLA+
* invariants (Election Safety, Leader Completeness, term monotonicity,
* monotonic commit index) and the controller verification checklist
* continue to hold. The tests below are the mechanical evidence for
* premises (1)–(4).
* ====================================================================== */
/*
* test_invariant_prevote_sequence_pure_no_persist (Req 6.2, 2.3, 1.2)
*
* PROPERTY (term monotonicity / no persistent mutation): drive a long,
* mixed sequence of PreVote operations through a node and assert that across
* the WHOLE sequence the persistent Raft state (term, voted_for,
* voted_for_term, role) never changes and on_persist is NEVER called.
*
* The sequence deliberately exercises: begin → answer an incoming request
* (grant) → answer a stale-term request (deny) → reply tally with a grant,
* a duplicate of that grant, an out-of-range peer, and a deny — i.e. every
* PreVote entry point and both grant/deny outcomes, including a WON
* terminal. If ANY PreVote code path leaked a term/vote/role write or a
* persist, this property fails — which would be a real Election-Safety /
* term-monotonicity finding, not a test bug.
*/
static void test_invariant_prevote_sequence_pure_no_persist(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_vote_eligible = mock_vote_eligible;
cbs.on_trace = mock_trace;
reset_mock();
g_mock.vote_eligible_return_value = 1; /* grant when consulted */
/* 3-node FOLLOWER at term=5, having voted for peer 1 in an earlier term.
* These persistent values are the canary: nothing below may change them. */
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
s.voted_for = 1;
s.voted_for_term = 4;
const uint64_t term0 = s.term;
const int voted_for0 = s.voted_for;
const uint64_t voted_for_term0 = s.voted_for_term;
const raft_role_t role0 = s.role;
/* (a) begin a round — self-grant + prospective term, no persist */
TEST_ASSERT_EQUAL_INT(0, raft_begin_pre_vote(&s));
/* (b) answer an incoming PreVote request that PASSES eligibility — grant */
TEST_ASSERT_EQUAL_INT(1, raft_on_pre_vote_request(&s, 2, 6 /* higher */, 0, 0));
/* (c) answer a STALE-term incoming request — deny */
TEST_ASSERT_EQUAL_INT(0, raft_on_pre_vote_request(&s, 2, 3 /* stale */, 0, 0));
/* (d) answer an incoming request the eligibility gate REJECTS — deny */
g_mock.vote_eligible_return_value = 0;
TEST_ASSERT_EQUAL_INT(0, raft_on_pre_vote_request(&s, 2, 6, 0, 0));
g_mock.vote_eligible_return_value = 1;
/* (e) tally replies: a grant (drives to WON quorum on 3-node), then a
* duplicate of the same grant, an out-of-range peer, and a deny. */
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON,
raft_on_pre_vote_reply(&s, 1, 1 /* grant */));
/* round closed after WON; further replies are no-ops on persistent state */
(void)raft_on_pre_vote_reply(&s, 1, 1 /* duplicate grant */);
(void)raft_on_pre_vote_reply(&s, 99, 1 /* out-of-range */);
(void)raft_on_pre_vote_reply(&s, 2, 0 /* late deny */);
/* INVARIANT: persistent Raft state byte-for-byte unchanged. */
TEST_ASSERT_EQUAL_UINT64(term0, s.term);
TEST_ASSERT_EQUAL_INT(voted_for0, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(voted_for_term0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(role0, s.role);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* never promoted */
/* INVARIANT: PreVote never persists and never fires leadership callbacks. */
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_invariant_prevote_lost_sequence_pure_no_persist (Req 6.2, 1.4)
*
* Same purity property as above, but driving the round to the LOST terminal
* outcome (all peers deny). A WON path and a LOST path are the two ways a
* round can end; both must be side-effect-free. This guards the case where
* a frozen-then-resumed standby gets no PreVote majority (Req 4) — the round
* must leave term/vote/role untouched so the node simply re-attaches.
*/
static void test_invariant_prevote_lost_sequence_pure_no_persist(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 9));
s.voted_for = 0;
s.voted_for_term = 9;
const uint64_t term0 = s.term;
const int voted_for0 = s.voted_for;
const uint64_t voted_for_term0 = s.voted_for_term;
const raft_role_t role0 = s.role;
TEST_ASSERT_EQUAL_INT(0, raft_begin_pre_vote(&s));
/* both peers deny → LOST, no majority */
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING,
raft_on_pre_vote_reply(&s, 1, 0));
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_LOST,
raft_on_pre_vote_reply(&s, 2, 0));
/* INVARIANT: persistent state unchanged across a LOST round. */
TEST_ASSERT_EQUAL_UINT64(term0, s.term);
TEST_ASSERT_EQUAL_INT(voted_for0, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(voted_for_term0, s.voted_for_term);
TEST_ASSERT_EQUAL_INT(role0, s.role);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
}
/*
* test_invariant_prevote_won_grants_no_leadership (Req 6.1)
*
* ELECTION SAFETY property: a PreVote round that reaches RAFT_PRE_VOTE_WON
* grants NO leadership. The node must remain RAFT_FOLLOWER and no
* on_become_leader / on_become_follower callback may fire — a PreVote WON is
* a STATUS the controller acts on, not a leadership transition. Only the
* separate, unchanged raft_start_election path creates a leader.
*
* The test then demonstrates the contrast: invoking the real
* raft_start_election (the only leadership-creating path) from the same
* still-FOLLOWER node bumps the term and self-votes — proving the leader is
* created exclusively by the real path, never by the PreVote WON itself.
*/
static void test_invariant_prevote_won_grants_no_leadership(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
/* arm a round on a 3-node FOLLOWER at term=5 (self-grant in progress) */
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
const uint64_t term_before_won = s.term;
/* one peer grant → WON on a 3-node cluster (quorum=2) */
int rc = raft_on_pre_vote_reply(&s, 1, 1 /* grant */);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, rc);
/* INVARIANT (Election Safety): WON created NO leader. */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); /* still a follower */
TEST_ASSERT_EQUAL_UINT64(term_before_won, s.term); /* term not advanced */
TEST_ASSERT_EQUAL_INT(0, g_mock.become_leader_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
TEST_ASSERT_EQUAL_INT(0, g_mock.persist_count);
/* CONTRAST: leadership is created ONLY by the unchanged real path.
* Starting the real election now bumps the term + records the self-vote
* (this is the sole term-writing / leadership-creating transition). */
reset_mock();
TEST_ASSERT_EQUAL_INT(RAFT_OK, raft_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
TEST_ASSERT_EQUAL_UINT64(term_before_won + 1, s.term); /* real bump here */
TEST_ASSERT_EQUAL_INT(1, g_mock.persist_count); /* persisted here */
}
/*
* test_invariant_real_vote_higher_term_adopt_unchanged (Req 5.2, 5.3)
*
* REAL-VOTE-PATH-UNCHANGED property: PreVote must not have altered the
* legitimate higher-term-adopt failover path that the bug-339/bug-340 fixes
* depend on. After running a full PreVote round on a node, the real
* raft_on_vote_request still adopts a strictly higher term and steps a
* leader down (fires on_become_follower), and raft_on_vote_reply still wins
* at quorum (fires on_become_leader) — exactly as before PreVote existed.
*
* This is the library-level guard for bug-339 (higher-term adopt on
* RDHUP failover) and bug-340 (rejoin), confirming PreVote sits beside the
* real-vote path without modifying it.
*/
static void test_invariant_real_vote_higher_term_adopt_unchanged(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
/* --- Part 1: higher-term real RequestVote adopts term + steps down a
* leader, even after a PreVote round ran on this node. --- */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 2));
/* Run a (lost) PreVote round first to prove it leaves the real-vote
* adopt path untouched. */
TEST_ASSERT_EQUAL_INT(0, raft_begin_pre_vote(&s));
(void)raft_on_pre_vote_reply(&s, 1, 0);
(void)raft_on_pre_vote_reply(&s, 2, 0);
TEST_ASSERT_EQUAL_UINT64(2, s.term); /* PreVote did not move the term */
/* Now this node is the leader in term 2 and a real higher-term vote
* request arrives — the unchanged Step 2 must adopt term 5 and step down. */
s.role = RAFT_LEADER;
s.voted_for = 0;
s.voted_for_term = 2;
reset_mock();
int rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
/* Higher-term adopt + leader step-down, exactly as bug-339/340 require. */
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
TEST_ASSERT_EQUAL_UINT64(5, reply_term);
TEST_ASSERT_EQUAL_INT(1, g_mock.become_follower_count); /* stepped down */
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc); /* then grants */
/* --- Part 2: real raft_on_vote_reply still elects a leader at quorum,
* unaffected by PreVote. --- */
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.role = RAFT_CANDIDATE;
s.term = 1;
s.votes_granted = 1; /* self-vote */
s.replies_received = 0;
s.election_decided = 0;
int rcr = raft_on_vote_reply(&s, 1, 1 /* grant */, 1);
TEST_ASSERT_EQUAL_INT(RAFT_BECAME_LEADER, rcr);
TEST_ASSERT_EQUAL_INT(RAFT_LEADER, s.role);
TEST_ASSERT_EQUAL_INT(1, g_mock.become_leader_count); /* real path elects */
}
/* ---------- test_raft entry point ---------- */
void test_raft(void)
{
printf("\n>> raft test\n\n");
RUN_TEST(test_raft_init_null_send_vote_request_returns_einval);
RUN_TEST(test_raft_init_null_become_leader_returns_einval);
RUN_TEST(test_raft_init_null_become_follower_returns_einval);
RUN_TEST(test_raft_init_null_persist_returns_einval);
RUN_TEST(test_raft_init_null_schedule_timeout_returns_einval);
RUN_TEST(test_raft_init_null_cancel_timeout_returns_einval);
RUN_TEST(test_raft_init_null_start_election_after_delay_returns_einval);
RUN_TEST(test_raft_init_null_persist_entry_returns_einval);
RUN_TEST(test_raft_init_null_get_entry_term_returns_einval);
RUN_TEST(test_raft_init_null_send_append_entries_returns_einval);
RUN_TEST(test_raft_init_null_truncate_from_returns_einval);
RUN_TEST(test_raft_init_log_callbacks_present_succeeds);
RUN_TEST(test_raft_init_null_commit_advanced_returns_einval);
RUN_TEST(test_raft_init_commit_advanced_present_succeeds);
RUN_TEST(test_raft_init_restores_commit_index);
RUN_TEST(test_raft_init_commit_index_default_zero);
RUN_TEST(test_raft_init_even_peers_returns_einval);
RUN_TEST(test_raft_init_oversize_peers_returns_einval);
RUN_TEST(test_raft_init_zero_peers_returns_einval);
RUN_TEST(test_raft_init_success_fields);
RUN_TEST(test_raft_init_default_election_timeout);
RUN_TEST(test_raft_init_optional_callbacks_null_succeeds);
RUN_TEST(test_raft_snapshot_returns_current_state);
/* raft_start_election (task 3.2) */
RUN_TEST(test_start_election_from_follower_3node);
RUN_TEST(test_start_election_single_node);
RUN_TEST(test_start_election_already_candidate);
RUN_TEST(test_start_election_already_leader);
/* raft_on_vote_reply (task 3.4) */
RUN_TEST(test_vote_reply_higher_term_steps_down);
RUN_TEST(test_vote_reply_noop_after_decided);
RUN_TEST(test_vote_reply_quorum_3node_1grant);
RUN_TEST(test_vote_reply_no_quorum_all_replied_3node);
RUN_TEST(test_vote_reply_quorum_5node);
RUN_TEST(test_vote_reply_pending_waiting);
RUN_TEST(test_leader_progress_init_single_node);
RUN_TEST(test_leader_progress_init_multi_node);
/* raft_log_append (task 3.1/3.2) */
RUN_TEST(test_log_append_leader_assigns_index_stamps_term);
RUN_TEST(test_log_append_follower_rejected);
RUN_TEST(test_log_append_candidate_rejected);
RUN_TEST(test_log_append_persist_failure_propagates);
/* raft_send_append_entries (task 4.1/4.2) */
RUN_TEST(test_send_ae_prev_index_zero_heartbeat);
RUN_TEST(test_send_ae_heartbeat_when_next_past_log);
RUN_TEST(test_send_ae_one_entry_when_next_within_log);
RUN_TEST(test_send_ae_non_leader_emits_nothing);
/* raft_on_append_entries_request (tasks 5.1+5.2 UpdateTerm-first, 6.1 logOk
* +reject+candidate-step-down, 6.2+6.3 accept already-have/conflict/append) */
RUN_TEST(test_ae_req_follower_adopts_higher_term);
RUN_TEST(test_ae_req_leader_adopts_higher_term_steps_down);
RUN_TEST(test_ae_req_heartbeat_adopts_higher_term);
RUN_TEST(test_ae_req_equal_term_no_term_change);
RUN_TEST(test_ae_req_stale_term_rejected);
RUN_TEST(test_ae_req_logok_prev_zero_accepts_append);
RUN_TEST(test_ae_req_logok_match_accepts);
RUN_TEST(test_ae_req_logok_mismatch_rejected);
RUN_TEST(test_ae_req_candidate_equal_term_steps_down);
RUN_TEST(test_ae_req_candidate_equal_term_steps_down_then_rejects);
RUN_TEST(test_ae_req_already_have_heartbeat);
RUN_TEST(test_ae_req_already_have_matching_term);
RUN_TEST(test_ae_req_conflict_truncate_then_persist);
RUN_TEST(test_ae_req_append_extends_log);
RUN_TEST(test_ae_req_leader_commit_not_acted);
/* raft_on_append_entries_reply (task 7.1/7.2) */
RUN_TEST(test_ae_reply_success_advances_indices);
RUN_TEST(test_ae_reply_failure_decrements_next_index_floor);
RUN_TEST(test_ae_reply_stale_term_ignored);
/* raft_advance_commit (task 3.1/3.2/3.3, AdvanceCommitIndex + Figure-8 rule) */
RUN_TEST(test_advance_commit_advances_current_term);
RUN_TEST(test_advance_commit_figure8_hold_previous_term);
RUN_TEST(test_advance_commit_no_quorum);
RUN_TEST(test_advance_commit_monotonic);
RUN_TEST(test_advance_commit_non_leader_eperm);
RUN_TEST(test_ae_reply_does_not_advance_commit);
/* raft_on_vote_request (task 3.3) */
RUN_TEST(test_vote_req_stale_term);
RUN_TEST(test_vote_req_higher_term_updates_state);
RUN_TEST(test_vote_req_same_term_already_voted_different);
RUN_TEST(test_vote_req_same_term_idempotent_same_candidate);
RUN_TEST(test_vote_req_leader_denies);
RUN_TEST(test_vote_req_on_vote_eligible_denies);
RUN_TEST(test_vote_req_on_vote_eligible_grants);
RUN_TEST(test_vote_req_on_vote_eligible_null);
RUN_TEST(test_vote_req_persist_fails);
RUN_TEST(test_vote_req_reply_term_always_set);
/* (lastLogTerm,lastLogIndex) binding-vote freshness (task 4.1, Req 3.1-3.6) */
RUN_TEST(test_vote_req_freshness_stale_term_denied_higher_index);
RUN_TEST(test_vote_req_freshness_fresher_term_granted_smaller_index);
RUN_TEST(test_vote_req_freshness_tie_term_index_boundary);
RUN_TEST(test_vote_req_freshness_empty_voter_log_term_zero);
RUN_TEST(test_vote_req_freshness_logok_precedes_app_gate);
/* (lastLogTerm,lastLogIndex) PreVote freshness (task 5.1, Req 4.1-4.5) */
RUN_TEST(test_pre_vote_req_freshness_stale_tuple_denied);
RUN_TEST(test_pre_vote_req_freshness_fresher_tuple_granted);
RUN_TEST(test_pre_vote_req_freshness_no_mutation_grant_and_deny);
RUN_TEST(test_pre_vote_req_freshness_stale_term_still_denied);
/* raft_on_election_timeout, raft_on_leader_lost, raft_check_and_start_election (task 3.5) */
RUN_TEST(test_election_timeout_undecided);
RUN_TEST(test_election_timeout_already_decided);
RUN_TEST(test_leader_lost_clears_voted_for);
RUN_TEST(test_check_and_start_voted_for_other);
RUN_TEST(test_check_and_start_not_voted);
RUN_TEST(test_check_and_start_voted_for_self);
/* raft_on_pre_vote_request (task 2.1) */
RUN_TEST(test_pre_vote_req_grant_higher_term_eligible);
RUN_TEST(test_pre_vote_req_grant_equal_term_eligible);
RUN_TEST(test_pre_vote_req_deny_stale_term);
RUN_TEST(test_pre_vote_req_deny_eligibility_rejects);
RUN_TEST(test_pre_vote_req_null_eligible_grants);
RUN_TEST(test_pre_vote_req_grant_preserves_existing_vote);
/* raft_begin_pre_vote (task 2.2) */
RUN_TEST(test_begin_pre_vote_uses_prospective_term_not_persisted);
RUN_TEST(test_begin_pre_vote_sends_once_per_non_self_peer);
RUN_TEST(test_begin_pre_vote_self_grant_and_in_progress);
RUN_TEST(test_begin_pre_vote_purity_no_persistent_mutation);
RUN_TEST(test_begin_pre_vote_single_node_wins);
RUN_TEST(test_begin_pre_vote_null_send_callback_suppressed);
/* raft_on_pre_vote_reply tally (task 2.3) */
RUN_TEST(test_pre_vote_reply_won_at_quorum);
RUN_TEST(test_pre_vote_reply_lost_when_all_reply_no_quorum);
RUN_TEST(test_pre_vote_reply_pending_when_undecided);
RUN_TEST(test_pre_vote_reply_duplicate_ignored);
RUN_TEST(test_pre_vote_reply_won_purity_state_unchanged);
RUN_TEST(test_pre_vote_reply_lost_purity_state_unchanged);
RUN_TEST(test_pre_vote_reply_out_of_range_peer_ignored);
RUN_TEST(test_pre_vote_reply_5node_won_at_three);
/* Raft safety-invariant assertions for the PreVote API (task 6.1, RN-1) */
RUN_TEST(test_invariant_prevote_sequence_pure_no_persist);
RUN_TEST(test_invariant_prevote_lost_sequence_pure_no_persist);
RUN_TEST(test_invariant_prevote_won_grants_no_leadership);
RUN_TEST(test_invariant_real_vote_higher_term_adopt_unchanged);
/* simulator init (task 4.1) */
#ifdef XCE_TESTING
/* conformance harness data types (raft-sim-conformance task 1.1) */
RUN_TEST(test_sim_conformance_types_compile);
RUN_TEST(test_sim_init_3node);
RUN_TEST(test_sim_trace_buf);
/* simulator network operations (task 4.2) */
RUN_TEST(test_sim_3node_election_one_leader);
RUN_TEST(test_sim_start_all_elections_no_winner);
RUN_TEST(test_sim_partition_no_quorum);
RUN_TEST(test_sim_fire_timeout_steps_down);
RUN_TEST(test_sim_deliver_append_entries_replicates);
RUN_TEST(test_sim_check_log_matching_pass_on_converged);
RUN_TEST(test_sim_check_log_matching_fail_on_divergence);
RUN_TEST(test_sim_3_2_log_matching_shared_idx_term_requires_identical_prefix);
RUN_TEST(test_sim_check_election_safety_fail_on_two_leaders);
RUN_TEST(test_sim_check_election_safety_pass_on_single_leader);
RUN_TEST(test_sim_9_1_replication_and_partition_catch_up);
RUN_TEST(test_sim_9_2_divergent_suffix_truncation);
RUN_TEST(test_sim_9_3_drop_dup_reorder_convergence);
RUN_TEST(test_sim_9_4_chaotic_churn_g5_regression);
RUN_TEST(test_sim_9_5_node_restart_resumes_replication);
/* simulator commit-safety invariant tests (tasks 7.1-7.4) */
RUN_TEST(test_sim_7_1_figure8_commit_refusal);
RUN_TEST(test_sim_7_2_leader_completeness_across_changes);
RUN_TEST(test_sim_7_3_freshness_gate_lexicographic);
RUN_TEST(test_sim_7_4_on_commit_advanced_trigger_semantics);
/* conformance init + in-memory adapter callbacks (task 1.2) */
RUN_TEST(test_sim_conformance_init_append_readback);
/* conformance bag push/deliver routing (task 2.1) */
RUN_TEST(test_sim_conformance_bag_deliver_routes_rpcs);
/* bag-only Drop/Duplicate/Reorder mutate only the bag (task 2.2) */
RUN_TEST(test_sim_2_2_drop_duplicate_preserve_node_state);
RUN_TEST(test_sim_2_2_reorder_prng_deterministic);
/* Group A: basic election correctness (task 5.1) */
RUN_TEST(test_proto_A1_single_node_self_elects);
RUN_TEST(test_proto_A2_3node_one_leader);
RUN_TEST(test_proto_A3_late_election_denied);
RUN_TEST(test_proto_A4_5node_quorum_3);
RUN_TEST(test_proto_A5_leader_denies_vote_request);
/* Group B: voted_for invariant (task 5.1) */
RUN_TEST(test_proto_B1_one_vote_per_term);
RUN_TEST(test_proto_B2_higher_term_updates_state);
RUN_TEST(test_proto_B3_stale_term_denied);
RUN_TEST(test_proto_B4_different_candidate_denied);
RUN_TEST(test_proto_B5_idempotent_same_candidate);
/* Group C: on_vote_eligible policy callback (task 5.2) */
RUN_TEST(test_proto_C1_eligible_denies);
RUN_TEST(test_proto_C2_eligible_grants);
RUN_TEST(test_proto_C3_eligible_null_grants);
RUN_TEST(test_proto_C4_stale_term_no_eligible_call);
RUN_TEST(test_proto_C5_already_voted_no_eligible_call);
/* Group H: Persistence and restart (task 5.2) */
RUN_TEST(test_proto_H1_init_with_persisted_state);
RUN_TEST(test_proto_H2_init_first_boot);
RUN_TEST(test_proto_H3_persist_before_vote_recorded);
RUN_TEST(test_proto_H4_persist_fail_denies);
RUN_TEST(test_proto_H5_start_election_self_vote_persisted);
RUN_TEST(test_proto_H6_restored_vote_blocks_second_vote);
RUN_TEST(test_proto_Hnull_required_callback_einval);
/* Group D: Quorum and Result (task 5.3) */
RUN_TEST(test_proto_D1_quorum_1grant_of_2_needed);
RUN_TEST(test_proto_D2_both_peers_deny_becomes_follower);
RUN_TEST(test_proto_D3_second_vote_reply_noop);
RUN_TEST(test_proto_D4_reply_higher_term_steps_down);
/* Group E: Election Timeout (task 5.3) */
RUN_TEST(test_proto_E1_timeout_before_quorum);
RUN_TEST(test_proto_E2_timeout_after_quorum_noop);
RUN_TEST(test_proto_E3_new_election_after_timeout);
/* Group F: RDHUP Failover (task 5.4) */
RUN_TEST(test_proto_F1_leader_lost_priority_delay);
RUN_TEST(test_proto_F2_voted_during_delay_skip);
RUN_TEST(test_proto_F3_no_vote_during_delay_starts_election);
RUN_TEST(test_proto_F4_simultaneous_rdhup_one_leader);
/* Group G: Split-Brain Prevention (task 5.4) */
RUN_TEST(test_proto_G1_no_split_brain_100_rounds);
RUN_TEST(test_proto_G2_partition_minority_no_quorum);
#endif
}