diff --git a/go.mod b/go.mod index 1c7ed65dd..0e036e3a8 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,12 @@ module github.com/ncarlier/reader require ( + github.com/brianvoe/gofakeit v3.17.0+incompatible github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448 // indirect github.com/getsentry/raven-go v0.2.0 - github.com/goadesign/goa v1.4.1 // indirect github.com/graphql-go/graphql v0.7.7 + github.com/kr/pretty v0.1.0 // indirect github.com/lib/pq v1.0.0 - github.com/ncarlier/webhookd v1.6.1 github.com/pkg/errors v0.8.1 // indirect github.com/rs/zerolog v1.12.0 - github.com/tdewolff/minify/v2 v2.3.8 - miniflux.app v0.0.0-20190312032319-fc473f1d11a2 ) diff --git a/go.sum b/go.sum index 9c8969b8d..56e2a0e0e 100644 --- a/go.sum +++ b/go.sum @@ -1,44 +1,19 @@ -github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= -github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/brianvoe/gofakeit v3.17.0+incompatible h1:C1+30+c0GtjgGDtRC+iePZeP1WMiwsWCELNJhmc7aIc= +github.com/brianvoe/gofakeit v3.17.0+incompatible/go.mod h1:kfwdRA90vvNhPutZWfH7WPaDzUjz+CZFqG+rPkOjGOc= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448 h1:8tNk6SPXzLDnATTrWoI5Bgw9s/x4uf0kmBpk21NZgI4= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= -github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= -github.com/goadesign/goa v1.4.1 h1:7klkZZ3eCXewU3E1//C2spxle0dzRRUVdeny/vdKrz4= -github.com/goadesign/goa v1.4.1/go.mod h1:d/9lpuZBK7HFi/7O0oXfwvdoIl+nx2bwKqctZe/lQao= -github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/graphql-go/graphql v0.7.7 h1:nwEsJGwPq9N6cElOO+NYyoWuELAQZ4GuJks0Rlco5og= github.com/graphql-go/graphql v0.7.7/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs= -github.com/ncarlier/webhookd v1.6.1 h1:3JcnibSfel0/EaMfy8Xt4Nznio1wIjFQQA73y1C3DsA= -github.com/ncarlier/webhookd v1.6.1/go.mod h1:ZRPYyvVcgm7Bosnv4vLbugL6WslDfnVBe2YMf5eUi2s= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/rs/zerolog v1.12.0 h1:aqZ1XRadoS8IBknR5IDFvGzbHly1X9ApIqOroooQF/c= github.com/rs/zerolog v1.12.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/tdewolff/minify/v2 v2.3.8 h1:Eyv23Tu+Rb5Q2vyxmvzUgtHetgneqAsaGv3950s1EeA= -github.com/tdewolff/minify/v2 v2.3.8/go.mod h1:DD1stRlSx6JsHfl1+E/HVMQeXiec9rD1UQ0epklIZLc= -github.com/tdewolff/parse/v2 v2.3.5 h1:/uS8JfhwVJsNkEh769GM5ENv6L9LOh2Z9uW3tCdlhs0= -github.com/tdewolff/parse/v2 v2.3.5/go.mod h1:HansaqmN4I/U7L6/tUp0NcwT2tFO0F4EAWYGSDzkYNk= -github.com/tdewolff/test v1.0.0/go.mod h1:DiQUlutnqlEvdvhSn2LPGy4TFwRauAaYDsL+683RNX4= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190228165749-92fc7df08ae7/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20181031143558-9b800f95dbbc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181208175041-ad97f365e150/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -miniflux.app v0.0.0-20190312032319-fc473f1d11a2 h1:exqt4s0x/9SAl1AWNtTK82NdxqY854yFczHnYrzoN04= -miniflux.app v0.0.0-20190312032319-fc473f1d11a2/go.mod h1:bJ+xO1f6GiNnkt4z5yyZd64M2dEBhlHXRbTRhnCMTI0= diff --git a/pkg/db/article.go b/pkg/db/article.go index eb0a62310..7f3a9cd83 100644 --- a/pkg/db/article.go +++ b/pkg/db/article.go @@ -4,8 +4,8 @@ import "github.com/ncarlier/reader/pkg/model" // ArticleRepository is the repository interface to manage Articles type ArticleRepository interface { - GetArticles() ([]model.Article, error) + GetArticlesByUserID(userID uint32) ([]model.Article, error) GetArticleByID(id uint32) (*model.Article, error) CreateOrUpdateArticle(article model.Article) (*model.Article, error) - DeleteArticle(article model.Article) (*model.Article, error) + DeleteArticle(article model.Article) error } diff --git a/pkg/db/postgres/article.go b/pkg/db/postgres/article.go index 4dda1a165..71655b633 100644 --- a/pkg/db/postgres/article.go +++ b/pkg/db/postgres/article.go @@ -1,29 +1,152 @@ package postgres import ( + "database/sql" + "errors" "fmt" "github.com/ncarlier/reader/pkg/model" ) -// GetArticles returns articles from DB -func (pg *DB) GetArticles() ([]model.Article, error) { - rows, err := pg.db.Query(` - SELECT - id, - category_id, - title, - text, - html, - url, - image, - hash, - status, - published_at, - created_at, - updated_at +const articleColumns = ` + id, + user_id, + category_id, + title, + text, + html, + url, + image, + hash, + status, + published_at, + created_at, + updated_at +` + +func mapRowToArticle(row *sql.Row, article *model.Article) error { + return row.Scan( + &article.ID, + &article.UserID, + &article.CategoryID, + &article.Title, + &article.Text, + &article.HTML, + &article.URL, + &article.Image, + &article.Hash, + &article.Status, + &article.PublishedAt, + &article.CreatedAt, + &article.UpdatedAt, + ) +} + +func mapRowsToArticle(rows *sql.Rows, article *model.Article) error { + return rows.Scan( + &article.ID, + &article.UserID, + &article.CategoryID, + &article.Title, + &article.Text, + &article.HTML, + &article.URL, + &article.Image, + &article.Hash, + &article.Status, + &article.PublishedAt, + &article.CreatedAt, + &article.UpdatedAt, + ) +} + +func (pg *DB) createArticle(article model.Article) (*model.Article, error) { + row := pg.db.QueryRow(fmt.Sprintf(` + INSERT INTO articles ( + user_id, + category_id, + title, + text, + html, + url, + image, + hash, + status, + published_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING %s + `, articleColumns), + article.UserID, + article.CategoryID, + article.Title, + article.Text, + article.HTML, + article.URL, + article.Image, + article.Hash, + article.Status, + article.PublishedAt, + ) + result := &model.Article{} + + if err := mapRowToArticle(row, result); err != nil { + return nil, err + } + return result, nil +} + +func (pg *DB) updateArticle(article model.Article) (*model.Article, error) { + row := pg.db.QueryRow(fmt.Sprintf(` + UPDATE article SET + category_id = $3, + title = $4, + text = $5, + html = $6, + url = $7, + image = $8, + hash = $9, + status = $10, + published_at = $11, + updated_at=NOW() + WHERE id=$1 AND user_id=$2 + RETURNING %s + `, articleColumns), + article.ID, + article.UserID, + article.CategoryID, + article.Title, + article.Text, + article.HTML, + article.URL, + article.Image, + article.Hash, + article.Status, + article.PublishedAt, + ) + + result := &model.Article{} + + if err := mapRowToArticle(row, result); err != nil { + return nil, err + } + return result, nil +} + +// CreateOrUpdateArticle creates or updates a article into the DB +func (pg *DB) CreateOrUpdateArticle(article model.Article) (*model.Article, error) { + if article.ID != nil { + return pg.updateArticle(article) + } + return pg.createArticle(article) +} + +// GetArticlesByUserID returns user's articles from DB +func (pg *DB) GetArticlesByUserID(userID uint32) ([]model.Article, error) { + rows, err := pg.db.Query(fmt.Sprintf(` + SELECT %s FROM articles - ORDER BY created_at DESC`) + WHERE user_id=$1 + ORDER BY created_at DESC`, articleColumns), userID) if err != nil { return nil, err } @@ -32,25 +155,11 @@ func (pg *DB) GetArticles() ([]model.Article, error) { var result []model.Article for rows.Next() { - article := model.Article{} - err = rows.Scan( - &article.ID, - &article.CategoryID, - &article.Title, - &article.Text, - &article.HTML, - &article.URL, - &article.Image, - &article.Hash, - &article.Status, - &article.PublishedAt, - &article.CreatedAt, - &article.UpdatedAt, - ) - if err != nil { + article := &model.Article{} + if err := mapRowsToArticle(rows, article); err != nil { return nil, err } - result = append(result, article) + result = append(result, *article) } err = rows.Err() if err != nil { @@ -61,15 +170,43 @@ func (pg *DB) GetArticles() ([]model.Article, error) { // GetArticleByID returns an article by its ID from DB func (pg *DB) GetArticleByID(id uint32) (*model.Article, error) { - return nil, fmt.Errorf("Not yet implemented") -} + row := pg.db.QueryRow(fmt.Sprintf(` + SELECT %s + FROM articles + WHERE id = $1`, articleColumns), + id, + ) -// CreateOrUpdateArticle creates an article into the DB -func (pg *DB) CreateOrUpdateArticle(article model.Article) (*model.Article, error) { - return nil, fmt.Errorf("Not yet implemented") + result := &model.Article{} + err := mapRowToArticle(row, result) + if err == sql.ErrNoRows { + return nil, nil + } else if err != nil { + return nil, err + } + return result, nil } // DeleteArticle remove an article from the DB -func (pg *DB) DeleteArticle(article model.Article) (*model.Article, error) { - return nil, fmt.Errorf("Not yet implemented") +func (pg *DB) DeleteArticle(article model.Article) error { + result, err := pg.db.Exec(` + DELETE FROM articles + WHERE ID=$1 + `, + article.ID, + ) + if err != nil { + return err + } + + count, err := result.RowsAffected() + if err != nil { + return err + } + + if count == 0 { + return errors.New("no article has been removed") + } + + return nil } diff --git a/pkg/db/test/article_test.go b/pkg/db/test/article_test.go index 06e3ee87a..722630c9d 100644 --- a/pkg/db/test/article_test.go +++ b/pkg/db/test/article_test.go @@ -4,13 +4,73 @@ import ( "testing" "github.com/ncarlier/reader/pkg/assert" + "github.com/ncarlier/reader/pkg/model" ) -func TestGetArticles(t *testing.T) { +func assertNewArticle(t *testing.T, article *model.Article) *model.Article { + article, err := testDB.CreateOrUpdateArticle(*article) + assert.Nil(t, err, "error on create/update article should be nil") + assert.NotNil(t, article, "article shouldn't be nil") + assert.NotNil(t, article.ID, "article ID shouldn't be nil") + return article +} + +func TestCreateOrUpdateArticle(t *testing.T) { + teardownTestCase := setupTestCase(t) + defer teardownTestCase(t) + + // Assert user exists + user := assertUserExists(t, "test-003") + // Assert category exists + category := assertCategoryExists(t, user.ID, "My test category") + + // Create article test case + builder := model.NewArticleBuilder() + article := builder.UserID( + *user.ID, + ).CategoryID( + *category.ID, + ).Random().Build() + + newArticle := assertNewArticle(t, article) + assert.Equal(t, article.Title, newArticle.Title, "") +} + +func TestDeleteArticle(t *testing.T) { + teardownTestCase := setupTestCase(t) + defer teardownTestCase(t) + + // Assert user exists + user := assertUserExists(t, "test-003") + // Assert category exists + category := assertCategoryExists(t, user.ID, "My test category") + + // Create article test case + builder := model.NewArticleBuilder() + article := builder.UserID( + *user.ID, + ).CategoryID( + *category.ID, + ).Random().Build() + + article = assertNewArticle(t, article) + + err := testDB.DeleteArticle(*article) + assert.Nil(t, err, "error on delete should be nil") + + article, err = testDB.GetArticleByID(*article.ID) + assert.Nil(t, err, "error should be nil") + assert.True(t, article == nil, "article should be nil") +} + +func TestGetArticlesByUserID(t *testing.T) { teardownTestCase := setupTestCase(t) defer teardownTestCase(t) - articles, err := testDB.GetArticles() + // Assert user exists + user := assertUserExists(t, "test-003") + + articles, err := testDB.GetArticlesByUserID(*user.ID) assert.Nil(t, err, "error should be nil") assert.NotNil(t, articles, "feed shouldn't be nil") assert.True(t, len(articles) >= 0, "articles shouldn't be empty") diff --git a/pkg/model/article.go b/pkg/model/article.go index 8db4b4470..a9afb4d49 100644 --- a/pkg/model/article.go +++ b/pkg/model/article.go @@ -1,10 +1,19 @@ package model -import "time" +import ( + "encoding/json" + "fmt" + "log" + "time" + + "github.com/brianvoe/gofakeit" + "github.com/ncarlier/reader/pkg/tooling" +) // Article structure definition type Article struct { - ID uint32 `json:"id,omitempty"` + ID *uint32 `json:"id,omitempty"` + UserID uint32 `json:"user_id,omitempty"` CategoryID *uint32 `json:"category_id,omitempty"` Title string `json:"title,omitempty"` Text *string `json:"text,omitempty"` @@ -17,3 +26,77 @@ type Article struct { CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } + +func (a Article) String() string { + result, _ := json.Marshal(a) + return string(result) +} + +// ArticleBuilder is a builder to create an Article +type ArticleBuilder struct { + article *Article +} + +// NewArticleBuilder creates new Article builder instance +func NewArticleBuilder() ArticleBuilder { + article := &Article{} + return ArticleBuilder{article} +} + +// Build creates the article +func (ab *ArticleBuilder) Build() *Article { + if ab.article.Status == "" { + ab.article.Status = "unread" + } + payload := ab.article.Title + if ab.article.URL != nil { + payload += *ab.article.URL + } + if ab.article.HTML != nil { + payload += *ab.article.HTML + } + ab.article.Hash = tooling.Hash(payload) + log.Println(ab.article) + return ab.article +} + +// Random fill article with random data +func (ab *ArticleBuilder) Random() *ArticleBuilder { + ab.article.Title = gofakeit.Sentence(3) + text := gofakeit.Paragraph(2, 2, 5, ".") + ab.article.Text = &text + html := fmt.Sprintf("

%s

", *ab.article.Text) + ab.article.HTML = &html + image := gofakeit.ImageURL(320, 200) + ab.article.Image = &image + url := gofakeit.URL() + ab.article.URL = &url + publishedAt := gofakeit.Date() + ab.article.PublishedAt = &publishedAt + + return ab +} + +// UserID set article user ID +func (ab *ArticleBuilder) UserID(userID uint32) *ArticleBuilder { + ab.article.UserID = userID + return ab +} + +// CategoryID set article category ID +func (ab *ArticleBuilder) CategoryID(categoryID uint32) *ArticleBuilder { + ab.article.CategoryID = &categoryID + return ab +} + +// Title set article title +func (ab *ArticleBuilder) Title(title string) *ArticleBuilder { + ab.article.Title = title + return ab +} + +// Text set article text +func (ab *ArticleBuilder) Text(text string) *ArticleBuilder { + ab.article.Text = &text + return ab +} diff --git a/pkg/tooling/hash.go b/pkg/tooling/hash.go new file mode 100644 index 000000000..1ca266c14 --- /dev/null +++ b/pkg/tooling/hash.go @@ -0,0 +1,13 @@ +package tooling + +import ( + "crypto/md5" + "encoding/hex" +) + +// Hash creats a hash from a payload string +func Hash(payload string) string { + hasher := md5.New() + hasher.Write([]byte(payload)) + return hex.EncodeToString(hasher.Sum(nil)) +} diff --git a/pkg/tooling/uuid.go b/pkg/tooling/uuid.go new file mode 100644 index 000000000..7c0a53b3a --- /dev/null +++ b/pkg/tooling/uuid.go @@ -0,0 +1,21 @@ +package tooling + +import ( + "crypto/rand" + "fmt" + "io" +) + +// NewUUID generates a random UUID according to RFC 4122 +func NewUUID() (string, error) { + uuid := make([]byte, 16) + n, err := io.ReadFull(rand.Reader, uuid) + if n != len(uuid) || err != nil { + return "", err + } + // variant bits; see section 4.1.1 + uuid[8] = uuid[8]&^0xc0 | 0x80 + // version 4 (pseudo-random); see section 4.1.3 + uuid[6] = uuid[6]&^0xf0 | 0x40 + return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]), nil +}