Skip to content

Commit 68c4fbd

Browse files
committed
test: add cross-module dependency testing and CI pipeline
Implements a comprehensive dependency testing strategy to catch regressions when any component changes: Phase 1 — Isolated Contract Tests (no Docker): - Gateway <-> Broker: contract_test.go (8 tests) - Verifies Gateway handles Broker schema drift (null fields, missing state, empty body, status code mapping) - Tests Broker-down scenario - Gateway <-> Bridge: fault_injection_test.go (3 tests) - Verifies 429 rate limiting is treated as recoverable (retried) - Verifies 401 unauthorized is treated as permanent (no retry) - Verifies ErrorEnvelope.StatusCode type assertion contract Phase 2 — E2E Orchestration: - scripts/test-e2e.sh: runs Phase 1 then optionally spins up the full Docker stack and runs all SDK smoke tests against it Phase 3 — CI Pipeline: - .github/workflows/ci.yml: runs on every PR and push to main - Go: SDK, Gateway, Bridge, Broker tests - TypeScript: SDK build verification - Python: full unit test suite Makefile updated: - 'make test' now includes Python + TS SDKs - 'make test-e2e' for full dependency verification Verified: make test ✅ (all modules pass)
1 parent c04cac9 commit 68c4fbd

5 files changed

Lines changed: 489 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
name: Unit & Contract Tests
15+
runs-on: ubuntu-latest
16+
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Setup Go
22+
uses: actions/setup-go@v5
23+
with:
24+
go-version-file: nexus-gateway/go.mod
25+
26+
- name: Setup Node.js
27+
uses: actions/setup-node@v4
28+
with:
29+
node-version: 22
30+
31+
- name: Setup Python
32+
uses: actions/setup-python@v5
33+
with:
34+
python-version: '3.12'
35+
36+
- name: Install TS SDK dependencies
37+
working-directory: nexus-sdk-ts
38+
run: npm ci
39+
40+
# --- Go Modules ---
41+
- name: Go SDK — test
42+
working-directory: nexus-sdk
43+
run: go test ./... -count=1
44+
45+
- name: Gateway — test (incl. broker contract tests)
46+
working-directory: nexus-gateway
47+
run: go test ./... -count=1
48+
49+
- name: Bridge — test (incl. fault injection)
50+
working-directory: nexus-bridge
51+
run: go test ./... -count=1 -timeout=30s
52+
53+
- name: Broker — test
54+
working-directory: nexus-broker
55+
run: go test ./... -count=1
56+
57+
# --- TypeScript SDK ---
58+
- name: TypeScript SDK — build
59+
working-directory: nexus-sdk-ts
60+
run: npm run build
61+
62+
# --- Python SDK ---
63+
- name: Python SDK — test
64+
working-directory: nexus-sdk-python
65+
run: python3 -m unittest discover -s tests -v

Makefile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,19 @@ logs:
2121
build:
2222
docker-compose build
2323

24-
# Run all tests (Broker + Gateway + Bridge + SDK)
24+
# Run all unit + contract tests (no Docker required)
2525
test:
2626
@echo "Running tests for all modules..."
2727
(cd nexus-broker && go test ./...)
2828
(cd nexus-gateway && go test ./...)
29-
(cd nexus-bridge && go test ./...)
29+
(cd nexus-bridge && go test ./... -timeout=30s)
3030
(cd nexus-sdk && go test ./...)
31+
(cd nexus-sdk-python && python3 -m unittest discover -s tests -q)
32+
(cd nexus-sdk-ts && npm run build)
33+
34+
# Run full E2E dependency tests (Phase 1 + Phase 2 with Docker)
35+
test-e2e:
36+
@bash scripts/test-e2e.sh
3137

