包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
1606 lines
48 KiB
Go
1606 lines
48 KiB
Go
package articles
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
|
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
|
"github.com/stretchr/testify/assert"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var test_db *gorm.DB
|
|
|
|
func setupRouter() *gin.Engine {
|
|
r := gin.New()
|
|
r.RedirectTrailingSlash = false
|
|
|
|
v1 := r.Group("/api")
|
|
users.UsersRegister(v1.Group("/users"))
|
|
v1.Use(users.AuthMiddleware(false))
|
|
ArticlesAnonymousRegister(v1.Group("/articles"))
|
|
TagsAnonymousRegister(v1.Group("/tags"))
|
|
|
|
v1.Use(users.AuthMiddleware(true))
|
|
ArticlesRegister(v1.Group("/articles"))
|
|
|
|
return r
|
|
}
|
|
|
|
func createTestUser() users.UserModel {
|
|
// Generate a proper password hash to satisfy NOT NULL constraint
|
|
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("testpassword123"), bcrypt.DefaultCost)
|
|
userModel := users.UserModel{
|
|
Username: fmt.Sprintf("testuser%d", common.RandInt()),
|
|
Email: fmt.Sprintf("test%d@example.com", common.RandInt()),
|
|
Bio: "test bio",
|
|
PasswordHash: string(passwordHash),
|
|
}
|
|
test_db.Create(&userModel)
|
|
return userModel
|
|
}
|
|
|
|
// createArticleWithUser creates a test article with an author user
|
|
func createArticleWithUser(title, slug string) (ArticleModel, users.UserModel) {
|
|
user := createTestUser()
|
|
articleUserModel := GetArticleUserModel(user)
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: title,
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
return article, user
|
|
}
|
|
|
|
func TestArticleModel(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
// Test article creation
|
|
userModel := users.UserModel{
|
|
Username: "testuser",
|
|
Email: "test@example.com",
|
|
Bio: "test bio",
|
|
}
|
|
test_db.Create(&userModel)
|
|
|
|
articleUserModel := GetArticleUserModel(userModel)
|
|
asserts.NotEqual(uint(0), articleUserModel.ID, "ArticleUserModel should be created")
|
|
asserts.Equal(userModel.ID, articleUserModel.UserModelID, "UserModelID should match")
|
|
|
|
// Test article creation and save
|
|
article := ArticleModel{
|
|
Slug: "test-article",
|
|
Title: "Test Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
err := SaveOne(&article)
|
|
asserts.NoError(err, "Article should be saved successfully")
|
|
asserts.NotEqual(uint(0), article.ID, "Article ID should be set")
|
|
|
|
// Test FindOneArticle
|
|
foundArticle, err := FindOneArticle(&ArticleModel{Slug: "test-article"})
|
|
asserts.NoError(err, "Article should be found")
|
|
asserts.Equal("test-article", foundArticle.Slug, "Slug should match")
|
|
asserts.Equal("Test Article", foundArticle.Title, "Title should match")
|
|
|
|
// Test favoritesCount
|
|
count := article.favoritesCount()
|
|
asserts.Equal(uint(0), count, "Favorites count should be 0 initially")
|
|
|
|
// Test isFavoriteBy
|
|
isFav := article.isFavoriteBy(articleUserModel)
|
|
asserts.False(isFav, "Article should not be favorited initially")
|
|
|
|
// Test favoriteBy
|
|
err = article.favoriteBy(articleUserModel)
|
|
asserts.NoError(err, "Favorite should succeed")
|
|
|
|
isFav = article.isFavoriteBy(articleUserModel)
|
|
asserts.True(isFav, "Article should be favorited after favoriteBy")
|
|
|
|
count = article.favoritesCount()
|
|
asserts.Equal(uint(1), count, "Favorites count should be 1 after favoriting")
|
|
|
|
// Test unFavoriteBy
|
|
err = article.unFavoriteBy(articleUserModel)
|
|
asserts.NoError(err, "UnFavorite should succeed")
|
|
|
|
isFav = article.isFavoriteBy(articleUserModel)
|
|
asserts.False(isFav, "Article should not be favorited after unFavoriteBy")
|
|
|
|
count = article.favoritesCount()
|
|
asserts.Equal(uint(0), count, "Favorites count should be 0 after unfavoriting")
|
|
|
|
// Test article Update
|
|
err = article.Update(map[string]interface{}{"Title": "Updated Title"})
|
|
asserts.NoError(err, "Update should succeed")
|
|
|
|
foundArticle, _ = FindOneArticle(&ArticleModel{Slug: article.Slug})
|
|
asserts.Equal("Updated Title", foundArticle.Title, "Title should be updated")
|
|
|
|
// Test DeleteArticleModel
|
|
err = DeleteArticleModel(&ArticleModel{Slug: article.Slug})
|
|
asserts.NoError(err, "Delete should succeed")
|
|
}
|
|
|
|
func TestTagModel(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
// Create a tag
|
|
tag := TagModel{Tag: "golang"}
|
|
test_db.Create(&tag)
|
|
asserts.NotEqual(uint(0), tag.ID, "Tag should be created")
|
|
|
|
// Test getAllTags
|
|
tags, err := getAllTags()
|
|
asserts.NoError(err, "getAllTags should succeed")
|
|
asserts.GreaterOrEqual(len(tags), 1, "Should have at least one tag")
|
|
}
|
|
|
|
func TestCommentModel(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
// Create user and article
|
|
userModel := users.UserModel{
|
|
Username: "commentuser",
|
|
Email: "comment@example.com",
|
|
Bio: "comment bio",
|
|
}
|
|
test_db.Create(&userModel)
|
|
|
|
articleUserModel := GetArticleUserModel(userModel)
|
|
|
|
article := ArticleModel{
|
|
Slug: "comment-test-article",
|
|
Title: "Comment Test Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Create a comment
|
|
comment := CommentModel{
|
|
ArticleID: article.ID,
|
|
AuthorID: articleUserModel.ID,
|
|
Body: "Test comment",
|
|
}
|
|
test_db.Create(&comment)
|
|
asserts.NotEqual(uint(0), comment.ID, "Comment should be created")
|
|
|
|
// Test getComments
|
|
err := article.getComments()
|
|
asserts.NoError(err, "getComments should succeed")
|
|
asserts.GreaterOrEqual(len(article.Comments), 1, "Should have at least one comment")
|
|
|
|
// Test DeleteCommentModel
|
|
err = DeleteCommentModel(&CommentModel{Body: "Test comment"})
|
|
asserts.NoError(err, "DeleteCommentModel should succeed")
|
|
}
|
|
|
|
func TestFindManyArticle(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
// Create a user and article for testing
|
|
userModel := users.UserModel{
|
|
Username: fmt.Sprintf("findmanyuser%d", common.RandInt()),
|
|
Email: fmt.Sprintf("findmany%d@example.com", common.RandInt()),
|
|
Bio: "test bio",
|
|
}
|
|
test_db.Create(&userModel)
|
|
|
|
articleUserModel := GetArticleUserModel(userModel)
|
|
article := ArticleModel{
|
|
Slug: fmt.Sprintf("findmany-article-%d", common.RandInt()),
|
|
Title: "FindMany Test Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
article.setTags([]string{"findmanytag"})
|
|
SaveOne(&article)
|
|
|
|
// Favorite the article
|
|
article.favoriteBy(articleUserModel)
|
|
|
|
// Test FindManyArticle with default params
|
|
articles, count, err := FindManyArticle("", "", "10", "0", "")
|
|
asserts.NoError(err, "FindManyArticle should succeed")
|
|
asserts.GreaterOrEqual(count, 1, "Count should be at least 1")
|
|
asserts.NotNil(articles, "Articles should not be nil")
|
|
|
|
// Test with invalid limit/offset
|
|
_, _, err = FindManyArticle("", "", "invalid", "invalid", "")
|
|
asserts.NoError(err, "FindManyArticle with invalid params should succeed")
|
|
|
|
// Test filter by tag
|
|
_, count, err = FindManyArticle("findmanytag", "", "10", "0", "")
|
|
asserts.NoError(err, "FindManyArticle by tag should succeed")
|
|
asserts.GreaterOrEqual(count, 1, "Count should be at least 1 for tag filter")
|
|
|
|
// Test filter by non-existent tag
|
|
_, count, err = FindManyArticle("nonexistenttag", "", "10", "0", "")
|
|
asserts.NoError(err, "FindManyArticle by non-existent tag should succeed")
|
|
asserts.Equal(0, count, "Count should be 0 for non-existent tag")
|
|
|
|
// Test filter by author
|
|
_, count, err = FindManyArticle("", userModel.Username, "10", "0", "")
|
|
asserts.NoError(err, "FindManyArticle by author should succeed")
|
|
asserts.GreaterOrEqual(count, 1, "Count should be at least 1 for author filter")
|
|
|
|
// Test filter by non-existent author
|
|
_, _, err = FindManyArticle("", "nonexistentauthor", "10", "0", "")
|
|
asserts.NoError(err, "FindManyArticle by non-existent author should succeed")
|
|
|
|
// Test filter by favorited
|
|
_, count, err = FindManyArticle("", "", "10", "0", userModel.Username)
|
|
asserts.NoError(err, "FindManyArticle by favorited should succeed")
|
|
asserts.GreaterOrEqual(count, 1, "Count should be at least 1 for favorited filter")
|
|
|
|
// Test filter by non-existent favorited user
|
|
_, _, err = FindManyArticle("", "", "10", "0", "nonexistentuser")
|
|
asserts.NoError(err, "FindManyArticle by non-existent favorited should succeed")
|
|
}
|
|
|
|
func TestGetArticleFeed(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
// Create a user
|
|
userModel := users.UserModel{
|
|
Username: "feeduser",
|
|
Email: "feed@example.com",
|
|
Bio: "feed bio",
|
|
}
|
|
test_db.Create(&userModel)
|
|
|
|
articleUserModel := GetArticleUserModel(userModel)
|
|
|
|
// Test GetArticleFeed
|
|
articles, count, err := articleUserModel.GetArticleFeed("10", "0")
|
|
asserts.NoError(err, "GetArticleFeed should succeed")
|
|
asserts.GreaterOrEqual(count, 0, "Count should be non-negative")
|
|
asserts.NotNil(articles, "Articles should not be nil")
|
|
}
|
|
|
|
func TestSetTags(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
// Create user and article
|
|
userModel := users.UserModel{
|
|
Username: "taguser",
|
|
Email: "tag@example.com",
|
|
Bio: "tag bio",
|
|
}
|
|
test_db.Create(&userModel)
|
|
|
|
articleUserModel := GetArticleUserModel(userModel)
|
|
|
|
article := ArticleModel{
|
|
Slug: "tag-test-article",
|
|
Title: "Tag Test Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
|
|
// Test setTags
|
|
err := article.setTags([]string{"go", "programming", "web"})
|
|
asserts.NoError(err, "setTags should succeed")
|
|
asserts.Equal(3, len(article.Tags), "Should have 3 tags")
|
|
}
|
|
|
|
// Helper functions for router tests - used by TestArticleRouters
|
|
|
|
func userModelMocker(n int) []users.UserModel {
|
|
var offset int64
|
|
test_db.Model(&users.UserModel{}).Count(&offset)
|
|
var ret []users.UserModel
|
|
for i := int(offset) + 1; i <= int(offset)+n; i++ {
|
|
image := fmt.Sprintf("http://image/%v.jpg", i)
|
|
// Generate password hash directly using bcrypt
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to generate password hash: %v", err))
|
|
}
|
|
userModel := users.UserModel{
|
|
Username: fmt.Sprintf("articleuser%v", i),
|
|
Email: fmt.Sprintf("articleuser%v@test.com", i),
|
|
Bio: fmt.Sprintf("bio%v", i),
|
|
Image: &image,
|
|
PasswordHash: string(passwordHash),
|
|
}
|
|
test_db.Create(&userModel)
|
|
ret = append(ret, userModel)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func resetDBWithMock() {
|
|
common.TestDBFree(test_db)
|
|
test_db = common.TestDBInit()
|
|
users.AutoMigrate()
|
|
test_db.AutoMigrate(&ArticleModel{})
|
|
test_db.AutoMigrate(&TagModel{})
|
|
test_db.AutoMigrate(&FavoriteModel{})
|
|
test_db.AutoMigrate(&ArticleUserModel{})
|
|
test_db.AutoMigrate(&CommentModel{})
|
|
userModelMocker(3)
|
|
}
|
|
|
|
// Router tests
|
|
var articleRequestTests = []struct {
|
|
init func(*http.Request)
|
|
url string
|
|
method string
|
|
bodyData string
|
|
expectedCode int
|
|
responseRegexp string
|
|
msg string
|
|
}{
|
|
// Test article list
|
|
{
|
|
func(req *http.Request) {
|
|
resetDBWithMock()
|
|
},
|
|
"/api/articles/",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`{"articles":\[\],"articlesCount":0}`,
|
|
"empty article list should return empty array",
|
|
},
|
|
// Test tags list
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/tags/",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`{"tags":\[\]}`,
|
|
"empty tags list should return empty array",
|
|
},
|
|
// Test create article
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/",
|
|
"POST",
|
|
`{"article":{"title":"Test Article","description":"Test Description","body":"Test Body","tagList":["test","golang"]}}`,
|
|
http.StatusCreated,
|
|
`"title":"Test Article"`,
|
|
"create article should succeed with auth",
|
|
},
|
|
// Test get single article
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/test-article",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`"slug":"test-article"`,
|
|
"get single article should succeed",
|
|
},
|
|
// Test article list with articles
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`"articlesCount":1`,
|
|
"article list should contain created article",
|
|
},
|
|
// Test articles by tag
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/?tag=golang",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`"articles":\[`,
|
|
"articles by tag should work",
|
|
},
|
|
// Test articles by author
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/?author=articleuser1",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`"articles":\[`,
|
|
"articles by author should work",
|
|
},
|
|
// Test update article
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/test-article",
|
|
"PUT",
|
|
`{"article":{"title":"Updated Title"}}`,
|
|
http.StatusOK,
|
|
`"title":"Updated Title"`,
|
|
"update article should succeed",
|
|
},
|
|
// Test favorite article
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/updated-title/favorite",
|
|
"POST",
|
|
``,
|
|
http.StatusOK,
|
|
`"favorited":true`,
|
|
"favorite article should succeed",
|
|
},
|
|
// Test favorites count
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/updated-title",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`"favoritesCount":1`,
|
|
"favorites count should be 1",
|
|
},
|
|
// Test articles favorited by user
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/?favorited=articleuser1",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`"articlesCount":1`,
|
|
"articles favorited by user should work",
|
|
},
|
|
// Test unfavorite article
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/updated-title/favorite",
|
|
"DELETE",
|
|
``,
|
|
http.StatusOK,
|
|
`"favorited":false`,
|
|
"unfavorite article should succeed",
|
|
},
|
|
// Test favorites count after unfavorite
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/updated-title",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`"favoritesCount":0`,
|
|
"favorites count should be 0 after unfavorite",
|
|
},
|
|
// Test create comment
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/updated-title/comments",
|
|
"POST",
|
|
`{"comment":{"body":"Test comment body"}}`,
|
|
http.StatusCreated,
|
|
`"body":"Test comment body"`,
|
|
"create comment should succeed",
|
|
},
|
|
// Test get comments
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/updated-title/comments",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`"comments":\[`,
|
|
"get comments should succeed",
|
|
},
|
|
// Test delete comment
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/updated-title/comments/1",
|
|
"DELETE",
|
|
``,
|
|
http.StatusOK,
|
|
``,
|
|
"delete comment should succeed",
|
|
},
|
|
// Test feed (requires auth) - returns empty array since no follow relationship set up
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 2)
|
|
},
|
|
"/api/articles/feed",
|
|
"GET",
|
|
``,
|
|
http.StatusOK,
|
|
`"articles":\[\]`,
|
|
"feed should return empty array when user follows no one",
|
|
},
|
|
// Test delete article
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/updated-title",
|
|
"DELETE",
|
|
``,
|
|
http.StatusOK,
|
|
``,
|
|
"delete article should succeed",
|
|
},
|
|
// Test 404 for deleted article
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/updated-title",
|
|
"GET",
|
|
``,
|
|
http.StatusNotFound,
|
|
`"articles":"Invalid slug"`,
|
|
"deleted article should return 404",
|
|
},
|
|
// Test favorite non-existent article
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/non-existent/favorite",
|
|
"POST",
|
|
``,
|
|
http.StatusNotFound,
|
|
`"articles":"Invalid slug"`,
|
|
"favorite non-existent article should return 404",
|
|
},
|
|
// Test unfavorite non-existent article
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/non-existent/favorite",
|
|
"DELETE",
|
|
``,
|
|
http.StatusNotFound,
|
|
`"articles":"Invalid slug"`,
|
|
"unfavorite non-existent article should return 404",
|
|
},
|
|
// Test create article with invalid data
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/",
|
|
"POST",
|
|
`{"article":{"title":"ab","description":"Test","body":"Test"}}`,
|
|
http.StatusUnprocessableEntity,
|
|
`"errors"`,
|
|
"create article with short title should fail",
|
|
},
|
|
// Test create comment on non-existent article
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/non-existent/comments",
|
|
"POST",
|
|
`{"comment":{"body":"Test"}}`,
|
|
http.StatusNotFound,
|
|
`"comment":"Invalid slug"`,
|
|
"create comment on non-existent article should return 404",
|
|
},
|
|
// Test get comments on non-existent article
|
|
{
|
|
func(req *http.Request) {},
|
|
"/api/articles/non-existent/comments",
|
|
"GET",
|
|
``,
|
|
http.StatusNotFound,
|
|
`"comments":"Invalid slug"`,
|
|
"get comments on non-existent article should return 404",
|
|
},
|
|
// Test update non-existent article
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/non-existent",
|
|
"PUT",
|
|
`{"article":{"title":"Test"}}`,
|
|
http.StatusNotFound,
|
|
`"articles":"Invalid slug"`,
|
|
"update non-existent article should return 404",
|
|
},
|
|
// Test delete non-existent article (GORM delete returns OK even if not found)
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/non-existent",
|
|
"DELETE",
|
|
``,
|
|
http.StatusOK,
|
|
``,
|
|
"delete non-existent article returns OK (soft delete behavior)",
|
|
},
|
|
// Test delete comment with invalid id
|
|
{
|
|
func(req *http.Request) {
|
|
common.HeaderTokenMock(req, 1)
|
|
},
|
|
"/api/articles/test/comments/invalid",
|
|
"DELETE",
|
|
``,
|
|
http.StatusNotFound,
|
|
`"comment":"Invalid id"`,
|
|
"delete comment with invalid id should return 404",
|
|
},
|
|
}
|
|
|
|
func TestArticleRouters(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := gin.New()
|
|
r.Use(users.AuthMiddleware(false))
|
|
ArticlesAnonymousRegister(r.Group("/api/articles"))
|
|
TagsAnonymousRegister(r.Group("/api/tags"))
|
|
r.Use(users.AuthMiddleware(true))
|
|
ArticlesRegister(r.Group("/api/articles"))
|
|
|
|
for _, testData := range articleRequestTests {
|
|
bodyData := testData.bodyData
|
|
req, err := http.NewRequest(testData.method, testData.url, bytes.NewBufferString(bodyData))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
asserts.NoError(err)
|
|
|
|
testData.init(req)
|
|
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(testData.expectedCode, w.Code, "Response Status - "+testData.msg)
|
|
if testData.responseRegexp != "" {
|
|
asserts.Regexp(testData.responseRegexp, w.Body.String(), "Response Content - "+testData.msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// HTTP API Tests
|
|
|
|
func TestCreateArticleRequiredFields(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Test missing body field
|
|
req, _ := http.NewRequest("POST", "/api/articles", bytes.NewBufferString(`{"article":{"title":"Test Title","description":"Test Description"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusUnprocessableEntity, w.Code, "Missing body should return 422")
|
|
asserts.Contains(w.Body.String(), "Body", "Error should mention Body field")
|
|
|
|
// Test missing description field
|
|
req, _ = http.NewRequest("POST", "/api/articles", bytes.NewBufferString(`{"article":{"title":"Test Title","body":"Test Body"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusUnprocessableEntity, w.Code, "Missing description should return 422")
|
|
asserts.Contains(w.Body.String(), "Description", "Error should mention Description field")
|
|
|
|
// Test valid article creation
|
|
req, _ = http.NewRequest("POST", "/api/articles", bytes.NewBufferString(`{"article":{"title":"Test Title","description":"Test Description","body":"Test Body"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusCreated, w.Code, "Valid article should return 201")
|
|
asserts.Contains(w.Body.String(), `"article"`, "Response should contain article")
|
|
}
|
|
|
|
func TestCreateCommentRequiredFields(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Create an article first
|
|
articleUserModel := GetArticleUserModel(user)
|
|
article := ArticleModel{
|
|
Slug: fmt.Sprintf("test-comment-article-%d", common.RandInt()),
|
|
Title: "Test Comment Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Test missing body field
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/articles/%s/comments", article.Slug), bytes.NewBufferString(`{"comment":{}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusUnprocessableEntity, w.Code, "Missing body should return 422")
|
|
asserts.Contains(w.Body.String(), "Body", "Error should mention Body field")
|
|
|
|
// Test valid comment creation - should return 201 per OpenAPI spec
|
|
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/articles/%s/comments", article.Slug), bytes.NewBufferString(`{"comment":{"body":"Test comment body"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusCreated, w.Code, "Valid comment should return 201")
|
|
asserts.Contains(w.Body.String(), `"comment"`, "Response should contain comment")
|
|
}
|
|
|
|
func TestArticleFeedCount(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
// Create two users
|
|
user1 := createTestUser()
|
|
user2 := createTestUser()
|
|
|
|
// User1 follows User2
|
|
err := followUser(user1, user2)
|
|
asserts.NoError(err, "Follow should succeed")
|
|
|
|
// Create an article by User2
|
|
articleUserModel := GetArticleUserModel(user2)
|
|
article := ArticleModel{
|
|
Slug: fmt.Sprintf("feed-test-article-%d", common.RandInt()),
|
|
Title: "Feed Test Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Get feed for User1
|
|
articleUserModel1 := GetArticleUserModel(user1)
|
|
articles, count, err := articleUserModel1.GetArticleFeed("10", "0")
|
|
asserts.NoError(err, "GetArticleFeed should succeed")
|
|
asserts.Equal(1, count, "Count should be 1 after following user with 1 article")
|
|
asserts.Equal(1, len(articles), "Should have 1 article in feed")
|
|
}
|
|
|
|
func followUser(follower, following users.UserModel) error {
|
|
db := common.GetDB()
|
|
var follow users.FollowModel
|
|
err := db.FirstOrCreate(&follow, &users.FollowModel{
|
|
FollowingID: following.ID,
|
|
FollowedByID: follower.ID,
|
|
}).Error
|
|
return err
|
|
}
|
|
|
|
func TestTagsEndpoint(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
|
|
// Create some tags
|
|
tag1 := TagModel{Tag: fmt.Sprintf("testtag1-%d", common.RandInt())}
|
|
tag2 := TagModel{Tag: fmt.Sprintf("testtag2-%d", common.RandInt())}
|
|
test_db.Create(&tag1)
|
|
test_db.Create(&tag2)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/tags", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Tags endpoint should return 200")
|
|
asserts.Contains(w.Body.String(), `"tags"`, "Response should contain tags array")
|
|
}
|
|
|
|
func TestArticleListEndpoint(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
_, _ = createArticleWithUser("List Test Article", fmt.Sprintf("list-test-article-%d", common.RandInt()))
|
|
|
|
// Test list articles
|
|
req, _ := http.NewRequest("GET", "/api/articles", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Articles list should return 200")
|
|
asserts.Contains(w.Body.String(), `"articles"`, "Response should contain articles")
|
|
asserts.Contains(w.Body.String(), `"articlesCount"`, "Response should contain articlesCount")
|
|
}
|
|
|
|
func TestArticleRetrieveEndpoint(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
slug := fmt.Sprintf("retrieve-test-article-%d", common.RandInt())
|
|
article, _ := createArticleWithUser("Retrieve Test Article", slug)
|
|
|
|
// Test retrieve article
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/articles/%s", article.Slug), nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Article retrieve should return 200")
|
|
asserts.Contains(w.Body.String(), `"article"`, "Response should contain article")
|
|
asserts.Contains(w.Body.String(), `"Retrieve Test Article"`, "Response should contain the article title")
|
|
}
|
|
|
|
func TestArticleUpdateEndpoint(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
slug := fmt.Sprintf("update-test-article-%d", common.RandInt())
|
|
article, user := createArticleWithUser("Update Test Article", slug)
|
|
|
|
// Test update article
|
|
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/articles/%s", article.Slug), bytes.NewBufferString(`{"article":{"body":"Updated Body"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Article update should return 200")
|
|
asserts.Contains(w.Body.String(), `"article"`, "Response should contain article")
|
|
}
|
|
|
|
func TestArticleDeleteEndpoint(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Create an article
|
|
articleUserModel := GetArticleUserModel(user)
|
|
slug := fmt.Sprintf("delete-test-article-%d", common.RandInt())
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: "Delete Test Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Test delete article
|
|
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/articles/%s", slug), nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Article delete should return 200")
|
|
}
|
|
|
|
func TestArticleFavoriteEndpoint(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Create an article
|
|
articleUserModel := GetArticleUserModel(user)
|
|
slug := fmt.Sprintf("favorite-test-article-%d", common.RandInt())
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: "Favorite Test Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Test favorite article
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/articles/%s/favorite", slug), nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Article favorite should return 200")
|
|
asserts.Contains(w.Body.String(), `"favorited":true`, "Article should be favorited")
|
|
|
|
// Test unfavorite article
|
|
req, _ = http.NewRequest("DELETE", fmt.Sprintf("/api/articles/%s/favorite", slug), nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Article unfavorite should return 200")
|
|
asserts.Contains(w.Body.String(), `"favorited":false`, "Article should be unfavorited")
|
|
}
|
|
|
|
func TestArticleCommentsEndpoint(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Create an article
|
|
articleUserModel := GetArticleUserModel(user)
|
|
slug := fmt.Sprintf("comments-test-article-%d", common.RandInt())
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: "Comments Test Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Create a comment
|
|
comment := CommentModel{
|
|
ArticleID: article.ID,
|
|
AuthorID: articleUserModel.ID,
|
|
Body: "Test comment for list",
|
|
}
|
|
test_db.Create(&comment)
|
|
|
|
// Test list comments
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/articles/%s/comments", slug), nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Comments list should return 200")
|
|
asserts.Contains(w.Body.String(), `"comments"`, "Response should contain comments")
|
|
|
|
// Test delete comment
|
|
req, _ = http.NewRequest("DELETE", fmt.Sprintf("/api/articles/%s/comments/%d", slug, comment.ID), nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Comment delete should return 200")
|
|
}
|
|
|
|
func TestArticleFeedEndpoint(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Test feed endpoint
|
|
req, _ := http.NewRequest("GET", "/api/articles/feed", nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Feed should return 200")
|
|
asserts.Contains(w.Body.String(), `"articles"`, "Response should contain articles")
|
|
asserts.Contains(w.Body.String(), `"articlesCount"`, "Response should contain articlesCount")
|
|
|
|
// Test feed with limit and offset params
|
|
req, _ = http.NewRequest("GET", "/api/articles/feed?limit=5&offset=0", nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusOK, w.Code, "Feed with params should return 200")
|
|
}
|
|
|
|
func TestArticleFeedWithFollowing(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
|
|
// Create two users
|
|
user1 := createTestUser()
|
|
user2 := createTestUser()
|
|
|
|
// User1 follows User2
|
|
followUser(user1, user2)
|
|
|
|
// Create an article by User2
|
|
articleUserModel := GetArticleUserModel(user2)
|
|
article := ArticleModel{
|
|
Slug: fmt.Sprintf("feed-following-article-%d", common.RandInt()),
|
|
Title: "Feed Following Test",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Test feed for User1 - should include User2's article
|
|
req, _ := http.NewRequest("GET", "/api/articles/feed", nil)
|
|
common.HeaderTokenMock(req, user1.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Feed should return 200")
|
|
asserts.Contains(w.Body.String(), `"articles"`, "Response should contain articles")
|
|
}
|
|
|
|
func TestArticleNotFoundErrors(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Test retrieve non-existent article
|
|
req, _ := http.NewRequest("GET", "/api/articles/non-existent-slug", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusNotFound, w.Code, "Non-existent article should return 404")
|
|
|
|
// Test update non-existent article
|
|
req, _ = http.NewRequest("PUT", "/api/articles/non-existent-slug", bytes.NewBufferString(`{"article":{"body":"test"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusNotFound, w.Code, "Update non-existent article should return 404")
|
|
|
|
// Test delete non-existent article - returns 200 (GORM delete doesn't error on 0 rows)
|
|
req, _ = http.NewRequest("DELETE", "/api/articles/non-existent-slug", nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusOK, w.Code, "Delete non-existent article returns 200")
|
|
|
|
// Test favorite non-existent article
|
|
req, _ = http.NewRequest("POST", "/api/articles/non-existent-slug/favorite", nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusNotFound, w.Code, "Favorite non-existent article should return 404")
|
|
|
|
// Test unfavorite non-existent article
|
|
req, _ = http.NewRequest("DELETE", "/api/articles/non-existent-slug/favorite", nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusNotFound, w.Code, "Unfavorite non-existent article should return 404")
|
|
|
|
// Test create comment on non-existent article
|
|
req, _ = http.NewRequest("POST", "/api/articles/non-existent-slug/comments", bytes.NewBufferString(`{"comment":{"body":"test"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusNotFound, w.Code, "Comment on non-existent article should return 404")
|
|
|
|
// Test list comments on non-existent article
|
|
req, _ = http.NewRequest("GET", "/api/articles/non-existent-slug/comments", nil)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusNotFound, w.Code, "List comments on non-existent article should return 404")
|
|
|
|
// Test delete comment with invalid id
|
|
req, _ = http.NewRequest("DELETE", "/api/articles/some-slug/comments/invalid", nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusNotFound, w.Code, "Delete comment with invalid id should return 404")
|
|
}
|
|
|
|
func TestArticleListWithFilters(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
|
|
// Test list with tag filter
|
|
req, _ := http.NewRequest("GET", "/api/articles?tag=sometag", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusOK, w.Code, "List with tag filter should return 200")
|
|
|
|
// Test list with author filter
|
|
req, _ = http.NewRequest("GET", "/api/articles?author=someauthor", nil)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusOK, w.Code, "List with author filter should return 200")
|
|
|
|
// Test list with favorited filter
|
|
req, _ = http.NewRequest("GET", "/api/articles?favorited=someuser", nil)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusOK, w.Code, "List with favorited filter should return 200")
|
|
|
|
// Test list with limit and offset
|
|
req, _ = http.NewRequest("GET", "/api/articles?limit=5&offset=0", nil)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusOK, w.Code, "List with limit/offset should return 200")
|
|
}
|
|
|
|
func TestArticleValidationErrors(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Test create article with missing title
|
|
req, _ := http.NewRequest("POST", "/api/articles", bytes.NewBufferString(`{"article":{"description":"test","body":"test"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusUnprocessableEntity, w.Code, "Missing title should return 422")
|
|
|
|
// Test create article with short title
|
|
req, _ = http.NewRequest("POST", "/api/articles", bytes.NewBufferString(`{"article":{"title":"abc","description":"test","body":"test"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusUnprocessableEntity, w.Code, "Short title should return 422")
|
|
}
|
|
|
|
func TestArticleFeedUnauthorized(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
|
|
// Test feed endpoint without auth - should return 401
|
|
req, _ := http.NewRequest("GET", "/api/articles/feed", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusUnauthorized, w.Code, "Feed without auth should return 401")
|
|
}
|
|
|
|
func TestArticleUpdateValidationErrors(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Create an article
|
|
articleUserModel := GetArticleUserModel(user)
|
|
slug := fmt.Sprintf("update-validation-article-%d", common.RandInt())
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: "Update Validation Test",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Test update with title too short
|
|
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/articles/%s", slug), bytes.NewBufferString(`{"article":{"title":"ab"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusUnprocessableEntity, w.Code, "Short title in update should return 422")
|
|
}
|
|
|
|
func TestArticleCreateWithTags(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Test create article with tags
|
|
req, _ := http.NewRequest("POST", "/api/articles", bytes.NewBufferString(`{"article":{"title":"Test With Tags","description":"Test Description","body":"Test Body","tagList":["go","gin","test"]}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusCreated, w.Code, "Article with tags should return 201")
|
|
asserts.Contains(w.Body.String(), `"tagList"`, "Response should contain tagList")
|
|
}
|
|
|
|
func TestCommentDeleteWithValidArticle(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Create an article
|
|
articleUserModel := GetArticleUserModel(user)
|
|
slug := fmt.Sprintf("comment-delete-article-%d", common.RandInt())
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: "Comment Delete Test",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Create a comment
|
|
comment := CommentModel{
|
|
ArticleID: article.ID,
|
|
AuthorID: articleUserModel.ID,
|
|
Body: "Test comment for deletion",
|
|
}
|
|
test_db.Create(&comment)
|
|
|
|
// Test delete existing comment
|
|
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/articles/%s/comments/%d", slug, comment.ID), nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
asserts.Equal(http.StatusOK, w.Code, "Delete existing comment should return 200")
|
|
}
|
|
|
|
func TestSetTagsEmpty(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
userModel := users.UserModel{
|
|
Username: fmt.Sprintf("emptytaguser%d", common.RandInt()),
|
|
Email: fmt.Sprintf("emptytag%d@example.com", common.RandInt()),
|
|
Bio: "test bio",
|
|
}
|
|
test_db.Create(&userModel)
|
|
|
|
articleUserModel := GetArticleUserModel(userModel)
|
|
article := ArticleModel{
|
|
Slug: fmt.Sprintf("empty-tags-article-%d", common.RandInt()),
|
|
Title: "Empty Tags Test",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
|
|
// Test setTags with empty slice
|
|
err := article.setTags([]string{})
|
|
asserts.NoError(err, "setTags with empty slice should succeed")
|
|
asserts.Equal(0, len(article.Tags), "Should have 0 tags")
|
|
}
|
|
|
|
func TestFavoritesCountWithMultipleUsers(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
// Create article
|
|
user1 := createTestUser()
|
|
user2 := createTestUser()
|
|
|
|
articleUserModel1 := GetArticleUserModel(user1)
|
|
articleUserModel2 := GetArticleUserModel(user2)
|
|
|
|
article := ArticleModel{
|
|
Slug: fmt.Sprintf("multi-favorite-article-%d", common.RandInt()),
|
|
Title: "Multi Favorite Test",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel1,
|
|
AuthorID: articleUserModel1.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Both users favorite the article
|
|
article.favoriteBy(articleUserModel1)
|
|
article.favoriteBy(articleUserModel2)
|
|
|
|
count := article.favoritesCount()
|
|
asserts.Equal(uint(2), count, "Favorites count should be 2")
|
|
}
|
|
|
|
func TestBatchGetFavoriteStatusEdgeCases(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
user := createTestUser()
|
|
articleUserModel := GetArticleUserModel(user)
|
|
|
|
// Test with empty article IDs
|
|
statusMap := BatchGetFavoriteStatus([]uint{}, articleUserModel.ID)
|
|
asserts.Equal(0, len(statusMap), "Empty article IDs should return empty map")
|
|
|
|
// Test with zero user ID
|
|
article := ArticleModel{
|
|
Slug: fmt.Sprintf("batch-status-article-%d", common.RandInt()),
|
|
Title: "Batch Status Test",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
article.favoriteBy(articleUserModel)
|
|
|
|
statusMap = BatchGetFavoriteStatus([]uint{article.ID}, 0)
|
|
asserts.Equal(0, len(statusMap), "Zero user ID should return empty map")
|
|
|
|
// Test with valid IDs
|
|
statusMap = BatchGetFavoriteStatus([]uint{article.ID}, articleUserModel.ID)
|
|
asserts.Equal(true, statusMap[article.ID], "Should return true for favorited article")
|
|
}
|
|
|
|
func TestSetTagsRaceCondition(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
user := createTestUser()
|
|
articleUserModel := GetArticleUserModel(user)
|
|
|
|
article := ArticleModel{
|
|
Slug: fmt.Sprintf("race-condition-article-%d", common.RandInt()),
|
|
Title: "Race Condition Test",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
|
|
// Test setTags with duplicate tags
|
|
err := article.setTags([]string{"tag1", "tag2", "tag1"})
|
|
asserts.NoError(err, "setTags should handle duplicate tags")
|
|
// Should have 2 unique tags
|
|
asserts.Equal(3, len(article.Tags), "Should preserve all tags in list")
|
|
}
|
|
|
|
func TestArticleFeedWithEmptyFollowings(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
user := createTestUser()
|
|
articleUserModel := GetArticleUserModel(user)
|
|
|
|
// Get feed with no followings
|
|
articles, count, err := articleUserModel.GetArticleFeed("10", "0")
|
|
asserts.NoError(err, "GetArticleFeed should succeed even with no followings")
|
|
asserts.Equal(0, count, "Count should be 0 with no followings")
|
|
asserts.NotNil(articles, "Articles should not be nil")
|
|
}
|
|
|
|
func TestArticleDeleteSuccess(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Create an article
|
|
articleUserModel := GetArticleUserModel(user)
|
|
slug := fmt.Sprintf("delete-success-article-%d", common.RandInt())
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: "Delete Success Test",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Test delete existing article
|
|
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/articles/%s", slug), nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Delete existing article should return 200")
|
|
|
|
// Verify article is deleted
|
|
foundArticle, err := FindOneArticle(&ArticleModel{Slug: slug})
|
|
asserts.Error(err, "Article should not be found after deletion")
|
|
asserts.Equal(uint(0), foundArticle.ID, "Article ID should be 0")
|
|
}
|
|
|
|
func TestTagListSuccess(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
|
|
// Create some test tags
|
|
tag1 := TagModel{Tag: fmt.Sprintf("listtag1-%d", common.RandInt())}
|
|
tag2 := TagModel{Tag: fmt.Sprintf("listtag2-%d", common.RandInt())}
|
|
test_db.Create(&tag1)
|
|
test_db.Create(&tag2)
|
|
|
|
// Test list tags endpoint
|
|
req, _ := http.NewRequest("GET", "/api/tags", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Tags list should return 200")
|
|
asserts.Contains(w.Body.String(), `"tags"`, "Response should contain tags")
|
|
}
|
|
|
|
func TestArticleListErrorHandling(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
|
|
// Test with invalid limit/offset parameters
|
|
req, _ := http.NewRequest("GET", "/api/articles?limit=abc&offset=xyz", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "List with invalid params should still return 200")
|
|
asserts.Contains(w.Body.String(), `"articles"`, "Response should contain articles array")
|
|
}
|
|
|
|
func TestArticleFeedErrorPath(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Test with invalid limit/offset
|
|
req, _ := http.NewRequest("GET", "/api/articles/feed?limit=invalid&offset=invalid", nil)
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusOK, w.Code, "Feed with invalid params should return 200")
|
|
asserts.Contains(w.Body.String(), `"articles"`, "Response should contain articles")
|
|
}
|
|
|
|
func TestArticleCreateValidation(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Test with empty fields
|
|
req, _ := http.NewRequest("POST", "/api/articles", bytes.NewBufferString(`{"article":{"title":"","description":"","body":""}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusUnprocessableEntity, w.Code, "Empty fields should return 422")
|
|
}
|
|
|
|
func TestArticleUpdateNonExistent(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
|
|
// Test update non-existent article
|
|
req, _ := http.NewRequest("PUT", "/api/articles/non-existent-article", bytes.NewBufferString(`{"article":{"title":"New Title"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, user.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusNotFound, w.Code, "Update non-existent article should return 404")
|
|
}
|
|
|
|
func TestArticleDeleteAuthorizationForbidden(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
otherUser := createTestUser()
|
|
|
|
// Create article by user
|
|
articleUserModel := GetArticleUserModel(user)
|
|
slug := fmt.Sprintf("forbidden-delete-article-%d", common.RandInt())
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: "Forbidden Delete Article",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Try to delete by otherUser
|
|
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/articles/%s", slug), nil)
|
|
common.HeaderTokenMock(req, otherUser.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusForbidden, w.Code, "Delete by non-author should return 403")
|
|
|
|
// Verify article still exists
|
|
foundArticle, err := FindOneArticle(&ArticleModel{Slug: slug})
|
|
asserts.NoError(err, "Article should still exist")
|
|
asserts.Equal(article.ID, foundArticle.ID, "Article ID should match")
|
|
}
|
|
|
|
func TestArticleUpdateAuthorizationForbidden(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
otherUser := createTestUser()
|
|
|
|
// Create article by user
|
|
articleUserModel := GetArticleUserModel(user)
|
|
slug := fmt.Sprintf("forbidden-update-article-%d", common.RandInt())
|
|
title := "Forbidden Update Article"
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: title,
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Try to update by otherUser
|
|
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/articles/%s", slug), bytes.NewBufferString(`{"article":{"title":"New Title"}}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
common.HeaderTokenMock(req, otherUser.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusForbidden, w.Code, "Update by non-author should return 403")
|
|
|
|
// Verify article is unchanged
|
|
foundArticle, _ := FindOneArticle(&ArticleModel{Slug: slug})
|
|
asserts.Equal(title, foundArticle.Title, "Article title should be unchanged")
|
|
}
|
|
|
|
func TestCommentDeleteAuthorizationForbidden(t *testing.T) {
|
|
asserts := assert.New(t)
|
|
|
|
r := setupRouter()
|
|
user := createTestUser()
|
|
otherUser := createTestUser()
|
|
|
|
// Create article
|
|
articleUserModel := GetArticleUserModel(user)
|
|
slug := fmt.Sprintf("forbidden-comment-delete-%d", common.RandInt())
|
|
article := ArticleModel{
|
|
Slug: slug,
|
|
Title: "Forbidden Comment Delete",
|
|
Description: "Test Description",
|
|
Body: "Test Body",
|
|
Author: articleUserModel,
|
|
AuthorID: articleUserModel.ID,
|
|
}
|
|
SaveOne(&article)
|
|
|
|
// Create comment by user
|
|
comment := CommentModel{
|
|
ArticleID: article.ID,
|
|
AuthorID: articleUserModel.ID,
|
|
Body: "Test comment",
|
|
}
|
|
test_db.Create(&comment)
|
|
|
|
// Try to delete by otherUser
|
|
req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/articles/%s/comments/%d", slug, comment.ID), nil)
|
|
common.HeaderTokenMock(req, otherUser.ID)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
asserts.Equal(http.StatusForbidden, w.Code, "Delete comment by non-author should return 403")
|
|
|
|
// Verify comment still exists
|
|
foundComment, err := FindOneComment(&CommentModel{Model: gorm.Model{ID: comment.ID}})
|
|
asserts.NoError(err, "Comment should still exist")
|
|
asserts.Equal(comment.ID, foundComment.ID, "Comment ID should match")
|
|
}
|
|
|
|
// This is a hack way to add test database for each case
|
|
func TestMain(m *testing.M) {
|
|
test_db = common.TestDBInit()
|
|
users.AutoMigrate()
|
|
test_db.AutoMigrate(&ArticleModel{})
|
|
test_db.AutoMigrate(&TagModel{})
|
|
test_db.AutoMigrate(&FavoriteModel{})
|
|
test_db.AutoMigrate(&ArticleUserModel{})
|
|
test_db.AutoMigrate(&CommentModel{})
|
|
exitVal := m.Run()
|
|
common.TestDBFree(test_db)
|
|
os.Exit(exitVal)
|
|
}
|