Skip to content

Commit 1f2bce9

Browse files
test(coverage): api handlers resource/billing/webhooks slice + internal/db → ≥95% (#160)
* test(coverage): internal/db → 100% + readyz.go ≥95% Adds package-var seams (readMigrationDir/readMigrationFile/sqlOpen in internal/db; readyzSQLOpen in handlers) to reach the genuinely-unreachable embed-read / lazy-driver-open error branches, plus a synchronous runExporterLoop split so the ctx.Done arm is recorded deterministically. - internal/db: 94.7% → 100.0% - handlers/readyz.go: customerDBCheck, redisFailedPing, statusToFloat, buildChecks upstream branches all ≥95% Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): dev.go + openapi.go → 100% dev.go: NewSetTierHandler all arms (invalid body/team_id/tier/uuid, upgrade failure via closed pool, success via real team). openapi.go: SetOpenAPIEnvironment empty-guard, ServeOpenAPI dev+prod(cached) paths, stripInternalSetTierPath edge guards (no-colon/no-brace/unbalanced), escaped-string brace walker, middle-vs-last-entry comma trimming. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): resource.go provider helpers + handler error arms + audit emitters - Provider helpers (revoke/grant Postgres/Mongo, setRedisACLEnabled, rotate{Postgres,Redis,Mongo}Password): validation, open/connect (via resourcePGOpen/resourceMongoConnect seams), exec/command-error, and real-backend success arms. - Handler methods (List/Get/Delete/GetCredentials/RotateCredentials/Pause/ Resume): unauthorized, invalid-uuid, not-found, cross-team-404, DB-error, no_connection_url, aes_key_invalid, decrypt_failed arms via Locals-shim app. - Audit emitters → 100% (user-actor arm + InsertAuditEvent warn arm). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): resource.go Pause/Resume/Rotate/Delete branch coverage Adds provider-no-op arms (redis/mongo/storage), provider-failed 503 arms, tier-gate, already-paused/invalid-state/not-paused conflicts, rotate provider-warn arms (postgres/redis/mongo non-fatal), and Pause/Resume/Delete success paths via a no-backend Locals-shim fixture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): webhook.go helpers — verifyWebhookHMAC/idempotency/maxStored/decrypt verifyWebhookHMAC, lookupIdempotentReceive, storeIdempotentReceive, decryptWebhookURL → 100%; webhookMaxStored → 100% (floor arm via custom 0-stored plans registry); storeEncryptedURL success+update-fail+key-parse arms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): onboarding.go email helpers + claim-verification + audit isValidEmail (all reachable rejection arms incl. angle-addr), maskEmailForLog → 100%, emitOnboardingClaimedAudit → 100% (success + Nil-user + warn arms), sendClaimVerificationEmail (nil-mailer, empty-email, send-success, send-fail, create-link-fail arms). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): admin parse/order/MRR helpers → 100% adminParseTierFilter (all return cases), adminParseLimit/Offset (clamp arms), adminOrderClause (all sort keys + invalid), escapeLikePattern, computeMRR (nil-plans + priced), parsePromoAuditSince (RFC3339/date/empty/invalid). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): billing.go chargedPaymentMeta + receiptDedupKey → 100% Internal tests over the unexported rzp* webhook types: nil/bad-json/valid payment entity; receipt dedup key empty-sub / paid_count / payment-id-fallback / neither arms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): admin_customer_notes ListNotes/DeleteNote → 100%, CreateNote arms invalid-uuid, db_failed (list/delete), team_not_found, missing/empty/too-long body, note_not_found, success arms via Locals-shim notes app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): admin_impersonate Impersonate error+success arms invalid_team_id, team_not_found(404), db_failed(503), team_has_no_users(409), and the JWT-mint success path via Locals-shim app + real team/user rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5203bc3 commit 1f2bce9

18 files changed

Lines changed: 2276 additions & 14 deletions

