6eba500cb15dc2ebc5135b6a1cf31b040ada6f32

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Migrate to pgx, and tests, and add CI

Diff

This diff is truncated to protect this page.

  1diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..a1322d778875b32aea7133a5e59dab1f15b28a65
  4--- /dev/null
  5+++ b/.github/workflows/ci.yaml
  6@@ -0,0 +1,62 @@
  7+---
  8+name: "Go CI"
  9+
 10+"on":
 11+  push:
 12+    branches:
 13+      - main
 14+
 15+jobs:
 16+  test:
 17+    name: "Run linter and tests"
 18+    runs-on: ubuntu-latest
 19+    timeout-minutes: 5
 20+    services:
 21+      postgres:
 22+        image: postgres:18
 23+        env:
 24+          POSTGRES_PASSWORD: postgres
 25+        options: >-
 26+          --health-cmd pg_isready
 27+          --health-interval 5s
 28+          --health-timeout 2s
 29+          --health-retries 5
 30+
 31+    steps:
 32+      - uses: actions/checkout@v6
 33+
 34+      - uses: actions/setup-go@v6
 35+        with:
 36+          go-version-file: "go.mod"
 37+          cache: true
 38+
 39+      - name: Linter
 40+        run: make lint
 41+
 42+      - name: Tests
 43+        env:
 44+          DB_URI: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
 45+        run: make test
 46+
 47+      - name: Set up Docker Buildx
 48+        uses: docker/setup-buildx-action@v3
 49+
 50+      - name: Login to Docker Hub
 51+        uses: docker/login-action@v3
 52+        with:
 53+          username: ${{ secrets.DOCKER_USERNAME }}
 54+          password: ${{ secrets.DOCKER_PASSWORD }}
 55+
 56+      - name: Build and push API image
 57+        uses: docker/build-push-action@v6
 58+        with:
 59+          push: true
 60+          tags: theedgeofrage/ytrssil:api
 61+          target: api
 62+
 63+      - name: Build and push migrations image
 64+        uses: docker/build-push-action@v6
 65+        with:
 66+          push: true
 67+          tags: theedgeofrage/ytrssil:migrations
 68+          target: migrations
 69diff --git a/CLAUDE.md b/CLAUDE.md
 70index db73210d744ef0634070948af15b21986d28c42d..3f47d715450989fea81355bcac156960e8b7e4a7 100644
 71--- a/CLAUDE.md
 72+++ b/CLAUDE.md
 73@@ -1 +1,3 @@
 74-- Don't use serena for templ files, it only supports normal go code. Also don't attempt to edit the generated templ code
 75+- Don't use serena for templ files, it only supports normal go code.
 76+- Don't attempt to edit the generated templ code
 77+- Don't invoke ANY go commands other than `go vet` and `go mod tidy`
 78diff --git a/Makefile b/Makefile
 79index c884ebc6d89acab2f4b9f2ff448beed687cffcc6..d63907351bae7fb9cc53f023d3114fdb2aab57d2 100644
 80--- a/Makefile
 81+++ b/Makefile
 82@@ -19,8 +19,7 @@ lint: bin/golangci-lint
 83 	bin/golangci-lint -c .golangci.yml run ./...
 84 
 85 test:
 86-	go mod tidy
 87-	go test -timeout=10s -race -benchmem ./...
 88+	go test -timeout=30s -race ./...
 89 
 90 air: bin/air
 91 	@./bin/air -c .air.toml
 92diff --git a/cmd/main.go b/cmd/main.go
 93index e7d972d73eec6c8802be68af9c0ce639d27df82d..5fa2689505f1fcc021062ac57ba60e164aa0a9e9 100644
 94--- a/cmd/main.go
 95+++ b/cmd/main.go
 96@@ -59,6 +59,7 @@ func main() {
 97 		)
 98 		return
 99 	}
