Skip to content

Commit cd01329

Browse files
test(coverage): twin StorageExceeded arm + ResourceLogs/streamLogsSSE error arms
Cover the seam2-reachable arms in internal/handlers: - ProvisionForTwin StorageExceeded warning arm (db.go:574 / cache.go:502 / nosql.go:507) via the checkStorageQuota seam forced to exceeded=true, driven through the bufconn fakeProvisioner twin pipeline to a real 201. - ResourceLogs error/edge arms (logs.go): lookup_failed (closed DB), tail<1 clamp, pods_unavailable (List reactor error). - streamLogsSSE WriteString-error arms (sse_logs.go:61 + 72) via a size-1 bufio buffer over an always-failing writer. No production behaviour change; all new lines covered by new tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b549ed commit cd01329

3 files changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package handlers_test
2+
3+
// logs_resourcelogs_twinlogs_test.go — covers the error/edge arms of
4+
// LogsHandler.ResourceLogs (logs.go) that logs_coverage_test.go leaves open:
5+
//
6+
// logs.go:157-158 — lookup_failed: GetResourceByToken returns a non-NotFound
7+
// error (driven with a closed DB).
8+
// logs.go:194-196 — tail clamp: ?tail=0 (n<1) clamps up to 1.
9+
// logs.go:206-211 — pods_unavailable: the pod List call returns an error
10+
// (driven with a PrependReactor on the fake clientset).
11+
//
12+
// The clientset is the in-memory k8s fake (SetClientset seam), so these run
13+
// under CI's postgres-only matrix without a live cluster.
14+
15+
import (
16+
"errors"
17+
"net/http"
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
corev1 "k8s.io/api/core/v1"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/runtime"
25+
k8sfake "k8s.io/client-go/kubernetes/fake"
26+
k8stesting "k8s.io/client-go/testing"
27+
28+
"instant.dev/internal/handlers"
29+
"instant.dev/internal/testhelpers"
30+
)
31+
32+
// TestLogs_LookupFailed_503 drives logs.go:157-158: a DB error (not a
33+
// not-found) on GetResourceByToken returns 503 lookup_failed. We build the
34+
// handler against a CLOSED *sql.DB so the query fails with a driver error that
35+
// is NOT *models.ErrResourceNotFound.
36+
func TestLogs_LookupFailed_503(t *testing.T) {
37+
db, _ := testhelpers.SetupTestDB(t)
38+
h := handlers.NewLogsHandler(db)
39+
h.SetClientset(k8sfake.NewSimpleClientset())
40+
// Close the DB now so GetResourceByToken's query returns a driver error
41+
// (sql.ErrConnDone) — NOT a *models.ErrResourceNotFound — driving the
42+
// lookup_failed 503 arm rather than the not_found 404 arm.
43+
require.NoError(t, db.Close())
44+
45+
app := logsTestApp(t, db, h)
46+
// A syntactically valid UUID so we pass the parse gate and reach the lookup.
47+
resp := logsGet(t, app, "11111111-1111-1111-1111-111111111111", "")
48+
defer resp.Body.Close()
49+
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
50+
}
51+
52+
// TestLogs_TailClampLow_StreamsSSE drives logs.go:194-196: ?tail=0 (n<1) clamps
53+
// up to 1 and the happy path still streams. Needs a pod in the fake clientset.
54+
func TestLogs_TailClampLow_StreamsSSE(t *testing.T) {
55+
db, clean := testhelpers.SetupTestDB(t)
56+
defer clean()
57+
58+
const ns = "ns-clamp-low"
59+
cs := k8sfake.NewSimpleClientset(&corev1.Pod{
60+
ObjectMeta: metav1.ObjectMeta{
61+
Name: "postgres-0",
62+
Namespace: ns,
63+
Labels: map[string]string{"app": "postgres"},
64+
},
65+
})
66+
h := handlers.NewLogsHandler(db)
67+
h.SetClientset(cs)
68+
app := logsTestApp(t, db, h)
69+
70+
token := seedLogsResource(t, db, "postgres", "growth", "active", ns)
71+
resp := logsGet(t, app, token, "tail=0") // n<1 → clamp to 1
72+
defer resp.Body.Close()
73+
require.Equal(t, http.StatusOK, resp.StatusCode)
74+
assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
75+
}
76+
77+
// TestLogs_ListPodsError_503 drives logs.go:206-211: the pod List call errors.
78+
// A PrependReactor on the fake clientset makes List("pods") return an error so
79+
// the pods_unavailable arm runs (distinct from the empty-list pod_not_found arm
80+
// already covered).
81+
func TestLogs_ListPodsError_503(t *testing.T) {
82+
db, clean := testhelpers.SetupTestDB(t)
83+
defer clean()
84+
85+
cs := k8sfake.NewSimpleClientset()
86+
cs.PrependReactor("list", "pods",
87+
func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) {
88+
return true, nil, errors.New("apiserver unreachable")
89+
})
90+
h := handlers.NewLogsHandler(db)
91+
h.SetClientset(cs)
92+
app := logsTestApp(t, db, h)
93+
94+
token := seedLogsResource(t, db, "postgres", "growth", "active", "ns-list-err")
95+
resp := logsGet(t, app, token, "")
96+
defer resp.Body.Close()
97+
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
98+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package handlers
2+
3+
// sse_logs_writeerr_twinlogs_test.go — covers the two write-error early-return
4+
// arms of streamLogsSSE (sse_logs.go) that the existing sse_logs_test.go leaves
5+
// open because its failingWriter only ever surfaces the error at the *Flush*
6+
// call (line 64), never at the WriteString call itself:
7+
//
8+
// sse_logs.go:61-63 — WriteString of a data line returns an error → return.
9+
// sse_logs.go:72-74 — WriteString of the end marker returns an error → return.
10+
//
11+
// A bufio.Writer's WriteString only returns an error when an internal flush
12+
// (forced when its buffer fills) hits the underlying writer's error. The
13+
// existing tests use the default 4 KiB buffer, so the small SSE lines never
14+
// force a mid-WriteString flush — the error always lands on the explicit
15+
// w.Flush() instead. Wrapping an immediately-failing writer in a size-1 bufio
16+
// buffer forces the flush to happen *inside* WriteString, surfacing the error
17+
// at lines 61 and 72.
18+
19+
import (
20+
"bufio"
21+
"strings"
22+
"testing"
23+
)
24+
25+
// alwaysFailWriter fails on the very first Write — modelling a fasthttp client
26+
// that disconnected before any byte landed.
27+
type alwaysFailWriter struct{ writes int }
28+
29+
func (a *alwaysFailWriter) Write(p []byte) (int, error) {
30+
a.writes++
31+
return 0, errWriteClosed
32+
}
33+
34+
// errWriteClosed is a sentinel write error (kept as a package-level var so the
35+
// closure above stays allocation-free and the intent is named).
36+
var errWriteClosed = &writeClosedError{}
37+
38+
type writeClosedError struct{}
39+
40+
func (*writeClosedError) Error() string { return "writer closed" }
41+
42+
// TestStreamLogsSSE_DataWriteStringError_BreaksPump drives sse_logs.go:61-63:
43+
// the WriteString of a data line returns an error (not just the later Flush),
44+
// so the pump returns immediately and the deferred Close + cancel still run.
45+
func TestStreamLogsSSE_DataWriteStringError_BreaksPump(t *testing.T) {
46+
stream := &trackedStream{Reader: strings.NewReader("a line that exceeds one byte\nsecond line\n")}
47+
// size-1 buffer → the first WriteString forces an internal flush mid-write,
48+
// surfacing the underlying writer error from WriteString itself (line 61),
49+
// not from the explicit Flush (line 64).
50+
fw := &alwaysFailWriter{}
51+
w := bufio.NewWriterSize(fw, 1)
52+
53+
cancelled := false
54+
streamLogsSSE(w, stream, func() { cancelled = true })
55+
56+
if stream.closes != 1 {
57+
t.Errorf("stream Close called %d times after WriteString error, want 1", stream.closes)
58+
}
59+
if !cancelled {
60+
t.Error("cancel not invoked after data-line WriteString error")
61+
}
62+
}
63+
64+
// TestStreamLogsSSE_EndMarkerWriteStringError drives sse_logs.go:72-74: an empty
65+
// stream writes no data lines, then the end-marker WriteString hits the failing
66+
// underlying writer (via the size-1 buffer flush) and returns — exercising the
67+
// end-marker write-error branch. Teardown (Close + cancel) still runs via defer.
68+
func TestStreamLogsSSE_EndMarkerWriteStringError(t *testing.T) {
69+
stream := &trackedStream{Reader: strings.NewReader("")} // no data lines
70+
fw := &alwaysFailWriter{}
71+
w := bufio.NewWriterSize(fw, 1)
72+
73+
cancelled := false
74+
streamLogsSSE(w, stream, func() { cancelled = true })
75+
76+
if fw.writes == 0 {
77+
t.Error("end-marker WriteString did not reach the underlying writer")
78+
}
79+
if stream.closes != 1 {
80+
t.Errorf("stream Close called %d times after end-marker write error, want 1", stream.closes)
81+
}
82+
if !cancelled {
83+
t.Error("cancel not invoked after end-marker WriteString error")
84+
}
85+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package handlers_test
2+
3+
// twin_storage_exceeded_twinlogs_test.go — covers the `if res.StorageExceeded`
4+
// warning arm of the three twin renderers:
5+
//
6+
// db.go:574 DBHandler.ProvisionForTwin
7+
// cache.go:502 CacheHandler.ProvisionForTwin
8+
// nosql.go:507 NoSQLHandler.ProvisionForTwin
9+
//
10+
// That arm sets resp["warning"] + the X-Instant-Notice header, and is reachable
11+
// only when ProvisionForTwinCore returns StorageExceeded=true — a state that
12+
// requires a freshly-twinned resource to already exceed its tier's storage cap.
13+
// The checkStorageQuota seam (seams.go, driven by forceStorageExceeded in
14+
// storage_exceeded_seam2_test.go) forces exceeded=true at exactly the Core gate,
15+
// so the renderer takes the warning arm and surfaces it on the 201 response.
16+
//
17+
// Backend is the bufconn-backed fakeProvisioner from
18+
// coverage_provisioner_grpc_test.go (setupGRPCProvFixture), so the twin pipeline
19+
// reaches a real 201 (not a 503) under CI's postgres-only matrix — unlike the
20+
// live-backend seam2 anon/auth tests which skip when the customer backend is
21+
// unreachable.
22+
23+
import (
24+
"encoding/json"
25+
"io"
26+
"net/http"
27+
"net/http/httptest"
28+
"strings"
29+
"testing"
30+
31+
"github.com/stretchr/testify/assert"
32+
"github.com/stretchr/testify/require"
33+
34+
"instant.dev/internal/testhelpers"
35+
)
36+
37+
// postTwinDevRaw POSTs a single twin to the development env (which bypasses the
38+
// approval gate) and returns the raw response plus the decoded warning field, so
39+
// a test can assert both the X-Instant-Notice header and the warning JSON the
40+
// StorageExceeded arm sets.
41+
func postTwinDevRaw(t *testing.T, fx grpcProvFixture, sourceToken, jwt string) (*http.Response, string) {
42+
t.Helper()
43+
b, _ := json.Marshal(map[string]any{"env": "development"})
44+
req := httptest.NewRequest(http.MethodPost,
45+
"/api/v1/resources/"+sourceToken+"/provision-twin", strings.NewReader(string(b)))
46+
req.Header.Set("Content-Type", "application/json")
47+
req.Header.Set("Authorization", "Bearer "+jwt)
48+
resp, err := fx.app.Test(req, 15000)
49+
require.NoError(t, err)
50+
raw, _ := io.ReadAll(resp.Body)
51+
var parsed map[string]any
52+
_ = json.Unmarshal(raw, &parsed)
53+
warning, _ := parsed["warning"].(string)
54+
return resp, warning
55+
}
56+
57+
// assertTwinWarningArm asserts the twin renderer surfaced the storage-limit
58+
// warning that the StorageExceeded arm sets (both the JSON field and the notice
59+
// header).
60+
func assertTwinWarningArm(t *testing.T, resp *http.Response, warning string) {
61+
t.Helper()
62+
require.Equal(t, http.StatusCreated, resp.StatusCode)
63+
assert.Equal(t, "storage_limit_reached", resp.Header.Get("X-Instant-Notice"),
64+
"StorageExceeded twin arm must stamp the X-Instant-Notice header")
65+
assert.Contains(t, warning, "Storage limit reached",
66+
"StorageExceeded twin arm must surface the warning field")
67+
}
68+
69+
func TestTwin_DB_StorageExceeded_WarningArm(t *testing.T) {
70+
restore := forceStorageExceeded(t)
71+
defer restore()
72+
73+
fake := &fakeProvisioner{}
74+
fx := setupGRPCProvFixture(t, fake, false)
75+
teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro")
76+
jwt := authSessionJWT(t, fx.db, teamID)
77+
_, srcToken := seedSourceResource(t, fx.db, teamID, "postgres", "pro", "production")
78+
79+
resp, warning := postTwinDevRaw(t, fx, srcToken, jwt)
80+
defer resp.Body.Close()
81+
assertTwinWarningArm(t, resp, warning)
82+
}
83+
84+
func TestTwin_Cache_StorageExceeded_WarningArm(t *testing.T) {
85+
restore := forceStorageExceeded(t)
86+
defer restore()
87+
88+
fake := &fakeProvisioner{}
89+
fx := setupGRPCProvFixture(t, fake, false)
90+
teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro")
91+
jwt := authSessionJWT(t, fx.db, teamID)
92+
_, srcToken := seedSourceResource(t, fx.db, teamID, "redis", "pro", "production")
93+
94+
resp, warning := postTwinDevRaw(t, fx, srcToken, jwt)
95+
defer resp.Body.Close()
96+
assertTwinWarningArm(t, resp, warning)
97+
}
98+
99+
func TestTwin_NoSQL_StorageExceeded_WarningArm(t *testing.T) {
100+
restore := forceStorageExceeded(t)
101+
defer restore()
102+
103+
fake := &fakeProvisioner{}
104+
fx := setupGRPCProvFixture(t, fake, false)
105+
teamID := testhelpers.MustCreateTeamDB(t, fx.db, "pro")
106+
jwt := authSessionJWT(t, fx.db, teamID)
107+
_, srcToken := seedSourceResource(t, fx.db, teamID, "mongodb", "pro", "production")
108+
109+
resp, warning := postTwinDevRaw(t, fx, srcToken, jwt)
110+
defer resp.Body.Close()
111+
assertTwinWarningArm(t, resp, warning)
112+
}

0 commit comments

Comments
 (0)