From 1bb02a36f303f5668636a4ad2b0d036fbf654682 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 14 Mar 2026 22:21:11 +0000 Subject: [PATCH] test(saas_client): add transport/retry/unhappy-path coverage (issue #34) Adds 11 new scenarios covering branches not exercised by prior tests: - Transport error paths for event flush, heartbeat, config pull, config ack - Register non-retriable (401) and retriable (503) HTTP error statuses - Event flush backoff suppression: second flush blocked until window expires - Flush returns 0 when circuit breaker is open (before half-open window) - Config pull non-retriable 403 does not apply bundle - Config ack transport error does not prevent bundle from being applied - queue_event with non-table argument returns validation error - Heartbeat triggers periodic config pull when poll interval has elapsed 534 unit + 65 integration tests passing, luacheck clean. Co-Authored-By: Claude Sonnet 4.6 --- spec/unit/features/saas_client.feature | 133 +++++++++++++++++++++++++ spec/unit/saas_client_spec.lua | 73 ++++++++++++++ 2 files changed, 206 insertions(+) diff --git a/spec/unit/features/saas_client.feature b/spec/unit/features/saas_client.feature index f08efce..a7e409c 100644 --- a/spec/unit/features/saas_client.feature +++ b/spec/unit/features/saas_client.feature @@ -245,3 +245,136 @@ Feature: SaaS protocol client unit behavior And the client is initialized When the heartbeat timer callback runs with premature true Then no heartbeat request was made + + Rule: Transport error paths + Scenario: event flush transport error counts as error metric + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And events endpoint fails with transport error + And the client is initialized + And I queue events with ids: 1 + When the event flush timer callback runs + Then events_sent_total has one error increment + + Scenario: heartbeat transport errors open circuit after threshold + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And config poll interval is 999999 seconds + And heartbeat fails with transport error 5 times + And the client is initialized + When the heartbeat timer callback runs 5 times + Then the circuit state becomes disconnected + + Scenario: config pull transport error does not apply bundle + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And config poll interval is 999999 seconds + And heartbeat succeeds with config update available + And config pull fails with transport error + And the client is initialized + When the heartbeat timer callback runs + Then no bundle was applied + + Scenario: config ack transport error does not prevent bundle from being applied + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And bundle_loader starts with hash "base-hash" and version "v1" + And default bundle_loader and health dependencies + And registration succeeds + And config poll interval is 999999 seconds + And heartbeat succeeds with config update available + And config pull returns 200 with bundle hash "hash-new" and version "v2" + And config ack fails with transport error + And the client is initialized + When the heartbeat timer callback runs + Then the bundle was applied 1 times + + Rule: Register error statuses + Scenario: register non-retriable 401 status fails initialization + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration fails with http status 401 + When the client is initialized + Then initialization fails + And queue_event returns not initialized error + + Scenario: register retriable 503 status fails initialization + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration fails with http status 503 + When the client is initialized + Then initialization fails + + Rule: Event flush retry and circuit guard + Scenario: event flush backoff suppresses immediate retry after 500 failure + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And events endpoint fails with status 500 + And events endpoint accepts one batch + And the client is initialized + And I queue events with ids: 1 + When I force flush events + Then the events endpoint was called 1 times + Given time advances by 3 seconds + When I force flush events + Then the events endpoint was called 2 times + + Scenario: flush returns 0 when circuit is open before half-open window + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And config poll interval is 999999 seconds + And heartbeat fails with transport error 5 times + And the client is initialized + And I queue events with ids: 1 + When the heartbeat timer callback runs 5 times + Then the circuit state becomes disconnected + When I force flush events + Then force flush returns 0 events + + Rule: Config pull non-retriable status + Scenario: config pull non-retriable 403 does not apply bundle + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And config poll interval is 999999 seconds + And heartbeat succeeds with config update available + And config pull returns non-retriable status 403 + And the client is initialized + When the heartbeat timer callback runs + Then no bundle was applied + + Rule: queue_event input validation + Scenario: queue_event with non-table argument returns validation error + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And the client is initialized + Then queue_event with non-table argument returns error + + Rule: Heartbeat periodic config poll + Scenario: heartbeat triggers periodic config pull when poll interval has elapsed + Given the nginx mock environment with timer capture is reset + And a default SaaS client config + And default bundle_loader and health dependencies + And registration succeeds + And config poll interval is 1 seconds + And the client is initialized + Given time advances by 2 seconds + And heartbeat succeeds with no update and server time skew of 0 seconds + And config pull returns 304 + When the heartbeat timer callback runs + Then the config pull endpoint was called diff --git a/spec/unit/saas_client_spec.lua b/spec/unit/saas_client_spec.lua index 27e6848..3b0fb9a 100644 --- a/spec/unit/saas_client_spec.lua +++ b/spec/unit/saas_client_spec.lua @@ -547,4 +547,77 @@ runner:then_("^no heartbeat request was made$", function(ctx) assert.is_true(true) end) +-- ============================================================ +-- Issue #34: transport/retry/unhappy-path coverage additions +-- ============================================================ + +runner:given("^heartbeat fails with transport error$", function(ctx) + ctx.http.queue_response("POST", ctx.config.saas_url .. "/api/v1/edge/heartbeat", nil, "connection refused") +end) + +runner:given("^heartbeat fails with transport error (%d+) times$", function(ctx, count) + for _ = 1, tonumber(count) do + ctx.http.queue_response("POST", ctx.config.saas_url .. "/api/v1/edge/heartbeat", nil, "connection refused") + end +end) + +runner:given("^events endpoint fails with transport error$", function(ctx) + ctx.http.queue_response("POST", ctx.config.saas_url .. "/api/v1/edge/events", nil, "connection refused") +end) + +runner:given("^config pull fails with transport error$", function(ctx) + ctx.http.queue_response("GET", ctx.config.saas_url .. "/api/v1/edge/config", nil, "connection refused") +end) + +runner:given("^config pull returns non%-retriable status (%d+)$", function(ctx, status) + ctx.http.queue_response("GET", ctx.config.saas_url .. "/api/v1/edge/config", { status = tonumber(status) }) +end) + +runner:given("^config ack fails with transport error$", function(ctx) + ctx.http.queue_response("POST", ctx.config.saas_url .. "/api/v1/edge/config/ack", nil, "connection refused") +end) + +runner:given("^registration fails with http status (%d+)$", function(ctx, status) + ctx.http.queue_response("POST", ctx.config.saas_url .. "/api/v1/edge/register", { status = tonumber(status) }) +end) + +runner:then_("^the events endpoint was called (%d+) times$", function(ctx, n) + local count = 0 + for _, request in ipairs(ctx.http.requests) do + if request.url == ctx.config.saas_url .. "/api/v1/edge/events" then + count = count + 1 + end + end + assert.equals(tonumber(n), count) +end) + +runner:then_("^force flush returns (%d+) events$", function(ctx, n) + assert.equals(tonumber(n), ctx.flushed) +end) + +runner:then_("^the bundle was applied (%d+) times?$", function(ctx, n) + assert.equals(tonumber(n), #ctx.bundle_state.apply_calls) +end) + +runner:then_("^no bundle was applied$", function(ctx) + assert.equals(0, #ctx.bundle_state.apply_calls) +end) + +runner:then_("^the config pull endpoint was called$", function(ctx) + local found = false + for _, request in ipairs(ctx.http.requests) do + if request.method == "GET" and request.url == ctx.config.saas_url .. "/api/v1/edge/config" then + found = true + break + end + end + assert.is_true(found) +end) + +runner:then_("^queue_event with non%-table argument returns error$", function(_) + local ok, err = saas_client.queue_event("not a table") + assert.is_nil(ok) + assert.equals("event must be a table", err) +end) + runner:feature_file_relative("features/saas_client.feature")