6eba500cb15dc2ebc5135b6a1cf31b040ada6f32
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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 {