From fa2a3cccec7ecad6ca580f355b6b521e6f4087f9 Mon Sep 17 00:00:00 2001 From: Pavle Portic Date: Sat, 29 Oct 2022 05:09:52 +0200 Subject: [PATCH] Parse feeds and fetch new videos --- cmd/main.go | 4 +- cmd/main_test.go | 37 ++- db/channels.go | 75 ++++++ db/db.go | 31 ++- db/psql.go | 6 +- db/users.go | 87 ++++-- db/videos.go | 45 +++- feedparser/date.go | 13 + feedparser/feedparser.go | 74 +++++ go.mod | 4 +- go.sum | 14 +- handler/handler.go | 92 ++++++- handler/handler_test.go | 8 +- httpserver/auth/auth.go | 7 +- httpserver/auth/auth_test.go | 16 +- httpserver/ytrssil/server.go | 2 + httpserver/ytrssil/users.go | 33 ++- httpserver/ytrssil/videos.go | 10 + migrations/000001_init.down.sql | 1 + migrations/000001_init.up.sql | 31 ++- mocks/db/db.go | 462 ++++++++++++++++++++++++++++++-- models/channel.go | 6 +- models/http.go | 2 +- models/video.go | 10 +- 24 files changed, 953 insertions(+), 117 deletions(-) create mode 100644 db/channels.go create mode 100644 feedparser/date.go create mode 100644 feedparser/feedparser.go diff --git a/cmd/main.go b/cmd/main.go index a2631bf..795318b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -33,9 +33,9 @@ func main() { return } - db, err := db.NewPSQLDB(log, config.DB) + db, err := db.NewPostgresDB(log, config.DB) if err != nil { - log.Log("level", "FATAL", "call", "db.NewPSQLDB", "error", err) + log.Log("level", "FATAL", "call", "db.NewPostgresDB", "error", err) return } diff --git a/cmd/main_test.go b/cmd/main_test.go index 33583fd..e42d985 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -1,6 +1,9 @@ package main import ( + "bytes" + "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -15,6 +18,7 @@ import ( "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) var testConfig config.Config @@ -23,12 +27,12 @@ func init() { testConfig = config.TestConfig() } -func setupTestServer(t *testing.T, authEnabled bool) *http.Server { +func setupTestServer(t *testing.T, authEnabled bool) (*http.Server, db.DB) { l := log.NewNopLogger() - db, err := db.NewPSQLDB(l, testConfig.DB) + db, err := db.NewPostgresDB(l, testConfig.DB) if !assert.NoError(t, err) { - return nil + return nil, nil } handler := handler.New(l, db) @@ -39,17 +43,17 @@ func setupTestServer(t *testing.T, authEnabled bool) *http.Server { auth.AuthMiddleware(db), ) if !assert.NoError(t, err) { - return nil + return nil, nil } return &http.Server{ Addr: fmt.Sprintf(":%v", testConfig.Gin.Port), Handler: router, - } + }, db } func TestHealthz(t *testing.T) { - server := setupTestServer(t, false) + server, _ := setupTestServer(t, false) if !assert.NotNil(t, server) { return } @@ -61,3 +65,24 @@ func TestHealthz(t *testing.T) { assert.Equal(t, 200, w.Code) assert.Equal(t, "healthy", w.Body.String()) } + +func TestCreateUser(t *testing.T) { + server, db := setupTestServer(t, false) + if !assert.NotNil(t, server) { + return + } + + jsonData, err := json.Marshal(models.User{Username: "test", Password: "test"}) + if !assert.Nil(t, err) { + return + } + data := bytes.NewBuffer(jsonData) + req, _ := http.NewRequest("POST", "/register", data) + w := httptest.NewRecorder() + server.Handler.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Equal(t, `{"msg":"user created"}`, w.Body.String()) + + db.DeleteUser(context.TODO(), "test") +} diff --git a/db/channels.go b/db/channels.go new file mode 100644 index 0000000..a2caac2 --- /dev/null +++ b/db/channels.go @@ -0,0 +1,75 @@ +package db + +import ( + "context" + + "github.com/lib/pq" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +var createChannelQuery = `INSERT INTO channels (id, name) VALUES ($1, $2)` + +func (d *postgresDB) CreateChannel(ctx context.Context, channel models.Channel) error { + _, err := d.db.ExecContext(ctx, createChannelQuery, channel.ID, channel.Name) + if err != nil { + if pgerr, ok := err.(*pq.Error); ok { + if pgerr.Code == "23505" { + return ErrChannelExists + } + } + + d.l.Log("level", "ERROR", "function", "db.CreateChannel", "error", err) + return err + } + + return nil +} + +var listChannelsQuery = `SELECT id, name FROM channels` + +func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error) { + rows, err := d.db.QueryContext(ctx, listChannelsQuery) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.ListChannels", "call", "sql.QueryContext", "error", err) + return nil, err + } + defer rows.Close() + + channels := make([]models.Channel, 0) + for rows.Next() { + var channel models.Channel + err = rows.Scan(&channel.ID, &channel.Name) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.ListChannels", "call", "sql.Scan", "error", err) + return nil, err + } + channels = append(channels, channel) + } + + return channels, nil +} + +var getChannelSubscribersQuery = `SELECT username FROM user_subscriptions WHERE channel_id = $1` + +func (d *postgresDB) GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error) { + rows, err := d.db.QueryContext(ctx, getChannelSubscribersQuery, channelID) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.GetChannelSubscribers", "call", "sql.QueryContext", "error", err) + return nil, err + } + defer rows.Close() + + subs := make([]string, 0) + for rows.Next() { + var sub string + err = rows.Scan(&sub) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.GetChannelSubscribers", "call", "sql.Scan", "error", err) + return nil, err + } + subs = append(subs, sub) + } + + return subs, nil +} diff --git a/db/db.go b/db/db.go index 0632c65..25efe7a 100644 --- a/db/db.go +++ b/db/db.go @@ -2,14 +2,41 @@ package db import ( "context" + "errors" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) +var ( + ErrChannelExists = errors.New("channel already exists") + ErrAlreadySubscribed = errors.New("already subscribed to channel") + ErrVideoExists = errors.New("video already exists") +) + // DB represents a database layer for getting video and channel data type DB interface { // GetNewVideos returns unwatched videos from all channels - GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) - AuthenticateUser(ctx context.Context, username string, password string) (bool, error) + GetNewVideos(ctx context.Context, username string) ([]models.Video, error) + // CreateVideo adds a newly published video to the database + CreateVideo(ctx context.Context, video models.Video, channelID string) error + + // CreateChannel starts tracking a new channel and fetch new videos for it + CreateChannel(ctx context.Context, channel models.Channel) error + // ListChannels lists all channels from the database + ListChannels(ctx context.Context) ([]models.Channel, error) + // GetChannelSubscribers lists all channels from the database + GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error) + + // AuthenticateUser verifies a user's password against a hashed value + AuthenticateUser(ctx context.Context, user models.User) (bool, error) + // CreateUser registers a new user in the database CreateUser(ctx context.Context, user models.User) error + // DeleteUser registers a new user in the database + DeleteUser(ctx context.Context, username string) error + // SubscribeUserToChannel will start showing new videos for that channel to the user + SubscribeUserToChannel(ctx context.Context, username string, channelID string) error + // AddVideoToUser will list the video in the users feed + AddVideoToUser(ctx context.Context, username string, videoID string) error + // WatchVideo marks a video as watched so it no longer shows in the feed + WatchVideo(ctx context.Context, username string, videoID string) error } diff --git a/db/psql.go b/db/psql.go index 7045e05..601740b 100644 --- a/db/psql.go +++ b/db/psql.go @@ -9,18 +9,18 @@ import ( "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" ) -type psqlDB struct { +type postgresDB struct { l log.Logger db *sql.DB } -func NewPSQLDB(log log.Logger, dbCfg ytrssilConfig.DB) (*psqlDB, error) { +func NewPostgresDB(log log.Logger, dbCfg ytrssilConfig.DB) (*postgresDB, error) { db, err := sql.Open("postgres", dbCfg.DBURI) if err != nil { return nil, err } - return &psqlDB{ + return &postgresDB{ l: log, db: db, }, nil diff --git a/db/users.go b/db/users.go index 993da92..740b48e 100644 --- a/db/users.go +++ b/db/users.go @@ -2,27 +2,27 @@ package db import ( "context" - "fmt" "github.com/alexedwards/argon2id" - "github.com/georgysavva/scany/v2/sqlscan" + "github.com/lib/pq" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) -var authenticateUserQuery = `SELECT username, password FROM users WHERE username = $1` +var authenticateUserQuery = `SELECT password FROM users WHERE username = $1` -func (d *psqlDB) AuthenticateUser(ctx context.Context, username string, password string) (bool, error) { - var user []*models.User - err := sqlscan.Select(ctx, d.db, &user, authenticateUserQuery, username) +func (d *postgresDB) AuthenticateUser(ctx context.Context, user models.User) (bool, error) { + row := d.db.QueryRowContext(ctx, authenticateUserQuery, user.Username) + var hashedPassword string + err := row.Scan(&hashedPassword) if err != nil { - d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to query user", "error", err) + d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err) return false, err } - match, err := argon2id.ComparePasswordAndHash(password, user[0].Password) + match, err := argon2id.ComparePasswordAndHash(user.Password, hashedPassword) if err != nil { - d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to check hashed passsword", "error", err) + d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err) return false, err } @@ -31,22 +31,65 @@ func (d *psqlDB) AuthenticateUser(ctx context.Context, username string, password var createUserQuery = `INSERT INTO users (username, password) VALUES ($1, $2)` -func (d *psqlDB) CreateUser(ctx context.Context, user models.User) error { - res, err := d.db.Exec(createUserQuery, user.Username, user.Password) +func (d *postgresDB) CreateUser(ctx context.Context, user models.User) error { + _, err := d.db.ExecContext(ctx, createUserQuery, user.Username, user.Password) if err != nil { - d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to create user", "error", err) + d.l.Log("level", "ERROR", "function", "db.CreateUser", "error", err) + return err + } + + return nil +} + +var deleteUserQuery = `DELETE FROM users WHERE username = $1` + +func (d *postgresDB) DeleteUser(ctx context.Context, username string) error { + _, err := d.db.ExecContext(ctx, deleteUserQuery, username) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.DeleteUser", "error", err) + return err + } + + return nil +} + +var subscribeUserToChannelQuery = `INSERT INTO user_subscriptions (username, channel_id) VALUES ($1, $2)` + +func (d *postgresDB) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error { + _, err := d.db.ExecContext(ctx, subscribeUserToChannelQuery, username, channelID) + if err != nil { + if pgerr, ok := err.(*pq.Error); ok { + if pgerr.Code == "23505" { + return ErrAlreadySubscribed + } + } + d.l.Log("level", "ERROR", "function", "db.SubscribeUserToChannel", "error", err) + return err + } + + return nil +} + +var addVideoToUserQuery = `INSERT INTO user_videos (username, video_id) VALUES ($1, $2)` + +func (d *postgresDB) AddVideoToUser(ctx context.Context, username string, videoID string) error { + _, err := d.db.ExecContext(ctx, addVideoToUserQuery, username, videoID) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.AddVideoToUser", "error", err) + return err + } + + return nil +} + +var watchVideoQuery = `UPDATE user_videos SET watch_timestamp = NOW() WHERE username = $2 AND video_id = $3` + +func (d *postgresDB) WatchVideo(ctx context.Context, username string, videoID string) error { + _, err := d.db.ExecContext(ctx, watchVideoQuery, username, videoID) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.WatchVideo", "error", err) return err } - affected, err := res.RowsAffected() - if err != nil { - d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err) - return err - } - - if affected != 1 { - d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err) - return fmt.Errorf("expected to insert one row, but %d were inserted", affected) - } return nil } diff --git a/db/videos.go b/db/videos.go index ebe995e..47f1cb1 100644 --- a/db/videos.go +++ b/db/videos.go @@ -3,9 +3,8 @@ package db import ( "context" - "github.com/georgysavva/scany/v2/sqlscan" - "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" + "github.com/lib/pq" ) var getNewVideosQuery = ` @@ -25,13 +24,47 @@ var getNewVideosQuery = ` ORDER BY published_timestamp ` -func (d *psqlDB) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) { - var videos []*models.Video - err := sqlscan.Select(ctx, d.db, &videos, getNewVideosQuery, username) +func (d *postgresDB) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) { + rows, err := d.db.QueryContext(ctx, getNewVideosQuery, username) if err != nil { - d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "msg", "failed to query new videos", "error", err) + d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "call", "sql.QueryContext", "error", err) return nil, err } + defer rows.Close() + + videos := make([]models.Video, 0) + for rows.Next() { + var video models.Video + err = rows.Scan( + &video.ID, + &video.Title, + &video.PublishedTime, + &video.WatchTime, + &video.ChannelName, + ) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "call", "sql.Scan", "error", err) + return nil, err + } + videos = append(videos, video) + } return videos, nil } + +var createVideoQuery = `INSERT INTO videos (id, title, published_timestamp, channel_id) VALUES ($1, $2, $3, $4)` + +func (d *postgresDB) CreateVideo(ctx context.Context, video models.Video, channelID string) error { + _, err := d.db.ExecContext(ctx, createVideoQuery, video.ID, video.Title, video.PublishedTime, channelID) + if err != nil { + if pgerr, ok := err.(*pq.Error); ok { + if pgerr.Code == "23505" { + return ErrVideoExists + } + } + d.l.Log("level", "ERROR", "function", "db.CreateVideo", "call", "sql.Exec", "error", err) + return err + } + + return nil +} diff --git a/feedparser/date.go b/feedparser/date.go new file mode 100644 index 0000000..9059432 --- /dev/null +++ b/feedparser/date.go @@ -0,0 +1,13 @@ +package feedparser + +import ( + "time" +) + +// Date type +type Date string + +// Parse (Date function) and returns Time, error +func (d Date) Parse() (time.Time, error) { + return time.Parse(time.RFC3339, string(d)) // ISO8601 +} diff --git a/feedparser/feedparser.go b/feedparser/feedparser.go new file mode 100644 index 0000000..afebb54 --- /dev/null +++ b/feedparser/feedparser.go @@ -0,0 +1,74 @@ +package feedparser + +import ( + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" + "github.com/paulrosania/go-charset/charset" +) + +var ( + ErrInvalidChannelID = errors.New("invalid channel ID") + ErrParseFailed = errors.New("failed to parse feed") +) + +var urlFormat = "https://www.youtube.com/feeds/videos.xml?channel_id=%s" + +// Video struct for each video in the feed +type Video struct { + ID string `xml:"id"` + Title string `xml:"title"` + Published Date `xml:"published"` +} + +// Channel struct for RSS +type Channel struct { + Name string `xml:"title"` + Videos []Video `xml:"entry"` +} + +func read(l log.Logger, url string) (io.ReadCloser, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + l.Log("level", "ERROR", "function", "feedparser.read", "call", "http.NewRequest", "error", err) + return nil, err + } + + response, err := http.DefaultClient.Do(req) + if err != nil { + l.Log("level", "ERROR", "function", "feedparser.read", "call", "http.Do", "error", err) + return nil, err + } + + if response.StatusCode == http.StatusNotFound { + return nil, ErrInvalidChannelID + } else if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get feed with status %d", response.StatusCode) + } + + return response.Body, nil +} + +// Parse parses a YouTube channel XML feed from a channel ID +func Parse(l log.Logger, channelID string) (*Channel, error) { + url := fmt.Sprintf(urlFormat, channelID) + reader, err := read(l, url) + if err != nil { + return nil, err + } + + defer reader.Close() + xmlDecoder := xml.NewDecoder(reader) + xmlDecoder.CharsetReader = charset.NewReader + + var channel Channel + if err := xmlDecoder.Decode(&channel); err != nil { + l.Log("level", "ERROR", "function", "feedparser.read", "call", "xml.Decode", "error", err) + return nil, fmt.Errorf("%w: %s", ErrParseFailed, err.Error()) + } + return &channel, nil +} diff --git a/go.mod b/go.mod index 3ecac63..e30c893 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,11 @@ go 1.19 require ( github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 - github.com/georgysavva/scany/v2 v2.0.0-alpha.3 github.com/gin-gonic/gin v1.8.1 github.com/go-kit/log v0.2.1 github.com/jessevdk/go-flags v1.5.0 github.com/lib/pq v1.10.7 + github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c github.com/stretchr/testify v1.8.0 ) @@ -21,7 +21,6 @@ require ( github.com/go-playground/validator/v10 v10.11.0 // indirect github.com/goccy/go-json v0.9.11 // indirect github.com/google/go-cmp v0.5.8 // indirect - github.com/jackc/pgx/v5 v5.0.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/mattn/go-isatty v0.0.16 // indirect @@ -29,6 +28,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/ugorji/go/codec v1.2.7 // indirect golang.org/x/crypto v0.1.0 // indirect golang.org/x/net v0.1.0 // indirect diff --git a/go.sum b/go.sum index 72d3f1d..6f3783e 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,9 @@ github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I= github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc= -github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/georgysavva/scany/v2 v2.0.0-alpha.3 h1:+n7kJr/xyVQJcsE2ICn0jd/a4QaxaWnx+a+GJQTGRrQ= -github.com/georgysavva/scany/v2 v2.0.0-alpha.3/go.mod h1:sigOdh+0qb/+aOs3TVhehVT10p8qJL7K/Zhyz8vWo38= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8= @@ -25,17 +22,11 @@ github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2B github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= -github.com/jackc/pgx/v5 v5.0.3 h1:4flM5ecR/555F0EcnjdaZa6MhBU+nr0QbZIo5vaKjuM= -github.com/jackc/pgx/v5 v5.0.3/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o= -github.com/jackc/puddle/v2 v2.0.0 h1:Kwk/AlLigcnZsDssc3Zun1dk1tAtQNPaBBxBHWn0Mjc= github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -59,17 +50,18 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c h1:P6XGcuPTigoHf4TSu+3D/7QOQ1MbL6alNwrGhcW7sKw= +github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c/go.mod h1:YnNlZP7l4MhyGQ4CBRwv6ohZTPrUJJZtEv4ZgADkbs4= github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg= github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/handler/handler.go b/handler/handler.go index d1dbdfb..cac613e 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -2,17 +2,22 @@ package handler import ( "context" + "errors" + "strings" "github.com/alexedwards/argon2id" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) type Handler interface { CreateUser(ctx context.Context, user models.User) error - GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) + SubscribeToChannel(ctx context.Context, username string, channelID string) error + GetNewVideos(ctx context.Context, username string) ([]models.Video, error) + FetchVideos(ctx context.Context) error } type handler struct { @@ -34,6 +39,89 @@ func (h *handler) CreateUser(ctx context.Context, user models.User) error { return h.db.CreateUser(ctx, user) } -func (h *handler) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) { +func (h *handler) SubscribeToChannel(ctx context.Context, username string, channelID string) error { + parsedChannel, err := feedparser.Parse(h.log, channelID) + if err != nil { + return err + } + + channel := models.Channel{ + ID: channelID, + Name: parsedChannel.Name, + } + + err = h.db.CreateChannel(ctx, channel) + if !errors.Is(err, db.ErrChannelExists) { + return err + } + + return h.db.SubscribeUserToChannel(ctx, username, channelID) +} + +func (h *handler) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) { return h.db.GetNewVideos(ctx, username) } + +func (h *handler) addVideoToAllSubscribers(ctx context.Context, channelID string, videoID string) error { + subs, err := h.db.GetChannelSubscribers(ctx, channelID) + if err != nil { + h.log.Log("level", "ERROR", "call", "db.GetChannelSubscribers", "err", err) + return err + } + + for _, sub := range subs { + err = h.db.AddVideoToUser(ctx, sub, videoID) + if err != nil { + h.log.Log("level", "ERROR", "call", "db.AddVideoToUser", "err", err) + continue + } + } + + return nil +} + +func (h *handler) fetchVideosForChannel(ctx context.Context, channelID string, parsedChannel *feedparser.Channel) { + for _, parsedVideo := range parsedChannel.Videos { + date, err := parsedVideo.Published.Parse() + if err != nil { + h.log.Log("level", "WARNING", "call", "feedparser.Parse", "err", err) + continue + } + + id := strings.Split(parsedVideo.ID, ":")[2] + video := models.Video{ + ID: id, + Title: parsedVideo.Title, + PublishedTime: date, + } + err = h.db.CreateVideo(ctx, video, channelID) + if err != nil { + if !errors.Is(err, db.ErrVideoExists) { + h.log.Log("level", "WARNING", "call", "db.CreateVideo", "err", err) + } + continue + } + err = h.addVideoToAllSubscribers(ctx, channelID, id) + if err != nil { + continue + } + } +} + +func (h *handler) FetchVideos(ctx context.Context) error { + channels, err := h.db.ListChannels(ctx) + if err != nil { + return err + } + + for _, channel := range channels { + parsedChannel, err := feedparser.Parse(h.log, channel.ID) + if err != nil { + continue + } + + h.fetchVideosForChannel(ctx, channel.ID, parsedChannel) + } + + return nil +} diff --git a/handler/handler_test.go b/handler/handler_test.go index 76ad44e..c9bd147 100644 --- a/handler/handler_test.go +++ b/handler/handler_test.go @@ -24,10 +24,10 @@ func TestGetNewVideos(t *testing.T) { l := log.NewNopLogger() handler := New(l, &db_mock.DBMock{ - GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) { - return []*models.Video{ + GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) { + return []models.Video{ { - VideoID: "test", + ID: "test", ChannelName: "test", Title: "test", PublishedTime: time.Now(), @@ -43,7 +43,7 @@ func TestGetNewVideos(t *testing.T) { // Assert if assert.NoError(t, err) { if assert.NotNil(t, resp) { - assert.Equal(t, resp[0].VideoID, "test") + assert.Equal(t, resp[0].ID, "test") assert.Equal(t, resp[0].Title, "test") } } diff --git a/httpserver/auth/auth.go b/httpserver/auth/auth.go index dbce9a3..16dbe78 100644 --- a/httpserver/auth/auth.go +++ b/httpserver/auth/auth.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) // AuthMiddleware will authenticate against a static API key @@ -16,7 +17,11 @@ func AuthMiddleware(db db.DB) gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid basic auth header"}) return } - authenticated, err := db.AuthenticateUser(c.Request.Context(), username, password) + user := models.User{ + Username: username, + Password: password, + } + authenticated, err := db.AuthenticateUser(c.Request.Context(), user) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return diff --git a/httpserver/auth/auth_test.go b/httpserver/auth/auth_test.go index 33dee70..4b8f814 100644 --- a/httpserver/auth/auth_test.go +++ b/httpserver/auth/auth_test.go @@ -6,15 +6,17 @@ import ( "net/http/httptest" "testing" - db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + + db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) func setupTestServer() *http.Server { db := &db_mock.DBMock{ - AuthenticateUserFunc: func(ctx context.Context, username, password string) (bool, error) { - return username == "username" && password == "password", nil + AuthenticateUserFunc: func(ctx context.Context, user models.User) (bool, error) { + return user.Username == "username" && user.Password == "password", nil }, } @@ -37,7 +39,7 @@ func TestSuccessfulAuthentication(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/", nil) - req.Header.Add("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=") // username:password + req.SetBasicAuth("username", "password") // Valid credentials server.Handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -52,7 +54,7 @@ func TestMissingAuthorizationHeader(t *testing.T) { server.Handler.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) - assert.Equal(t, `{"error":"invalid authorization header"}`, w.Body.String()) + assert.Equal(t, `{"error":"invalid basic auth header"}`, w.Body.String()) } func TestWrongCredentials(t *testing.T) { @@ -60,9 +62,9 @@ func TestWrongCredentials(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/", nil) - req.Header.Add("Authorization", "Basic d3Jvbmc=") + req.SetBasicAuth("test", "test") // Invalid credentials server.Handler.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) - assert.Equal(t, `{"error":"invalid API Key"}`, w.Body.String()) + assert.Equal(t, `{"error":"invalid username or password"}`, w.Body.String()) } diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go index a0c6d6d..4f5ac66 100644 --- a/httpserver/ytrssil/server.go +++ b/httpserver/ytrssil/server.go @@ -37,12 +37,14 @@ func SetupGinRouter(l log.Logger, handler handler.Handler, authMiddleware func(c } engine.GET("/healthz", srv.Healthz) engine.POST("/register", srv.CreateUser) + engine.POST("/fetch", srv.FetchVideos) // all APIs go in this routing group and require authentication api := engine.Group("/api") api.Use(authMiddleware) { api.GET("videos/new", srv.GetNewVideos) + api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannel) } return engine, nil diff --git a/httpserver/ytrssil/users.go b/httpserver/ytrssil/users.go index b4c5be9..6938880 100644 --- a/httpserver/ytrssil/users.go +++ b/httpserver/ytrssil/users.go @@ -1,10 +1,14 @@ package ytrssil import ( + "errors" "net/http" - "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" "github.com/gin-gonic/gin" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) func (s *server) CreateUser(c *gin.Context) { @@ -23,3 +27,30 @@ func (s *server) CreateUser(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"msg": "user created"}) } + +func (s *server) SubscribeToChannel(c *gin.Context) { + var channel models.Channel + err := c.BindUri(&channel) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + username := c.GetString("username") + + err = s.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID) + if err != nil { + if errors.Is(err, db.ErrAlreadySubscribed) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, feedparser.ErrInvalidChannelID) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "subscribed to channel successfully"}) +} diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go index e3ba2a0..9aaab82 100644 --- a/httpserver/ytrssil/videos.go +++ b/httpserver/ytrssil/videos.go @@ -19,3 +19,13 @@ func (s *server) GetNewVideos(c *gin.Context) { Videos: videos, }) } + +func (s *server) FetchVideos(c *gin.Context) { + err := s.handler.FetchVideos(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "videos fetched successfully"}) +} diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql index 0205bcb..09adbdb 100644 --- a/migrations/000001_init.down.sql +++ b/migrations/000001_init.down.sql @@ -1,3 +1,4 @@ +DROP TABLE IF EXISTS user_subscriptions; DROP TABLE IF EXISTS user_videos; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS videos; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql index fe887b1..fed133a 100644 --- a/migrations/000001_init.up.sql +++ b/migrations/000001_init.up.sql @@ -1,24 +1,29 @@ CREATE TABLE IF NOT EXISTS channels ( - id text NOT NULL PRIMARY KEY, - name text NOT NULL, - feed_url text NOT NULL + id text NOT NULL PRIMARY KEY + , name text NOT NULL ); CREATE TABLE IF NOT EXISTS videos ( - id text NOT NULL PRIMARY KEY, - title text NOT NULL, - published_timestamp timestamp with time zone NOT NULL, - channel_id text NOT NULL REFERENCES channels(id) + id text NOT NULL PRIMARY KEY + , title text NOT NULL + , published_timestamp timestamp with time zone NOT NULL + , channel_id text NOT NULL REFERENCES channels(id) ); CREATE TABLE IF NOT EXISTS users ( - username text NOT NULL PRIMARY KEY, - password text NOT NULL + username text NOT NULL PRIMARY KEY + , password text NOT NULL ); CREATE TABLE IF NOT EXISTS user_videos ( - id SERIAL PRIMARY KEY, - username text NOT NULL REFERENCES users(username), - video_id text NOT NULL REFERENCES videos(id), - watch_timestamp timestamp with time zone + username text NOT NULL REFERENCES users(username) + , video_id text NOT NULL REFERENCES videos(id) + , watch_timestamp timestamp with time zone + , CONSTRAINT user_videos_pkey PRIMARY KEY (username, video_id) +); + +CREATE TABLE IF NOT EXISTS user_subscriptions ( + username text NOT NULL REFERENCES users(username) + , channel_id text NOT NULL REFERENCES channels(id) + , CONSTRAINT user_subscriptions_pkey PRIMARY KEY (channel_id, username) ); diff --git a/mocks/db/db.go b/mocks/db/db.go index e2ed9ca..d88b2e1 100644 --- a/mocks/db/db.go +++ b/mocks/db/db.go @@ -20,15 +20,39 @@ var _ db.DB = &DBMock{} // // // make and configure a mocked db.DB // mockedDB := &DBMock{ -// AuthenticateUserFunc: func(ctx context.Context, username string, password string) (bool, error) { +// AddVideoToUserFunc: func(ctx context.Context, username string, videoID string) error { +// panic("mock out the AddVideoToUser method") +// }, +// AuthenticateUserFunc: func(ctx context.Context, user models.User) (bool, error) { // panic("mock out the AuthenticateUser method") // }, +// CreateChannelFunc: func(ctx context.Context, channel models.Channel) error { +// panic("mock out the CreateChannel method") +// }, // CreateUserFunc: func(ctx context.Context, user models.User) error { // panic("mock out the CreateUser method") // }, -// GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) { +// CreateVideoFunc: func(ctx context.Context, video models.Video, channelID string) error { +// panic("mock out the CreateVideo method") +// }, +// DeleteUserFunc: func(ctx context.Context, username string) error { +// panic("mock out the DeleteUser method") +// }, +// GetChannelSubscribersFunc: func(ctx context.Context, channelID string) ([]string, error) { +// panic("mock out the GetChannelSubscribers method") +// }, +// GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) { // panic("mock out the GetNewVideos method") // }, +// ListChannelsFunc: func(ctx context.Context) ([]models.Channel, error) { +// panic("mock out the ListChannels method") +// }, +// SubscribeUserToChannelFunc: func(ctx context.Context, username string, channelID string) error { +// panic("mock out the SubscribeUserToChannel method") +// }, +// WatchVideoFunc: func(ctx context.Context, username string, videoID string) error { +// panic("mock out the WatchVideo method") +// }, // } // // // use mockedDB in code that requires db.DB @@ -36,25 +60,63 @@ var _ db.DB = &DBMock{} // // } type DBMock struct { + // AddVideoToUserFunc mocks the AddVideoToUser method. + AddVideoToUserFunc func(ctx context.Context, username string, videoID string) error + // AuthenticateUserFunc mocks the AuthenticateUser method. - AuthenticateUserFunc func(ctx context.Context, username string, password string) (bool, error) + AuthenticateUserFunc func(ctx context.Context, user models.User) (bool, error) + + // CreateChannelFunc mocks the CreateChannel method. + CreateChannelFunc func(ctx context.Context, channel models.Channel) error // CreateUserFunc mocks the CreateUser method. CreateUserFunc func(ctx context.Context, user models.User) error + // CreateVideoFunc mocks the CreateVideo method. + CreateVideoFunc func(ctx context.Context, video models.Video, channelID string) error + + // DeleteUserFunc mocks the DeleteUser method. + DeleteUserFunc func(ctx context.Context, username string) error + + // GetChannelSubscribersFunc mocks the GetChannelSubscribers method. + GetChannelSubscribersFunc func(ctx context.Context, channelID string) ([]string, error) + // GetNewVideosFunc mocks the GetNewVideos method. - GetNewVideosFunc func(ctx context.Context, username string) ([]*models.Video, error) + GetNewVideosFunc func(ctx context.Context, username string) ([]models.Video, error) + + // ListChannelsFunc mocks the ListChannels method. + ListChannelsFunc func(ctx context.Context) ([]models.Channel, error) + + // SubscribeUserToChannelFunc mocks the SubscribeUserToChannel method. + SubscribeUserToChannelFunc func(ctx context.Context, username string, channelID string) error + + // WatchVideoFunc mocks the WatchVideo method. + WatchVideoFunc func(ctx context.Context, username string, videoID string) error // calls tracks calls to the methods. calls struct { - // AuthenticateUser holds details about calls to the AuthenticateUser method. - AuthenticateUser []struct { + // AddVideoToUser holds details about calls to the AddVideoToUser method. + AddVideoToUser []struct { // Ctx is the ctx argument value. Ctx context.Context // Username is the username argument value. Username string - // Password is the password argument value. - Password string + // VideoID is the videoID argument value. + VideoID string + } + // AuthenticateUser holds details about calls to the AuthenticateUser method. + AuthenticateUser []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // User is the user argument value. + User models.User + } + // CreateChannel holds details about calls to the CreateChannel method. + CreateChannel []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Channel is the channel argument value. + Channel models.Channel } // CreateUser holds details about calls to the CreateUser method. CreateUser []struct { @@ -63,6 +125,29 @@ type DBMock struct { // User is the user argument value. User models.User } + // CreateVideo holds details about calls to the CreateVideo method. + CreateVideo []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Video is the video argument value. + Video models.Video + // ChannelID is the channelID argument value. + ChannelID string + } + // DeleteUser holds details about calls to the DeleteUser method. + DeleteUser []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Username is the username argument value. + Username string + } + // GetChannelSubscribers holds details about calls to the GetChannelSubscribers method. + GetChannelSubscribers []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // ChannelID is the channelID argument value. + ChannelID string + } // GetNewVideos holds details about calls to the GetNewVideos method. GetNewVideos []struct { // Ctx is the ctx argument value. @@ -70,30 +155,99 @@ type DBMock struct { // Username is the username argument value. Username string } + // ListChannels holds details about calls to the ListChannels method. + ListChannels []struct { + // Ctx is the ctx argument value. + Ctx context.Context + } + // SubscribeUserToChannel holds details about calls to the SubscribeUserToChannel method. + SubscribeUserToChannel []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Username is the username argument value. + Username string + // ChannelID is the channelID argument value. + ChannelID string + } + // WatchVideo holds details about calls to the WatchVideo method. + WatchVideo []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Username is the username argument value. + Username string + // VideoID is the videoID argument value. + VideoID string + } } - lockAuthenticateUser sync.RWMutex - lockCreateUser sync.RWMutex - lockGetNewVideos sync.RWMutex + lockAddVideoToUser sync.RWMutex + lockAuthenticateUser sync.RWMutex + lockCreateChannel sync.RWMutex + lockCreateUser sync.RWMutex + lockCreateVideo sync.RWMutex + lockDeleteUser sync.RWMutex + lockGetChannelSubscribers sync.RWMutex + lockGetNewVideos sync.RWMutex + lockListChannels sync.RWMutex + lockSubscribeUserToChannel sync.RWMutex + lockWatchVideo sync.RWMutex } -// AuthenticateUser calls AuthenticateUserFunc. -func (mock *DBMock) AuthenticateUser(ctx context.Context, username string, password string) (bool, error) { - if mock.AuthenticateUserFunc == nil { - panic("DBMock.AuthenticateUserFunc: method is nil but DB.AuthenticateUser was just called") +// AddVideoToUser calls AddVideoToUserFunc. +func (mock *DBMock) AddVideoToUser(ctx context.Context, username string, videoID string) error { + if mock.AddVideoToUserFunc == nil { + panic("DBMock.AddVideoToUserFunc: method is nil but DB.AddVideoToUser was just called") } callInfo := struct { Ctx context.Context Username string - Password string + VideoID string }{ Ctx: ctx, Username: username, - Password: password, + VideoID: videoID, + } + mock.lockAddVideoToUser.Lock() + mock.calls.AddVideoToUser = append(mock.calls.AddVideoToUser, callInfo) + mock.lockAddVideoToUser.Unlock() + return mock.AddVideoToUserFunc(ctx, username, videoID) +} + +// AddVideoToUserCalls gets all the calls that were made to AddVideoToUser. +// Check the length with: +// +// len(mockedDB.AddVideoToUserCalls()) +func (mock *DBMock) AddVideoToUserCalls() []struct { + Ctx context.Context + Username string + VideoID string +} { + var calls []struct { + Ctx context.Context + Username string + VideoID string + } + mock.lockAddVideoToUser.RLock() + calls = mock.calls.AddVideoToUser + mock.lockAddVideoToUser.RUnlock() + return calls +} + +// AuthenticateUser calls AuthenticateUserFunc. +func (mock *DBMock) AuthenticateUser(ctx context.Context, user models.User) (bool, error) { + if mock.AuthenticateUserFunc == nil { + panic("DBMock.AuthenticateUserFunc: method is nil but DB.AuthenticateUser was just called") + } + callInfo := struct { + Ctx context.Context + User models.User + }{ + Ctx: ctx, + User: user, } mock.lockAuthenticateUser.Lock() mock.calls.AuthenticateUser = append(mock.calls.AuthenticateUser, callInfo) mock.lockAuthenticateUser.Unlock() - return mock.AuthenticateUserFunc(ctx, username, password) + return mock.AuthenticateUserFunc(ctx, user) } // AuthenticateUserCalls gets all the calls that were made to AuthenticateUser. @@ -101,14 +255,12 @@ func (mock *DBMock) AuthenticateUser(ctx context.Context, username string, passw // // len(mockedDB.AuthenticateUserCalls()) func (mock *DBMock) AuthenticateUserCalls() []struct { - Ctx context.Context - Username string - Password string + Ctx context.Context + User models.User } { var calls []struct { - Ctx context.Context - Username string - Password string + Ctx context.Context + User models.User } mock.lockAuthenticateUser.RLock() calls = mock.calls.AuthenticateUser @@ -116,6 +268,42 @@ func (mock *DBMock) AuthenticateUserCalls() []struct { return calls } +// CreateChannel calls CreateChannelFunc. +func (mock *DBMock) CreateChannel(ctx context.Context, channel models.Channel) error { + if mock.CreateChannelFunc == nil { + panic("DBMock.CreateChannelFunc: method is nil but DB.CreateChannel was just called") + } + callInfo := struct { + Ctx context.Context + Channel models.Channel + }{ + Ctx: ctx, + Channel: channel, + } + mock.lockCreateChannel.Lock() + mock.calls.CreateChannel = append(mock.calls.CreateChannel, callInfo) + mock.lockCreateChannel.Unlock() + return mock.CreateChannelFunc(ctx, channel) +} + +// CreateChannelCalls gets all the calls that were made to CreateChannel. +// Check the length with: +// +// len(mockedDB.CreateChannelCalls()) +func (mock *DBMock) CreateChannelCalls() []struct { + Ctx context.Context + Channel models.Channel +} { + var calls []struct { + Ctx context.Context + Channel models.Channel + } + mock.lockCreateChannel.RLock() + calls = mock.calls.CreateChannel + mock.lockCreateChannel.RUnlock() + return calls +} + // CreateUser calls CreateUserFunc. func (mock *DBMock) CreateUser(ctx context.Context, user models.User) error { if mock.CreateUserFunc == nil { @@ -152,8 +340,120 @@ func (mock *DBMock) CreateUserCalls() []struct { return calls } +// CreateVideo calls CreateVideoFunc. +func (mock *DBMock) CreateVideo(ctx context.Context, video models.Video, channelID string) error { + if mock.CreateVideoFunc == nil { + panic("DBMock.CreateVideoFunc: method is nil but DB.CreateVideo was just called") + } + callInfo := struct { + Ctx context.Context + Video models.Video + ChannelID string + }{ + Ctx: ctx, + Video: video, + ChannelID: channelID, + } + mock.lockCreateVideo.Lock() + mock.calls.CreateVideo = append(mock.calls.CreateVideo, callInfo) + mock.lockCreateVideo.Unlock() + return mock.CreateVideoFunc(ctx, video, channelID) +} + +// CreateVideoCalls gets all the calls that were made to CreateVideo. +// Check the length with: +// +// len(mockedDB.CreateVideoCalls()) +func (mock *DBMock) CreateVideoCalls() []struct { + Ctx context.Context + Video models.Video + ChannelID string +} { + var calls []struct { + Ctx context.Context + Video models.Video + ChannelID string + } + mock.lockCreateVideo.RLock() + calls = mock.calls.CreateVideo + mock.lockCreateVideo.RUnlock() + return calls +} + +// DeleteUser calls DeleteUserFunc. +func (mock *DBMock) DeleteUser(ctx context.Context, username string) error { + if mock.DeleteUserFunc == nil { + panic("DBMock.DeleteUserFunc: method is nil but DB.DeleteUser was just called") + } + callInfo := struct { + Ctx context.Context + Username string + }{ + Ctx: ctx, + Username: username, + } + mock.lockDeleteUser.Lock() + mock.calls.DeleteUser = append(mock.calls.DeleteUser, callInfo) + mock.lockDeleteUser.Unlock() + return mock.DeleteUserFunc(ctx, username) +} + +// DeleteUserCalls gets all the calls that were made to DeleteUser. +// Check the length with: +// +// len(mockedDB.DeleteUserCalls()) +func (mock *DBMock) DeleteUserCalls() []struct { + Ctx context.Context + Username string +} { + var calls []struct { + Ctx context.Context + Username string + } + mock.lockDeleteUser.RLock() + calls = mock.calls.DeleteUser + mock.lockDeleteUser.RUnlock() + return calls +} + +// GetChannelSubscribers calls GetChannelSubscribersFunc. +func (mock *DBMock) GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error) { + if mock.GetChannelSubscribersFunc == nil { + panic("DBMock.GetChannelSubscribersFunc: method is nil but DB.GetChannelSubscribers was just called") + } + callInfo := struct { + Ctx context.Context + ChannelID string + }{ + Ctx: ctx, + ChannelID: channelID, + } + mock.lockGetChannelSubscribers.Lock() + mock.calls.GetChannelSubscribers = append(mock.calls.GetChannelSubscribers, callInfo) + mock.lockGetChannelSubscribers.Unlock() + return mock.GetChannelSubscribersFunc(ctx, channelID) +} + +// GetChannelSubscribersCalls gets all the calls that were made to GetChannelSubscribers. +// Check the length with: +// +// len(mockedDB.GetChannelSubscribersCalls()) +func (mock *DBMock) GetChannelSubscribersCalls() []struct { + Ctx context.Context + ChannelID string +} { + var calls []struct { + Ctx context.Context + ChannelID string + } + mock.lockGetChannelSubscribers.RLock() + calls = mock.calls.GetChannelSubscribers + mock.lockGetChannelSubscribers.RUnlock() + return calls +} + // GetNewVideos calls GetNewVideosFunc. -func (mock *DBMock) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) { +func (mock *DBMock) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) { if mock.GetNewVideosFunc == nil { panic("DBMock.GetNewVideosFunc: method is nil but DB.GetNewVideos was just called") } @@ -187,3 +487,115 @@ func (mock *DBMock) GetNewVideosCalls() []struct { mock.lockGetNewVideos.RUnlock() return calls } + +// ListChannels calls ListChannelsFunc. +func (mock *DBMock) ListChannels(ctx context.Context) ([]models.Channel, error) { + if mock.ListChannelsFunc == nil { + panic("DBMock.ListChannelsFunc: method is nil but DB.ListChannels was just called") + } + callInfo := struct { + Ctx context.Context + }{ + Ctx: ctx, + } + mock.lockListChannels.Lock() + mock.calls.ListChannels = append(mock.calls.ListChannels, callInfo) + mock.lockListChannels.Unlock() + return mock.ListChannelsFunc(ctx) +} + +// ListChannelsCalls gets all the calls that were made to ListChannels. +// Check the length with: +// +// len(mockedDB.ListChannelsCalls()) +func (mock *DBMock) ListChannelsCalls() []struct { + Ctx context.Context +} { + var calls []struct { + Ctx context.Context + } + mock.lockListChannels.RLock() + calls = mock.calls.ListChannels + mock.lockListChannels.RUnlock() + return calls +} + +// SubscribeUserToChannel calls SubscribeUserToChannelFunc. +func (mock *DBMock) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error { + if mock.SubscribeUserToChannelFunc == nil { + panic("DBMock.SubscribeUserToChannelFunc: method is nil but DB.SubscribeUserToChannel was just called") + } + callInfo := struct { + Ctx context.Context + Username string + ChannelID string + }{ + Ctx: ctx, + Username: username, + ChannelID: channelID, + } + mock.lockSubscribeUserToChannel.Lock() + mock.calls.SubscribeUserToChannel = append(mock.calls.SubscribeUserToChannel, callInfo) + mock.lockSubscribeUserToChannel.Unlock() + return mock.SubscribeUserToChannelFunc(ctx, username, channelID) +} + +// SubscribeUserToChannelCalls gets all the calls that were made to SubscribeUserToChannel. +// Check the length with: +// +// len(mockedDB.SubscribeUserToChannelCalls()) +func (mock *DBMock) SubscribeUserToChannelCalls() []struct { + Ctx context.Context + Username string + ChannelID string +} { + var calls []struct { + Ctx context.Context + Username string + ChannelID string + } + mock.lockSubscribeUserToChannel.RLock() + calls = mock.calls.SubscribeUserToChannel + mock.lockSubscribeUserToChannel.RUnlock() + return calls +} + +// WatchVideo calls WatchVideoFunc. +func (mock *DBMock) WatchVideo(ctx context.Context, username string, videoID string) error { + if mock.WatchVideoFunc == nil { + panic("DBMock.WatchVideoFunc: method is nil but DB.WatchVideo was just called") + } + callInfo := struct { + Ctx context.Context + Username string + VideoID string + }{ + Ctx: ctx, + Username: username, + VideoID: videoID, + } + mock.lockWatchVideo.Lock() + mock.calls.WatchVideo = append(mock.calls.WatchVideo, callInfo) + mock.lockWatchVideo.Unlock() + return mock.WatchVideoFunc(ctx, username, videoID) +} + +// WatchVideoCalls gets all the calls that were made to WatchVideo. +// Check the length with: +// +// len(mockedDB.WatchVideoCalls()) +func (mock *DBMock) WatchVideoCalls() []struct { + Ctx context.Context + Username string + VideoID string +} { + var calls []struct { + Ctx context.Context + Username string + VideoID string + } + mock.lockWatchVideo.RLock() + calls = mock.calls.WatchVideo + mock.lockWatchVideo.RUnlock() + return calls +} diff --git a/models/channel.go b/models/channel.go index 0d779cb..095ae8b 100644 --- a/models/channel.go +++ b/models/channel.go @@ -2,9 +2,7 @@ package models type Channel struct { // YouTube ID of the channel - ChannelID string `json:"channel_id" dynamodbav:"channel_id"` + ID string `json:"channel_id" uri:"channel_id" binding:"required"` // Name of the channel - Name string `json:"name" dynamodbav:"name"` - // Feed is the URL for the RSS feed - FeedURL string `json:"feed_url" dynamodbav:"feed_url"` + Name string `json:"name"` } diff --git a/models/http.go b/models/http.go index c907f98..d59ad49 100644 --- a/models/http.go +++ b/models/http.go @@ -1,5 +1,5 @@ package models type GetNewVideosResponse struct { - Videos []*Video `json:"videos"` + Videos []Video `json:"videos"` } diff --git a/models/video.go b/models/video.go index b3a3fb1..581795a 100644 --- a/models/video.go +++ b/models/video.go @@ -6,13 +6,13 @@ import ( type Video struct { // YouTube ID of the video - VideoID string `json:"video_id" db:"video_id"` + ID string `json:"video_id"` // Name of the channel the video belongs to - ChannelName string `json:"channel_name" db:"channel_name"` + ChannelName string `json:"channel_name"` // Title of the video - Title string `json:"title" db:"title"` + Title string `json:"title"` // Video publish timestamp - PublishedTime time.Time `json:"published_timestamp" db:"published_timestamp"` + PublishedTime time.Time `json:"published_timestamp"` // Video watch timestamp - WatchTime *time.Time `json:"watch_timestamp" db:"watch_timestamp"` + WatchTime *time.Time `json:"watch_timestamp"` }