diff --git a/.kiro/specs/raft-library/tasks.md b/.kiro/specs/raft-library/tasks.md index abdfcfb..09bfbc2 100644 --- a/.kiro/specs/raft-library/tasks.md +++ b/.kiro/specs/raft-library/tasks.md @@ -102,7 +102,7 @@ - All 10 Unity test cases pass; `raft_sim_dump_log` confirms correct event sequence for A2 - _Requirements: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3, 3.5, 8.2, 8.3_ -- [ ] 5.2 Test Groups C and H: on_vote_eligible policy and persistence/restart (11 cases) +- [x] 5.2 Test Groups C and H: on_vote_eligible policy and persistence/restart (11 cases) - C1: all Raft invariants pass, `on_vote_eligible` returns 0 → denied - C2: all Raft invariants pass, `on_vote_eligible` returns 1 → granted - C3: `on_vote_eligible` is NULL at init → eligible candidates granted when Raft invariants pass diff --git a/src/lib/testsuite/test_raft.c b/src/lib/testsuite/test_raft.c index c264ed3..1e26117 100644 --- a/src/lib/testsuite/test_raft.c +++ b/src/lib/testsuite/test_raft.c @@ -1829,6 +1829,340 @@ static void test_proto_A5_leader_denies_vote_request(void) 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); + 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_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) ---------- */ /* @@ -2065,5 +2399,20 @@ void test_raft(void) 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_Hnull_required_callback_einval); #endif }