myxce/src/lib/testsuite/test_raft.c
Rongsong Shen fc87a662cd fix(raft): reject n_peers > RAFT_MAX_PEERS in raft_init
next_index[]/match_index[]/pre_vote_replies[] are fixed-size RAFT_MAX_PEERS
arrays indexed by peer; without an upper-bound check a caller passing
n_peers > 8 would OOB-write in the leader-progress and pre-vote loops.
Add the guard (returns -EINVAL, no mutation) + canary-checked test.

Latent since task 1.1; surfaced by task 2.2 review (first loop writes into
the new arrays). No current caller exceeds the bound (sim caps at 8, HA <= 4).
2026-06-21 15:42:37 +00:00

4234 lines
141 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;
}
/* 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;
/* 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_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;
}
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;
/* 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);
}
/* 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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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_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_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);
}
/* ---------- 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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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, &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 */
/* ---------- 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);
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); /* 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); /* 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);
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);
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);
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 */));
/* (c) answer a STALE-term incoming request — deny */
TEST_ASSERT_EQUAL_INT(0, raft_on_pre_vote_request(&s, 2, 3 /* stale */));
/* (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));
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, &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_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_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);
/* 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
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);
/* 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
}