Compare commits
9 Commits
1b53c2b66b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 65109a787c | |||
| 75f1406edf | |||
| 11dc9b507f | |||
| da616438e9 | |||
| 7486865343 | |||
| fd0fd8954a | |||
| 628db14f59 | |||
| 7be196e4c3 | |||
| 2f4bd45efb |
@@ -56,16 +56,16 @@ func newProgressIndicatorWithClock(total int, description string, c clock) *Prog
|
||||
|
||||
func (p *ProgressIndicator) Update(current int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.current = current
|
||||
now := p.clock.Now()
|
||||
|
||||
if now.Sub(p.lastUpdate) < 100*time.Millisecond {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
p.lastUpdate = now
|
||||
p.mu.Unlock()
|
||||
p.display()
|
||||
}
|
||||
|
||||
|
||||
@@ -44,15 +44,14 @@ func captureOutput(fn func()) string {
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
defer func() {
|
||||
fn()
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
_ = r.Close()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,29 +6,17 @@ import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"goyco/internal/config"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/repositories"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
seedRandSource *rand.Rand
|
||||
seedRandOnce sync.Once
|
||||
)
|
||||
|
||||
func initSeedRand() {
|
||||
seedRandOnce.Do(func() {
|
||||
seed := time.Now().UnixNano()
|
||||
seedRandSource = rand.New(rand.NewSource(seed))
|
||||
})
|
||||
}
|
||||
|
||||
func HandleSeedCommand(cfg *config.Config, name string, args []string) error {
|
||||
fs := newFlagSet(name, printSeedUsage)
|
||||
if err := parseCommand(fs, args, name); err != nil {
|
||||
@@ -39,11 +27,13 @@ func HandleSeedCommand(cfg *config.Config, name string, args []string) error {
|
||||
}
|
||||
|
||||
return withDatabase(cfg, func(db *gorm.DB) error {
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
postRepo := repositories.NewPostRepository(db)
|
||||
voteRepo := repositories.NewVoteRepository(db)
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
userRepo := repositories.NewUserRepository(db).WithTx(tx)
|
||||
postRepo := repositories.NewPostRepository(db).WithTx(tx)
|
||||
voteRepo := repositories.NewVoteRepository(db).WithTx(tx)
|
||||
return runSeedCommand(userRepo, postRepo, voteRepo, fs.Args())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func runSeedCommand(userRepo repositories.UserRepository, postRepo repositories.PostRepository, voteRepo repositories.VoteRepository, args []string) error {
|
||||
@@ -72,45 +62,37 @@ func printSeedUsage() {
|
||||
fmt.Fprintln(os.Stderr, " --votes-per-post: average votes per post (default: 15)")
|
||||
}
|
||||
|
||||
func clampFlagValue(value *int, min int, name string) {
|
||||
if *value < min {
|
||||
if !IsJSONOutput() {
|
||||
fmt.Fprintf(os.Stderr, "Warning: --%s value %d is too low, clamping to %d\n", name, *value, min)
|
||||
}
|
||||
*value = min
|
||||
}
|
||||
}
|
||||
|
||||
func seedDatabase(userRepo repositories.UserRepository, postRepo repositories.PostRepository, voteRepo repositories.VoteRepository, args []string) error {
|
||||
fs := flag.NewFlagSet("seed database", flag.ContinueOnError)
|
||||
numPosts := fs.Int("posts", 40, "number of posts to create")
|
||||
numUsers := fs.Int("users", 5, "number of additional users to create")
|
||||
votesPerPost := fs.Int("votes-per-post", 15, "average votes per post")
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(os.Stderr, "Usage: goyco seed database [--posts <n>] [--users <n>] [--votes-per-post <n>]")
|
||||
fmt.Fprintln(os.Stderr, "\nOptions:")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
originalUsers := *numUsers
|
||||
originalPosts := *numPosts
|
||||
originalVotesPerPost := *votesPerPost
|
||||
|
||||
if *numUsers < 0 {
|
||||
if !IsJSONOutput() {
|
||||
fmt.Fprintf(os.Stderr, "Warning: --users value %d is negative, clamping to 0\n", *numUsers)
|
||||
}
|
||||
*numUsers = 0
|
||||
}
|
||||
|
||||
if *numPosts <= 0 {
|
||||
if !IsJSONOutput() {
|
||||
fmt.Fprintf(os.Stderr, "Warning: --posts value %d is too low, clamping to 1\n", *numPosts)
|
||||
}
|
||||
*numPosts = 1
|
||||
}
|
||||
|
||||
if *votesPerPost < 0 {
|
||||
if !IsJSONOutput() {
|
||||
fmt.Fprintf(os.Stderr, "Warning: --votes-per-post value %d is negative, clamping to 0\n", *votesPerPost)
|
||||
}
|
||||
*votesPerPost = 0
|
||||
}
|
||||
|
||||
if !IsJSONOutput() && (originalUsers != *numUsers || originalPosts != *numPosts || originalVotesPerPost != *votesPerPost) {
|
||||
fmt.Fprintf(os.Stderr, "Using clamped values: --users=%d --posts=%d --votes-per-post=%d\n", *numUsers, *numPosts, *votesPerPost)
|
||||
}
|
||||
clampFlagValue(numUsers, 0, "users")
|
||||
clampFlagValue(numPosts, 1, "posts")
|
||||
clampFlagValue(votesPerPost, 0, "votes-per-post")
|
||||
|
||||
if !IsJSONOutput() {
|
||||
fmt.Println("Starting database seeding...")
|
||||
@@ -129,71 +111,35 @@ func seedDatabase(userRepo repositories.UserRepository, postRepo repositories.Po
|
||||
return fmt.Errorf("precompute user password hash: %w", err)
|
||||
}
|
||||
|
||||
spinner := NewSpinner("Creating seed user")
|
||||
if !IsJSONOutput() {
|
||||
spinner.Spin()
|
||||
}
|
||||
|
||||
seedUser, err := ensureSeedUser(userRepo, string(seedPasswordHash))
|
||||
if err != nil {
|
||||
if !IsJSONOutput() {
|
||||
spinner.Complete()
|
||||
}
|
||||
return fmt.Errorf("ensure seed user: %w", err)
|
||||
}
|
||||
if !IsJSONOutput() {
|
||||
spinner.Complete()
|
||||
fmt.Printf("Seed user ready: ID=%d Username=%s\n", seedUser.ID, seedUser.Username)
|
||||
}
|
||||
|
||||
processor := NewParallelProcessor()
|
||||
processor.SetPasswordHash(string(userPasswordHash))
|
||||
generator := newSeedGenerator(string(userPasswordHash))
|
||||
allUsers := []database.User{*seedUser}
|
||||
|
||||
var progress *ProgressIndicator
|
||||
if !IsJSONOutput() && *numUsers > 0 {
|
||||
progress = NewProgressIndicator(*numUsers, "Creating users (parallel)")
|
||||
}
|
||||
users, err := processor.CreateUsersInParallel(userRepo, *numUsers, progress)
|
||||
users, err := createUsers(generator, userRepo, *numUsers, "Creating users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create random users: %w", err)
|
||||
}
|
||||
if !IsJSONOutput() && progress != nil {
|
||||
progress.Complete()
|
||||
return err
|
||||
}
|
||||
allUsers = append(allUsers, users...)
|
||||
|
||||
allUsers := append([]database.User{*seedUser}, users...)
|
||||
|
||||
if !IsJSONOutput() && *numPosts > 0 {
|
||||
progress = NewProgressIndicator(*numPosts, "Creating posts (parallel)")
|
||||
}
|
||||
posts, err := processor.CreatePostsInParallel(postRepo, seedUser.ID, *numPosts, progress)
|
||||
posts, err := createPosts(generator, postRepo, seedUser.ID, *numPosts, "Creating posts")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create random posts: %w", err)
|
||||
}
|
||||
if !IsJSONOutput() && progress != nil {
|
||||
progress.Complete()
|
||||
return err
|
||||
}
|
||||
|
||||
if !IsJSONOutput() && len(posts) > 0 {
|
||||
progress = NewProgressIndicator(len(posts), "Creating votes (parallel)")
|
||||
}
|
||||
votes, err := processor.CreateVotesInParallel(voteRepo, allUsers, posts, *votesPerPost, progress)
|
||||
votes, err := createVotes(generator, voteRepo, allUsers, posts, *votesPerPost, "Creating votes")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create random votes: %w", err)
|
||||
}
|
||||
if !IsJSONOutput() && progress != nil {
|
||||
progress.Complete()
|
||||
return err
|
||||
}
|
||||
|
||||
if !IsJSONOutput() && len(posts) > 0 {
|
||||
progress = NewProgressIndicator(len(posts), "Updating scores (parallel)")
|
||||
}
|
||||
err = processor.UpdatePostScoresInParallel(postRepo, voteRepo, posts, progress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update post scores: %w", err)
|
||||
}
|
||||
if !IsJSONOutput() && progress != nil {
|
||||
progress.Complete()
|
||||
if err := updateScores(generator, postRepo, voteRepo, posts, "Updating scores"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateSeedConsistency(voteRepo, allUsers, posts); err != nil {
|
||||
@@ -225,11 +171,15 @@ const (
|
||||
)
|
||||
|
||||
func ensureSeedUser(userRepo repositories.UserRepository, passwordHash string) (*database.User, error) {
|
||||
if user, err := userRepo.GetByUsername(seedUsername); err == nil {
|
||||
user, err := userRepo.GetByUsername(seedUsername)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("failed to check if seed user exists: %w", err)
|
||||
}
|
||||
|
||||
user := &database.User{
|
||||
user = &database.User{
|
||||
Username: seedUsername,
|
||||
Email: seedEmail,
|
||||
Password: passwordHash,
|
||||
@@ -243,10 +193,6 @@ func ensureSeedUser(userRepo repositories.UserRepository, passwordHash string) (
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func getVoteCounts(voteRepo repositories.VoteRepository, postID uint) (int, int, error) {
|
||||
return voteRepo.GetVoteCountsByPostID(postID)
|
||||
}
|
||||
|
||||
func validateSeedConsistency(voteRepo repositories.VoteRepository, users []database.User, posts []database.Post) error {
|
||||
userIDSet := make(map[uint]struct{}, len(users))
|
||||
for _, user := range users {
|
||||
@@ -259,8 +205,11 @@ func validateSeedConsistency(voteRepo repositories.VoteRepository, users []datab
|
||||
}
|
||||
|
||||
for _, post := range posts {
|
||||
if err := validatePost(post, userIDSet); err != nil {
|
||||
return err
|
||||
if post.AuthorID == nil {
|
||||
return fmt.Errorf("post %d has no author ID", post.ID)
|
||||
}
|
||||
if _, exists := userIDSet[*post.AuthorID]; !exists {
|
||||
return fmt.Errorf("post %d references non-existent author ID %d", post.ID, *post.AuthorID)
|
||||
}
|
||||
|
||||
votes, err := voteRepo.GetByPostID(post.ID)
|
||||
@@ -268,46 +217,293 @@ func validateSeedConsistency(voteRepo repositories.VoteRepository, users []datab
|
||||
return fmt.Errorf("failed to retrieve votes for post %d: %w", post.ID, err)
|
||||
}
|
||||
|
||||
if err := validateVotesForPost(post.ID, votes, userIDSet, postIDSet); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePost(post database.Post, userIDSet map[uint]struct{}) error {
|
||||
if post.AuthorID == nil {
|
||||
return fmt.Errorf("post %d has no author ID", post.ID)
|
||||
}
|
||||
|
||||
if _, exists := userIDSet[*post.AuthorID]; !exists {
|
||||
return fmt.Errorf("post %d references non-existent author ID %d", post.ID, *post.AuthorID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateVotesForPost(postID uint, votes []database.Vote, userIDSet map[uint]struct{}, postIDSet map[uint]struct{}) error {
|
||||
for _, vote := range votes {
|
||||
if vote.PostID != postID {
|
||||
return fmt.Errorf("vote %d references post ID %d but was retrieved for post %d", vote.ID, vote.PostID, postID)
|
||||
if vote.PostID != post.ID {
|
||||
return fmt.Errorf("vote %d references post ID %d but was retrieved for post %d", vote.ID, vote.PostID, post.ID)
|
||||
}
|
||||
|
||||
if _, exists := postIDSet[vote.PostID]; !exists {
|
||||
return fmt.Errorf("vote %d references non-existent post ID %d", vote.ID, vote.PostID)
|
||||
}
|
||||
|
||||
if vote.UserID != nil {
|
||||
if _, exists := userIDSet[*vote.UserID]; !exists {
|
||||
return fmt.Errorf("vote %d references non-existent user ID %d", vote.ID, *vote.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
if vote.Type != database.VoteUp && vote.Type != database.VoteDown {
|
||||
return fmt.Errorf("vote %d has invalid type %q", vote.ID, vote.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type seedGenerator struct {
|
||||
passwordHash string
|
||||
randSource *rand.Rand
|
||||
}
|
||||
|
||||
func newSeedGenerator(passwordHash string) *seedGenerator {
|
||||
seed := time.Now().UnixNano()
|
||||
return &seedGenerator{
|
||||
passwordHash: passwordHash,
|
||||
randSource: rand.New(rand.NewSource(seed)),
|
||||
}
|
||||
}
|
||||
|
||||
func isRetryableError(err error, keywords ...string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(errMsg, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
|
||||
constraintLower := strings.ToLower(pqErr.Constraint)
|
||||
errMsgLower := strings.ToLower(pqErr.Message)
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(constraintLower, keyword) || strings.Contains(errMsgLower, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(errMsg, "duplicate") {
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(errMsg, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func createUsers(g *seedGenerator, userRepo repositories.UserRepository, count int, desc string) ([]database.User, error) {
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
progress := maybeProgress(count, desc)
|
||||
users := make([]database.User, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
user, err := g.createSingleUser(userRepo, i+1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create random user: %w", err)
|
||||
}
|
||||
users = append(users, user)
|
||||
if progress != nil {
|
||||
progress.Increment()
|
||||
}
|
||||
}
|
||||
if progress != nil {
|
||||
progress.Complete()
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func createPosts(g *seedGenerator, postRepo repositories.PostRepository, authorID uint, count int, desc string) ([]database.Post, error) {
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
progress := maybeProgress(count, desc)
|
||||
posts := make([]database.Post, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
post, err := g.createSinglePost(postRepo, authorID, i+1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create random post: %w", err)
|
||||
}
|
||||
posts = append(posts, post)
|
||||
if progress != nil {
|
||||
progress.Increment()
|
||||
}
|
||||
}
|
||||
if progress != nil {
|
||||
progress.Complete()
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func createVotes(g *seedGenerator, voteRepo repositories.VoteRepository, users []database.User, posts []database.Post, avgVotesPerPost int, desc string) (int, error) {
|
||||
if len(posts) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
progress := maybeProgress(len(posts), desc)
|
||||
votes := 0
|
||||
for _, post := range posts {
|
||||
count, err := g.createVotesForPost(voteRepo, users, post, avgVotesPerPost)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create random votes for post %d: %w", post.ID, err)
|
||||
}
|
||||
votes += count
|
||||
if progress != nil {
|
||||
progress.Increment()
|
||||
}
|
||||
}
|
||||
if progress != nil {
|
||||
progress.Complete()
|
||||
}
|
||||
return votes, nil
|
||||
}
|
||||
|
||||
func updateScores(g *seedGenerator, postRepo repositories.PostRepository, voteRepo repositories.VoteRepository, posts []database.Post, desc string) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
progress := maybeProgress(len(posts), desc)
|
||||
for _, post := range posts {
|
||||
if err := g.updateSinglePostScore(postRepo, voteRepo, post); err != nil {
|
||||
return fmt.Errorf("update post scores: %w", err)
|
||||
}
|
||||
if progress != nil {
|
||||
progress.Increment()
|
||||
}
|
||||
}
|
||||
if progress != nil {
|
||||
progress.Complete()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maybeProgress(count int, desc string) *ProgressIndicator {
|
||||
if !IsJSONOutput() && count > 0 {
|
||||
return NewProgressIndicator(count, desc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *seedGenerator) generateRandomIdentifier() string {
|
||||
const length = 12
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
identifier := make([]byte, length)
|
||||
for i := range identifier {
|
||||
identifier[i] = chars[g.randSource.Intn(len(chars))]
|
||||
}
|
||||
return string(identifier)
|
||||
}
|
||||
|
||||
func (g *seedGenerator) createSingleUser(userRepo repositories.UserRepository, index int) (database.User, error) {
|
||||
const maxRetries = 10
|
||||
var lastErr error
|
||||
for attempt := range maxRetries {
|
||||
randomID := g.generateRandomIdentifier()
|
||||
user := &database.User{
|
||||
Username: fmt.Sprintf("user_%s", randomID),
|
||||
Email: fmt.Sprintf("user_%s@goyco.local", randomID),
|
||||
Password: g.passwordHash,
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := userRepo.Create(user); err != nil {
|
||||
lastErr = err
|
||||
if !isRetryableError(err, "username", "email", "users_username_key", "users_email_key") {
|
||||
return database.User{}, fmt.Errorf("failed to create user (attempt %d/%d): %w", attempt+1, maxRetries, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return *user, nil
|
||||
}
|
||||
return database.User{}, fmt.Errorf("failed to create user after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
var (
|
||||
sampleTitles = []string{"Amazing JavaScript Framework", "Python Best Practices", "Go Performance Tips", "Database Optimization", "Web Security Guide", "Machine Learning Basics", "Cloud Architecture", "DevOps Automation", "API Design Patterns", "Frontend Optimization", "Backend Scaling", "Container Orchestration", "Microservices Architecture", "Testing Strategies", "Code Review Process", "Version Control Best Practices", "Continuous Integration", "Monitoring and Alerting", "Error Handling Patterns", "Data Structures Explained"}
|
||||
sampleDomains = []string{"example.com", "techblog.org", "devguide.net", "programming.io", "codeexamples.com", "tutorialhub.org", "bestpractices.dev", "learnprogramming.net", "codingtips.org", "softwareengineering.com"}
|
||||
)
|
||||
|
||||
func (g *seedGenerator) createSinglePost(postRepo repositories.PostRepository, authorID uint, index int) (database.Post, error) {
|
||||
title := sampleTitles[index%len(sampleTitles)]
|
||||
if index >= len(sampleTitles) {
|
||||
title = fmt.Sprintf("%s - Part %d", title, (index/len(sampleTitles))+1)
|
||||
}
|
||||
domain := sampleDomains[index%len(sampleDomains)]
|
||||
content := fmt.Sprintf("Autogenerated seed post #%d\n\nThis is sample content for testing purposes. The post discusses %s and provides valuable insights.", index, title)
|
||||
|
||||
const maxRetries = 10
|
||||
var lastErr error
|
||||
for attempt := range maxRetries {
|
||||
randomID := g.generateRandomIdentifier()
|
||||
post := &database.Post{
|
||||
Title: title,
|
||||
URL: fmt.Sprintf("https://%s/article/%s", domain, randomID),
|
||||
Content: content,
|
||||
AuthorID: &authorID,
|
||||
UpVotes: 0,
|
||||
DownVotes: 0,
|
||||
Score: 0,
|
||||
}
|
||||
if err := postRepo.Create(post); err != nil {
|
||||
lastErr = err
|
||||
if !isRetryableError(err, "url", "posts_url_key") {
|
||||
return database.Post{}, fmt.Errorf("failed to create post (attempt %d/%d): %w", attempt+1, maxRetries, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return *post, nil
|
||||
}
|
||||
return database.Post{}, fmt.Errorf("failed to create post after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func (g *seedGenerator) createVotesForPost(voteRepo repositories.VoteRepository, users []database.User, post database.Post, avgVotesPerPost int) (int, error) {
|
||||
numVotes := g.randSource.Intn(avgVotesPerPost*2 + 1)
|
||||
|
||||
if numVotes == 0 && avgVotesPerPost > 0 {
|
||||
if g.randSource.Intn(5) > 0 {
|
||||
numVotes = 1
|
||||
}
|
||||
}
|
||||
|
||||
totalVotes := 0
|
||||
usedUsers := make(map[uint]bool)
|
||||
|
||||
for i := 0; i < numVotes && len(usedUsers) < len(users); i++ {
|
||||
userIdx := g.randSource.Intn(len(users))
|
||||
user := users[userIdx]
|
||||
|
||||
if usedUsers[user.ID] {
|
||||
continue
|
||||
}
|
||||
usedUsers[user.ID] = true
|
||||
|
||||
voteTypeInt := g.randSource.Intn(10)
|
||||
var voteType database.VoteType
|
||||
if voteTypeInt < 7 {
|
||||
voteType = database.VoteUp
|
||||
} else {
|
||||
voteType = database.VoteDown
|
||||
}
|
||||
|
||||
vote := &database.Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post.ID,
|
||||
Type: voteType,
|
||||
}
|
||||
|
||||
if err := voteRepo.CreateOrUpdate(vote); err != nil {
|
||||
return totalVotes, fmt.Errorf("create or update vote: %w", err)
|
||||
}
|
||||
|
||||
totalVotes++
|
||||
}
|
||||
|
||||
return totalVotes, nil
|
||||
}
|
||||
|
||||
func (g *seedGenerator) updateSinglePostScore(postRepo repositories.PostRepository, voteRepo repositories.VoteRepository, post database.Post) error {
|
||||
upVotes, downVotes, err := voteRepo.GetVoteCountsByPostID(post.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get vote counts: %w", err)
|
||||
}
|
||||
post.UpVotes = upVotes
|
||||
post.DownVotes = downVotes
|
||||
post.Score = upVotes - downVotes
|
||||
return postRepo.Update(&post)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/repositories"
|
||||
@@ -13,6 +16,20 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
seedRandSource *rand.Rand
|
||||
seedRandOnce sync.Once
|
||||
)
|
||||
|
||||
const testPasswordHash = "test_password_hash"
|
||||
|
||||
func initSeedRand() {
|
||||
seedRandOnce.Do(func() {
|
||||
seed := time.Now().UnixNano()
|
||||
seedRandSource = rand.New(rand.NewSource(seed))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSeedCommand(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
@@ -46,11 +63,11 @@ func TestSeedCommand(t *testing.T) {
|
||||
seedUserCount := 0
|
||||
var seedUser *database.User
|
||||
regularUserCount := 0
|
||||
for i := range users {
|
||||
if users[i].Username == "seed_admin" {
|
||||
for idx := range users {
|
||||
if users[idx].Username == seedUsername {
|
||||
seedUserCount++
|
||||
seedUser = &users[i]
|
||||
} else if strings.HasPrefix(users[i].Username, "user_") {
|
||||
seedUser = &users[idx]
|
||||
} else if strings.HasPrefix(users[idx].Username, "user_") {
|
||||
regularUserCount++
|
||||
}
|
||||
}
|
||||
@@ -63,12 +80,12 @@ func TestSeedCommand(t *testing.T) {
|
||||
t.Fatal("Expected seed user to be created")
|
||||
}
|
||||
|
||||
if seedUser.Username != "seed_admin" {
|
||||
t.Errorf("Expected username to be 'seed_admin', got '%s'", seedUser.Username)
|
||||
if seedUser.Username != seedUsername {
|
||||
t.Errorf("Expected username to be %q, got '%s'", seedUsername, seedUser.Username)
|
||||
}
|
||||
|
||||
if seedUser.Email != "seed_admin@goyco.local" {
|
||||
t.Errorf("Expected email to be 'seed_admin@goyco.local', got '%s'", seedUser.Email)
|
||||
if seedUser.Email != seedEmail {
|
||||
t.Errorf("Expected email to be %q, got '%s'", seedEmail, seedUser.Email)
|
||||
}
|
||||
|
||||
if !seedUser.EmailVerified {
|
||||
@@ -88,20 +105,20 @@ func TestSeedCommand(t *testing.T) {
|
||||
t.Errorf("Expected 5 posts, got %d", len(posts))
|
||||
}
|
||||
|
||||
for i, post := range posts {
|
||||
for idx, post := range posts {
|
||||
if post.Title == "" {
|
||||
t.Errorf("Post %d has empty title", i)
|
||||
t.Errorf("Post %d has empty title", idx)
|
||||
}
|
||||
if post.URL == "" {
|
||||
t.Errorf("Post %d has empty URL", i)
|
||||
t.Errorf("Post %d has empty URL", idx)
|
||||
}
|
||||
if post.AuthorID == nil || *post.AuthorID != seedUser.ID {
|
||||
t.Errorf("Post %d has wrong author ID: expected %d, got %v", i, seedUser.ID, post.AuthorID)
|
||||
t.Errorf("Post %d has wrong author ID: expected %d, got %v", idx, seedUser.ID, post.AuthorID)
|
||||
}
|
||||
|
||||
expectedScore := post.UpVotes - post.DownVotes
|
||||
if post.Score != expectedScore {
|
||||
t.Errorf("Post %d has incorrect score: expected %d, got %d", i, expectedScore, post.Score)
|
||||
t.Errorf("Post %d has incorrect score: expected %d, got %d", idx, expectedScore, post.Score)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,11 +150,12 @@ func TestSeedCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateRandomPath(t *testing.T) {
|
||||
const articlePathPrefix = "/article/"
|
||||
initSeedRand()
|
||||
pathLength := seedRandSource.Intn(20)
|
||||
path := "/article/"
|
||||
path := articlePathPrefix
|
||||
|
||||
for i := 0; i < pathLength+5; i++ {
|
||||
for idx := 0; idx < pathLength+5; idx++ {
|
||||
randomChar := seedRandSource.Intn(26)
|
||||
path += string(rune('a' + randomChar))
|
||||
}
|
||||
@@ -152,13 +170,14 @@ func TestGenerateRandomPath(t *testing.T) {
|
||||
|
||||
initSeedRand()
|
||||
secondPathLength := seedRandSource.Intn(20)
|
||||
secondPath := "/article/"
|
||||
for i := 0; i < secondPathLength+5; i++ {
|
||||
var secondPath strings.Builder
|
||||
secondPath.WriteString(articlePathPrefix)
|
||||
for idx := 0; idx < secondPathLength+5; idx++ {
|
||||
randomChar := seedRandSource.Intn(26)
|
||||
secondPath += string(rune('a' + randomChar))
|
||||
secondPath.WriteString(string(rune('a' + randomChar)))
|
||||
}
|
||||
|
||||
if path == secondPath {
|
||||
if path == secondPath.String() {
|
||||
t.Error("Generated paths should be different")
|
||||
}
|
||||
}
|
||||
@@ -271,6 +290,22 @@ func TestSeedDatabaseFlagParsing(t *testing.T) {
|
||||
t.Errorf("zero votes-per-post should be valid, got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("help flag returns no error", func(t *testing.T) {
|
||||
err := seedDatabase(userRepo, postRepo, voteRepo, []string{"--help"})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("help flag should return no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("short help flag returns no error", func(t *testing.T) {
|
||||
err := seedDatabase(userRepo, postRepo, voteRepo, []string{"-h"})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("short help flag should return no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSeedCommandIdempotency(t *testing.T) {
|
||||
@@ -302,7 +337,7 @@ func TestSeedCommandIdempotency(t *testing.T) {
|
||||
|
||||
seedUserCount := 0
|
||||
for _, user := range users {
|
||||
if user.Username == "seed_admin" {
|
||||
if user.Username == seedUsername {
|
||||
seedUserCount++
|
||||
}
|
||||
}
|
||||
@@ -338,10 +373,10 @@ func TestSeedCommandIdempotency(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("database remains consistent after multiple runs", func(t *testing.T) {
|
||||
for i := range 2 {
|
||||
for idx := range 2 {
|
||||
err := seedDatabase(userRepo, postRepo, voteRepo, []string{"--users", "0", "--posts", "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Seed run %d failed: %v", i+1, err)
|
||||
t.Fatalf("Seed run %d failed: %v", idx+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,9 +421,9 @@ func TestSeedCommandIdempotency(t *testing.T) {
|
||||
}
|
||||
|
||||
func findSeedUser(users []database.User) *database.User {
|
||||
for i := range users {
|
||||
if users[i].Username == "seed_admin" {
|
||||
return &users[i]
|
||||
for idx := range users {
|
||||
if users[idx].Username == seedUsername {
|
||||
return &users[idx]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -488,14 +523,14 @@ func TestEnsureSeedUser(t *testing.T) {
|
||||
}
|
||||
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
passwordHash := "test_password_hash"
|
||||
passwordHash := testPasswordHash
|
||||
|
||||
firstUser, err := ensureSeedUser(userRepo, passwordHash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create seed user: %v", err)
|
||||
}
|
||||
|
||||
if firstUser.Username != "seed_admin" || firstUser.Email != "seed_admin@goyco.local" || firstUser.Password != passwordHash || !firstUser.EmailVerified {
|
||||
if firstUser.Username != seedUsername || firstUser.Email != seedEmail || firstUser.Password != passwordHash || !firstUser.EmailVerified {
|
||||
t.Errorf("Invalid seed user: username=%s, email=%s, password matches=%v, emailVerified=%v",
|
||||
firstUser.Username, firstUser.Email, firstUser.Password == passwordHash, firstUser.EmailVerified)
|
||||
}
|
||||
@@ -509,9 +544,9 @@ func TestEnsureSeedUser(t *testing.T) {
|
||||
t.Errorf("Expected same user to be reused (ID %d), got different user (ID %d)", firstUser.ID, secondUser.ID)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
for idx := range 3 {
|
||||
if _, err := ensureSeedUser(userRepo, passwordHash); err != nil {
|
||||
t.Fatalf("Call %d failed: %v", i+1, err)
|
||||
t.Fatalf("Call %d failed: %v", idx+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,7 +557,7 @@ func TestEnsureSeedUser(t *testing.T) {
|
||||
|
||||
seedUserCount := 0
|
||||
for _, user := range users {
|
||||
if user.Username == "seed_admin" {
|
||||
if user.Username == seedUsername {
|
||||
seedUserCount++
|
||||
}
|
||||
}
|
||||
@@ -531,3 +566,25 @@ func TestEnsureSeedUser(t *testing.T) {
|
||||
t.Errorf("Expected exactly 1 seed user, got %d", seedUserCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSeedUser_HandlesDatabaseErrors(t *testing.T) {
|
||||
userRepo := testutils.NewMockUserRepository()
|
||||
passwordHash := testPasswordHash
|
||||
|
||||
dbError := fmt.Errorf("database connection failed")
|
||||
|
||||
userRepo.SetGetByUsernameError(dbError)
|
||||
|
||||
_, err := ensureSeedUser(userRepo, passwordHash)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when GetByUsername returns database error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "failed to check if seed user exists") {
|
||||
t.Errorf("Expected error message about checking seed user, got: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), dbError.Error()) {
|
||||
t.Errorf("Expected error to wrap original database error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @title Goyco API
|
||||
// @version 0.1.0
|
||||
// @version 0.1.1
|
||||
// @description Goyco is a Y Combinator-style news aggregation platform API.
|
||||
// @contact.name Goyco Team
|
||||
// @contact.email sandro@cazzaniga.fr
|
||||
@@ -55,7 +55,7 @@ func run(args []string) error {
|
||||
|
||||
docs.SwaggerInfo.Title = fmt.Sprintf("%s API", cfg.App.Title)
|
||||
docs.SwaggerInfo.Description = "Y Combinator-style news board API."
|
||||
docs.SwaggerInfo.Version = version.Version
|
||||
docs.SwaggerInfo.Version = version.GetVersion()
|
||||
docs.SwaggerInfo.BasePath = "/api"
|
||||
docs.SwaggerInfo.Host = fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
|
||||
docs.SwaggerInfo.Schemes = []string{"http"}
|
||||
|
||||
@@ -2129,7 +2129,7 @@ const docTemplate = `{
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "0.1.0",
|
||||
Version: "0.1.1",
|
||||
Host: "localhost:8080",
|
||||
BasePath: "/api",
|
||||
Schemes: []string{"http"},
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"name": "GPLv3",
|
||||
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
||||
},
|
||||
"version": "0.1.0"
|
||||
"version": "0.1.1"
|
||||
},
|
||||
"host": "localhost:8080",
|
||||
"basePath": "/api",
|
||||
|
||||
@@ -221,7 +221,7 @@ info:
|
||||
name: GPLv3
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
title: Goyco API
|
||||
version: 0.1.0
|
||||
version: 0.1.1
|
||||
paths:
|
||||
/api:
|
||||
get:
|
||||
|
||||
@@ -75,7 +75,7 @@ func (h *APIHandler) GetAPIInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
apiInfo := map[string]any{
|
||||
"name": fmt.Sprintf("%s API", h.config.App.Title),
|
||||
"version": version.Version,
|
||||
"version": version.GetVersion(),
|
||||
"description": "Y Combinator-style news board API",
|
||||
"endpoints": map[string]any{
|
||||
"authentication": map[string]any{
|
||||
@@ -145,7 +145,7 @@ func (h *APIHandler) GetHealth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if h.healthChecker != nil {
|
||||
health := h.healthChecker.CheckHealth()
|
||||
health["version"] = version.Version
|
||||
health["version"] = version.GetVersion()
|
||||
SendSuccessResponse(w, "Health check successful", health)
|
||||
return
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func (h *APIHandler) GetHealth(w http.ResponseWriter, r *http.Request) {
|
||||
health := map[string]any{
|
||||
"status": "healthy",
|
||||
"timestamp": currentTimestamp,
|
||||
"version": version.Version,
|
||||
"version": version.GetVersion(),
|
||||
"services": map[string]any{
|
||||
"database": "connected",
|
||||
"api": "running",
|
||||
@@ -230,7 +230,7 @@ func (h *APIHandler) GetMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
"system": map[string]any{
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"version": version.Version,
|
||||
"version": version.GetVersion(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
package version
|
||||
|
||||
const Version = "0.1.0"
|
||||
const version = "0.1.1"
|
||||
|
||||
func GetVersion() string {
|
||||
return version
|
||||
}
|
||||
|
||||
@@ -8,39 +8,39 @@ import (
|
||||
func TestVersionSemver(t *testing.T) {
|
||||
semverRegex := regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)
|
||||
|
||||
if !semverRegex.MatchString(Version) {
|
||||
t.Errorf("Version %q does not follow semantic versioning format (MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD])", Version)
|
||||
if !semverRegex.MatchString(GetVersion()) {
|
||||
t.Errorf("Version %q does not follow semantic versioning format (MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD])", GetVersion())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionSemverFlexible(t *testing.T) {
|
||||
flexibleSemverRegex := regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)
|
||||
|
||||
if !flexibleSemverRegex.MatchString(Version) {
|
||||
t.Errorf("Version %q does not follow semantic versioning format (MAJOR.MINOR[.PATCH][-PRERELEASE][+BUILD])", Version)
|
||||
if !flexibleSemverRegex.MatchString(GetVersion()) {
|
||||
t.Errorf("Version %q does not follow semantic versioning format (MAJOR.MINOR[.PATCH][-PRERELEASE][+BUILD])", GetVersion())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionNotEmpty(t *testing.T) {
|
||||
if Version == "" {
|
||||
if GetVersion() == "" {
|
||||
t.Error("Version should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionFormat(t *testing.T) {
|
||||
if !regexp.MustCompile(`\d+\.\d+`).MatchString(Version) {
|
||||
t.Errorf("Version %q should contain at least MAJOR.MINOR format", Version)
|
||||
if !regexp.MustCompile(`\d+\.\d+`).MatchString(GetVersion()) {
|
||||
t.Errorf("Version %q should contain at least MAJOR.MINOR format", GetVersion())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionStartsWithNumber(t *testing.T) {
|
||||
if !regexp.MustCompile(`^\d+`).MatchString(Version) {
|
||||
t.Errorf("Version %q should start with a number", Version)
|
||||
if !regexp.MustCompile(`^\d+`).MatchString(GetVersion()) {
|
||||
t.Errorf("Version %q should start with a number", GetVersion())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionNoLeadingZeros(t *testing.T) {
|
||||
parts := regexp.MustCompile(`^(\d+)\.(\d+)`).FindStringSubmatch(Version)
|
||||
parts := regexp.MustCompile(`^(\d+)\.(\d+)`).FindStringSubmatch(GetVersion())
|
||||
if len(parts) >= 3 {
|
||||
major := parts[1]
|
||||
minor := parts[2]
|
||||
|
||||
Reference in New Issue
Block a user