3238
# Clean up build artifacts and temp files
3339
clean:
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package bridge_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
"github.com/Prescott-Data/nexus-framework/nexus-bridge"
10+
"github.com/Prescott-Data/nexus-framework/nexus-bridge/pkg/auth"
11+
oauthsdk "github.com/Prescott-Data/nexus-framework/nexus-sdk"
12+
)
13+
14+
// --- Gateway <-> Bridge Fault Injection Tests ---
15+
// These tests verify that the Bridge correctly handles error conditions
16+
// from the Gateway (via the SDK). If the Gateway's error contract changes,
17+
// these tests will fail, preventing silent dependency breaks.
18+
19+
// mockFaultTokenProvider lets us inject specific SDK errors.
20+
type mockFaultTokenProvider struct {
21+
getTokenFunc func(ctx context.Context, connectionID string) (*auth.Token, error)
22+
refreshConnectionFunc func(ctx context.Context, connectionID string) (*auth.Token, error)
23+
}
24+
25+
func (m *mockFaultTokenProvider) GetToken(ctx context.Context, connectionID string) (*auth.Token, error) {
26+
return m.getTokenFunc(ctx, connectionID)
27+
}
28+
29+
func (m *mockFaultTokenProvider) RefreshConnection(ctx context.Context, connectionID string) (*auth.Token, error) {
30+
if m.refreshConnectionFunc != nil {
31+
return m.refreshConnectionFunc(ctx, connectionID)
32+
}
33+
return nil, errors.New("not implemented")
34+
}
35+
36+
// TestBridge_RateLimited429_IsRecoverable verifies that when the Gateway
37+
// returns a 429 (via ErrorEnvelope.StatusCode), the Bridge treats it as
38+
// a recoverable error and retries instead of marking it permanent.
39+
func TestBridge_RateLimited429_IsRecoverable(t *testing.T) {
40+
t.Parallel()
41+
42+
var callCount int
43+
provider := &mockFaultTokenProvider{
44+
getTokenFunc: func(ctx context.Context, connectionID string) (*auth.Token, error) {
45+
callCount++
46+
if callCount <= 2 {
47+
// Simulate what the SDK returns on 429
48+
return nil, oauthsdk.ErrorEnvelope{
49+
Code: "rate_limited",
50+
Message: "Too Many Requests",
51+
StatusCode: 429,
52+
}
53+
}
54+
// Third call succeeds
55+
return &auth.Token{
56+
Strategy: auth.AuthStrategy{Type: "oauth2"},
57+
Credentials: auth.Credentials{"access_token": "tok"},
58+
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
59+
}, nil
60+
},
61+
}
62+
63+
retryPolicy := bridge.RetryPolicy{
64+
MinBackoff: 10 * time.Millisecond,
65+
MaxBackoff: 50 * time.Millisecond,
66+
Jitter: 5 * time.Millisecond,
67+
}
68+
b := bridge.New(provider, bridge.WithRetryPolicy(retryPolicy), bridge.WithLogger(&testLogger{t: t}))
69+
70+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
71+
defer cancel()
72+
73+
// MaintainWebSocket will try to get a token, get 429'd twice, then succeed
74+
// and try to connect to a bogus URL — which fails permanently.
75+
// We just need to verify the 429 was retried (callCount > 1).
76+
_ = b.MaintainWebSocket(ctx, "conn-rate-limited", "ws://127.0.0.1:1", &noopHandler{})
77+
78+
if callCount < 2 {
79+
t.Errorf("expected at least 2 GetToken calls (429 should be retried), got %d", callCount)
80+
}
81+
}
82+
83+
// TestBridge_AuthError401_IsPermanent verifies that a 401 Unauthorized
84+
// from the Gateway is treated as a permanent error (no retry).
85+
func TestBridge_AuthError401_IsPermanent(t *testing.T) {
86+
t.Parallel()
87+
88+
var callCount int
89+
provider := &mockFaultTokenProvider{
90+
getTokenFunc: func(ctx context.Context, connectionID string) (*auth.Token, error) {
91+
callCount++
92+
return nil, oauthsdk.ErrorEnvelope{
93+
Code: "unauthorized",
94+
Message: "Invalid credentials",
95+
StatusCode: 401,
96+
}
97+
},
98+
}
99+
100+
retryPolicy := bridge.RetryPolicy{
101+
MinBackoff: 10 * time.Millisecond,
102+
MaxBackoff: 50 * time.Millisecond,
103+
Jitter: 5 * time.Millisecond,
104+
}
105+
b := bridge.New(provider, bridge.WithRetryPolicy(retryPolicy), bridge.WithLogger(&testLogger{t: t}))
106+
107+
err := b.MaintainWebSocket(context.Background(), "conn-401", "ws://127.0.0.1:1", &noopHandler{})
108+
109+
// Should be a PermanentError — not retried
110+
var permErr *bridge.PermanentError
111+
if !errors.As(err, &permErr) {
112+
t.Fatalf("expected PermanentError for 401, got: %v", err)
113+
}
114+
115+
if callCount != 1 {
116+
t.Errorf("expected exactly 1 GetToken call (401 should NOT be retried), got %d", callCount)
117+
}
118+
}
119+
120+
// TestBridge_SDKErrorEnvelopeContract verifies the Bridge can type-assert
121+
// on oauthsdk.ErrorEnvelope and access StatusCode. If the SDK changes the
122+
// ErrorEnvelope type or removes StatusCode, this test breaks.
123+
func TestBridge_SDKErrorEnvelopeContract(t *testing.T) {
124+
t.Parallel()
125+
126+
// Create an ErrorEnvelope as the SDK would
127+
err := oauthsdk.ErrorEnvelope{
128+
Code: "rate_limited",
129+
Message: "slow down",
130+
StatusCode: 429,
131+
}
132+
133+
// Verify it implements the error interface
134+
var _ error = err
135+
136+
// Verify errors.As works (this is how the bridge detects it)
137+
var envErr oauthsdk.ErrorEnvelope
138+
if !errors.As(err, &envErr) {
139+
t.Fatal("errors.As failed to match ErrorEnvelope")
140+
}
141+
142+
if envErr.StatusCode != 429 {
143+
t.Errorf("expected StatusCode 429, got %d", envErr.StatusCode)
144+
}
145+
if envErr.Code != "rate_limited" {
146+
t.Errorf("expected Code 'rate_limited', got %q", envErr.Code)
147+
}
148+
}

0 commit comments

Comments
 (0)