internal/db/pool_metrics.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,20 @@ func StartPoolStatsExporter(ctx context.Context, pool *sql.DB, label string) {
5454
// Prom rules can't distinguish from "process unreachable").
5555
publishStats(pool, label)
5656

57+
runExporterLoop(ctx, pool, label, ticker.C)
58+
}
59+
60+
// runExporterLoop is the blocking sample-or-stop select loop, split out so a
61+
// unit test can drive both arms synchronously (on the test goroutine, so the
62+
// atomic coverage counters are recorded deterministically rather than racing
63+
// the spawning goroutine's exit).
64+
func runExporterLoop(ctx context.Context, pool *sql.DB, label string, tick <-chan time.Time) {
5765
for {
5866
select {
5967
case <-ctx.Done():
6068
slog.Info("db.pool_metrics.exporter_stopped", "label", label)
6169
return
62-
case <-ticker.C:
70+
case <-tick:
6371
publishStats(pool, label)
6472
}
6573
}

internal/db/postgres.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ import (
1919
//go:embed migrations/*.sql
2020
var migrationsFS embed.FS
2121

22+
// Seams for testing genuinely-unreachable error branches. Production code
23+
// always uses the real embedded FS + lib/pq driver; tests swap these to
24+
// drive the embed-read / driver-open failure paths that can't otherwise
25+
// be reached because the FS is compiled in and lib/pq opens lazily.
26+
var (
27+
readMigrationDir = func() ([]fs.DirEntry, error) { return fs.ReadDir(migrationsFS, "migrations") }
28+
readMigrationFile = func(name string) ([]byte, error) { return fs.ReadFile(migrationsFS, "migrations/"+name) }
29+
sqlOpen = sql.Open
30+
)
31+
2232
// RunMigrations executes all embedded SQL migration files in alphabetical order.
2333
// All SQL files use CREATE TABLE IF NOT EXISTS / ALTER TABLE ADD COLUMN IF NOT EXISTS /
2434
// CREATE INDEX IF NOT EXISTS — safe to re-run on every startup.
@@ -36,7 +46,7 @@ func RunMigrations(db *sql.DB) error {
3646
}
3747

3848
for _, name := range names {
39-
content, err := fs.ReadFile(migrationsFS, "migrations/"+name)
49+
content, err := readMigrationFile(name)
4050
if err != nil {
4151
return fmt.Errorf("db.RunMigrations: read %s: %w", name, err)
4252
}
@@ -68,7 +78,7 @@ func RunMigrations(db *sql.DB) error {
6878
// filenames. Exported via MigrationFiles for read-only callers that want
6979
// to compare the in-binary set against the DB-tracked set.
7080
func embeddedMigrationFilenames() ([]string, error) {
71-
entries, err := fs.ReadDir(migrationsFS, "migrations")
81+
entries, err := readMigrationDir()
7282
if err != nil {
7383
return nil, fmt.Errorf("read dir: %w", err)
7484
}
@@ -173,7 +183,7 @@ func envDuration(name string, def time.Duration) time.Duration {
173183
// API_PG_CONN_MAX_LIFETIME (default 4m) — Go time.Duration
174184
// API_PG_CONN_MAX_IDLE_TIME (default 90s)
175185
func ConnectPostgres(databaseURL string) *sql.DB {
176-
db, err := sql.Open("postgres", databaseURL)
186+
db, err := sqlOpen("postgres", databaseURL)
177187
if err != nil {
178188
panic(&ErrDBConnect{Cause: err})
179189
}

internal/db/seam_rbw_test.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Package db: coverage for the genuinely-unreachable error branches in the
2+
// migration runner + ConnectPostgres, driven through the package-var seams
3+
// (readMigrationDir / readMigrationFile / sqlOpen). These paths can't be
4+
// reached with the real embedded FS (compiled in, always readable) or the
5+
// lib/pq driver (sql.Open is lazy and never errors on a DSN), so the seams
6+
// let a test inject the failure deterministically.
7+
//
8+
// Rule-17 coverage block:
9+
// Symptom: postgres.go 34-36 / 40-42 / 72-74 / 177-178 uncovered (94.1%)
10+
// Enumeration: grep 'postgres.go' cover.out | awk '$NF==0'
11+
// Sites found: 4 zero-count blocks
12+
// Sites touched: 4 (this file)
13+
// Coverage test: TestRunMigrations_FilenameListError, _FileReadError,
14+
// TestEmbeddedMigrationFilenames_ReadDirError,
15+
// TestConnectPostgres_PanicsOnOpenError,
16+
// TestStartPoolStatsExporter_CtxDoneAfterFirstTick
17+
package db
18+
19+
import (
20+
"context"
21+
"database/sql"
22+
"errors"
23+
"io/fs"
24+
"strings"
25+
"testing"
26+
"time"
27+
)
28+
29+
// withSeams swaps the package-var seams for the duration of a test and
30+
// restores them after. Centralises the save/restore so a forgotten restore
31+
// can't leak into the next test in the serialized (-p 1) run.
32+
func withSeams(t *testing.T, dir func() ([]fs.DirEntry, error), file func(string) ([]byte, error), open func(string, string) (*sql.DB, error)) {
33+
t.Helper()
34+
origDir, origFile, origOpen := readMigrationDir, readMigrationFile, sqlOpen
35+
if dir != nil {
36+
readMigrationDir = dir
37+
}
38+
if file != nil {
39+
readMigrationFile = file
40+
}
41+
if open != nil {
42+
sqlOpen = open
43+
}
44+
t.Cleanup(func() {
45+
readMigrationDir = origDir
46+
readMigrationFile = origFile
47+
sqlOpen = origOpen
48+
})
49+
}
50+
51+
// TestEmbeddedMigrationFilenames_ReadDirError covers postgres.go:72-74 — the
52+
// fs.ReadDir failure path inside embeddedMigrationFilenames.
53+
func TestEmbeddedMigrationFilenames_ReadDirError(t *testing.T) {
54+
sentinel := errors.New("boom: embed dir unreadable")
55+
withSeams(t, func() ([]fs.DirEntry, error) { return nil, sentinel }, nil, nil)
56+
57+
names, err := embeddedMigrationFilenames()
58+
if err == nil {
59+
t.Fatal("embeddedMigrationFilenames: want error, got nil")
60+
}
61+
if names != nil {
62+
t.Errorf("names should be nil on error, got %v", names)
63+
}
64+
if !errors.Is(err, sentinel) {
65+
t.Errorf("error should wrap sentinel: %v", err)
66+
}
67+
if !strings.Contains(err.Error(), "read dir") {
68+
t.Errorf("error wrapping lost 'read dir': %v", err)
69+
}
70+
}
71+
72+
// TestRunMigrations_FilenameListError covers postgres.go:34-36 — RunMigrations
73+
// returns early when embeddedMigrationFilenames (via the dir seam) errors.
74+
func TestRunMigrations_FilenameListError(t *testing.T) {
75+
sentinel := errors.New("boom: cannot list migrations")
76+
withSeams(t, func() ([]fs.DirEntry, error) { return nil, sentinel }, nil, nil)
77+
78+
err := RunMigrations(nil) // db never touched — list fails first
79+
if err == nil {
80+
t.Fatal("RunMigrations: want error from filename list, got nil")
81+
}
82+
if !strings.Contains(err.Error(), "db.RunMigrations") {
83+
t.Errorf("error wrapping lost prefix: %v", err)
84+
}
85+
if !errors.Is(err, sentinel) {
86+
t.Errorf("error should wrap sentinel: %v", err)
87+
}
88+
}
89+
90+
// TestRunMigrations_FileReadError covers postgres.go:40-42 — RunMigrations
91+
// returns when reading a listed migration file fails. The dir seam yields a
92+
// non-empty name list so the loop body runs, and the file seam fails the read.
93+
func TestRunMigrations_FileReadError(t *testing.T) {
94+
sentinel := errors.New("boom: file vanished")
95+
withSeams(t,
96+
func() ([]fs.DirEntry, error) { return []fs.DirEntry{fakeDirEntry{name: "001_x.sql"}}, nil },
97+
func(string) ([]byte, error) { return nil, sentinel },
98+
nil,
99+
)
100+
101+
err := RunMigrations(nil) // db never reached — read fails before Exec
102+
if err == nil {
103+
t.Fatal("RunMigrations: want error from file read, got nil")
104+
}
105+
if !strings.Contains(err.Error(), "read 001_x.sql") {
106+
t.Errorf("error should name the failing file: %v", err)
107+
}
108+
if !errors.Is(err, sentinel) {
109+
t.Errorf("error should wrap sentinel: %v", err)
110+
}
111+
}
112+
113+
// fakeDirEntry is a minimal fs.DirEntry so the dir seam can return a
114+
// synthetic non-empty file list without touching a real FS.
115+
type fakeDirEntry struct{ name string }
116+
117+
func (f fakeDirEntry) Name() string { return f.name }
118+
func (f fakeDirEntry) IsDir() bool { return false }
119+
func (f fakeDirEntry) Type() fs.FileMode { return 0 }
120+
func (f fakeDirEntry) Info() (fs.FileInfo, error) { return nil, nil }
121+
122+
// TestConnectPostgres_PanicsOnOpenError covers postgres.go:177-178 — the
123+
// sql.Open failure panic. lib/pq's Open is lazy and never errors on a DSN,
124+
// so this branch is only reachable via the sqlOpen seam.
125+
func TestConnectPostgres_PanicsOnOpenError(t *testing.T) {
126+
sentinel := errors.New("boom: driver open failed")
127+
withSeams(t, nil, nil, func(string, string) (*sql.DB, error) { return nil, sentinel })
128+
129+
defer func() {
130+
r := recover()
131+
if r == nil {
132+
t.Fatal("ConnectPostgres: expected panic on open error")
133+
}
134+
e, ok := r.(*ErrDBConnect)
135+
if !ok {
136+
t.Fatalf("panic value: want *ErrDBConnect, got %T (%v)", r, r)
137+
}
138+
if !errors.Is(e.Cause, sentinel) {
139+
t.Errorf("ErrDBConnect.Cause should wrap sentinel, got %v", e.Cause)
140+
}
141+
}()
142+
ConnectPostgres("postgres://whatever")
143+
}
144+
145+
// TestRunExporterLoop_CtxDoneArm drives the ctx.Done() branch
146+
// (pool_metrics.go ctx.Done case) SYNCHRONOUSLY on the test goroutine, so the
147+
// atomic coverage counter is recorded deterministically. A pre-cancelled
148+
// context + an empty (never-firing) tick channel forces the ctx.Done() arm.
149+
func TestRunExporterLoop_CtxDoneArm(t *testing.T) {
150+
dsn := testDSN()
151+
pool, err := sql.Open("postgres", dsn)
152+
if err != nil {
153+
t.Skipf("postgres open: %v", err)
154+
}
155+
defer pool.Close()
156+
157+
ctx, cancel := context.WithCancel(context.Background())
158+
cancel() // already done before the loop is entered
159+
tick := make(chan time.Time)
160+
161+
// runs on this goroutine and returns immediately via the ctx.Done() arm.
162+
runExporterLoop(ctx, pool, "rbw-ctxdone", tick)
163+
}
164+
165+
// TestRunExporterLoop_TickArm drives the ticker arm synchronously: a pre-fed
166+
// tick channel publishes one sample, then ctx cancellation exits the loop.
167+
func TestRunExporterLoop_TickArm(t *testing.T) {
168+
dsn := testDSN()
169+
pool, err := sql.Open("postgres", dsn)
170+
if err != nil {
171+
t.Skipf("postgres open: %v", err)
172+
}
173+
defer pool.Close()
174+
175+
tick := make(chan time.Time, 1)
176+
tick <- time.Now() // one sample will publish
177+
178+
ctx, cancel := context.WithCancel(context.Background())
179+
// Cancel shortly after the single tick is consumed so the loop takes the
180+
// tick arm at least once, then exits via ctx.Done().
181+
go func() {
182+
time.Sleep(50 * time.Millisecond)
183+
cancel()
184+
}()
185+
186+
runExporterLoop(ctx, pool, "rbw-tick", tick)
187+
}
188+
189+
// TestStartPoolStatsExporter_CtxDoneAfterFirstTick exercises the public entry
190+
// end-to-end (immediate publish + loop) and asserts clean shutdown on cancel.
191+
func TestStartPoolStatsExporter_CtxDoneAfterFirstTick(t *testing.T) {
192+
dsn := testDSN()
193+
pool, err := sql.Open("postgres", dsn)
194+
if err != nil {
195+
t.Skipf("postgres open: %v", err)
196+
}
197+
defer pool.Close()
198+
199+
ctx, cancel := context.WithCancel(context.Background())
200+
done := make(chan struct{})
201+
go func() {
202+
StartPoolStatsExporter(ctx, pool, "rbw-ctxdone-e2e")
203+
close(done)
204+
}()
205+
time.Sleep(50 * time.Millisecond)
206+
cancel()
207+
208+
select {
209+
case <-done:
210+
case <-time.After(2 * time.Second):
211+
t.Fatal("StartPoolStatsExporter did not return after ctx cancel")
212+
}
213+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package handlers_test
2+
3+
import (
4+
"errors"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"instant.dev/internal/handlers"
11+
"instant.dev/internal/plans"
12+
)
13+
14+
// TestAdminParseTierFilter covers every documented return case.
15+
func TestAdminParseTierFilter(t *testing.T) {
16+
out, empty := handlers.AdminParseTierFilterForTest("")
17+
require.Nil(t, out)
18+
require.False(t, empty)
19+
20+
out, empty = handlers.AdminParseTierFilterForTest("pro")
21+
require.Equal(t, []string{"pro"}, out)
22+
require.False(t, empty)
23+
24+
out, _ = handlers.AdminParseTierFilterForTest("hobby, ,pro,HOBBY") // dedupe + case + whitespace
25+
require.Equal(t, []string{"hobby", "pro"}, out)
26+
27+
out, empty = handlers.AdminParseTierFilterForTest("platinum") // all unknown
28+
require.Nil(t, out)
29+
require.True(t, empty)
30+
31+
out, empty = handlers.AdminParseTierFilterForTest(" , , ") // only whitespace → no filter
32+
require.Nil(t, out)
33+
require.False(t, empty)
34+
35+
out, _ = handlers.AdminParseTierFilterForTest("pro,platinum") // partial unknown
36+
require.Equal(t, []string{"pro"}, out)
37+
}
38+
39+
// TestAdminParseLimit covers default / clamp-low / clamp-high / valid.
40+
func TestAdminParseLimit(t *testing.T) {
41+
require.Equal(t, 25, handlers.AdminParseLimitForTest("", 25, 100))
42+
require.Equal(t, 25, handlers.AdminParseLimitForTest("abc", 25, 100))
43+
require.Equal(t, 25, handlers.AdminParseLimitForTest("0", 25, 100))
44+
require.Equal(t, 25, handlers.AdminParseLimitForTest("-3", 25, 100))
45+
require.Equal(t, 100, handlers.AdminParseLimitForTest("9999", 25, 100))
46+
require.Equal(t, 42, handlers.AdminParseLimitForTest(" 42 ", 25, 100))
47+
}
48+
49+
// TestAdminParseOffset covers default / negative / valid.
50+
func TestAdminParseOffset(t *testing.T) {
51+
require.Equal(t, 0, handlers.AdminParseOffsetForTest(""))
52+
require.Equal(t, 0, handlers.AdminParseOffsetForTest("xyz"))
53+
require.Equal(t, 0, handlers.AdminParseOffsetForTest("-1"))
54+
require.Equal(t, 7, handlers.AdminParseOffsetForTest(" 7 "))
55+
}
56+
57+
// TestAdminOrderClause covers all sort keys + the invalid arm.
58+
func TestAdminOrderClause(t *testing.T) {
59+
for _, sb := range []string{"", "mrr", "last_active", "created_at", "storage_bytes"} {
60+
clause, err := handlers.AdminOrderClauseForTest(sb)
61+
require.NoError(t, err, "sort_by=%q", sb)
62+
require.NotEmpty(t, clause)
63+
}
64+
_, err := handlers.AdminOrderClauseForTest("'; DROP TABLE--")
65+
require.Error(t, err)
66+
}
67+
68+
// TestEscapeLikePattern covers the three metacharacters + backslash.
69+
func TestEscapeLikePattern(t *testing.T) {
70+
require.Equal(t, `\%\_`, handlers.EscapeLikePatternForTest("%_"))
71+
require.Equal(t, `\\`, handlers.EscapeLikePatternForTest(`\`))
72+
require.Equal(t, "plain", handlers.EscapeLikePatternForTest("plain"))
73+
}
74+
75+
// TestComputeMRR covers the nil-plans arm + the priced arm.
76+
func TestComputeMRR(t *testing.T) {
77+
// nil plans → (0,0)
78+
hNil := handlers.NewAdminCustomersHandler(nil, nil)
79+
m, a := handlers.ComputeMRRForTest(hNil, "pro")
80+
require.Equal(t, 0, m)
81+
require.Equal(t, 0, a)
82+
83+
// real registry → monthly>0 for a paid tier, annual = 12×monthly
84+
h := handlers.NewAdminCustomersHandler(nil, plans.Default())
85+
m, a = handlers.ComputeMRRForTest(h, "pro")
86+
require.Greater(t, m, 0)
87+
require.Equal(t, m*12, a)
88+
}
89+
90+
// TestParsePromoAuditSince covers empty / RFC3339 / date-only / invalid.
91+
func TestParsePromoAuditSince(t *testing.T) {
92+
tm, err := handlers.ParsePromoAuditSinceForTest("")
93+
require.NoError(t, err)
94+
require.True(t, tm.IsZero())
95+
96+
tm, err = handlers.ParsePromoAuditSinceForTest("2026-05-20T10:00:00Z")
97+
require.NoError(t, err)
98+
require.False(t, tm.IsZero())
99+
100+
tm, err = handlers.ParsePromoAuditSinceForTest("2026-05-20")
101+
require.NoError(t, err)
102+
require.Equal(t, 2026, tm.Year())
103+
104+
_, err = handlers.ParsePromoAuditSinceForTest("not-a-date")
105+
require.Error(t, err)
106+
}
107+
108+
var _ = errors.Is
109+
var _ = time.Now

0 commit comments

Comments
 (0)