Skip to content

Commit 944e79a

Browse files
committed
feat(contributions): add unit tests for contribution repository and service
- Introduced comprehensive unit tests for the PostgresContributionRepository, covering Create, Delete, Update, and List functionalities. - Implemented a fakeContributionRepo for testing the ContributionService, ensuring proper validation and error handling. - Enhanced the contribution model to include LinkType and updated related queries and tests accordingly. - Improved error handling in service methods to provide clearer feedback for validation issues. - Utilized sqlmock to simulate database interactions in repository tests, validating behavior under various scenarios.
1 parent 3251398 commit 944e79a

4 files changed

Lines changed: 825 additions & 4 deletions

File tree

internal/repository/contributions.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ const (
1414
deleteContributionQuery = `DELETE FROM contributions WHERE id = $1`
1515
updateContributionQuery = `UPDATE contributions SET status = $1 WHERE id = $2`
1616

17-
listContributionsBaseQuery = `SELECT id, course_name, link_url, note, status, created_at FROM contributions`
17+
listContributionsBaseQuery = `SELECT id, course_name, link_url, link_type, note, status, created_at FROM contributions`
1818
listContributionsNoFilterQuery = listContributionsBaseQuery + ` ORDER BY created_at DESC LIMIT $1 OFFSET $2`
19-
listContributionsWithQQuery = listContributionsBaseQuery + ` WHERE (course_name ILIKE $1 OR link_url ILIKE $1 OR note ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3`
19+
listContributionsWithQQuery = listContributionsBaseQuery + ` WHERE (course_name ILIKE $1 OR link_url ILIKE $1 OR link_type ILIKE $1 OR note ILIKE $1 ) ORDER BY created_at DESC LIMIT $2 OFFSET $3`
2020
listContributionsWithStatusQuery = listContributionsBaseQuery + ` WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`
21-
listContributionsWithQStatusQuery = listContributionsBaseQuery + ` WHERE (course_name ILIKE $1 OR link_url ILIKE $1 OR note ILIKE $1) AND status = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4`
21+
listContributionsWithQStatusQuery = listContributionsBaseQuery + ` WHERE (course_name ILIKE $1 OR link_url ILIKE $1 OR link_type ILIKE $1 OR note ILIKE $1) AND status = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4`
2222
)
2323

