Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions spec/unit/features/saas_client.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
73 changes: 73 additions & 0 deletions spec/unit/saas_client_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading