To gitea and beyond, let's go(-yco)

This commit is contained in:
2025-11-10 19:12:09 +01:00
parent 8f6133392d
commit 71a031342b
245 changed files with 83994 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
package repositories
import (
"gorm.io/gorm"
"goyco/internal/database"
)
type AccountDeletionRepository interface {
Create(req *database.AccountDeletionRequest) error
GetByTokenHash(hash string) (*database.AccountDeletionRequest, error)
DeleteByID(id uint) error
DeleteByUserID(userID uint) error
}
type accountDeletionRepository struct {
db *gorm.DB
}
func NewAccountDeletionRepository(db *gorm.DB) AccountDeletionRepository {
return &accountDeletionRepository{db: db}
}
func (r *accountDeletionRepository) Create(req *database.AccountDeletionRequest) error {
return r.db.Create(req).Error
}
func (r *accountDeletionRepository) GetByTokenHash(hash string) (*database.AccountDeletionRequest, error) {
var request database.AccountDeletionRequest
if err := r.db.Where("token_hash = ?", hash).First(&request).Error; err != nil {
return nil, err
}
return &request, nil
}
func (r *accountDeletionRepository) DeleteByID(id uint) error {
return r.db.Delete(&database.AccountDeletionRequest{}, id).Error
}
func (r *accountDeletionRepository) DeleteByUserID(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&database.AccountDeletionRequest{}).Error
}

View File

@@ -0,0 +1,232 @@
package repositories
import (
"strings"
"testing"
"gorm.io/gorm"
"goyco/internal/database"
)
func TestAccountDeletionRepository_Create(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful creation", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
request := suite.CreateTestAccountDeletionRequest(user.ID, "token-hash-123")
if suite.GetAccountDeletionRequestCount() != 1 {
t.Errorf("Expected 1 account deletion request, got %d", suite.GetAccountDeletionRequestCount())
}
if request.ID == 0 {
t.Error("Expected request ID to be assigned")
}
})
t.Run("duplicate token hash", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestAccountDeletionRequest(user.ID, "duplicate-token")
request2 := &database.AccountDeletionRequest{
UserID: user.ID,
TokenHash: "duplicate-token",
}
err := suite.DeletionRepo.Create(request2)
if err == nil {
t.Error("Expected error for duplicate token hash")
}
})
t.Run("request with invalid user", func(t *testing.T) {
suite.Reset()
request := &database.AccountDeletionRequest{
UserID: 999,
TokenHash: "token-hash-123",
}
err := suite.DeletionRepo.Create(request)
if err != nil {
t.Errorf("Unexpected error for invalid user (SQLite allows this): %v", err)
}
})
}
func TestAccountDeletionRepository_GetByTokenHash(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing request", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestAccountDeletionRequest(user.ID, "token-hash-123")
retrieved, err := suite.DeletionRepo.GetByTokenHash("token-hash-123")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if retrieved == nil {
t.Fatal("Expected request, got nil")
}
if retrieved.TokenHash != "token-hash-123" {
t.Errorf("Expected token hash 'token-hash-123', got %s", retrieved.TokenHash)
}
if retrieved.UserID != user.ID {
t.Errorf("Expected user ID %d, got %d", user.ID, retrieved.UserID)
}
})
t.Run("non-existing token hash", func(t *testing.T) {
suite.Reset()
_, err := suite.DeletionRepo.GetByTokenHash("nonexistent-token")
if err == nil {
t.Error("Expected error for non-existing token hash")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
}
func TestAccountDeletionRepository_DeleteByID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful delete", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
request := suite.CreateTestAccountDeletionRequest(user.ID, "token-hash-123")
err := suite.DeletionRepo.DeleteByID(request.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
_, err = suite.DeletionRepo.GetByTokenHash("token-hash-123")
if err == nil {
t.Error("Expected error for deleted request")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
t.Run("delete non-existing request", func(t *testing.T) {
suite.Reset()
err := suite.DeletionRepo.DeleteByID(999)
if err != nil {
t.Fatalf("Delete should succeed even for non-existing request (GORM behavior)")
}
})
}
func TestAccountDeletionRepository_DeleteByUserID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful delete", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestAccountDeletionRequest(user.ID, "token-hash-1")
err := suite.DeletionRepo.DeleteByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if suite.GetAccountDeletionRequestCount() != 0 {
t.Errorf("Expected 0 requests after delete, got %d", suite.GetAccountDeletionRequestCount())
}
})
t.Run("delete for user without requests", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
err := suite.DeletionRepo.DeleteByUserID(user.ID)
if err != nil {
t.Fatalf("Delete should succeed even for user without requests (GORM behavior)")
}
})
}
func TestAccountDeletionRepository_EdgeCases(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty token hash", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
request := &database.AccountDeletionRequest{
UserID: user.ID,
TokenHash: "",
}
err := suite.DeletionRepo.Create(request)
if err != nil {
t.Errorf("Unexpected error for empty token hash: %v", err)
}
})
t.Run("zero user ID", func(t *testing.T) {
suite.Reset()
request := &database.AccountDeletionRequest{
UserID: 0,
TokenHash: "token-hash-123",
}
err := suite.DeletionRepo.Create(request)
if err != nil {
t.Errorf("Unexpected error for zero user ID: %v", err)
}
})
t.Run("very long token hash", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
longToken := strings.Repeat("a", 300)
request := &database.AccountDeletionRequest{
UserID: user.ID,
TokenHash: longToken,
}
err := suite.DeletionRepo.Create(request)
if err != nil {
t.Errorf("Unexpected error for long token hash: %v", err)
}
})
}
func TestAccountDeletionRepository_ConcurrentAccess(t *testing.T) {
suite := NewTestSuite(t)
t.Run("concurrent creates", func(t *testing.T) {
suite.Reset()
user1 := suite.CreateTestUser("user1", "user1@example.com", "password123")
user2 := suite.CreateTestUser("user2", "user2@example.com", "password123")
suite.CreateTestAccountDeletionRequest(user1.ID, "token-hash-1")
suite.CreateTestAccountDeletionRequest(user2.ID, "token-hash-2")
if suite.GetAccountDeletionRequestCount() != 2 {
t.Errorf("Expected 2 requests, got %d", suite.GetAccountDeletionRequestCount())
}
})
}

View File

@@ -0,0 +1,628 @@
package repositories
import (
"fmt"
"testing"
"goyco/internal/database"
"gorm.io/gorm"
)
func TestDatabase_AssertUserExists(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing user", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
retrieved := suite.AssertUserExists(user.ID)
if retrieved == nil {
t.Fatal("Expected user, got nil")
}
if retrieved.ID != user.ID {
t.Errorf("Expected user ID %d, got %d", user.ID, retrieved.ID)
}
})
}
func TestDatabase_AssertUserNotExists(t *testing.T) {
suite := NewTestSuite(t)
t.Run("non-existing user", func(t *testing.T) {
suite.Reset()
suite.AssertUserNotExists(999)
})
}
func TestDatabase_AssertPostExists(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing post", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
retrieved := suite.AssertPostExists(post.ID)
if retrieved == nil {
t.Fatal("Expected post, got nil")
}
if retrieved.ID != post.ID {
t.Errorf("Expected post ID %d, got %d", post.ID, retrieved.ID)
}
})
}
func TestDatabase_AssertPostNotExists(t *testing.T) {
suite := NewTestSuite(t)
t.Run("non-existing post", func(t *testing.T) {
suite.Reset()
suite.AssertPostNotExists(999)
})
}
func TestDatabase_AssertVoteExists(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing vote", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
vote := suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
retrieved := suite.AssertVoteExists(vote.ID)
if retrieved == nil {
t.Fatal("Expected vote, got nil")
}
if retrieved.ID != vote.ID {
t.Errorf("Expected vote ID %d, got %d", vote.ID, retrieved.ID)
}
})
}
func TestDatabase_AssertVoteNotExists(t *testing.T) {
suite := NewTestSuite(t)
t.Run("non-existing vote", func(t *testing.T) {
suite.Reset()
suite.AssertVoteNotExists(999)
})
}
func TestDatabase_AssertAccountDeletionRequestExists(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing request", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
request := suite.CreateTestAccountDeletionRequest(user.ID, "token-hash-123")
retrieved := suite.AssertAccountDeletionRequestExists("token-hash-123")
if retrieved == nil {
t.Fatal("Expected request, got nil")
}
if retrieved.ID != request.ID {
t.Errorf("Expected request ID %d, got %d", request.ID, retrieved.ID)
}
})
}
func TestDatabase_AssertAccountDeletionRequestNotExists(t *testing.T) {
suite := NewTestSuite(t)
t.Run("non-existing request", func(t *testing.T) {
suite.Reset()
suite.AssertAccountDeletionRequestNotExists("nonexistent-token")
})
}
func TestDatabase_GetUserCount(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty database", func(t *testing.T) {
suite.Reset()
count := suite.GetUserCount()
if count != 0 {
t.Errorf("Expected 0 users, got %d", count)
}
})
t.Run("with users", func(t *testing.T) {
suite.Reset()
suite.CreateTestUser("user1", "user1@example.com", "password123")
suite.CreateTestUser("user2", "user2@example.com", "password123")
count := suite.GetUserCount()
if count != 2 {
t.Errorf("Expected 2 users, got %d", count)
}
})
}
func TestDatabase_GetPostCount(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty database", func(t *testing.T) {
suite.Reset()
count := suite.GetPostCount()
if count != 0 {
t.Errorf("Expected 0 posts, got %d", count)
}
})
t.Run("with posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestPost(user.ID, "Post 1", "https://example.com/1", "Content 1")
suite.CreateTestPost(user.ID, "Post 2", "https://example.com/2", "Content 2")
count := suite.GetPostCount()
if count != 2 {
t.Errorf("Expected 2 posts, got %d", count)
}
})
}
func TestDatabase_GetVoteCount(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty database", func(t *testing.T) {
suite.Reset()
count := suite.GetVoteCount()
if count != 0 {
t.Errorf("Expected 0 votes, got %d", count)
}
})
t.Run("with votes", func(t *testing.T) {
suite.Reset()
user1 := suite.CreateTestUser("user1", "user1@example.com", "password123")
user2 := suite.CreateTestUser("user2", "user2@example.com", "password123")
post := suite.CreateTestPost(user1.ID, "Test Post", "https://example.com", "Test content")
suite.CreateTestVote(user1.ID, post.ID, database.VoteUp)
suite.CreateTestVote(user2.ID, post.ID, database.VoteDown)
count := suite.GetVoteCount()
if count != 2 {
t.Errorf("Expected 2 votes, got %d", count)
}
})
}
func TestDatabase_GetAccountDeletionRequestCount(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty database", func(t *testing.T) {
suite.Reset()
count := suite.GetAccountDeletionRequestCount()
if count != 0 {
t.Errorf("Expected 0 requests, got %d", count)
}
})
t.Run("with requests", func(t *testing.T) {
suite.Reset()
user1 := suite.CreateTestUser("user1", "user1@example.com", "password123")
user2 := suite.CreateTestUser("user2", "user2@example.com", "password123")
suite.CreateTestAccountDeletionRequest(user1.ID, "token-hash-1")
suite.CreateTestAccountDeletionRequest(user2.ID, "token-hash-2")
count := suite.GetAccountDeletionRequestCount()
if count != 2 {
t.Errorf("Expected 2 requests, got %d", count)
}
})
}
func TestDatabase_Reset(t *testing.T) {
suite := NewTestSuite(t)
t.Run("reset clears all data", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
suite.CreateTestAccountDeletionRequest(user.ID, "token-hash-123")
if suite.GetUserCount() != 1 {
t.Errorf("Expected 1 user before reset, got %d", suite.GetUserCount())
}
if suite.GetPostCount() != 1 {
t.Errorf("Expected 1 post before reset, got %d", suite.GetPostCount())
}
if suite.GetVoteCount() != 1 {
t.Errorf("Expected 1 vote before reset, got %d", suite.GetVoteCount())
}
if suite.GetAccountDeletionRequestCount() != 1 {
t.Errorf("Expected 1 request before reset, got %d", suite.GetAccountDeletionRequestCount())
}
suite.Reset()
if suite.GetUserCount() != 0 {
t.Errorf("Expected 0 users after reset, got %d", suite.GetUserCount())
}
if suite.GetPostCount() != 0 {
t.Errorf("Expected 0 posts after reset, got %d", suite.GetPostCount())
}
if suite.GetVoteCount() != 0 {
t.Errorf("Expected 0 votes after reset, got %d", suite.GetVoteCount())
}
if suite.GetAccountDeletionRequestCount() != 0 {
t.Errorf("Expected 0 requests after reset, got %d", suite.GetAccountDeletionRequestCount())
}
})
}
func TestDatabase_CreateTestUser(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful creation", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
if user.ID == 0 {
t.Error("Expected user ID to be assigned")
}
if user.Username != "testuser" {
t.Errorf("Expected username 'testuser', got %s", user.Username)
}
if user.Email != "test@example.com" {
t.Errorf("Expected email 'test@example.com', got %s", user.Email)
}
if !user.EmailVerified {
t.Error("Expected user to be email verified")
}
})
}
func TestDatabase_CreateTestPost(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful creation", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
if post.ID == 0 {
t.Error("Expected post ID to be assigned")
}
if post.Title != "Test Post" {
t.Errorf("Expected title 'Test Post', got %s", post.Title)
}
if post.URL != "https://example.com" {
t.Errorf("Expected URL 'https://example.com', got %s", post.URL)
}
if post.Content != "Test content" {
t.Errorf("Expected content 'Test content', got %s", post.Content)
}
if post.AuthorID == nil || *post.AuthorID != user.ID {
t.Errorf("Expected author ID %d, got %v", user.ID, post.AuthorID)
}
})
}
func TestDatabase_CreateTestVote(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful creation", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
vote := suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
if vote.ID == 0 {
t.Error("Expected vote ID to be assigned")
}
if vote.UserID == nil || *vote.UserID != user.ID {
t.Errorf("Expected user ID %d, got %v", user.ID, vote.UserID)
}
if vote.PostID != post.ID {
t.Errorf("Expected post ID %d, got %d", post.ID, vote.PostID)
}
if vote.Type != database.VoteUp {
t.Errorf("Expected vote type %v, got %v", database.VoteUp, vote.Type)
}
})
}
func TestDatabase_CreateTestAccountDeletionRequest(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful creation", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
request := suite.CreateTestAccountDeletionRequest(user.ID, "token-hash-123")
if request.ID == 0 {
t.Error("Expected request ID to be assigned")
}
if request.UserID != user.ID {
t.Errorf("Expected user ID %d, got %d", user.ID, request.UserID)
}
if request.TokenHash != "token-hash-123" {
t.Errorf("Expected token hash 'token-hash-123', got %s", request.TokenHash)
}
})
}
func TestApplyPagination(t *testing.T) {
suite := NewTestSuite(t)
tests := []struct {
name string
limit int
offset int
setupQuery func(*gorm.DB) *gorm.DB
verifyPagination func(*testing.T, *gorm.DB, int, int)
}{
{
name: "limit > 0 and offset > 0",
limit: 10,
offset: 5,
setupQuery: func(db *gorm.DB) *gorm.DB {
return db.Model(&database.User{})
},
verifyPagination: func(t *testing.T, query *gorm.DB, limit, offset int) {
for i := 0; i < 20; i++ {
suite.CreateTestUser(
fmt.Sprintf("testuser_%d", i),
fmt.Sprintf("user%d@example.com", i),
"password123",
)
}
var users []database.User
result := query.Find(&users)
if result.Error != nil {
t.Fatalf("Query failed: %v", result.Error)
}
if len(users) != limit {
t.Errorf("Expected %d users, got %d", limit, len(users))
}
},
},
{
name: "limit > 0 and offset = 0",
limit: 5,
offset: 0,
setupQuery: func(db *gorm.DB) *gorm.DB {
return db.Model(&database.User{})
},
verifyPagination: func(t *testing.T, query *gorm.DB, limit, offset int) {
for i := 0; i < 10; i++ {
suite.CreateTestUser(
fmt.Sprintf("testuser_%d", i),
fmt.Sprintf("user%d@example.com", i),
"password123",
)
}
var users []database.User
result := query.Find(&users)
if result.Error != nil {
t.Fatalf("Query failed: %v", result.Error)
}
if len(users) != limit {
t.Errorf("Expected %d users, got %d", limit, len(users))
}
},
},
{
name: "limit = 0 (should not apply limit)",
limit: 0,
offset: 5,
setupQuery: func(db *gorm.DB) *gorm.DB {
return db.Model(&database.User{})
},
verifyPagination: func(t *testing.T, query *gorm.DB, limit, offset int) {
for i := 0; i < 10; i++ {
suite.CreateTestUser(
fmt.Sprintf("testuser_%d", i),
fmt.Sprintf("user%d@example.com", i),
"password123",
)
}
var users []database.User
result := query.Find(&users)
if result.Error != nil {
t.Fatalf("Query failed: %v", result.Error)
}
expected := 5
if len(users) != expected {
t.Errorf("Expected %d users with offset %d, got %d", expected, offset, len(users))
}
},
},
{
name: "offset = 0 (should not apply offset)",
limit: 10,
offset: 0,
setupQuery: func(db *gorm.DB) *gorm.DB {
return db.Model(&database.User{})
},
verifyPagination: func(t *testing.T, query *gorm.DB, limit, offset int) {
for i := 0; i < 15; i++ {
suite.CreateTestUser(
fmt.Sprintf("testuser_%d", i),
fmt.Sprintf("user%d@example.com", i),
"password123",
)
}
var users []database.User
result := query.Find(&users)
if result.Error != nil {
t.Fatalf("Query failed: %v", result.Error)
}
if len(users) != limit {
t.Errorf("Expected %d users, got %d", limit, len(users))
}
},
},
{
name: "limit = 0 and offset = 0 (should not apply pagination)",
limit: 0,
offset: 0,
setupQuery: func(db *gorm.DB) *gorm.DB {
return db.Model(&database.User{})
},
verifyPagination: func(t *testing.T, query *gorm.DB, limit, offset int) {
for i := 0; i < 10; i++ {
suite.CreateTestUser(
fmt.Sprintf("testuser_%d", i),
fmt.Sprintf("user%d@example.com", i),
"password123",
)
}
var users []database.User
result := query.Find(&users)
if result.Error != nil {
t.Fatalf("Query failed: %v", result.Error)
}
if len(users) != 10 {
t.Errorf("Expected all 10 users, got %d", len(users))
}
},
},
{
name: "negative limit (should not apply limit)",
limit: -5,
offset: 10,
setupQuery: func(db *gorm.DB) *gorm.DB {
return db.Model(&database.User{})
},
verifyPagination: func(t *testing.T, query *gorm.DB, limit, offset int) {
for i := 0; i < 20; i++ {
suite.CreateTestUser(
fmt.Sprintf("testuser_%d", i),
fmt.Sprintf("user%d@example.com", i),
"password123",
)
}
var users []database.User
result := query.Find(&users)
if result.Error != nil {
t.Fatalf("Query failed: %v", result.Error)
}
expected := 10
if len(users) != expected {
t.Errorf("Expected %d users with offset %d, got %d", expected, offset, len(users))
}
},
},
{
name: "negative offset (should not apply offset)",
limit: 10,
offset: -5,
setupQuery: func(db *gorm.DB) *gorm.DB {
return db.Model(&database.User{})
},
verifyPagination: func(t *testing.T, query *gorm.DB, limit, offset int) {
for i := 0; i < 15; i++ {
suite.CreateTestUser(
fmt.Sprintf("testuser_%d", i),
fmt.Sprintf("user%d@example.com", i),
"password123",
)
}
var users []database.User
result := query.Find(&users)
if result.Error != nil {
t.Fatalf("Query failed: %v", result.Error)
}
if len(users) != limit {
t.Errorf("Expected %d users, got %d", limit, len(users))
}
},
},
{
name: "large limit and offset values",
limit: 1000,
offset: 500,
setupQuery: func(db *gorm.DB) *gorm.DB {
return db.Model(&database.User{})
},
verifyPagination: func(t *testing.T, query *gorm.DB, limit, offset int) {
for i := 0; i < 2000; i++ {
suite.CreateTestUser(
fmt.Sprintf("testuser_%d", i),
fmt.Sprintf("user%d@example.com", i),
"password123",
)
}
var users []database.User
result := query.Find(&users)
if result.Error != nil {
t.Fatalf("Query failed: %v", result.Error)
}
if len(users) != limit {
t.Errorf("Expected %d users, got %d", limit, len(users))
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
suite.Reset()
baseQuery := tt.setupQuery(suite.DB)
paginatedQuery := ApplyPagination(baseQuery, tt.limit, tt.offset)
tt.verifyPagination(t, paginatedQuery, tt.limit, tt.offset)
})
}
}

View File

@@ -0,0 +1,237 @@
package repositories
import (
"fmt"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"goyco/internal/database"
)
type TestDatabase struct {
DB *gorm.DB
UserRepo UserRepository
PostRepo PostRepository
VoteRepo VoteRepository
DeletionRepo AccountDeletionRepository
RefreshTokenRepo *RefreshTokenRepository
}
func NewTestDatabase(t *testing.T) *TestDatabase {
dbName := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("Failed to connect to test database: %v", err)
}
err = db.AutoMigrate(
&database.User{},
&database.Post{},
&database.Vote{},
&database.AccountDeletionRequest{},
&database.RefreshToken{},
)
if err != nil {
t.Fatalf("Failed to migrate test database: %v", err)
}
userRepo := NewUserRepository(db)
postRepo := NewPostRepository(db)
voteRepo := NewVoteRepository(db)
deletionRepo := NewAccountDeletionRepository(db)
refreshTokenRepo := NewRefreshTokenRepository(db)
return &TestDatabase{
DB: db,
UserRepo: userRepo,
PostRepo: postRepo,
VoteRepo: voteRepo,
DeletionRepo: deletionRepo,
RefreshTokenRepo: refreshTokenRepo,
}
}
func (td *TestDatabase) Cleanup() {
if td.DB != nil {
sqlDB, err := td.DB.DB()
if err == nil {
sqlDB.Close()
}
}
}
func (td *TestDatabase) Reset() {
td.DB.Exec("DELETE FROM votes")
td.DB.Exec("DELETE FROM posts")
td.DB.Exec("DELETE FROM account_deletion_requests")
td.DB.Exec("DELETE FROM refresh_tokens")
td.DB.Exec("DELETE FROM users")
}
func (td *TestDatabase) CreateTestUser(username, email, password string) *database.User {
user := &database.User{
Username: username,
Email: email,
Password: password,
EmailVerified: true,
}
err := td.UserRepo.Create(user)
if err != nil {
panic(fmt.Sprintf("Failed to create test user: %v", err))
}
return user
}
func (td *TestDatabase) CreateTestPost(authorID uint, title, url, content string) *database.Post {
post := &database.Post{
Title: title,
URL: url,
Content: content,
AuthorID: &authorID,
}
err := td.PostRepo.Create(post)
if err != nil {
panic(fmt.Sprintf("Failed to create test post: %v", err))
}
return post
}
func (td *TestDatabase) CreateTestVote(userID, postID uint, voteType database.VoteType) *database.Vote {
vote := &database.Vote{
UserID: &userID,
PostID: postID,
Type: voteType,
}
err := td.VoteRepo.Create(vote)
if err != nil {
panic(fmt.Sprintf("Failed to create test vote: %v", err))
}
return vote
}
func (td *TestDatabase) CreateTestAccountDeletionRequest(userID uint, tokenHash string) *database.AccountDeletionRequest {
request := &database.AccountDeletionRequest{
UserID: userID,
TokenHash: tokenHash,
}
err := td.DeletionRepo.Create(request)
if err != nil {
panic(fmt.Sprintf("Failed to create test account deletion request: %v", err))
}
return request
}
type TestSuite struct {
*TestDatabase
t *testing.T
}
func NewTestSuite(t *testing.T) *TestSuite {
testDB := NewTestDatabase(t)
t.Cleanup(func() {
testDB.Cleanup()
})
return &TestSuite{
TestDatabase: testDB,
t: t,
}
}
func (ts *TestSuite) Reset() {
ts.TestDatabase.Reset()
}
func (ts *TestSuite) AssertUserExists(userID uint) *database.User {
user, err := ts.UserRepo.GetByID(userID)
if err != nil {
ts.t.Fatalf("Expected user %d to exist, got error: %v", userID, err)
}
return user
}
func (ts *TestSuite) AssertUserNotExists(userID uint) {
_, err := ts.UserRepo.GetByID(userID)
if err == nil {
ts.t.Fatalf("Expected user %d to not exist", userID)
}
}
func (ts *TestSuite) AssertPostExists(postID uint) *database.Post {
post, err := ts.PostRepo.GetByID(postID)
if err != nil {
ts.t.Fatalf("Expected post %d to exist, got error: %v", postID, err)
}
return post
}
func (ts *TestSuite) AssertPostNotExists(postID uint) {
_, err := ts.PostRepo.GetByID(postID)
if err == nil {
ts.t.Fatalf("Expected post %d to not exist", postID)
}
}
func (ts *TestSuite) AssertVoteExists(voteID uint) *database.Vote {
vote, err := ts.VoteRepo.GetByID(voteID)
if err != nil {
ts.t.Fatalf("Expected vote %d to exist, got error: %v", voteID, err)
}
return vote
}
func (ts *TestSuite) AssertVoteNotExists(voteID uint) {
_, err := ts.VoteRepo.GetByID(voteID)
if err == nil {
ts.t.Fatalf("Expected vote %d to not exist", voteID)
}
}
func (ts *TestSuite) AssertAccountDeletionRequestExists(tokenHash string) *database.AccountDeletionRequest {
request, err := ts.DeletionRepo.GetByTokenHash(tokenHash)
if err != nil {
ts.t.Fatalf("Expected account deletion request with token %s to exist, got error: %v", tokenHash, err)
}
return request
}
func (ts *TestSuite) AssertAccountDeletionRequestNotExists(tokenHash string) {
_, err := ts.DeletionRepo.GetByTokenHash(tokenHash)
if err == nil {
ts.t.Fatalf("Expected account deletion request with token %s to not exist", tokenHash)
}
}
func (ts *TestSuite) GetUserCount() int64 {
var count int64
ts.DB.Model(&database.User{}).Count(&count)
return count
}
func (ts *TestSuite) GetPostCount() int64 {
var count int64
ts.DB.Model(&database.Post{}).Count(&count)
return count
}
func (ts *TestSuite) GetVoteCount() int64 {
var count int64
ts.DB.Model(&database.Vote{}).Count(&count)
return count
}
func (ts *TestSuite) GetAccountDeletionRequestCount() int64 {
var count int64
ts.DB.Model(&database.AccountDeletionRequest{}).Count(&count)
return count
}
func (ts *TestSuite) CreateInvalidUserID() *uint {
id := uint(999)
return &id
}

View File

@@ -0,0 +1,13 @@
package repositories
import "gorm.io/gorm"
func ApplyPagination(query *gorm.DB, limit, offset int) *gorm.DB {
if limit > 0 {
query = query.Limit(limit)
}
if offset > 0 {
query = query.Offset(offset)
}
return query
}

View File

@@ -0,0 +1,158 @@
package repositories
import (
"gorm.io/gorm"
"goyco/internal/database"
)
type PostRepository interface {
Create(post *database.Post) error
GetByID(id uint) (*database.Post, error)
GetAll(limit, offset int) ([]database.Post, error)
GetByUserID(userID uint, limit, offset int) ([]database.Post, error)
Update(post *database.Post) error
Delete(id uint) error
Count() (int64, error)
CountByUserID(userID uint) (int64, error)
GetTopPosts(limit int) ([]database.Post, error)
GetNewestPosts(limit int) ([]database.Post, error)
Search(query string, limit, offset int) ([]database.Post, error)
GetPostsByDeletedUsers() ([]database.Post, error)
HardDeletePostsByDeletedUsers() (int64, error)
HardDeleteAll() (int64, error)
WithTx(tx *gorm.DB) PostRepository
}
type postRepository struct {
db *gorm.DB
sanitizer *SearchSanitizer
}
func NewPostRepository(db *gorm.DB) PostRepository {
return &postRepository{
db: db,
sanitizer: NewSearchSanitizer(),
}
}
func (r *postRepository) Create(post *database.Post) error {
return r.db.Create(post).Error
}
func (r *postRepository) GetByID(id uint) (*database.Post, error) {
var post database.Post
err := r.db.Preload("Author").First(&post, id).Error
if err != nil {
return nil, err
}
return &post, nil
}
func (r *postRepository) GetAll(limit, offset int) ([]database.Post, error) {
var posts []database.Post
query := r.db.Preload("Author").Order("score DESC, created_at DESC, id DESC")
query = ApplyPagination(query, limit, offset)
err := query.Find(&posts).Error
return posts, err
}
func (r *postRepository) GetByUserID(userID uint, limit, offset int) ([]database.Post, error) {
var posts []database.Post
query := r.db.Where("author_id = ?", userID).Preload("Author").Order("created_at DESC")
query = ApplyPagination(query, limit, offset)
err := query.Find(&posts).Error
return posts, err
}
func (r *postRepository) Update(post *database.Post) error {
return r.db.Save(post).Error
}
func (r *postRepository) Delete(id uint) error {
return r.db.Delete(&database.Post{}, id).Error
}
func (r *postRepository) Count() (int64, error) {
var count int64
err := r.db.Model(&database.Post{}).Count(&count).Error
return count, err
}
func (r *postRepository) CountByUserID(userID uint) (int64, error) {
var count int64
err := r.db.Model(&database.Post{}).Where("author_id = ?", userID).Count(&count).Error
return count, err
}
func (r *postRepository) GetTopPosts(limit int) ([]database.Post, error) {
var posts []database.Post
query := r.db.Preload("Author").Order("score DESC, created_at DESC, id DESC")
query = ApplyPagination(query, limit, 0)
err := query.Find(&posts).Error
return posts, err
}
func (r *postRepository) GetNewestPosts(limit int) ([]database.Post, error) {
var posts []database.Post
query := r.db.Preload("Author").Order("created_at DESC")
query = ApplyPagination(query, limit, 0)
err := query.Find(&posts).Error
return posts, err
}
func (r *postRepository) Search(query string, limit, offset int) ([]database.Post, error) {
var posts []database.Post
sanitizedQuery, err := r.sanitizer.SanitizeSearchQuery(query)
if err != nil {
return nil, err
}
if err := r.sanitizer.ValidateSearchQuery(sanitizedQuery); err != nil {
return nil, err
}
if sanitizedQuery == "" {
return posts, nil
}
dbQuery := r.db.Preload("Author").Where("UPPER(title) LIKE UPPER(?) OR UPPER(content) LIKE UPPER(?)", "%"+sanitizedQuery+"%", "%"+sanitizedQuery+"%").Order("score DESC, created_at DESC, id DESC")
dbQuery = ApplyPagination(dbQuery, limit, offset)
err = dbQuery.Find(&posts).Error
return posts, err
}
func (r *postRepository) WithTx(tx *gorm.DB) PostRepository {
return &postRepository{db: tx, sanitizer: r.sanitizer}
}
func (r *postRepository) GetPostsByDeletedUsers() ([]database.Post, error) {
var posts []database.Post
err := r.db.Unscoped().
Preload("Author").
Where("author_id IS NULL OR author_id IN (SELECT id FROM users WHERE deleted_at IS NOT NULL)").
Find(&posts).Error
return posts, err
}
func (r *postRepository) HardDeletePostsByDeletedUsers() (int64, error) {
result := r.db.Unscoped().
Where("author_id IS NULL OR author_id IN (SELECT id FROM users WHERE deleted_at IS NOT NULL)").
Delete(&database.Post{})
return result.RowsAffected, result.Error
}
func (r *postRepository) HardDeleteAll() (int64, error) {
var rowsAffected int64
err := r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Unscoped().Where("1 = 1").Delete(&database.Post{})
rowsAffected = result.RowsAffected
return result.Error
})
return rowsAffected, err
}

View File

@@ -0,0 +1,804 @@
package repositories
import (
"errors"
"strconv"
"strings"
"testing"
"time"
"gorm.io/gorm"
"goyco/internal/database"
)
func TestPostRepository_Create(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful creation", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
if suite.GetPostCount() != 1 {
t.Errorf("Expected 1 post, got %d", suite.GetPostCount())
}
if post.ID == 0 {
t.Error("Expected post ID to be assigned")
}
})
t.Run("post with invalid author", func(t *testing.T) {
suite.Reset()
post := &database.Post{
Title: "Test Post",
URL: "https://example.com",
Content: "Test content",
AuthorID: suite.CreateInvalidUserID(),
}
err := suite.PostRepo.Create(post)
if err != nil {
t.Errorf("Unexpected error for invalid author (SQLite allows this): %v", err)
}
})
t.Run("duplicate URL", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestPost(user.ID, "Post 1", "https://example.com", "Content 1")
post2 := &database.Post{
Title: "Post 2",
URL: "https://example.com",
Content: "Content 2",
AuthorID: &user.ID,
}
err := suite.PostRepo.Create(post2)
if err == nil {
t.Error("Expected error for duplicate URL")
}
if !errors.Is(err, gorm.ErrDuplicatedKey) && !strings.Contains(err.Error(), "duplicate") && !strings.Contains(err.Error(), "UNIQUE constraint") {
t.Errorf("Expected duplicate key error, got %v", err)
}
})
}
func TestPostRepository_GetByID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing post", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
retrieved, err := suite.PostRepo.GetByID(post.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if retrieved == nil {
t.Fatal("Expected post, got nil")
}
if retrieved.ID != post.ID {
t.Errorf("Expected ID %d, got %d", post.ID, retrieved.ID)
}
if retrieved.Title != post.Title {
t.Errorf("Expected title %s, got %s", post.Title, retrieved.Title)
}
if retrieved.URL != post.URL {
t.Errorf("Expected URL %s, got %s", post.URL, retrieved.URL)
}
if retrieved.AuthorID == nil || post.AuthorID == nil {
t.Error("Expected both AuthorID pointers to be non-nil")
} else if *retrieved.AuthorID != *post.AuthorID {
t.Errorf("Expected author ID %d, got %d", *post.AuthorID, *retrieved.AuthorID)
}
})
t.Run("non-existing post", func(t *testing.T) {
suite.Reset()
_, err := suite.PostRepo.GetByID(999)
if err == nil {
t.Error("Expected error for non-existing post")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
}
func TestPostRepository_GetAll(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty database", func(t *testing.T) {
suite.Reset()
posts, err := suite.PostRepo.GetAll(10, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(posts) != 0 {
t.Errorf("Expected 0 posts, got %d", len(posts))
}
})
t.Run("with posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestPost(user.ID, "Post 1", "https://example.com/1", "Content 1")
suite.CreateTestPost(user.ID, "Post 2", "https://example.com/2", "Content 2")
suite.CreateTestPost(user.ID, "Post 3", "https://example.com/3", "Content 3")
retrieved, err := suite.PostRepo.GetAll(10, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 3 {
t.Errorf("Expected 3 posts, got %d", len(retrieved))
}
})
t.Run("with limit", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser2", "test2@example.com", "password123")
for i := 0; i < 5; i++ {
suite.CreateTestPost(user.ID,
"Post "+strconv.Itoa(i),
"https://example.com/"+strconv.Itoa(i),
"Content "+strconv.Itoa(i))
}
retrieved, err := suite.PostRepo.GetAll(2, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 posts, got %d", len(retrieved))
}
})
t.Run("with offset", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser3", "test3@example.com", "password123")
for i := 0; i < 5; i++ {
suite.CreateTestPost(user.ID,
"Post "+strconv.Itoa(i),
"https://example.com/"+strconv.Itoa(i),
"Content "+strconv.Itoa(i))
}
retrieved, err := suite.PostRepo.GetAll(2, 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 posts, got %d", len(retrieved))
}
})
}
func TestPostRepository_GetByUserID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("user with posts", func(t *testing.T) {
suite.Reset()
user1 := suite.CreateTestUser("user1", "user1@example.com", "password123")
user2 := suite.CreateTestUser("user2", "user2@example.com", "password123")
suite.CreateTestPost(user1.ID, "User1 Post", "https://example.com/1", "Content 1")
suite.CreateTestPost(user2.ID, "User2 Post", "https://example.com/2", "Content 2")
suite.CreateTestPost(user1.ID, "User1 Post 2", "https://example.com/3", "Content 3")
retrieved, err := suite.PostRepo.GetByUserID(user1.ID, 10, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 posts for user1, got %d", len(retrieved))
}
for _, post := range retrieved {
if post.AuthorID == nil || *post.AuthorID != user1.ID {
t.Errorf("Expected author ID %d, got %v", user1.ID, post.AuthorID)
}
}
})
t.Run("user without posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("noposts", "noposts@example.com", "password123")
retrieved, err := suite.PostRepo.GetByUserID(user.ID, 10, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 0 {
t.Errorf("Expected 0 posts, got %d", len(retrieved))
}
})
}
func TestPostRepository_Update(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful update", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
post.Title = "Updated Post"
post.Content = "Updated content"
post.Score = 10
post.UpVotes = 12
post.DownVotes = 2
err := suite.PostRepo.Update(post)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
retrieved, err := suite.PostRepo.GetByID(post.ID)
if err != nil {
t.Fatalf("Failed to retrieve post: %v", err)
}
if retrieved.Title != "Updated Post" {
t.Errorf("Expected title 'Updated Post', got %s", retrieved.Title)
}
if retrieved.Content != "Updated content" {
t.Errorf("Expected content 'Updated content', got %s", retrieved.Content)
}
if retrieved.Score != 10 {
t.Errorf("Expected score 10, got %d", retrieved.Score)
}
if retrieved.UpVotes != 12 {
t.Errorf("Expected up votes 12, got %d", retrieved.UpVotes)
}
if retrieved.DownVotes != 2 {
t.Errorf("Expected down votes 2, got %d", retrieved.DownVotes)
}
})
}
func TestPostRepository_Delete(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful delete", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
err := suite.PostRepo.Delete(post.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
_, err = suite.PostRepo.GetByID(post.ID)
if err == nil {
t.Error("Expected error for deleted post")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
}
func TestPostRepository_Count(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty database", func(t *testing.T) {
suite.Reset()
count, err := suite.PostRepo.Count()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 0 {
t.Errorf("Expected count 0, got %d", count)
}
})
t.Run("with posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
for i := 0; i < 5; i++ {
suite.CreateTestPost(user.ID,
"Post "+strconv.Itoa(i),
"https://example.com/"+strconv.Itoa(i),
"Content "+strconv.Itoa(i))
}
count, err := suite.PostRepo.Count()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 5 {
t.Errorf("Expected count 5, got %d", count)
}
})
}
func TestPostRepository_GetTopPosts(t *testing.T) {
suite := NewTestSuite(t)
t.Run("with posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post1 := &database.Post{
Title: "Low Score",
URL: "https://example.com/1",
Content: "Content 1",
AuthorID: &user.ID,
Score: 1,
}
post2 := &database.Post{
Title: "High Score",
URL: "https://example.com/2",
Content: "Content 2",
AuthorID: &user.ID,
Score: 10,
}
post3 := &database.Post{
Title: "Medium Score",
URL: "https://example.com/3",
Content: "Content 3",
AuthorID: &user.ID,
Score: 5,
}
suite.PostRepo.Create(post1)
suite.PostRepo.Create(post2)
suite.PostRepo.Create(post3)
retrieved, err := suite.PostRepo.GetTopPosts(2)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 top posts, got %d", len(retrieved))
}
for i := 0; i < len(retrieved)-1; i++ {
if retrieved[i].Score < retrieved[i+1].Score {
t.Errorf("Posts not in descending order: %d < %d", retrieved[i].Score, retrieved[i+1].Score)
}
}
})
}
func TestPostRepository_GetNewestPosts(t *testing.T) {
suite := NewTestSuite(t)
t.Run("with posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post1 := &database.Post{
Title: "Old Post",
URL: "https://example.com/1",
Content: "Content 1",
AuthorID: &user.ID,
}
post2 := &database.Post{
Title: "New Post",
URL: "https://example.com/2",
Content: "Content 2",
AuthorID: &user.ID,
}
post1.CreatedAt = time.Now().Add(-2 * time.Hour)
post2.CreatedAt = time.Now().Add(-1 * time.Hour)
suite.PostRepo.Create(post1)
suite.PostRepo.Create(post2)
retrieved, err := suite.PostRepo.GetNewestPosts(2)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 newest posts, got %d", len(retrieved))
}
if retrieved[0].CreatedAt.Before(retrieved[1].CreatedAt) {
t.Error("Expected posts to be ordered by creation time (newest first)")
}
})
}
func TestPostRepository_Search(t *testing.T) {
suite := NewTestSuite(t)
t.Run("with matching posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestPost(user.ID, "Golang Tutorial", "https://example.com/1", "Learn Go programming")
suite.CreateTestPost(user.ID, "Python Guide", "https://example.com/2", "Learn Python programming")
suite.CreateTestPost(user.ID, "Go Best Practices", "https://example.com/3", "Advanced Go techniques")
retrieved, err := suite.PostRepo.Search("Go", 10, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 posts matching 'Go', got %d", len(retrieved))
}
for _, post := range retrieved {
if post.Title != "Golang Tutorial" && post.Title != "Go Best Practices" {
t.Errorf("Unexpected post in search results: %s", post.Title)
}
}
})
t.Run("case insensitive search", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestPost(user.ID, "Golang Tutorial", "https://example.com/1", "Learn Go programming")
retrieved, err := suite.PostRepo.Search("golang", 10, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 1 {
t.Errorf("Expected 1 post matching 'golang', got %d", len(retrieved))
}
if retrieved[0].Title != "Golang Tutorial" {
t.Errorf("Expected 'Golang Tutorial', got %s", retrieved[0].Title)
}
})
t.Run("no matching posts", func(t *testing.T) {
suite.Reset()
retrieved, err := suite.PostRepo.Search("nonexistent", 10, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 0 {
t.Errorf("Expected 0 posts matching 'nonexistent', got %d", len(retrieved))
}
})
t.Run("search with limit and offset", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser2", "test2@example.com", "password123")
for i := 0; i < 5; i++ {
suite.CreateTestPost(user.ID,
"Go Post "+strconv.Itoa(i),
"https://example.com/go"+strconv.Itoa(i),
"Go content "+strconv.Itoa(i))
}
retrieved, err := suite.PostRepo.Search("Go", 2, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 posts with limit, got %d", len(retrieved))
}
retrieved, err = suite.PostRepo.Search("Go", 2, 1)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 posts with offset, got %d", len(retrieved))
}
})
}
func TestPostRepository_CountByUserID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("user with posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestPost(user.ID, "Post 1", "https://example.com/1", "Content 1")
suite.CreateTestPost(user.ID, "Post 2", "https://example.com/2", "Content 2")
suite.CreateTestPost(user.ID, "Post 3", "https://example.com/3", "Content 3")
count, err := suite.PostRepo.CountByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 3 {
t.Errorf("Expected 3 posts, got %d", count)
}
})
t.Run("user without posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
count, err := suite.PostRepo.CountByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 0 {
t.Errorf("Expected 0 posts, got %d", count)
}
})
}
func TestPostRepository_WithTx(t *testing.T) {
suite := NewTestSuite(t)
t.Run("transaction repository", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
tx := suite.DB.Begin()
defer tx.Rollback()
txPostRepo := suite.PostRepo.WithTx(tx)
post := &database.Post{
Title: "Transaction Post",
URL: "https://example.com",
Content: "Test content",
AuthorID: &user.ID,
}
err := txPostRepo.Create(post)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
retrieved, err := txPostRepo.GetByID(post.ID)
if err != nil {
t.Fatalf("Expected to find post in transaction, got %v", err)
}
if retrieved.Title != "Transaction Post" {
t.Errorf("Expected title 'Transaction Post', got %s", retrieved.Title)
}
})
}
func TestPostRepository_GetPostsByDeletedUsers(t *testing.T) {
suite := NewTestSuite(t)
t.Run("posts by deleted users", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
err := suite.UserRepo.Delete(user.ID)
if err != nil {
t.Fatalf("Failed to delete user: %v", err)
}
posts, err := suite.PostRepo.GetPostsByDeletedUsers()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(posts) != 1 {
t.Errorf("Expected 1 post by deleted user, got %d", len(posts))
}
if posts[0].ID != post.ID {
t.Errorf("Expected post ID %d, got %d", post.ID, posts[0].ID)
}
})
t.Run("no posts by deleted users", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
posts, err := suite.PostRepo.GetPostsByDeletedUsers()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(posts) != 0 {
t.Errorf("Expected 0 posts by deleted users, got %d", len(posts))
}
})
}
func TestPostRepository_HardDeletePostsByDeletedUsers(t *testing.T) {
suite := NewTestSuite(t)
t.Run("hard delete posts by deleted users", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
err := suite.UserRepo.Delete(user.ID)
if err != nil {
t.Fatalf("Failed to delete user: %v", err)
}
deletedCount, err := suite.PostRepo.HardDeletePostsByDeletedUsers()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if deletedCount != 1 {
t.Errorf("Expected 1 post deleted, got %d", deletedCount)
}
_, err = suite.PostRepo.GetByID(post.ID)
if err == nil {
t.Error("Expected error for hard deleted post")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
t.Run("no posts to delete", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
deletedCount, err := suite.PostRepo.HardDeletePostsByDeletedUsers()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if deletedCount != 0 {
t.Errorf("Expected 0 posts deleted, got %d", deletedCount)
}
})
}
func TestPostRepository_HardDeleteAll(t *testing.T) {
suite := NewTestSuite(t)
t.Run("hard delete all posts", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
suite.CreateTestPost(user.ID, "Post 1", "https://example.com/1", "Content 1")
suite.CreateTestPost(user.ID, "Post 2", "https://example.com/2", "Content 2")
deletedCount, err := suite.PostRepo.HardDeleteAll()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if deletedCount != 2 {
t.Errorf("Expected 2 posts deleted, got %d", deletedCount)
}
posts, err := suite.PostRepo.GetAll(10, 0)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(posts) != 0 {
t.Errorf("Expected 0 posts, got %d", len(posts))
}
})
t.Run("hard delete with no posts", func(t *testing.T) {
suite.Reset()
deletedCount, err := suite.PostRepo.HardDeleteAll()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if deletedCount != 0 {
t.Errorf("Expected 0 posts deleted, got %d", deletedCount)
}
})
}
func TestPostRepository_EdgeCases(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty URL", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser2", "test2@example.com", "password123")
post := &database.Post{
Title: "Test Post",
URL: "",
Content: "Test content",
AuthorID: &user.ID,
}
err := suite.PostRepo.Create(post)
if err != nil {
t.Errorf("Unexpected error for empty URL: %v", err)
}
})
t.Run("invalid URL format", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser3", "test3@example.com", "password123")
post := &database.Post{
Title: "Test Post",
URL: "not-a-url",
Content: "Test content",
AuthorID: &user.ID,
}
err := suite.PostRepo.Create(post)
if err != nil {
t.Errorf("Unexpected error for invalid URL format: %v", err)
}
})
t.Run("very long title", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser4", "test4@example.com", "password123")
longTitle := strings.Repeat("a", 300)
post := &database.Post{
Title: longTitle,
URL: "https://example.com",
Content: "Test content",
AuthorID: &user.ID,
}
err := suite.PostRepo.Create(post)
if err != nil {
t.Errorf("Unexpected error for long title: %v", err)
}
})
}

View File

@@ -0,0 +1,13 @@
package repositories
import "goyco/internal/database"
type RefreshTokenRepositoryInterface interface {
Create(token *database.RefreshToken) error
GetByTokenHash(tokenHash string) (*database.RefreshToken, error)
DeleteByUserID(userID uint) error
DeleteExpired() error
DeleteByID(id uint) error
GetByUserID(userID uint) ([]database.RefreshToken, error)
CountByUserID(userID uint) (int64, error)
}

View File

@@ -0,0 +1,55 @@
package repositories
import (
"time"
"gorm.io/gorm"
"goyco/internal/database"
)
type RefreshTokenRepository struct {
db *gorm.DB
}
var _ RefreshTokenRepositoryInterface = (*RefreshTokenRepository)(nil)
func NewRefreshTokenRepository(db *gorm.DB) *RefreshTokenRepository {
return &RefreshTokenRepository{db: db}
}
func (r *RefreshTokenRepository) Create(token *database.RefreshToken) error {
return r.db.Create(token).Error
}
func (r *RefreshTokenRepository) GetByTokenHash(tokenHash string) (*database.RefreshToken, error) {
var token database.RefreshToken
err := r.db.Where("token_hash = ?", tokenHash).First(&token).Error
if err != nil {
return nil, err
}
return &token, nil
}
func (r *RefreshTokenRepository) DeleteByUserID(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&database.RefreshToken{}).Error
}
func (r *RefreshTokenRepository) DeleteExpired() error {
return r.db.Where("expires_at < ?", time.Now()).Delete(&database.RefreshToken{}).Error
}
func (r *RefreshTokenRepository) DeleteByID(id uint) error {
return r.db.Delete(&database.RefreshToken{}, id).Error
}
func (r *RefreshTokenRepository) GetByUserID(userID uint) ([]database.RefreshToken, error) {
var tokens []database.RefreshToken
err := r.db.Where("user_id = ?", userID).Find(&tokens).Error
return tokens, err
}
func (r *RefreshTokenRepository) CountByUserID(userID uint) (int64, error) {
var count int64
err := r.db.Model(&database.RefreshToken{}).Where("user_id = ?", userID).Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,414 @@
package repositories
import (
"strings"
"testing"
"time"
"gorm.io/gorm"
"goyco/internal/database"
)
func TestRefreshTokenRepository_Create(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful creation", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
token := &database.RefreshToken{
UserID: user.ID,
TokenHash: "token-hash-123",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
err := suite.RefreshTokenRepo.Create(token)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if token.ID == 0 {
t.Error("Expected token ID to be assigned")
}
})
t.Run("token with invalid user", func(t *testing.T) {
suite.Reset()
token := &database.RefreshToken{
UserID: 999,
TokenHash: "token-hash-123",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
err := suite.RefreshTokenRepo.Create(token)
if err != nil {
t.Errorf("Unexpected error for invalid user (SQLite allows this): %v", err)
}
})
t.Run("duplicate token hash", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
token1 := &database.RefreshToken{
UserID: user.ID,
TokenHash: "duplicate-token",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
err := suite.RefreshTokenRepo.Create(token1)
if err != nil {
t.Fatalf("Expected no error for first token, got %v", err)
}
token2 := &database.RefreshToken{
UserID: user.ID,
TokenHash: "duplicate-token",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
err = suite.RefreshTokenRepo.Create(token2)
if err == nil {
t.Error("Expected error for duplicate token hash")
}
})
}
func TestRefreshTokenRepository_GetByTokenHash(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing token", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
token := &database.RefreshToken{
UserID: user.ID,
TokenHash: "token-hash-123",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
err := suite.RefreshTokenRepo.Create(token)
if err != nil {
t.Fatalf("Failed to create token: %v", err)
}
retrieved, err := suite.RefreshTokenRepo.GetByTokenHash("token-hash-123")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if retrieved == nil {
t.Fatal("Expected token, got nil")
}
if retrieved.TokenHash != "token-hash-123" {
t.Errorf("Expected token hash 'token-hash-123', got %s", retrieved.TokenHash)
}
if retrieved.UserID != user.ID {
t.Errorf("Expected user ID %d, got %d", user.ID, retrieved.UserID)
}
})
t.Run("non-existing token", func(t *testing.T) {
suite.Reset()
_, err := suite.RefreshTokenRepo.GetByTokenHash("nonexistent-token")
if err == nil {
t.Error("Expected error for non-existing token")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
}
func TestRefreshTokenRepository_DeleteByUserID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful delete", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
token1 := &database.RefreshToken{
UserID: user.ID,
TokenHash: "token-hash-1",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
token2 := &database.RefreshToken{
UserID: user.ID,
TokenHash: "token-hash-2",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
suite.RefreshTokenRepo.Create(token1)
suite.RefreshTokenRepo.Create(token2)
err := suite.RefreshTokenRepo.DeleteByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
_, err = suite.RefreshTokenRepo.GetByTokenHash("token-hash-1")
if err == nil {
t.Error("Expected error for deleted token")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
t.Run("delete for user without tokens", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
err := suite.RefreshTokenRepo.DeleteByUserID(user.ID)
if err != nil {
t.Fatalf("Delete should succeed even for user without tokens (GORM behavior)")
}
})
}
func TestRefreshTokenRepository_DeleteExpired(t *testing.T) {
suite := NewTestSuite(t)
t.Run("delete expired tokens", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
expiredToken := &database.RefreshToken{
UserID: user.ID,
TokenHash: "expired-token",
ExpiresAt: time.Now().Add(-1 * time.Hour),
}
validToken := &database.RefreshToken{
UserID: user.ID,
TokenHash: "valid-token",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
suite.RefreshTokenRepo.Create(expiredToken)
suite.RefreshTokenRepo.Create(validToken)
err := suite.RefreshTokenRepo.DeleteExpired()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
_, err = suite.RefreshTokenRepo.GetByTokenHash("expired-token")
if err == nil {
t.Error("Expected error for deleted expired token")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
_, err = suite.RefreshTokenRepo.GetByTokenHash("valid-token")
if err != nil {
t.Errorf("Expected valid token to still exist, got error: %v", err)
}
})
}
func TestRefreshTokenRepository_DeleteByID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful delete", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
token := &database.RefreshToken{
UserID: user.ID,
TokenHash: "token-hash-123",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
err := suite.RefreshTokenRepo.Create(token)
if err != nil {
t.Fatalf("Failed to create token: %v", err)
}
err = suite.RefreshTokenRepo.DeleteByID(token.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
_, err = suite.RefreshTokenRepo.GetByTokenHash("token-hash-123")
if err == nil {
t.Error("Expected error for deleted token")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
t.Run("delete non-existing token", func(t *testing.T) {
suite.Reset()
err := suite.RefreshTokenRepo.DeleteByID(999)
if err != nil {
t.Fatalf("Delete should succeed even for non-existing token (GORM behavior)")
}
})
}
func TestRefreshTokenRepository_GetByUserID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("user with tokens", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
token1 := &database.RefreshToken{
UserID: user.ID,
TokenHash: "token-hash-1",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
token2 := &database.RefreshToken{
UserID: user.ID,
TokenHash: "token-hash-2",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
suite.RefreshTokenRepo.Create(token1)
suite.RefreshTokenRepo.Create(token2)
tokens, err := suite.RefreshTokenRepo.GetByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(tokens) != 2 {
t.Errorf("Expected 2 tokens, got %d", len(tokens))
}
for _, token := range tokens {
if token.UserID != user.ID {
t.Errorf("Expected user ID %d, got %d", user.ID, token.UserID)
}
}
})
t.Run("user without tokens", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
tokens, err := suite.RefreshTokenRepo.GetByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(tokens) != 0 {
t.Errorf("Expected 0 tokens, got %d", len(tokens))
}
})
}
func TestRefreshTokenRepository_CountByUserID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("user with tokens", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
token1 := &database.RefreshToken{
UserID: user.ID,
TokenHash: "token-hash-1",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
token2 := &database.RefreshToken{
UserID: user.ID,
TokenHash: "token-hash-2",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
suite.RefreshTokenRepo.Create(token1)
suite.RefreshTokenRepo.Create(token2)
count, err := suite.RefreshTokenRepo.CountByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 2 {
t.Errorf("Expected 2 tokens, got %d", count)
}
})
t.Run("user without tokens", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
count, err := suite.RefreshTokenRepo.CountByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 0 {
t.Errorf("Expected 0 tokens, got %d", count)
}
})
}
func TestRefreshTokenRepository_EdgeCases(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty token hash", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
token := &database.RefreshToken{
UserID: user.ID,
TokenHash: "",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
err := suite.RefreshTokenRepo.Create(token)
if err != nil {
t.Errorf("Unexpected error for empty token hash: %v", err)
}
})
t.Run("zero user ID", func(t *testing.T) {
suite.Reset()
token := &database.RefreshToken{
UserID: 0,
TokenHash: "token-hash-123",
ExpiresAt: time.Now().Add(24 * time.Hour),
}
err := suite.RefreshTokenRepo.Create(token)
if err != nil {
t.Errorf("Unexpected error for zero user ID: %v", err)
}
})
t.Run("very long token hash", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
longToken := strings.Repeat("a", 300)
token := &database.RefreshToken{
UserID: user.ID,
TokenHash: longToken,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
err := suite.RefreshTokenRepo.Create(token)
if err != nil {
t.Errorf("Unexpected error for long token hash: %v", err)
}
})
}

View File

@@ -0,0 +1,165 @@
package repositories
import (
"regexp"
"strings"
"unicode"
)
type SearchSanitizer struct {
MaxQueryLength int
MaxSpecialChars int
}
func NewSearchSanitizer() *SearchSanitizer {
return &SearchSanitizer{
MaxQueryLength: 100,
MaxSpecialChars: 10,
}
}
func (s *SearchSanitizer) SanitizeSearchQuery(query string) (string, error) {
query = strings.TrimSpace(query)
if query == "" {
return "", nil
}
if len(query) > s.MaxQueryLength {
return "", &SearchError{
Type: "QueryTooLong",
Message: "Search query exceeds maximum length",
}
}
specialCharCount := 0
for _, char := range query {
if !unicode.IsLetter(char) && !unicode.IsDigit(char) && !unicode.IsSpace(char) {
specialCharCount++
}
}
if specialCharCount > s.MaxSpecialChars {
return "", &SearchError{
Type: "TooManySpecialChars",
Message: "Search query contains too many special characters",
}
}
query = s.removeDangerousPatterns(query)
query = s.normalizeWhitespace(query)
if len(query) > s.MaxQueryLength {
query = query[:s.MaxQueryLength]
}
return query, nil
}
func (s *SearchSanitizer) removeDangerousPatterns(query string) string {
query = regexp.MustCompile(`\*{3,}`).ReplaceAllString(query, "**")
query = regexp.MustCompile(`%{3,}`).ReplaceAllString(query, "%%")
query = regexp.MustCompile(`\.{3,}`).ReplaceAllString(query, "..")
query = regexp.MustCompile(`\?{3,}`).ReplaceAllString(query, "??")
query = regexp.MustCompile(`\+{3,}`).ReplaceAllString(query, "++")
query = regexp.MustCompile(`\{[^}]*\{[^}]*\}`).ReplaceAllString(query, "")
query = regexp.MustCompile(`\[[^\]]*\[[^\]]*\]`).ReplaceAllString(query, "")
query = regexp.MustCompile(`\([^)]*\([^)]*\)`).ReplaceAllString(query, "")
return query
}
func (s *SearchSanitizer) normalizeWhitespace(query string) string {
query = regexp.MustCompile(`\s+`).ReplaceAllString(query, " ")
query = strings.TrimSpace(query)
return query
}
func (s *SearchSanitizer) ValidateSearchQuery(query string) error {
if len(query) == 0 {
return nil
}
sqlInjectionPatterns := []string{
"';", "--", "/*", "*/", "xp_", "sp_", "exec", "execute",
"union", "select", "insert", "update", "delete", "drop",
"create", "alter", "grant", "revoke", "truncate",
}
lowerQuery := strings.ToLower(query)
for _, pattern := range sqlInjectionPatterns {
if strings.Contains(lowerQuery, pattern) {
return &SearchError{
Type: "InvalidQuery",
Message: "Search query contains potentially dangerous patterns",
}
}
}
if s.hasExcessiveRepetition(query) {
return &SearchError{
Type: "DoSPattern",
Message: "Search query contains patterns that could cause denial of service",
}
}
return nil
}
func (s *SearchSanitizer) hasExcessiveRepetition(query string) bool {
if s.hasRepeatedCharacters(query, 5) {
return true
}
words := strings.Fields(query)
wordCount := make(map[string]int)
for _, word := range words {
wordCount[strings.ToLower(word)]++
if wordCount[strings.ToLower(word)] > 3 {
return true
}
}
return false
}
func (s *SearchSanitizer) hasRepeatedCharacters(str string, maxRepeats int) bool {
if len(str) <= maxRepeats {
return false
}
currentChar := rune(0)
count := 0
for _, char := range str {
if char == currentChar {
count++
if count > maxRepeats {
return true
}
} else {
currentChar = char
count = 1
}
}
return false
}
type SearchError struct {
Type string
Message string
}
func (e *SearchError) Error() string {
return e.Message
}

View File

@@ -0,0 +1,209 @@
package repositories
import (
"testing"
)
func TestSearchSanitizer_SanitizeSearchQuery(t *testing.T) {
sanitizer := NewSearchSanitizer()
tests := []struct {
name string
query string
expectedResult string
expectError bool
errorType string
}{
{
name: "normal_search",
query: "golang programming",
expectedResult: "golang programming",
expectError: false,
},
{
name: "empty_query",
query: "",
expectedResult: "",
expectError: false,
},
{
name: "whitespace_only",
query: " ",
expectedResult: "",
expectError: false,
},
{
name: "normalize_whitespace",
query: "golang programming tutorial",
expectedResult: "golang programming tutorial",
expectError: false,
},
{
name: "remove_excessive_wildcards",
query: "test***wildcard",
expectedResult: "test**wildcard",
expectError: false,
},
{
name: "remove_excessive_dots",
query: "test...dots",
expectedResult: "test..dots",
expectError: false,
},
{
name: "query_too_long",
query: "a very long search query that exceeds the maximum length limit and should be rejected by the sanitizer",
expectedResult: "",
expectError: true,
errorType: "QueryTooLong",
},
{
name: "too_many_special_chars",
query: "test@#$%^&*()_+{}|:<>?[]\\;'\",./",
expectedResult: "",
expectError: true,
errorType: "TooManySpecialChars",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := sanitizer.SanitizeSearchQuery(tt.query)
if tt.expectError {
if err == nil {
t.Errorf("Expected error but got none")
return
}
if searchErr, ok := err.(*SearchError); ok {
if searchErr.Type != tt.errorType {
t.Errorf("Expected error type %s, got %s", tt.errorType, searchErr.Type)
}
} else {
t.Errorf("Expected SearchError, got %T", err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if result != tt.expectedResult {
t.Errorf("Expected result %q, got %q", tt.expectedResult, result)
}
}
})
}
}
func TestSearchSanitizer_ValidateSearchQuery(t *testing.T) {
sanitizer := NewSearchSanitizer()
tests := []struct {
name string
query string
expectError bool
errorType string
}{
{
name: "valid_query",
query: "golang programming",
expectError: false,
},
{
name: "empty_query",
query: "",
expectError: false,
},
{
name: "sql_injection_attempt",
query: "'; DROP TABLE users; --",
expectError: true,
errorType: "InvalidQuery",
},
{
name: "union_attack",
query: "test UNION SELECT * FROM users",
expectError: true,
errorType: "InvalidQuery",
},
{
name: "excessive_repetition",
query: "test test test test test",
expectError: true,
errorType: "DoSPattern",
},
{
name: "repeated_characters",
query: "aaaaaaaaaaaa",
expectError: true,
errorType: "DoSPattern",
},
{
name: "valid_with_special_chars",
query: "golang-programming_tutorial",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := sanitizer.ValidateSearchQuery(tt.query)
if tt.expectError {
if err == nil {
t.Errorf("Expected error but got none")
return
}
if searchErr, ok := err.(*SearchError); ok {
if searchErr.Type != tt.errorType {
t.Errorf("Expected error type %s, got %s", tt.errorType, searchErr.Type)
}
} else {
t.Errorf("Expected SearchError, got %T", err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
func TestSearchSanitizer_EdgeCases(t *testing.T) {
sanitizer := NewSearchSanitizer()
t.Run("unicode_characters", func(t *testing.T) {
query := "golang программирование 🚀"
result, err := sanitizer.SanitizeSearchQuery(query)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != query {
t.Errorf("Expected %q, got %q", query, result)
}
})
t.Run("mixed_case_sql_injection", func(t *testing.T) {
query := "test UnIoN SeLeCt * FrOm users"
err := sanitizer.ValidateSearchQuery(query)
if err == nil {
t.Errorf("Expected error for SQL injection attempt")
}
})
t.Run("normalize_whitespace_complex", func(t *testing.T) {
query := " \t\n golang \t\n programming \t\n "
result, err := sanitizer.SanitizeSearchQuery(query)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
expected := "golang programming"
if result != expected {
t.Errorf("Expected %q, got %q", expected, result)
}
})
}

View File

@@ -0,0 +1,306 @@
package repositories
import (
"fmt"
"strings"
"goyco/internal/database"
"goyco/internal/validation"
"gorm.io/gorm"
)
type UserRepository interface {
Create(user *database.User) error
GetByID(id uint) (*database.User, error)
GetByIDIncludingDeleted(id uint) (*database.User, error)
GetByUsername(username string) (*database.User, error)
GetByUsernameIncludingDeleted(username string) (*database.User, error)
GetByEmail(email string) (*database.User, error)
GetByVerificationToken(token string) (*database.User, error)
GetByPasswordResetToken(token string) (*database.User, error)
GetAll(limit, offset int) ([]database.User, error)
Update(user *database.User) error
Delete(id uint) error
HardDelete(id uint) error
SoftDeleteWithPosts(id uint) error
Lock(id uint) error
Unlock(id uint) error
GetPosts(userID uint, limit, offset int) ([]database.Post, error)
GetDeletedUsers() ([]database.User, error)
HardDeleteAll() (int64, error)
Count() (int64, error)
WithTx(tx *gorm.DB) UserRepository
}
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(user *database.User) error {
username := strings.TrimSpace(user.Username)
if username == "" {
return fmt.Errorf("username is required")
}
email := strings.TrimSpace(user.Email)
if email == "" {
return fmt.Errorf("email is required")
}
if err := validation.ValidateEmail(email); err != nil {
return err
}
normalizedEmail := strings.ToLower(email)
user.Email = normalizedEmail
user.Username = username
return r.db.Create(user).Error
}
func (r *userRepository) GetByID(id uint) (*database.User, error) {
var user database.User
err := r.db.Preload("Posts").First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetByIDIncludingDeleted(id uint) (*database.User, error) {
var user database.User
err := r.db.Unscoped().Preload("Posts").First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetByUsername(username string) (*database.User, error) {
var user database.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetByUsernameIncludingDeleted(username string) (*database.User, error) {
var user database.User
err := r.db.Unscoped().Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetByEmail(email string) (*database.User, error) {
var user database.User
err := r.db.Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetByVerificationToken(token string) (*database.User, error) {
var user database.User
err := r.db.Where("email_verification_token = ?", token).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetByPasswordResetToken(token string) (*database.User, error) {
var user database.User
err := r.db.Where("password_reset_token = ?", token).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) GetAll(limit, offset int) ([]database.User, error) {
var users []database.User
query := r.db.Order("created_at DESC")
query = ApplyPagination(query, limit, offset)
err := query.Find(&users).Error
return users, err
}
func (r *userRepository) Update(user *database.User) error {
if user == nil {
return fmt.Errorf("user is nil")
}
return r.db.Model(user).Select("*").Updates(user).Error
}
func (r *userRepository) Delete(id uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := deleteUserVotes(tx, id); err != nil {
return err
}
if err := tx.Model(&database.Post{}).Where("author_id = ?", id).Updates(map[string]any{
"author_id": nil,
"author_name": "(deleted)",
}).Error; err != nil {
return fmt.Errorf("update user posts: %w", err)
}
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&database.AccountDeletionRequest{}).Error; err != nil {
return fmt.Errorf("delete user deletion requests: %w", err)
}
if err := tx.Delete(&database.User{}, id).Error; err != nil {
return fmt.Errorf("delete user: %w", err)
}
return nil
})
}
func (r *userRepository) HardDelete(id uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := deleteUserVotes(tx, id); err != nil {
return err
}
if err := tx.Unscoped().Where("author_id = ?", id).Delete(&database.Post{}).Error; err != nil {
return fmt.Errorf("delete user posts: %w", err)
}
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&database.AccountDeletionRequest{}).Error; err != nil {
return fmt.Errorf("delete user deletion requests: %w", err)
}
if err := tx.Unscoped().Delete(&database.User{}, id).Error; err != nil {
return fmt.Errorf("delete user: %w", err)
}
return nil
})
}
func (r *userRepository) SoftDeleteWithPosts(id uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := deleteUserVotes(tx, id); err != nil {
return err
}
if err := tx.Unscoped().Model(&database.Post{}).Where("author_id = ?", id).Updates(map[string]any{
"author_id": nil,
"author_name": "(deleted)",
}).Error; err != nil {
return fmt.Errorf("update user posts: %w", err)
}
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&database.AccountDeletionRequest{}).Error; err != nil {
return fmt.Errorf("delete user deletion requests: %w", err)
}
if err := tx.Unscoped().Delete(&database.User{}, id).Error; err != nil {
return fmt.Errorf("delete user: %w", err)
}
return nil
})
}
func (r *userRepository) GetPosts(userID uint, limit, offset int) ([]database.Post, error) {
var posts []database.Post
query := r.db.Where("author_id = ?", userID).Preload("Author").Order("created_at DESC")
query = ApplyPagination(query, limit, offset)
err := query.Find(&posts).Error
return posts, err
}
func (r *userRepository) Lock(id uint) error {
return r.db.Model(&database.User{}).Where("id = ?", id).Update("locked", true).Error
}
func (r *userRepository) Unlock(id uint) error {
return r.db.Model(&database.User{}).Where("id = ?", id).Update("locked", false).Error
}
func (r *userRepository) GetDeletedUsers() ([]database.User, error) {
var users []database.User
err := r.db.Unscoped().
Where("deleted_at IS NOT NULL").
Find(&users).Error
return users, err
}
func (r *userRepository) HardDeleteAll() (int64, error) {
var totalDeleted int64
err := r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Unscoped().Where("1 = 1").Delete(&database.Vote{})
if result.Error != nil {
return fmt.Errorf("delete all votes: %w", result.Error)
}
totalDeleted += result.RowsAffected
result = tx.Unscoped().Where("1 = 1").Delete(&database.Post{})
if result.Error != nil {
return fmt.Errorf("delete all posts: %w", result.Error)
}
totalDeleted += result.RowsAffected
result = tx.Unscoped().Where("1 = 1").Delete(&database.AccountDeletionRequest{})
if result.Error != nil {
return fmt.Errorf("delete all account deletion requests: %w", result.Error)
}
totalDeleted += result.RowsAffected
result = tx.Unscoped().Where("1 = 1").Delete(&database.User{})
if result.Error != nil {
return fmt.Errorf("delete all users: %w", result.Error)
}
totalDeleted += result.RowsAffected
return nil
})
return totalDeleted, err
}
func (r *userRepository) Count() (int64, error) {
var count int64
err := r.db.Model(&database.User{}).Count(&count).Error
return count, err
}
func (r *userRepository) WithTx(tx *gorm.DB) UserRepository {
return &userRepository{db: tx}
}
func deleteUserVotes(tx *gorm.DB, userID uint) error {
if err := tx.Unscoped().Where("user_id = ?", userID).Delete(&database.Vote{}).Error; err != nil {
return fmt.Errorf("delete user votes: %w", err)
}
var posts []database.Post
if err := tx.Unscoped().Where("author_id = ?", userID).Find(&posts).Error; err != nil {
return fmt.Errorf("get user posts: %w", err)
}
if len(posts) > 0 {
postIDs := make([]uint, len(posts))
for i, post := range posts {
postIDs[i] = post.ID
}
if err := tx.Unscoped().Where("post_id IN (?)", postIDs).Delete(&database.Vote{}).Error; err != nil {
return fmt.Errorf("delete votes on user posts: %w", err)
}
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
package repositories
import (
"errors"
"gorm.io/gorm"
"goyco/internal/database"
)
type VoteRepository interface {
Create(vote *database.Vote) error
CreateOrUpdate(vote *database.Vote) error
GetByID(id uint) (*database.Vote, error)
GetByUserAndPost(userID, postID uint) (*database.Vote, error)
GetByVoteHash(voteHash string) (*database.Vote, error)
GetByPostID(postID uint) ([]database.Vote, error)
GetByUserID(userID uint) ([]database.Vote, error)
Update(vote *database.Vote) error
Delete(id uint) error
Count() (int64, error)
CountByPostID(postID uint) (int64, error)
CountByUserID(userID uint) (int64, error)
WithTx(tx *gorm.DB) VoteRepository
}
type voteRepository struct {
db *gorm.DB
}
func NewVoteRepository(db *gorm.DB) VoteRepository {
return &voteRepository{db: db}
}
func (r *voteRepository) Create(vote *database.Vote) error {
return r.db.Create(vote).Error
}
func (r *voteRepository) CreateOrUpdate(vote *database.Vote) error {
var existingVote *database.Vote
var err error
var lookupByUserID bool
if vote.UserID != nil {
existingVote, err = r.GetByUserAndPost(*vote.UserID, vote.PostID)
lookupByUserID = true
} else if vote.VoteHash != nil {
existingVote, err = r.GetByVoteHash(*vote.VoteHash)
lookupByUserID = false
} else {
return errors.New("vote must have either user_id or vote_hash")
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
err := r.Create(vote)
if err != nil && errors.Is(err, gorm.ErrDuplicatedKey) {
if lookupByUserID {
existingVote, err = r.GetByUserAndPost(*vote.UserID, vote.PostID)
} else {
existingVote, err = r.GetByVoteHash(*vote.VoteHash)
}
if err != nil {
return err
}
existingVote.Type = vote.Type
existingVote.UpdatedAt = vote.UpdatedAt
return r.Update(existingVote)
}
return err
}
return err
}
existingVote.Type = vote.Type
existingVote.UpdatedAt = vote.UpdatedAt
return r.Update(existingVote)
}
func (r *voteRepository) GetByID(id uint) (*database.Vote, error) {
var vote database.Vote
err := r.db.Preload("User").Preload("Post").First(&vote, id).Error
if err != nil {
return nil, err
}
return &vote, nil
}
func (r *voteRepository) GetByUserAndPost(userID, postID uint) (*database.Vote, error) {
var vote database.Vote
err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&vote).Error
if err != nil {
return nil, err
}
return &vote, nil
}
func (r *voteRepository) GetByVoteHash(voteHash string) (*database.Vote, error) {
var vote database.Vote
err := r.db.Where("vote_hash = ?", voteHash).First(&vote).Error
if err != nil {
return nil, err
}
return &vote, nil
}
func (r *voteRepository) GetByPostID(postID uint) ([]database.Vote, error) {
var votes []database.Vote
err := r.db.Where("post_id = ?", postID).Find(&votes).Error
return votes, err
}
func (r *voteRepository) GetByUserID(userID uint) ([]database.Vote, error) {
var votes []database.Vote
err := r.db.Where("user_id = ?", userID).Preload("Post").Find(&votes).Error
return votes, err
}
func (r *voteRepository) Update(vote *database.Vote) error {
return r.db.Save(vote).Error
}
func (r *voteRepository) Delete(id uint) error {
return r.db.Delete(&database.Vote{}, id).Error
}
func (r *voteRepository) CountByPostID(postID uint) (int64, error) {
var count int64
err := r.db.Model(&database.Vote{}).Where("post_id = ?", postID).Count(&count).Error
return count, err
}
func (r *voteRepository) CountByUserID(userID uint) (int64, error) {
var count int64
err := r.db.Model(&database.Vote{}).Where("user_id = ?", userID).Count(&count).Error
return count, err
}
func (r *voteRepository) WithTx(tx *gorm.DB) VoteRepository {
return &voteRepository{db: tx}
}
func (r *voteRepository) Count() (int64, error) {
var count int64
err := r.db.Model(&database.Vote{}).Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,781 @@
package repositories
import (
"sync"
"testing"
"gorm.io/gorm"
"goyco/internal/database"
)
func TestVoteRepository_Create(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful creation", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
if suite.GetVoteCount() != 1 {
t.Errorf("Expected 1 vote, got %d", suite.GetVoteCount())
}
})
t.Run("duplicate vote", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
vote2 := &database.Vote{
UserID: &user.ID,
PostID: post.ID,
Type: database.VoteDown,
}
err := suite.VoteRepo.Create(vote2)
if err == nil {
t.Error("Expected duplicate vote constraint error")
}
})
t.Run("vote with invalid user", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
vote := &database.Vote{
UserID: suite.CreateInvalidUserID(),
PostID: post.ID,
Type: database.VoteUp,
}
err := suite.VoteRepo.Create(vote)
if err != nil {
t.Errorf("Unexpected error for invalid user (SQLite allows this): %v", err)
}
})
t.Run("vote with invalid post", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
vote := &database.Vote{
UserID: &user.ID,
PostID: 999,
Type: database.VoteUp,
}
err := suite.VoteRepo.Create(vote)
if err != nil {
t.Errorf("Unexpected error for invalid post (SQLite allows this): %v", err)
}
})
}
func TestVoteRepository_GetByID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing vote", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
vote := suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
retrieved, err := suite.VoteRepo.GetByID(vote.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if retrieved == nil {
t.Fatal("Expected vote, got nil")
}
if retrieved.ID != vote.ID {
t.Errorf("Expected ID %d, got %d", vote.ID, retrieved.ID)
}
if vote.UserID == nil {
t.Fatal("Test setup error: vote.UserID is nil")
}
if retrieved.UserID == nil {
t.Fatalf("Expected user ID %d, got nil", *vote.UserID)
}
if *retrieved.UserID != *vote.UserID {
t.Errorf("Expected user ID %d, got %d", *vote.UserID, *retrieved.UserID)
}
if retrieved.PostID != vote.PostID {
t.Errorf("Expected post ID %d, got %d", vote.PostID, retrieved.PostID)
}
if retrieved.Type != vote.Type {
t.Errorf("Expected type %v, got %v", vote.Type, retrieved.Type)
}
})
t.Run("non-existing vote", func(t *testing.T) {
suite.Reset()
_, err := suite.VoteRepo.GetByID(999)
if err == nil {
t.Error("Expected error for non-existing vote")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
}
func TestVoteRepository_GetByUserAndPost(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing vote", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
retrieved, err := suite.VoteRepo.GetByUserAndPost(user.ID, post.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if retrieved == nil {
t.Fatal("Expected vote, got nil")
}
if retrieved.UserID == nil || *retrieved.UserID != user.ID {
t.Errorf("Expected user ID %d, got %d", user.ID, retrieved.UserID)
}
if retrieved.PostID != post.ID {
t.Errorf("Expected post ID %d, got %d", post.ID, retrieved.PostID)
}
if retrieved.Type != database.VoteUp {
t.Errorf("Expected type %v, got %v", database.VoteUp, retrieved.Type)
}
})
t.Run("non-existing vote", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
_, err := suite.VoteRepo.GetByUserAndPost(user.ID, post.ID)
if err == nil {
t.Error("Expected error for non-existing vote")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
}
func TestVoteRepository_GetByPostID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("post with votes", func(t *testing.T) {
suite.Reset()
user1 := suite.CreateTestUser("user1", "user1@example.com", "password123")
user2 := suite.CreateTestUser("user2", "user2@example.com", "password123")
post := suite.CreateTestPost(user1.ID, "Test Post", "https://example.com", "Test content")
suite.CreateTestVote(user1.ID, post.ID, database.VoteUp)
suite.CreateTestVote(user2.ID, post.ID, database.VoteDown)
retrieved, err := suite.VoteRepo.GetByPostID(post.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 votes, got %d", len(retrieved))
}
for _, vote := range retrieved {
if vote.PostID != post.ID {
t.Errorf("Expected post ID %d, got %d", post.ID, vote.PostID)
}
}
})
t.Run("post without votes", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
retrieved, err := suite.VoteRepo.GetByPostID(post.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 0 {
t.Errorf("Expected 0 votes, got %d", len(retrieved))
}
})
}
func TestVoteRepository_GetByUserID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("user with votes", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post1 := suite.CreateTestPost(user.ID, "Post 1", "https://example.com/1", "Content 1")
post2 := suite.CreateTestPost(user.ID, "Post 2", "https://example.com/2", "Content 2")
suite.CreateTestVote(user.ID, post1.ID, database.VoteUp)
suite.CreateTestVote(user.ID, post2.ID, database.VoteDown)
retrieved, err := suite.VoteRepo.GetByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 2 {
t.Errorf("Expected 2 votes, got %d", len(retrieved))
}
for _, vote := range retrieved {
if vote.UserID == nil || *vote.UserID != user.ID {
t.Errorf("Expected user ID %d, got %d", user.ID, vote.UserID)
}
}
})
t.Run("user without votes", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
retrieved, err := suite.VoteRepo.GetByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(retrieved) != 0 {
t.Errorf("Expected 0 votes, got %d", len(retrieved))
}
})
}
func TestVoteRepository_Update(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful update", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
vote := suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
vote.Type = database.VoteDown
err := suite.VoteRepo.Update(vote)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
retrieved, err := suite.VoteRepo.GetByID(vote.ID)
if err != nil {
t.Fatalf("Failed to retrieve vote: %v", err)
}
if retrieved.Type != database.VoteDown {
t.Errorf("Expected type %v, got %v", database.VoteDown, retrieved.Type)
}
})
}
func TestVoteRepository_Delete(t *testing.T) {
suite := NewTestSuite(t)
t.Run("successful delete", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
vote := suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
err := suite.VoteRepo.Delete(vote.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
_, err = suite.VoteRepo.GetByID(vote.ID)
if err == nil {
t.Error("Expected error for deleted vote")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
}
func TestVoteRepository_CountByPostID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("post with votes", func(t *testing.T) {
suite.Reset()
user1 := suite.CreateTestUser("user1", "user1@example.com", "password123")
user2 := suite.CreateTestUser("user2", "user2@example.com", "password123")
user3 := suite.CreateTestUser("user3", "user3@example.com", "password123")
post := suite.CreateTestPost(user1.ID, "Test Post", "https://example.com", "Test content")
suite.CreateTestVote(user1.ID, post.ID, database.VoteUp)
suite.CreateTestVote(user2.ID, post.ID, database.VoteDown)
suite.CreateTestVote(user3.ID, post.ID, database.VoteUp)
count, err := suite.VoteRepo.CountByPostID(post.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 3 {
t.Errorf("Expected 3 votes, got %d", count)
}
})
t.Run("post without votes", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
count, err := suite.VoteRepo.CountByPostID(post.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 0 {
t.Errorf("Expected 0 votes, got %d", count)
}
})
}
func TestVoteRepository_CountByUserID(t *testing.T) {
suite := NewTestSuite(t)
t.Run("user with votes", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post1 := suite.CreateTestPost(user.ID, "Post 1", "https://example.com/1", "Content 1")
post2 := suite.CreateTestPost(user.ID, "Post 2", "https://example.com/2", "Content 2")
suite.CreateTestVote(user.ID, post1.ID, database.VoteUp)
suite.CreateTestVote(user.ID, post2.ID, database.VoteDown)
count, err := suite.VoteRepo.CountByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 2 {
t.Errorf("Expected 2 votes, got %d", count)
}
})
t.Run("user without votes", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
count, err := suite.VoteRepo.CountByUserID(user.ID)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 0 {
t.Errorf("Expected 0 votes, got %d", count)
}
})
}
func TestVoteRepository_EdgeCases(t *testing.T) {
suite := NewTestSuite(t)
t.Run("invalid vote type", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
vote := &database.Vote{
UserID: &user.ID,
PostID: post.ID,
Type: "invalid",
}
err := suite.VoteRepo.Create(vote)
if err != nil {
t.Errorf("Unexpected error for invalid vote type: %v", err)
}
})
t.Run("zero user ID", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
vote := &database.Vote{
UserID: nil,
PostID: post.ID,
Type: database.VoteUp,
}
err := suite.VoteRepo.Create(vote)
if err != nil {
t.Errorf("Unexpected error for nil user ID: %v", err)
}
})
t.Run("zero post ID", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
vote := &database.Vote{
UserID: &user.ID,
PostID: 0,
Type: database.VoteUp,
}
err := suite.VoteRepo.Create(vote)
if err != nil {
t.Errorf("Unexpected error for zero post ID: %v", err)
}
})
}
func TestVoteRepository_CreateOrUpdate(t *testing.T) {
suite := NewTestSuite(t)
t.Run("create new vote", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
vote := &database.Vote{
UserID: &user.ID,
PostID: post.ID,
Type: database.VoteUp,
}
err := suite.VoteRepo.CreateOrUpdate(vote)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if vote.ID == 0 {
t.Error("Expected vote ID to be assigned")
}
})
t.Run("update existing vote", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
initialVote := suite.CreateTestVote(user.ID, post.ID, database.VoteUp)
updateVote := &database.Vote{
UserID: &user.ID,
PostID: post.ID,
Type: database.VoteDown,
}
err := suite.VoteRepo.CreateOrUpdate(updateVote)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
retrieved, err := suite.VoteRepo.GetByID(initialVote.ID)
if err != nil {
t.Fatalf("Failed to retrieve vote: %v", err)
}
if retrieved.Type != database.VoteDown {
t.Errorf("Expected vote type %v, got %v", database.VoteDown, retrieved.Type)
}
})
t.Run("create vote with vote hash", func(t *testing.T) {
suite.Reset()
post := suite.CreateTestPost(1, "Test Post", "https://example.com", "Test content")
vote := &database.Vote{
VoteHash: func() *string { s := "vote-hash-123"; return &s }(),
PostID: post.ID,
Type: database.VoteUp,
}
err := suite.VoteRepo.CreateOrUpdate(vote)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if vote.ID == 0 {
t.Error("Expected vote ID to be assigned")
}
})
t.Run("update vote with vote hash", func(t *testing.T) {
suite.Reset()
post := suite.CreateTestPost(1, "Test Post", "https://example.com", "Test content")
initialVote := &database.Vote{
VoteHash: func() *string { s := "vote-hash-123"; return &s }(),
PostID: post.ID,
Type: database.VoteUp,
}
err := suite.VoteRepo.Create(initialVote)
if err != nil {
t.Fatalf("Failed to create initial vote: %v", err)
}
updateVote := &database.Vote{
VoteHash: func() *string { s := "vote-hash-123"; return &s }(),
PostID: post.ID,
Type: database.VoteDown,
}
err = suite.VoteRepo.CreateOrUpdate(updateVote)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
retrieved, err := suite.VoteRepo.GetByVoteHash("vote-hash-123")
if err != nil {
t.Fatalf("Failed to retrieve vote: %v", err)
}
if retrieved.Type != database.VoteDown {
t.Errorf("Expected vote type %v, got %v", database.VoteDown, retrieved.Type)
}
})
t.Run("vote without user_id or vote_hash", func(t *testing.T) {
suite.Reset()
post := suite.CreateTestPost(1, "Test Post", "https://example.com", "Test content")
vote := &database.Vote{
PostID: post.ID,
Type: database.VoteUp,
}
err := suite.VoteRepo.CreateOrUpdate(vote)
if err == nil {
t.Error("Expected error for vote without user_id or vote_hash")
}
})
}
func TestVoteRepository_GetByVoteHash(t *testing.T) {
suite := NewTestSuite(t)
t.Run("existing vote with hash", func(t *testing.T) {
suite.Reset()
post := suite.CreateTestPost(1, "Test Post", "https://example.com", "Test content")
vote := &database.Vote{
VoteHash: func() *string { s := "vote-hash-123"; return &s }(),
PostID: post.ID,
Type: database.VoteUp,
}
err := suite.VoteRepo.Create(vote)
if err != nil {
t.Fatalf("Failed to create vote: %v", err)
}
retrieved, err := suite.VoteRepo.GetByVoteHash("vote-hash-123")
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if retrieved == nil {
t.Fatal("Expected vote, got nil")
}
if retrieved.VoteHash == nil || *retrieved.VoteHash != "vote-hash-123" {
t.Errorf("Expected vote hash 'vote-hash-123', got %v", retrieved.VoteHash)
}
if retrieved.PostID != post.ID {
t.Errorf("Expected post ID %d, got %d", post.ID, retrieved.PostID)
}
if retrieved.Type != database.VoteUp {
t.Errorf("Expected type %v, got %v", database.VoteUp, retrieved.Type)
}
})
t.Run("non-existing vote hash", func(t *testing.T) {
suite.Reset()
_, err := suite.VoteRepo.GetByVoteHash("nonexistent-hash")
if err == nil {
t.Error("Expected error for non-existing vote hash")
}
if err != gorm.ErrRecordNotFound {
t.Errorf("Expected ErrRecordNotFound, got %v", err)
}
})
}
func TestVoteRepository_WithTx(t *testing.T) {
suite := NewTestSuite(t)
t.Run("transaction repository", func(t *testing.T) {
suite.Reset()
user := suite.CreateTestUser("testuser", "test@example.com", "password123")
post := suite.CreateTestPost(user.ID, "Test Post", "https://example.com", "Test content")
tx := suite.DB.Begin()
defer tx.Rollback()
txVoteRepo := suite.VoteRepo.WithTx(tx)
vote := &database.Vote{
UserID: &user.ID,
PostID: post.ID,
Type: database.VoteUp,
}
err := txVoteRepo.Create(vote)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
retrieved, err := txVoteRepo.GetByID(vote.ID)
if err != nil {
t.Fatalf("Expected to find vote in transaction, got %v", err)
}
if retrieved.Type != database.VoteUp {
t.Errorf("Expected vote type %v, got %v", database.VoteUp, retrieved.Type)
}
})
}
func TestVoteRepository_Count(t *testing.T) {
suite := NewTestSuite(t)
t.Run("empty database", func(t *testing.T) {
suite.Reset()
count, err := suite.VoteRepo.Count()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 0 {
t.Errorf("Expected count 0, got %d", count)
}
})
t.Run("with votes", func(t *testing.T) {
suite.Reset()
user1 := suite.CreateTestUser("user1", "user1@example.com", "password123")
user2 := suite.CreateTestUser("user2", "user2@example.com", "password123")
post := suite.CreateTestPost(user1.ID, "Test Post", "https://example.com", "Test content")
suite.CreateTestVote(user1.ID, post.ID, database.VoteUp)
suite.CreateTestVote(user2.ID, post.ID, database.VoteDown)
count, err := suite.VoteRepo.Count()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if count != 2 {
t.Errorf("Expected count 2, got %d", count)
}
})
}
func TestVoteRepository_ConcurrentAccess(t *testing.T) {
suite := NewTestSuite(t)
t.Run("concurrent votes on same post with consistency check", func(t *testing.T) {
suite.Reset()
user1 := suite.CreateTestUser("user1", "user1@example.com", "password123")
user2 := suite.CreateTestUser("user2", "user2@example.com", "password123")
user3 := suite.CreateTestUser("user3", "user3@example.com", "password123")
post := suite.CreateTestPost(user1.ID, "Test Post", "https://example.com", "Test content")
var wg sync.WaitGroup
errors := make(chan error, 3)
wg.Add(3)
go func() {
defer wg.Done()
if err := suite.VoteRepo.Create(&database.Vote{UserID: &user1.ID, PostID: post.ID, Type: database.VoteUp}); err != nil {
errors <- err
}
}()
go func() {
defer wg.Done()
if err := suite.VoteRepo.Create(&database.Vote{UserID: &user2.ID, PostID: post.ID, Type: database.VoteDown}); err != nil {
errors <- err
}
}()
go func() {
defer wg.Done()
if err := suite.VoteRepo.Create(&database.Vote{UserID: &user3.ID, PostID: post.ID, Type: database.VoteUp}); err != nil {
errors <- err
}
}()
wg.Wait()
close(errors)
for err := range errors {
t.Errorf("Concurrent vote creation failed: %v", err)
}
count, err := suite.VoteRepo.CountByPostID(post.ID)
if err != nil {
t.Fatalf("Failed to count votes: %v", err)
}
if count != 3 {
t.Errorf("Expected 3 votes, got %d", count)
}
votes, err := suite.VoteRepo.GetByPostID(post.ID)
if err != nil {
t.Fatalf("Failed to get votes: %v", err)
}
if len(votes) != 3 {
t.Errorf("Expected 3 votes, got %d", len(votes))
}
uniqueUsers := make(map[uint]bool)
for _, vote := range votes {
if vote.UserID != nil {
if uniqueUsers[*vote.UserID] {
t.Errorf("Duplicate vote from user %d", *vote.UserID)
}
uniqueUsers[*vote.UserID] = true
}
}
})
}