Skip to content

Commit 5203bc3

Browse files
test(coverage): drive api internal/models to ≥95% via sqlmock seams (#159)
Add white-box sqlmock-driven branch coverage for every internal/models source file. Pure test-only additions — no production code changed. Each function's happy path, sql.ErrNoRows / sentinel-error branches, rows-affected guards, scan failures, rows.Err iteration errors, and transaction begin/commit/rollback paths are exercised via go-sqlmock so DB-error branches that can't be hit against a healthy Postgres are covered deterministically. models coverage: 34.2% → 98.7% (go test ./internal/models/... -short -p 1 -covermode=atomic). Remaining sub-95% functions are the crypto/rand.Read error branch in the *Plaintext token generators, which is unreachable in Go 1.26 (crypto/rand.Read reads the OS getrandom syscall directly and panics rather than returning an error, ignoring the package Reader var). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c8dbb7b commit 5203bc3

29 files changed

Lines changed: 5855 additions & 0 deletions
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package models
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/DATA-DOG/go-sqlmock"
11+
"github.com/google/uuid"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestCreateAdminCustomerNote_Branches(t *testing.T) {
16+
ctx := context.Background()
17+
18+
// empty body
19+
db, _ := newMock(t)
20+
_, err := CreateAdminCustomerNote(ctx, db, CreateAdminCustomerNoteParams{Body: " "})
21+
require.ErrorIs(t, err, ErrAdminCustomerNoteEmpty)
22+
23+
// too long
24+
db2, _ := newMock(t)
25+
_, err = CreateAdminCustomerNote(ctx, db2, CreateAdminCustomerNoteParams{Body: strings.Repeat("x", AdminCustomerNoteMaxBody+1)})
26+
require.ErrorIs(t, err, ErrAdminCustomerNoteTooLong)
27+
28+
// happy
29+
db3, mock := newMock(t)
30+
mock.ExpectQuery(`INSERT INTO admin_customer_notes`).
31+
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(uuid.New(), time.Now()))
32+
got, err := CreateAdminCustomerNote(ctx, db3, CreateAdminCustomerNoteParams{TeamID: uuid.New(), Body: "hi", AuthorEmail: "a@b.com"})
33+
require.NoError(t, err)
34+
require.Equal(t, "hi", got.Body)
35+
36+
// db error
37+
db4, mock := newMock(t)
38+
mock.ExpectQuery(`INSERT INTO admin_customer_notes`).WillReturnError(errors.New("boom"))
39+
_, err = CreateAdminCustomerNote(ctx, db4, CreateAdminCustomerNoteParams{Body: "x"})
40+
require.ErrorContains(t, err, "boom")
41+
}
42+
43+
func TestListAdminCustomerNotes_Branches(t *testing.T) {
44+
ctx := context.Background()
45+
cols := []string{"id", "team_id", "body", "author_email", "created_at"}
46+
47+
// clamps + happy
48+
db, mock := newMock(t)
49+
mock.ExpectQuery(`FROM admin_customer_notes`).
50+
WillReturnRows(sqlmock.NewRows(cols).AddRow(uuid.New(), uuid.New(), "b", "a@b.com", time.Now()))
51+
out, err := ListAdminCustomerNotes(ctx, db, uuid.New(), 0) // default limit
52+
require.NoError(t, err)
53+
require.Len(t, out, 1)
54+
55+
db2, mock2 := newMock(t)
56+
mock2.ExpectQuery(`FROM admin_customer_notes`).WillReturnRows(sqlmock.NewRows(cols))
57+
_, err = ListAdminCustomerNotes(ctx, db2, uuid.New(), 99999) // over max
58+
require.NoError(t, err)
59+
60+
// query error
61+
db3, mock3 := newMock(t)
62+
mock3.ExpectQuery(`FROM admin_customer_notes`).WillReturnError(errors.New("qerr"))
63+
_, err = ListAdminCustomerNotes(ctx, db3, uuid.New(), 10)
64+
require.ErrorContains(t, err, "qerr")
65+
66+
// scan error
67+
db4, mock4 := newMock(t)
68+
mock4.ExpectQuery(`FROM admin_customer_notes`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uuid.New()))
69+
_, err = ListAdminCustomerNotes(ctx, db4, uuid.New(), 10)
70+
require.Error(t, err)
71+
72+
// rows.Err()
73+
db5, mock5 := newMock(t)
74+
mock5.ExpectQuery(`FROM admin_customer_notes`).WillReturnRows(
75+
sqlmock.NewRows(cols).AddRow(uuid.New(), uuid.New(), "b", "a@b.com", time.Now()).RowError(0, errors.New("rowerr")))
76+
_, err = ListAdminCustomerNotes(ctx, db5, uuid.New(), 10)
77+
require.ErrorContains(t, err, "rowerr")
78+
}
79+
80+
func TestDeleteAdminCustomerNote_Branches(t *testing.T) {
81+
ctx := context.Background()
82+
83+
db, mock := newMock(t)
84+
mock.ExpectExec(`DELETE FROM admin_customer_notes`).WillReturnResult(sqlmock.NewResult(0, 1))
85+
require.NoError(t, DeleteAdminCustomerNote(ctx, db, uuid.New()))
86+
87+
db2, mock2 := newMock(t)
88+
mock2.ExpectExec(`DELETE FROM admin_customer_notes`).WillReturnResult(sqlmock.NewResult(0, 0))
89+
require.ErrorIs(t, DeleteAdminCustomerNote(ctx, db2, uuid.New()), ErrAdminCustomerNoteNotFound)
90+
91+
db3, mock3 := newMock(t)
92+
mock3.ExpectExec(`DELETE FROM admin_customer_notes`).WillReturnError(errors.New("boom"))
93+
require.ErrorContains(t, DeleteAdminCustomerNote(ctx, db3, uuid.New()), "boom")
94+
95+
db4, mock4 := newMock(t)
96+
mock4.ExpectExec(`DELETE FROM admin_customer_notes`).WillReturnResult(sqlmock.NewErrorResult(errors.New("raerr")))
97+
require.ErrorContains(t, DeleteAdminCustomerNote(ctx, db4, uuid.New()), "raerr")
98+
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
package models
2+
3+
// coverage_admin_promo_codes_test.go — sqlmock-driven branch coverage for
4+
// admin_promo_codes.go. White-box (package models) so the unexported
5+
// generatePromoCode var seam can be stubbed for the deterministic
6+
// collision-retry path. Every error branch is reached by injecting the
7+
// matching sqlmock failure; the happy paths assert the returned row shape.
8+
9+
import (
10+
"context"
11+
"errors"
12+
"testing"
13+
"time"
14+
15+
"github.com/DATA-DOG/go-sqlmock"
16+
"github.com/google/uuid"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestIsValidPromoKind_AllBranches(t *testing.T) {
21+
require.True(t, IsValidPromoKind(PromoKindPercentOff))
22+
require.True(t, IsValidPromoKind(PromoKindFirstMonthFree))
23+
require.True(t, IsValidPromoKind(PromoKindAmountOff))
24+
require.False(t, IsValidPromoKind("nope"))
25+
require.ElementsMatch(t,
26+
[]string{PromoKindPercentOff, PromoKindFirstMonthFree, PromoKindAmountOff},
27+
ValidPromoKinds())
28+
}
29+
30+
func TestIsValidPromoAuditEvent_AllBranches(t *testing.T) {
31+
require.True(t, IsValidPromoAuditEvent(PromoAuditEventIssued))
32+
require.True(t, IsValidPromoAuditEvent(PromoAuditEventRedeemed))
33+
require.True(t, IsValidPromoAuditEvent(PromoAuditEventExpired))
34+
require.False(t, IsValidPromoAuditEvent("garbage"))
35+
}
36+
37+
func TestIssueAdminPromoCode_Validation(t *testing.T) {
38+
db, _, _ := sqlmock.New()
39+
defer db.Close()
40+
ctx := context.Background()
41+
42+
_, err := IssueAdminPromoCode(ctx, db, CreateAdminPromoCodeParams{Kind: "bad", ValidForDays: 1, IssuedByEmail: "a@b.com"})
43+
require.ErrorIs(t, err, ErrInvalidPromoKind)
44+
45+
_, err = IssueAdminPromoCode(ctx, db, CreateAdminPromoCodeParams{Kind: PromoKindPercentOff, ValidForDays: 0, IssuedByEmail: "a@b.com"})
46+
require.ErrorIs(t, err, ErrInvalidPromoDuration)
47+
48+
_, err = IssueAdminPromoCode(ctx, db, CreateAdminPromoCodeParams{Kind: PromoKindPercentOff, ValidForDays: 1, Value: -1, IssuedByEmail: "a@b.com"})
49+
require.ErrorIs(t, err, ErrInvalidPromoValue)
50+
51+
_, err = IssueAdminPromoCode(ctx, db, CreateAdminPromoCodeParams{Kind: PromoKindPercentOff, ValidForDays: 1, IssuedByEmail: " "})
52+
require.Error(t, err)
53+
}
54+
55+
func TestIssueAdminPromoCode_HappyPath(t *testing.T) {
56+
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
57+
require.NoError(t, err)
58+
defer db.Close()
59+
ctx := context.Background()
60+
61+
mock.ExpectQuery(`INSERT INTO admin_promo_codes`).
62+
WillReturnRows(sqlmock.NewRows([]string{
63+
"id", "code", "team_id", "issued_by_email", "kind", "value", "applies_to", "used_at", "expires_at", "created_at",
64+
}).AddRow(uuid.New(), "ABCD1234", uuid.New(), "a@b.com", PromoKindPercentOff, 10, 5, nil, time.Now(), time.Now()))
65+
66+
row, err := IssueAdminPromoCode(ctx, db, CreateAdminPromoCodeParams{
67+
TeamID: uuid.New(), IssuedByEmail: "a@b.com", Kind: PromoKindPercentOff, Value: 10, AppliesTo: 5, ValidForDays: 30,
68+
})
69+
require.NoError(t, err)
70+
require.Equal(t, "ABCD1234", row.Code)
71+
require.NoError(t, mock.ExpectationsWereMet())
72+
}
73+
74+
func TestIssueAdminPromoCode_GenError(t *testing.T) {
75+
db, _, _ := sqlmock.New()
76+
defer db.Close()
77+
orig := generatePromoCode
78+
defer func() { generatePromoCode = orig }()
79+
generatePromoCode = func() (string, error) { return "", errors.New("rng dead") }
80+
81+
_, err := IssueAdminPromoCode(context.Background(), db, CreateAdminPromoCodeParams{
82+
IssuedByEmail: "a@b.com", Kind: PromoKindPercentOff, ValidForDays: 1,
83+
})
84+
require.ErrorContains(t, err, "rng dead")
85+
}
86+
87+
func TestIssueAdminPromoCode_NonUniqueDBError(t *testing.T) {
88+
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
89+
require.NoError(t, err)
90+
defer db.Close()
91+
mock.ExpectQuery(`INSERT INTO admin_promo_codes`).WillReturnError(errors.New("disk full"))
92+
93+
_, err = IssueAdminPromoCode(context.Background(), db, CreateAdminPromoCodeParams{
94+
IssuedByEmail: "a@b.com", Kind: PromoKindPercentOff, Value: 0, ValidForDays: 1,
95+
})
96+
require.ErrorContains(t, err, "disk full")
97+
}
98+
99+
func TestIssueAdminPromoCode_CollisionRetriesExhausted(t *testing.T) {
100+
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
101+
require.NoError(t, err)
102+
defer db.Close()
103+
for i := 0; i < 5; i++ {
104+
mock.ExpectQuery(`INSERT INTO admin_promo_codes`).WillReturnError(errors.New("duplicate key value violates unique constraint admin_promo_codes_code_key"))
105+
}
106+
_, err = IssueAdminPromoCode(context.Background(), db, CreateAdminPromoCodeParams{
107+
IssuedByEmail: "a@b.com", Kind: PromoKindAmountOff, Value: 100, ValidForDays: 7,
108+
})
109+
require.ErrorContains(t, err, "collision after retries")
110+
require.NoError(t, mock.ExpectationsWereMet())
111+
}
112+
113+
func TestGetAdminPromoCodeByCode_Branches(t *testing.T) {
114+
ctx := context.Background()
115+
cols := []string{"id", "code", "team_id", "issued_by_email", "kind", "value", "applies_to", "used_at", "expires_at", "created_at"}
116+
117+
// happy
118+
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
119+
mock.ExpectQuery(`SELECT id, code, team_id`).
120+
WillReturnRows(sqlmock.NewRows(cols).AddRow(uuid.New(), "X", uuid.New(), "a@b.com", PromoKindPercentOff, 5, nil, nil, time.Now(), time.Now()))
121+
got, err := GetAdminPromoCodeByCode(ctx, db, " x ", uuid.New())
122+
require.NoError(t, err)
123+
require.Equal(t, "X", got.Code)
124+
db.Close()
125+
126+
// not found
127+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
128+
mock.ExpectQuery(`SELECT id, code, team_id`).WillReturnError(errNoRows())
129+
_, err = GetAdminPromoCodeByCode(ctx, db, "x", uuid.New())
130+
require.ErrorIs(t, err, ErrAdminPromoCodeNotFound)
131+
db.Close()
132+
133+
// transient
134+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
135+
mock.ExpectQuery(`SELECT id, code, team_id`).WillReturnError(errors.New("conn reset"))
136+
_, err = GetAdminPromoCodeByCode(ctx, db, "x", uuid.New())
137+
require.ErrorContains(t, err, "conn reset")
138+
db.Close()
139+
}
140+
141+
func TestMarkAdminPromoCodeUsed_Branches(t *testing.T) {
142+
ctx := context.Background()
143+
144+
// happy — 1 row
145+
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
146+
mock.ExpectExec(`UPDATE admin_promo_codes`).WillReturnResult(sqlmock.NewResult(0, 1))
147+
require.NoError(t, MarkAdminPromoCodeUsed(ctx, db, uuid.New()))
148+
db.Close()
149+
150+
// already used — 0 rows
151+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
152+
mock.ExpectExec(`UPDATE admin_promo_codes`).WillReturnResult(sqlmock.NewResult(0, 0))
153+
require.ErrorIs(t, MarkAdminPromoCodeUsed(ctx, db, uuid.New()), ErrAdminPromoCodeAlreadyUsed)
154+
db.Close()
155+
156+
// exec error
157+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
158+
mock.ExpectExec(`UPDATE admin_promo_codes`).WillReturnError(errors.New("boom"))
159+
require.ErrorContains(t, MarkAdminPromoCodeUsed(ctx, db, uuid.New()), "boom")
160+
db.Close()
161+
162+
// rows-affected error
163+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
164+
mock.ExpectExec(`UPDATE admin_promo_codes`).WillReturnResult(sqlmock.NewErrorResult(errors.New("ra boom")))
165+
require.ErrorContains(t, MarkAdminPromoCodeUsed(ctx, db, uuid.New()), "ra boom")
166+
db.Close()
167+
}
168+
169+
func TestListPromoAuditEvents_Branches(t *testing.T) {
170+
ctx := context.Background()
171+
cols := []string{"event_type", "code", "team_id", "team_email", "issued_by_email", "kind", "value", "applies_to", "issued_at", "redeemed_at", "expired_at", "event_at"}
172+
173+
// happy + scan
174+
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
175+
mock.ExpectQuery(`WITH promo_events`).WillReturnRows(
176+
sqlmock.NewRows(cols).AddRow("issued", "C", uuid.New(), "a@b.com", "a@b.com", PromoKindPercentOff, 10, 0, time.Now(), nil, nil, time.Now()))
177+
out, err := ListPromoAuditEvents(ctx, db, ListPromoAuditEventsParams{Limit: 10})
178+
require.NoError(t, err)
179+
require.Len(t, out, 1)
180+
db.Close()
181+
182+
// query error
183+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
184+
mock.ExpectQuery(`WITH promo_events`).WillReturnError(errors.New("qerr"))
185+
_, err = ListPromoAuditEvents(ctx, db, ListPromoAuditEventsParams{})
186+
require.ErrorContains(t, err, "qerr")
187+
db.Close()
188+
189+
// scan error (wrong column count)
190+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
191+
mock.ExpectQuery(`WITH promo_events`).WillReturnRows(sqlmock.NewRows([]string{"event_type"}).AddRow("issued"))
192+
_, err = ListPromoAuditEvents(ctx, db, ListPromoAuditEventsParams{})
193+
require.Error(t, err)
194+
db.Close()
195+
196+
// rows.Err()
197+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
198+
mock.ExpectQuery(`WITH promo_events`).WillReturnRows(
199+
sqlmock.NewRows(cols).AddRow("issued", "C", uuid.New(), "a@b.com", "a@b.com", PromoKindPercentOff, 10, 0, time.Now(), nil, nil, time.Now()).RowError(0, errors.New("rowerr")))
200+
_, err = ListPromoAuditEvents(ctx, db, ListPromoAuditEventsParams{})
201+
require.ErrorContains(t, err, "rowerr")
202+
db.Close()
203+
}
204+
205+
func TestComputePromoStats_Branches(t *testing.T) {
206+
ctx := context.Background()
207+
208+
// happy with leaderboards
209+
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
210+
mock.ExpectQuery(`COUNT\(\*\) AS issued_total`).WillReturnRows(sqlmock.NewRows([]string{"issued_total", "redeemed_total", "expired_total"}).AddRow(10, 3, 2))
211+
mock.ExpectQuery(`GROUP BY lower\(issued_by_email\)`).WillReturnRows(sqlmock.NewRows([]string{"email", "n"}).AddRow("a@b.com", 7))
212+
mock.ExpectQuery(`WHERE used_at IS NOT NULL\s+GROUP BY code`).WillReturnRows(sqlmock.NewRows([]string{"code", "n"}).AddRow("X", 3))
213+
s, err := ComputePromoStats(ctx, db)
214+
require.NoError(t, err)
215+
require.Equal(t, 10, s.IssuedTotal)
216+
require.InDelta(t, 0.3, s.RedemptionRate, 0.0001)
217+
require.Len(t, s.TopIssuers, 1)
218+
require.Len(t, s.TopCodesByRedemption, 1)
219+
db.Close()
220+
221+
// totals error
222+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
223+
mock.ExpectQuery(`COUNT\(\*\) AS issued_total`).WillReturnError(errors.New("terr"))
224+
_, err = ComputePromoStats(ctx, db)
225+
require.ErrorContains(t, err, "terr")
226+
db.Close()
227+
228+
// issuers query error
229+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
230+
mock.ExpectQuery(`COUNT\(\*\) AS issued_total`).WillReturnRows(sqlmock.NewRows([]string{"issued_total", "redeemed_total", "expired_total"}).AddRow(0, 0, 0))
231+
mock.ExpectQuery(`GROUP BY lower\(issued_by_email\)`).WillReturnError(errors.New("ierr"))
232+
_, err = ComputePromoStats(ctx, db)
233+
require.ErrorContains(t, err, "ierr")
234+
db.Close()
235+
236+
// issuers scan error
237+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
238+
mock.ExpectQuery(`COUNT\(\*\) AS issued_total`).WillReturnRows(sqlmock.NewRows([]string{"issued_total", "redeemed_total", "expired_total"}).AddRow(5, 1, 0))
239+
mock.ExpectQuery(`GROUP BY lower\(issued_by_email\)`).WillReturnRows(sqlmock.NewRows([]string{"email"}).AddRow("a@b.com"))
240+
_, err = ComputePromoStats(ctx, db)
241+
require.Error(t, err)
242+
db.Close()
243+
244+
// codes query error
245+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
246+
mock.ExpectQuery(`COUNT\(\*\) AS issued_total`).WillReturnRows(sqlmock.NewRows([]string{"issued_total", "redeemed_total", "expired_total"}).AddRow(5, 1, 0))
247+
mock.ExpectQuery(`GROUP BY lower\(issued_by_email\)`).WillReturnRows(sqlmock.NewRows([]string{"email", "n"}).AddRow("a@b.com", 5))
248+
mock.ExpectQuery(`WHERE used_at IS NOT NULL\s+GROUP BY code`).WillReturnError(errors.New("cerr"))
249+
_, err = ComputePromoStats(ctx, db)
250+
require.ErrorContains(t, err, "cerr")
251+
db.Close()
252+
253+
// codes scan error
254+
db, mock, _ = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
255+
mock.ExpectQuery(`COUNT\(\*\) AS issued_total`).WillReturnRows(sqlmock.NewRows([]string{"issued_total", "redeemed_total", "expired_total"}).AddRow(5, 1, 0))
256+
mock.ExpectQuery(`GROUP BY lower\(issued_by_email\)`).WillReturnRows(sqlmock.NewRows([]string{"email", "n"}).AddRow("a@b.com", 5))
257+
mock.ExpectQuery(`WHERE used_at IS NOT NULL\s+GROUP BY code`).WillReturnRows(sqlmock.NewRows([]string{"code"}).AddRow("X"))
258+
_, err = ComputePromoStats(ctx, db)
259+
require.Error(t, err)
260+
db.Close()
261+
}

0 commit comments

Comments
 (0)