100+	defer db.Close()
101 	parser := feedparser.NewParser(logger)
102 	youTubeClient := youtube.NewYouTubeClient(logger, cfg.YouTubeAPIKey)
103 	handler := handler.New(logger, db, parser, youTubeClient)
104diff --git a/db/channels.go b/db/channels.go
105index 234acf39c42919c0bf2d088f93e344655e4d771a..7e85ee8769be61bf3bb9506a22cf307d3512ada5 100644
106--- a/db/channels.go
107+++ b/db/channels.go
108@@ -7,31 +7,26 @@ import (
109 	"github.com/TheEdgeOfRage/ytrssil-api/models"
110 )
111 
112-func (d *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Channel) error {
113+func (db *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Channel) error {
114 	const query = `
115 		INSERT INTO channels (id, name, subscribed, image_url) VALUES ($1, $2, $3, $4)
116 		ON CONFLICT (id) DO UPDATE SET subscribed = $3, image_url = $4
117 	`
118-	resp, err := d.db.ExecContext(ctx, query, channel.ID, channel.Name, channel.Subscribed, channel.ImageURL)
119+	resp, err := db.db.Exec(ctx, query, channel.ID, channel.Name, channel.Subscribed, channel.ImageURL)
120 	if err != nil {
121-		d.l.Error("Failed to subscribe to channel", "call", "sql.ExecContext", "error", err)
122+		db.l.Error("Failed to subscribe to channel", "call", "sql.ExecContext", "error", err)
123 		return err
124 	}
125 
126-	rows, err := resp.RowsAffected()
127-	if err != nil {
128-		d.l.Error("Failed to subscribe to channel", "call", "sql.RowsAffected", "error", err)
129-		return err
130-	}
131-	if rows == 0 {
132-		d.l.Error("Failed to subscribe to channel, no rows affected", "call", "sql.RowsAffected")
133+	if resp.RowsAffected() == 0 {
134+		db.l.Error("Failed to subscribe to channel, no rows affected", "call", "sql.RowsAffected")
135 		return fmt.Errorf("failed to subscribe to channel")
136 	}
137 
138 	return nil
139 }
140 
141-func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error) {
142+func (db *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error) {
143 	const query = `
144 		SELECT
145 			channels.id,
146@@ -45,9 +40,9 @@ func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error)
147 		GROUP BY channels.id, channels.name, channels.subscribed, channels.image_url
148 		ORDER BY channels.name
149 	`
150-	rows, err := d.db.QueryContext(ctx, query)
151+	rows, err := db.db.Query(ctx, query)
152 	if err != nil {
153-		d.l.Error("Failed to list channels", "call", "sql.QueryContext", "error", err)
154+		db.l.Error("Failed to list channels", "call", "sql.QueryContext", "error", err)
155 		return nil, err
156 	}
157 	defer rows.Close()
158@@ -57,7 +52,7 @@ func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error)
159 		var channel models.Channel
160 		err = rows.Scan(&channel.ID, &channel.Name, &channel.Subscribed, &channel.ImageURL, &channel.UnwatchedCount)
161 		if err != nil {
162-			d.l.Error("Failed to scan rows to list channels", "call", "sql.Scan", "error", err)
163+			db.l.Error("Failed to scan rows to list channels", "call", "sql.Scan", "error", err)
164 			return nil, err
165 		}
166 		channels = append(channels, channel)
167@@ -66,15 +61,15 @@ func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error)
168 	return channels, nil
169 }
170 
171-func (d *postgresDB) UnsubscribeFromChannel(ctx context.Context, channelID string) error {
172+func (db *postgresDB) UnsubscribeFromChannel(ctx context.Context, channelID string) error {
173 	const query = `UPDATE channels SET subscribed = false WHERE id = $1`
174-	resp, err := d.db.ExecContext(ctx, query, channelID)
175+	resp, err := db.db.Exec(ctx, query, channelID)
176 	if err != nil {
177-		d.l.Error("Failed to unsubscribe from channel", "call", "sql.ExecContext", "error", err)
178+		db.l.Error("Failed to unsubscribe from channel", "call", "sql.ExecContext", "error", err)
179 		return err
180 	}
181 
182-	if affected, err := resp.RowsAffected(); err != nil || affected != 1 {
183+	if resp.RowsAffected() != 1 {
184 		return ErrChannelNotFound
185 	}
186 
187diff --git a/db/db.go b/db/db.go
188index 8c1595eb9a42dce6544524e5c9ea326049ece9cd..02b74cb1ce8e9cd14d5793cf5a937047fb0e9422 100644
189--- a/db/db.go
190+++ b/db/db.go
191@@ -36,4 +36,7 @@ type DB interface {
192 	SetVideoWatchTime(ctx context.Context, videoID string, watchTime *time.Time) error
193 	// SetVideoProgress sets or unsets the watch progress of a video
194 	SetVideoProgress(ctx context.Context, videoID string, progress int) (*models.Video, error)
195+
196+	// Close closes the DB connection
197+	Close()
198 }
199diff --git a/db/psql.go b/db/psql.go
200index 730dad66c8e67fd352c4205393e0ce978668c54b..4a120c6fefa2cd03612deef406206efe003addfc 100644
201--- a/db/psql.go
202+++ b/db/psql.go
203@@ -1,21 +1,21 @@
204 package db
205 
206 import (
207-	"database/sql"
208+	"context"
209 	"log/slog"
210 
211-	_ "github.com/lib/pq"
212+	"github.com/jackc/pgx/v5/pgxpool"
213 )
214 
215 type postgresDB struct {
216 	l  *slog.Logger
217-	db *sql.DB
218+	db *pgxpool.Pool
219 }
220 
221 var _ DB = (*postgresDB)(nil)
222 
223 func NewPostgresDB(log *slog.Logger, dbURI string) (*postgresDB, error) {
224-	db, err := sql.Open("postgres", dbURI)
225+	db, err := pgxpool.New(context.Background(), dbURI)
226 	if err != nil {
227 		return nil, err
228 	}
229@@ -25,3 +25,7 @@ func NewPostgresDB(log *slog.Logger, dbURI string) (*postgresDB, error) {
230 		db: db,
231 	}, nil
232 }
233+
234+func (db *postgresDB) Close() {
235+	db.db.Close()
236+}
237diff --git a/db/videos.go b/db/videos.go
238index 34b27f9416eaf5d3fc8f44ada4d945aeb9cf446a..700868a76f9411a178379709e0ac9da56e533b1a 100644
239--- a/db/videos.go
240+++ b/db/videos.go
241@@ -7,7 +7,7 @@ import (
242 	"github.com/TheEdgeOfRage/ytrssil-api/models"
243 )
244 
245-func (d *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
246+func (db *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
247 	query := `
248 		SELECT
249 			videos.id
250@@ -27,9 +27,9 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.
251 		query += " DESC"
252 	}
253 
254-	rows, err := d.db.QueryContext(ctx, query)
255+	rows, err := db.db.Query(ctx, query)
256 	if err != nil {
257-		d.l.Error("Failed to query new videos", "call", "sql.QueryContext", "error", err)
258+		db.l.Error("Failed to query new videos", "call", "sql.QueryContext", "error", err)
259 		return nil, err
260 	}
261 	defer rows.Close()
262@@ -48,7 +48,7 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.
263 			&video.ChannelID,
264 		)
265 		if err != nil {
266-			d.l.Error("Failed to scan rows for get new videos", "call", "sql.Scan", "error", err)
267+			db.l.Error("Failed to scan rows for get new videos", "call", "sql.Scan", "error", err)
268 			return nil, err
269 		}
270 		videos = append(videos, video)
271@@ -57,7 +57,7 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.
272 	return videos, nil
273 }
274 
275-func (d *postgresDB) GetWatchedVideos(
276+func (db *postgresDB) GetWatchedVideos(
277 	ctx context.Context, sortDesc bool, limit int, offset int,
278 ) ([]models.Video, error) {
279 	query := `
280@@ -81,9 +81,9 @@ func (d *postgresDB) GetWatchedVideos(
281 	}
282 	query += " LIMIT $1 OFFSET $2"
283 
284-	rows, err := d.db.QueryContext(ctx, query, limit, offset)
285+	rows, err := db.db.Query(ctx, query, limit, offset)
286 	if err != nil {
287-		d.l.Error("Failed to query for watched videos", "call", "sql.QueryContext", "error", err)
288+		db.l.Error("Failed to query for watched videos", "call", "sql.QueryContext", "error", err)
289 		return nil, err
290 	}
291 	defer rows.Close()
292@@ -103,7 +103,7 @@ func (d *postgresDB) GetWatchedVideos(
293 			&video.ChannelID,
294 		)
295 		if err != nil {
296-			d.l.Error("Failed to scan rows for watched videos", "call", "sql.Scan", "error", err)
297+			db.l.Error("Failed to scan rows for watched videos", "call", "sql.Scan", "error", err)
298 			return nil, err
299 		}
300 		videos = append(videos, video)
301@@ -112,21 +112,21 @@ func (d *postgresDB) GetWatchedVideos(
302 	return videos, nil
303 }
304 
305-func (d *postgresDB) HasVideo(ctx context.Context, videoID string) (bool, error) {
306+func (db *postgresDB) HasVideo(ctx context.Context, videoID string) (bool, error) {
307 	query := `SELECT COUNT(1) FROM videos WHERE id = $1`
308-	row := d.db.QueryRowContext(ctx, query, videoID)
309+	row := db.db.QueryRow(ctx, query, videoID)
310 
311 	var count int
312 	err := row.Scan(&count)
313 	if err != nil {
314-		d.l.Error("Failed to query for video", "call", "sql.QueryRowContext", "error", err)
315+		db.l.Error("Failed to query for video", "call", "sql.QueryRowContext", "error", err)
316 		return false, err
317 	}
318 
319 	return count == 1, nil
320 }
321 
322-func (d *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID string) error {
323+func (db *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID string) error {
324 	query := `
325 		INSERT INTO videos (
326 			id
327@@ -139,7 +139,7 @@ func (d *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID
328 		ON CONFLICT DO NOTHING
329 	`
330 
331-	resp, err := d.db.ExecContext(
332+	resp, err := db.db.Exec(
333 		ctx,
334 		query,
335 		video.ID,
336@@ -150,32 +150,32 @@ func (d *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID
337 		channelID,
338 	)
339 	if err != nil {
340-		d.l.Error("Failed to add video", "call", "sql.Exec", "error", err)
341diff --git a/go.mod b/go.mod
342index c9e3534bd683893e4a41fb5c52dbb9753025bab3..58b8dc8d6e129ee6b1999fa80afa5832247ef944 100644
343--- a/go.mod
344+++ b/go.mod
345@@ -6,8 +6,9 @@ require (
346 	github.com/a-h/templ v0.3.960
347 	github.com/dustin/go-humanize v1.0.1
348 	github.com/gin-gonic/gin v1.11.0
349+	github.com/jackc/pgx/v5 v5.7.6
350 	github.com/jessevdk/go-flags v1.6.1
351-	github.com/lib/pq v1.10.9
352+	github.com/oklog/ulid/v2 v2.1.1
353 	github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c
354 	github.com/stretchr/testify v1.11.1
355 )
356@@ -24,8 +25,12 @@ require (
357 	github.com/go-playground/validator/v10 v10.27.0 // indirect
358 	github.com/goccy/go-json v0.10.2 // indirect
359 	github.com/goccy/go-yaml v1.18.0 // indirect
360+	github.com/jackc/pgpassfile v1.0.0 // indirect
361+	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
362+	github.com/jackc/puddle/v2 v2.2.2 // indirect
363 	github.com/json-iterator/go v1.1.12 // indirect
364 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
365+	github.com/kr/text v0.2.0 // indirect
366 	github.com/leodido/go-urn v1.4.0 // indirect
367 	github.com/mattn/go-isatty v0.0.20 // indirect
368 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
369@@ -34,6 +39,7 @@ require (
370 	github.com/pmezard/go-difflib v1.0.0 // indirect
371 	github.com/quic-go/qpack v0.5.1 // indirect
372 	github.com/quic-go/quic-go v0.54.0 // indirect
373+	github.com/rogpeppe/go-internal v1.14.1 // indirect
374 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
375 	github.com/ugorji/go/codec v1.3.0 // indirect
376 	go.uber.org/mock v0.5.0 // indirect
377diff --git a/go.sum b/go.sum
378index 94f5af57129632b0c0c5fe48d94eeef149562c4c..616d114bffb22b30030eec9c84d2690db5c5dcdc 100644
379--- a/go.sum
380+++ b/go.sum
381@@ -6,6 +6,7 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
382 github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
383 github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
384 github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
385+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
386 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
387 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
388 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
389@@ -32,16 +33,26 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
390 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
391 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
392 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
393+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
394+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
395+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
396+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
397+github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
398+github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
399+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
400+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
401 github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
402 github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
403 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
404 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
405 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
406 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
407+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
408+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
409+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
410+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
411 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
412 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
413-github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
414-github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
415 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
416 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
417 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
418@@ -49,8 +60,11 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
419 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
420 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
421 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
422+github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
423+github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
424 github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c h1:P6XGcuPTigoHf4TSu+3D/7QOQ1MbL6alNwrGhcW7sKw=
425 github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c/go.mod h1:YnNlZP7l4MhyGQ4CBRwv6ohZTPrUJJZtEv4ZgADkbs4=
426+github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
427 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
428 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
429 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
430@@ -59,10 +73,13 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
431 github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
432 github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
433 github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
434+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
435+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
436 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
437 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
438 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
439 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
440+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
441 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
442 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
443 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
444@@ -93,8 +110,9 @@ golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
445 golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
446 google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
447 google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
448-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
449 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
450+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
451+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
452 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
453 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
454 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
455diff --git a/httpserver/ytrssil/auth_test.go b/httpserver/ytrssil/auth_test.go
456new file mode 100644
457index 0000000000000000000000000000000000000000..2ea6190784cf7e59b208a172674cc917e476fb32
458--- /dev/null
459+++ b/httpserver/ytrssil/auth_test.go
460@@ -0,0 +1,67 @@
461+package ytrssil_test
462+
463+import (
464+	"net/http"
465+	"net/http/httptest"
466+	"net/url"
467+	"strings"
468+	"testing"
469+
470+	"github.com/stretchr/testify/suite"
471+)
472+
473+type AuthTestSuite struct {
474+	EndpointsTestSuite
475+}
476+
477+func TestAuthTestSuite(t *testing.T) {
478+	suite.Run(t, new(AuthTestSuite))
479+}
480+
481+func (s *AuthTestSuite) TestAuthPage() {
482+	w := httptest.NewRecorder()
483+	req, _ := http.NewRequest("GET", "/auth", nil)
484+	s.server.Handler.ServeHTTP(w, req)
485+
486+	s.Equal(http.StatusOK, w.Code)
487+	s.Contains(w.Body.String(), "Authentication")
488+}
489+
490+func (s *AuthTestSuite) TestHandleAuthSuccess() {
491+	form := url.Values{}
492+	form.Add("token", s.cfg.AuthToken)
493+
494+	w := httptest.NewRecorder()
495+	req, _ := http.NewRequest("POST", "/auth", strings.NewReader(form.Encode()))
496+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
497+	s.server.Handler.ServeHTTP(w, req)
498+
499+	s.Equal(http.StatusFound, w.Code)
500+	cookies := w.Result().Cookies()
501+	s.Require().Len(cookies, 1)
502+	s.Equal("token", cookies[0].Name)
503+	s.Equal(s.cfg.AuthToken, cookies[0].Value)
504+	s.Equal("/", w.Header().Get("Location"))
505+}
506+
507+func (s *AuthTestSuite) TestHandleAuthInvalidToken() {
508+	form := url.Values{}
509+	form.Add("token", "invalid-token")
510+
511+	w := httptest.NewRecorder()
512+	req, _ := http.NewRequest("POST", "/auth", strings.NewReader(form.Encode()))
513+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
514+	s.server.Handler.ServeHTTP(w, req)
515+
516+	s.Equal(http.StatusBadRequest, w.Code)
517+	s.Contains(w.Body.String(), "Invalid token")
518+}
519+
520+func (s *AuthTestSuite) TestAuthMiddleware() {
521+	w := httptest.NewRecorder()
522+	req, _ := http.NewRequest("GET", "/", nil)
523+	req.AddCookie(&http.Cookie{Name: "token", Value: s.cfg.AuthToken})
524+	s.server.Handler.ServeHTTP(w, req)
525+
526+	s.Equal(http.StatusOK, w.Code)
527+}
528diff --git a/httpserver/ytrssil/channels_test.go b/httpserver/ytrssil/channels_test.go
529new file mode 100644
530index 0000000000000000000000000000000000000000..3659255a1e69ac824d2cbc27b44343f15a4dda10
531--- /dev/null
532+++ b/httpserver/ytrssil/channels_test.go
533@@ -0,0 +1,135 @@
534+package ytrssil_test
535+
536+import (
537+	"context"
538+	"encoding/json"
539+	"fmt"
540+	"net/http"
541+	"net/http/httptest"
542+	"net/url"
543+	"strings"
544+	"testing"
545+
546+	"github.com/stretchr/testify/suite"
547+
548+	"github.com/TheEdgeOfRage/ytrssil-api/models"
549+)
550+
551+type ChannelsTestSuite struct {
552+	EndpointsTestSuite
553+}
554+
555+func TestChannelsTestSuite(t *testing.T) {
556+	suite.Run(t, new(ChannelsTestSuite))
557+}
558+
559+func (s *ChannelsTestSuite) TestSubscribeToChannelJSON() {
560+	channelID := "test-channel-123"
561+
562+	w := httptest.NewRecorder()
563+	req, _ := http.NewRequest("POST", fmt.Sprintf("/api/channels/%s/subscribe", channelID), nil)
564+	req.Header.Set("Authorization", s.cfg.AuthToken)
565+	s.server.Handler.ServeHTTP(w, req)
566+
567+	s.Equal(http.StatusOK, w.Code)
568+
569+	var response models.Channel
570+	err := json.Unmarshal(w.Body.Bytes(), &response)
571+	s.Require().NoError(err)
572+	s.Equal(channelID, response.ID)
573+	s.Equal(fmt.Sprintf("Test Channel %s", channelID), response.Name)
574+	s.True(response.Subscribed)
575+}
576+
577+func (s *ChannelsTestSuite) TestUnsubscribeFromChannelJSON() {
578+	channelID := "test-channel-456"
579+
580+	ctx := context.Background()
581+	err := s.db.SubscribeToChannel(ctx, models.Channel{
582+		ID:         channelID,
583+		Name:       "Test Channel",
584+		Subscribed: true,
585+	})
586+	s.Require().NoError(err)
587+
588+	w := httptest.NewRecorder()
589+	req, _ := http.NewRequest("POST", fmt.Sprintf("/api/channels/%s/unsubscribe", channelID), nil)
590+	req.Header.Set("Authorization", s.cfg.AuthToken)
591+	s.server.Handler.ServeHTTP(w, req)
592+
593+	s.Equal(http.StatusOK, w.Code)
594+
595+	var response map[string]string
596+	err = json.Unmarshal(w.Body.Bytes(), &response)
597+	s.Require().NoError(err)
598+	s.Equal("unsubscribed from channel successfully", response["msg"])
599+}
600+
601+func (s *ChannelsTestSuite) TestChannelsPage() {
602+	ctx := context.Background()
603+
604+	err := s.db.SubscribeToChannel(ctx, models.Channel{
605+		ID:         "channel-606",
606+		Name:       "Test Channel Page",
607+		Subscribed: true,
608+		ImageURL:   "https://example.com/image.jpg",
609+	})
610+	s.Require().NoError(err)
611+
612+	w := httptest.NewRecorder()
613+	req, _ := http.NewRequest("GET", "/channels", nil)
614+	req.AddCookie(&http.Cookie{Name: "token", Value: s.cfg.AuthToken})
615+	s.server.Handler.ServeHTTP(w, req)
616+
617+	s.Equal(http.StatusOK, w.Code)
618+	s.Contains(w.Body.String(), "Test Channel Page")
619+}
620+
621+func (s *ChannelsTestSuite) TestSubscribeToChannelPage() {
622+	form := url.Values{}
623+	form.Add("channel_id", "channel-707")
624+
625+	w := httptest.NewRecorder()
626+	req, _ := http.NewRequest("POST", "/subscribe", strings.NewReader(form.Encode()))
627+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
628+	req.AddCookie(&http.Cookie{Name: "token", Value: s.cfg.AuthToken})
629+	s.server.Handler.ServeHTTP(w, req)
630+
631+	s.Equal(http.StatusOK, w.Code)
632+	s.Contains(w.Body.String(), "channel-707")
633diff --git a/httpserver/ytrssil/suite_test.go b/httpserver/ytrssil/suite_test.go
634new file mode 100644
635index 0000000000000000000000000000000000000000..365f19e694d9e69101eda9e30735cc4e3f7fcfc8
636--- /dev/null
637+++ b/httpserver/ytrssil/suite_test.go
638@@ -0,0 +1,138 @@
639+package ytrssil_test
640+
641+import (
642+	"context"
643+	"fmt"
644+	"io"
645+	"log/slog"
646+	"net/http"
647+	"os/exec"
648+	"time"
649+
650+	"github.com/gin-gonic/gin"
651+	"github.com/jackc/pgx/v5"
652+	"github.com/oklog/ulid/v2"
653+	"github.com/stretchr/testify/suite"
654+
655+	"github.com/TheEdgeOfRage/ytrssil-api/config"
656+	"github.com/TheEdgeOfRage/ytrssil-api/db"
657+	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
658+	"github.com/TheEdgeOfRage/ytrssil-api/handler"
659+	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
660+	mockFeedparser "github.com/TheEdgeOfRage/ytrssil-api/mocks/feedparser"
661+	mockYouTube "github.com/TheEdgeOfRage/ytrssil-api/mocks/youtube"
662+	"github.com/TheEdgeOfRage/ytrssil-api/models"
663+)
664+
665+func init() {
666+	time.Local = time.UTC
667+}
668+
669+type EndpointsTestSuite struct {
670+	suite.Suite
671+	cfg           config.Config
672+	schema        string
673+	db            db.DB
674+	dbConn        *pgx.Conn
675+	parser        *mockFeedparser.ParserMock
676+	youtubeClient *mockYouTube.ClientMock
677+	server        *http.Server
678+}
679+
680+func (s *EndpointsTestSuite) SetupSuite() {
681+	var err error
682+	l := slog.New(slog.NewTextHandler(io.Discard, nil))
683+	s.cfg = config.TestConfig()
684+
685+	s.schema = fmt.Sprintf("ytrssil_test_%s", ulid.Make().String())
686+	s.dbConn, err = pgx.Connect(context.Background(), s.cfg.DBURI)
687+	if err != nil {
688+		panic(fmt.Sprintf("failed to connect to database: %v", err))
689+	}
690+	_, err = s.dbConn.Exec(context.Background(), fmt.Sprintf("CREATE SCHEMA %s", s.schema))
691+	if err != nil {
692+		panic(fmt.Sprintf("failed to create test schema: %v", err))
693+	}
694+
695+	testDBURI := fmt.Sprintf("%s&search_path=%s", s.cfg.DBURI, s.schema)
696+	cmd := exec.Command("./bin/migrate", "-database", testDBURI, "-path", "migrations", "up")
697+	cmd.Dir = "../.."
698+	output, err := cmd.CombinedOutput()
699+	if err != nil {
700+		panic(fmt.Sprintf("failed to run migrations: %v\nOutput: %s", err, string(output)))
701+	}
702+
703+	s.db, err = db.NewPostgresDB(l, testDBURI)
704+	if err != nil {
705+		panic(fmt.Sprintf("failed to connect to test database: %v", err))
706+	}
707+
708+	s.parser = &mockFeedparser.ParserMock{
709+		ParseFunc: func(channelID string) (*feedparser.Channel, error) {
710+			publishTime := time.Now().Add(-24 * time.Hour).Format(time.RFC3339)
711+			return &feedparser.Channel{
712+				ID:   channelID,
713+				Name: fmt.Sprintf("Test Channel %s", channelID),
714+				Videos: []*feedparser.Video{
715+					{
716+						ID:        fmt.Sprintf("yt:video:%s-video1", channelID),
717+						Title:     "Test Video 1",
718+						Published: feedparser.Date(publishTime),
719+						IsShort:   false,
720+					},
721+				},
722+			}, nil
723+		},
724+	}
725+
726+	s.youtubeClient = &mockYouTube.ClientMock{
727+		GetVideoDurationsFunc: func(ctx context.Context, videos map[string]*models.Video) error {
728+			for _, video := range videos {
729+				video.DurationSeconds = 300
730+			}
731+			return nil
732+		},
733+		GetVideoMetadataFunc: func(ctx context.Context, videoID string) (*models.Video, error) {
734+			return &models.Video{
735+				ID:              videoID,
736+				Title:           "Test Video",
737+				PublishedTime:   time.Now().Add(-24 * time.Hour),
738diff --git a/httpserver/ytrssil/videos_test.go b/httpserver/ytrssil/videos_test.go
739new file mode 100644
740index 0000000000000000000000000000000000000000..ac88c2731a11b4e67215f505a14998099b87e15f
741--- /dev/null
742+++ b/httpserver/ytrssil/videos_test.go
743@@ -0,0 +1,399 @@
744+package ytrssil_test
745+
746+import (
747+	"context"
748+	"encoding/json"
749+	"net/http"
750+	"net/http/httptest"
751+	"net/url"
752+	"strings"
753+	"testing"
754+	"time"
755+
756+	"github.com/stretchr/testify/suite"
757+
758+	"github.com/TheEdgeOfRage/ytrssil-api/models"
759+)
760+
761+type VideosTestSuite struct {
762+	EndpointsTestSuite
763+}
764+
765+func TestVideosTestSuite(t *testing.T) {
766+	suite.Run(t, new(VideosTestSuite))
767+}
768+
769+func (s *VideosTestSuite) TestGetNewVideosJSON() {
770+	ctx := context.Background()
771+	channelID := "test-channel-789"
772+
773+	err := s.db.SubscribeToChannel(ctx, models.Channel{
774+		ID:         channelID,
775+		Name:       "Test Channel",
776+		Subscribed: true,
777+	})
778+	s.Require().NoError(err)
779+
780+	err = s.db.AddVideo(ctx, models.Video{
781+		ID:              "video123",
782+		Title:           "Test Video",
783+		PublishedTime:   time.Now().Add(-1 * time.Hour),
784+		DurationSeconds: 300,
785+		IsShort:         false,
786+	}, channelID)
787+	s.Require().NoError(err)
788+
789+	w := httptest.NewRecorder()
790+	req, _ := http.NewRequest("GET", "/api/videos/new", nil)
791+	req.Header.Set("Authorization", s.cfg.AuthToken)
792+	s.server.Handler.ServeHTTP(w, req)
793+
794+	s.Equal(http.StatusOK, w.Code)
795+
796+	var response map[string][]models.Video
797+	err = json.Unmarshal(w.Body.Bytes(), &response)
798+	s.Require().NoError(err)
799+	s.Require().Contains(response, "videos")
800+	s.Require().Len(response["videos"], 1)
801+	s.Equal("video123", response["videos"][0].ID)
802+}
803+
804+func (s *VideosTestSuite) TestGetWatchedVideosJSON() {
805+	ctx := context.Background()
806+	channelID := "test-channel-101"
807+
808+	err := s.db.SubscribeToChannel(ctx, models.Channel{
809+		ID:         channelID,
810+		Name:       "Test Channel",
811+		Subscribed: true,
812+	})
813+	s.Require().NoError(err)
814+
815+	err = s.db.AddVideo(ctx, models.Video{
816+		ID:              "video456",
817+		Title:           "Watched Video",
818+		PublishedTime:   time.Now().Add(-1 * time.Hour),
819+		DurationSeconds: 300,
820+		IsShort:         false,
821+	}, channelID)
822+	s.Require().NoError(err)
823+
824+	watchTime := time.Now()
825+	err = s.db.SetVideoWatchTime(ctx, "video456", &watchTime)
826+	s.Require().NoError(err)
827+
828+	w := httptest.NewRecorder()
829+	req, _ := http.NewRequest("GET", "/api/videos/watched", nil)
830+	req.Header.Set("Authorization", s.cfg.AuthToken)
831+	s.server.Handler.ServeHTTP(w, req)
832+
833+	s.Equal(http.StatusOK, w.Code)
834+
835+	var response map[string][]models.Video
836+	err = json.Unmarshal(w.Body.Bytes(), &response)
837+	s.Require().NoError(err)
838+	s.Require().Contains(response, "videos")
839+	s.Require().Len(response["videos"], 1)
840+	s.Equal("video456", response["videos"][0].ID)
841+}
842+
843diff --git a/mocks/db/db.go b/mocks/db/db.go
844index 3be205326db4a789e1dbf4df1e2fdb7ce507e574..cda629a259f99d03d23571b27e9c22753c020ea1 100644
845--- a/mocks/db/db.go
846+++ b/mocks/db/db.go
847@@ -24,6 +24,9 @@ var _ db.DB = &DBMock{}
848 //			AddVideoFunc: func(ctx context.Context, video models.Video, channelID string) error {
849 //				panic("mock out the AddVideo method")
850 //			},
851+//			CloseFunc: func()  {
852+//				panic("mock out the Close method")
853+//			},
854 //			GetNewVideosFunc: func(ctx context.Context, sortDesc bool) ([]models.Video, error) {
855 //				panic("mock out the GetNewVideos method")
856 //			},
857@@ -58,6 +61,9 @@ type DBMock struct {
858 	// AddVideoFunc mocks the AddVideo method.
859 	AddVideoFunc func(ctx context.Context, video models.Video, channelID string) error
860 
861+	// CloseFunc mocks the Close method.
862+	CloseFunc func()
863+
864 	// GetNewVideosFunc mocks the GetNewVideos method.
865 	GetNewVideosFunc func(ctx context.Context, sortDesc bool) ([]models.Video, error)
866 
867@@ -93,6 +99,9 @@ type DBMock struct {
868 			// ChannelID is the channelID argument value.
869 			ChannelID string
870 		}
871+		// Close holds details about calls to the Close method.
872+		Close []struct {
873+		}
874 		// GetNewVideos holds details about calls to the GetNewVideos method.
875 		GetNewVideos []struct {
876 			// Ctx is the ctx argument value.
877@@ -157,6 +166,7 @@ type DBMock struct {
878 		}
879 	}
880 	lockAddVideo               sync.RWMutex
881+	lockClose                  sync.RWMutex
882 	lockGetNewVideos           sync.RWMutex
883 	lockGetWatchedVideos       sync.RWMutex
884 	lockHasVideo               sync.RWMutex
885@@ -207,6 +217,33 @@ func (mock *DBMock) AddVideoCalls() []struct {
886 	return calls
887 }
888 
889+// Close calls CloseFunc.
890+func (mock *DBMock) Close() {
891+	if mock.CloseFunc == nil {
892+		panic("DBMock.CloseFunc: method is nil but DB.Close was just called")
893+	}
894+	callInfo := struct {
895+	}{}
896+	mock.lockClose.Lock()
897+	mock.calls.Close = append(mock.calls.Close, callInfo)
898+	mock.lockClose.Unlock()
899+	mock.CloseFunc()
900+}
901+
902+// CloseCalls gets all the calls that were made to Close.
903+// Check the length with:
904+//
905+//	len(mockedDB.CloseCalls())
906+func (mock *DBMock) CloseCalls() []struct {
907+} {
908+	var calls []struct {
909+	}
910+	mock.lockClose.RLock()
911+	calls = mock.calls.Close
912+	mock.lockClose.RUnlock()
913+	return calls
914+}
915+
916 // GetNewVideos calls GetNewVideosFunc.
917 func (mock *DBMock) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
918 	if mock.GetNewVideosFunc == nil {