2424
type ContributionRepository interface {
@@ -102,7 +102,7 @@ func (c *PostgresContributionRepository) List(ctx context.Context, limit int, of
102102
var contributions []models.Contribution
103103
for rows.Next() {
104104
var contribution models.Contribution
105-
if err := rows.Scan(&contribution.ID, &contribution.CourseName, &contribution.LinkURL, &contribution.Note, &contribution.Status, &contribution.CreatedAt); err != nil {
105+
if err := rows.Scan(&contribution.ID, &contribution.CourseName, &contribution.LinkURL, &contribution.LinkType, &contribution.Note, &contribution.Status, &contribution.CreatedAt); err != nil {
106106
return nil, fmt.Errorf("list contributions rows scan: %w", err)
107107
}
108108
contributions = append(contributions, contribution)
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
package repository
2+
3+
import (
4+
"context"
5+
"errors"
6+
"reflect"
7+
"testing"
8+
9+
"github.com/DATA-DOG/go-sqlmock"
10+
11+
"infolinks-backend/internal/errs"
12+
"infolinks-backend/internal/models"
13+
)
14+
15+
func newTestContributionRepo(t *testing.T) (*PostgresContributionRepository, sqlmock.Sqlmock) {
16+
t.Helper()
17+
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
18+
if err != nil {
19+
t.Fatalf("sqlmock.New: %v", err)
20+
}
21+
t.Cleanup(func() { _ = db.Close() })
22+
return NewPostgresContributionRepository(db), mock
23+
}
24+
25+
func TestPostgresContributionRepository_Create(t *testing.T) {
26+
tests := []struct {
27+
name string
28+
contribution models.Contribution
29+
execErr error
30+
err error
31+
}{
32+
{
33+
name: "insert contribution",
34+
contribution: models.Contribution{CourseName: "My Course", LinkURL: "https://test.com", LinkType: "telegram", Note: "note"},
35+
},
36+
{
37+
name: "insert exec error",
38+
contribution: models.Contribution{CourseName: "My Course", LinkURL: "https://test.com", LinkType: "telegram", Note: "note"},
39+
execErr: errs.ErrDatabaseDown,
40+
err: errs.ErrDatabaseDown,
41+
},
42+
}
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
repo, mock := newTestContributionRepo(t)
46+
exp := mock.ExpectExec(insertContributionQuery).
47+
WithArgs(tt.contribution.CourseName, tt.contribution.LinkURL, tt.contribution.LinkType, tt.contribution.Note)
48+
if tt.execErr != nil {
49+
exp.WillReturnError(tt.execErr)
50+
} else {
51+
exp.WillReturnResult(sqlmock.NewResult(1, 1))
52+
}
53+
54+
err := repo.Create(context.Background(), tt.contribution)
55+
if err != nil {
56+
if !errors.Is(err, tt.err) {
57+
t.Fatalf("got %v, want %v", err, tt.err)
58+
}
59+
return
60+
}
61+
if tt.err != nil {
62+
t.Fatalf("Create succeeded, want error %v", tt.err)
63+
}
64+
if err := mock.ExpectationsWereMet(); err != nil {
65+
t.Fatalf("expectations: %v", err)
66+
}
67+
})
68+
}
69+
}
70+
71+
func TestPostgresContributionRepository_Delete(t *testing.T) {
72+
tests := []struct {
73+
name string
74+
id int
75+
execErr error
76+
rowsAffected int64
77+
err error
78+
}{
79+
{
80+
name: "contribution not found",
81+
id: 909,
82+
rowsAffected: 0,
83+
err: errs.ErrContributionNotFound,
84+
},
85+
{
86+
name: "delete exec error",
87+
id: 10,
88+
execErr: errs.ErrDatabaseDown,
89+
err: errs.ErrDatabaseDown,
90+
},
91+
{
92+
name: "accept a valid id",
93+
id: 15,
94+
rowsAffected: 1,
95+
},
96+
}
97+
for _, tt := range tests {
98+
t.Run(tt.name, func(t *testing.T) {
99+
repo, mock := newTestContributionRepo(t)
100+
exp := mock.ExpectExec(deleteContributionQuery).WithArgs(tt.id)
101+
if tt.execErr != nil {
102+
exp.WillReturnError(tt.execErr)
103+
} else {
104+
exp.WillReturnResult(sqlmock.NewResult(0, tt.rowsAffected))
105+
}
106+
107+
err := repo.Delete(context.Background(), tt.id)
108+
if err != nil {
109+
if !errors.Is(err, tt.err) {
110+
t.Fatalf("got %v, want %v", err, tt.err)
111+
}
112+
return
113+
}
114+
if tt.err != nil {
115+
t.Fatalf("Delete succeeded, want error %v", tt.err)
116+
}
117+
if err := mock.ExpectationsWereMet(); err != nil {
118+
t.Fatalf("expectations: %v", err)
119+
}
120+
})
121+
}
122+
}
123+
124+
func TestPostgresContributionRepository_Update(t *testing.T) {
125+
tests := []struct {
126+
name string
127+
status string
128+
id int
129+
execErr error
130+
rowsAffected int64
131+
err error
132+
}{
133+
{
134+
name: "Contribution not found",
135+
status: "pending",
136+
id: 909,
137+
rowsAffected: 0,
138+
err: errs.ErrContributionNotFound,
139+
},
140+
{
141+
name: "update exec error",
142+
status: "approved",
143+
id: 10,
144+
execErr: errs.ErrDatabaseDown,
145+
err: errs.ErrDatabaseDown,
146+
},
147+
{
148+
name: "accept valid pending status",
149+
status: "pending",
150+
id: 15,
151+
rowsAffected: 1,
152+
},
153+
{
154+
name: "accept valid approved status",
155+
status: "approved",
156+
id: 15,
157+
rowsAffected: 1,
158+
},
159+
}
160+
for _, tt := range tests {
161+
t.Run(tt.name, func(t *testing.T) {
162+
repo, mock := newTestContributionRepo(t)
163+
exp := mock.ExpectExec(updateContributionQuery).WithArgs(tt.status, tt.id)
164+
if tt.execErr != nil {
165+
exp.WillReturnError(tt.execErr)
166+
} else {
167+
exp.WillReturnResult(sqlmock.NewResult(0, tt.rowsAffected))
168+
}
169+
170+
err := repo.Update(context.Background(), tt.status, tt.id)
171+
if err != nil {
172+
if !errors.Is(err, tt.err) {
173+
t.Fatalf("got %v, want %v", err, tt.err)
174+
}
175+
return
176+
}
177+
if tt.err != nil {
178+
t.Fatalf("Update succeeded, want error %v", tt.err)
179+
}
180+
if err := mock.ExpectationsWereMet(); err != nil {
181+
t.Fatalf("expectations: %v", err)
182+
}
183+
})
184+
}
185+
}
186+
187+
func TestPostgresContributionRepository_List(t *testing.T) {
188+
sampleRow := models.Contribution{
189+
ID: 3,
190+
CourseName: "Linux",
191+
LinkURL: "https://example.com",
192+
LinkType: "telegram",
193+
Note: "broken link",
194+
Status: "pending",
195+
CreatedAt: "2026-03-15T11:26:45Z",
196+
}
197+
198+
tests := []struct {
199+
name string
200+
limit int
201+
offset int
202+
q string
203+
status string
204+
query string
205+
queryArgs []any
206+
queryErr error
207+
rows [][]any
208+
rowsErr error
209+
err error
210+
wantResult []models.Contribution
211+
}{
212+
{
213+
name: "list without filters",
214+
limit: 35,
215+
offset: 0,
216+
query: listContributionsNoFilterQuery,
217+
queryArgs: []any{35, 0},
218+
rows: [][]any{{sampleRow.ID, sampleRow.CourseName, sampleRow.LinkURL, sampleRow.LinkType, sampleRow.Note, sampleRow.Status, sampleRow.CreatedAt}},
219+
wantResult: []models.Contribution{sampleRow},
220+
},
221+
{
222+
name: "list with search query",
223+
limit: 15,
224+
offset: 25,
225+
q: "Linux",
226+
status: "pending",
227+
query: listContributionsWithQStatusQuery,
228+
queryArgs: []any{"%Linux%", "pending", 15, 25},
229+
rows: [][]any{{sampleRow.ID, sampleRow.CourseName, sampleRow.LinkURL, sampleRow.LinkType, sampleRow.Note, sampleRow.Status, sampleRow.CreatedAt}},
230+
wantResult: []models.Contribution{sampleRow},
231+
},
232+
{
233+
name: "list with status only",
234+
limit: 10,
235+
offset: 10,
236+
status: "approved",
237+
query: listContributionsWithStatusQuery,
238+
queryArgs: []any{"approved", 10, 10},
239+
rows: [][]any{{2, "Go", "https://go.dev", "telegram", "", "approved", "2024-02-01T00:00:00Z"}},
240+
wantResult: []models.Contribution{{ID: 2, CourseName: "Go", LinkURL: "https://go.dev", LinkType: "telegram", Status: "approved", CreatedAt: "2024-02-01T00:00:00Z"}},
241+
},
242+
{
243+
name: "list with q only",
244+
limit: 10,
245+
offset: 0,
246+
q: "Linux",
247+
query: listContributionsWithQQuery,
248+
queryArgs: []any{"%Linux%", 10, 0},
249+
rows: [][]any{{sampleRow.ID, sampleRow.CourseName, sampleRow.LinkURL, sampleRow.LinkType, sampleRow.Note, sampleRow.Status, sampleRow.CreatedAt}},
250+
wantResult: []models.Contribution{sampleRow},
251+
},
252+
{
253+
name: "list query error",
254+
limit: 10,
255+
offset: 0,
256+
status: "approved",
257+
query: listContributionsWithStatusQuery,
258+
queryArgs: []any{"approved", 10, 0},
259+
queryErr: errs.ErrDatabaseDown,
260+
err: errs.ErrDatabaseDown,
261+
},
262+
{
263+
name: "list rows error",
264+
limit: 10,
265+
offset: 0,
266+
status: "pending",
267+
query: listContributionsWithStatusQuery,
268+
queryArgs: []any{"pending", 10, 0},
269+
rows: [][]any{{sampleRow.ID, sampleRow.CourseName, sampleRow.LinkURL, sampleRow.LinkType, sampleRow.Note, sampleRow.Status, sampleRow.CreatedAt}},
270+
rowsErr: errs.ErrDatabaseDown,
271+
err: errs.ErrDatabaseDown,
272+
},
273+
}
274+
for _, tt := range tests {
275+
t.Run(tt.name, func(t *testing.T) {
276+
repo, mock := newTestContributionRepo(t)
277+
exp := mock.ExpectQuery(tt.query).WithArgs(driverValues(tt.queryArgs)...)
278+
if tt.queryErr != nil {
279+
exp.WillReturnError(tt.queryErr)
280+
} else {
281+
cols := []string{"id", "course_name", "link_url", "link_type", "note", "status", "created_at"}
282+
rows := sqlmock.NewRows(cols)
283+
for _, row := range tt.rows {
284+
rows.AddRow(driverValues(row)...)
285+
}
286+
if tt.rowsErr != nil {
287+
rows.CloseError(tt.rowsErr)
288+
}
289+
exp.WillReturnRows(rows)
290+
}
291+
292+
got, err := repo.List(context.Background(), tt.limit, tt.offset, tt.q, tt.status)
293+
if err != nil {
294+
if !errors.Is(err, tt.err) {
295+
t.Fatalf("got %v, want %v", err, tt.err)
296+
}
297+
return
298+
}
299+
if tt.err != nil {
300+
t.Fatalf("List succeeded, want error %v", tt.err)
301+
}
302+
if !reflect.DeepEqual(got, tt.wantResult) {
303+
t.Fatalf("List result: got %+v want %+v", got, tt.wantResult)
304+
}
305+
if err := mock.ExpectationsWereMet(); err != nil {
306+
t.Fatalf("expectations: %v", err)
307+
}
308+
})
309+
}
310+
}

internal/service/contributions.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func NewContributionService(repo repository.ContributionRepository) *Contributio
2222
func (c *ContributionService) Create(ctx context.Context, contribution models.Contribution) error {
2323
contribution.CourseName = strings.TrimSpace(contribution.CourseName)
2424
contribution.LinkURL = strings.TrimSpace(contribution.LinkURL)
25+
contribution.LinkType = strings.TrimSpace(contribution.LinkType)
2526
contribution.Note = strings.TrimSpace(contribution.Note)
2627
if contribution.CourseName == "" || contribution.LinkURL == "" {
2728
return errs.ErrCourseNameAndLinkUrlRequired

0 commit comments

Comments
 (0)