Compare commits

...

7 Commits

10 changed files with 65 additions and 58 deletions

View File

@@ -56,16 +56,16 @@ func newProgressIndicatorWithClock(total int, description string, c clock) *Prog
func (p *ProgressIndicator) Update(current int) { func (p *ProgressIndicator) Update(current int) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock()
p.current = current p.current = current
now := p.clock.Now() now := p.clock.Now()
if now.Sub(p.lastUpdate) < 100*time.Millisecond { if now.Sub(p.lastUpdate) < 100*time.Millisecond {
p.mu.Unlock()
return return
} }
p.lastUpdate = now p.lastUpdate = now
p.mu.Unlock()
p.display() p.display()
} }

View File

@@ -44,15 +44,14 @@ func captureOutput(fn func()) string {
r, w, _ := os.Pipe() r, w, _ := os.Pipe()
os.Stdout = w os.Stdout = w
defer func() { fn()
_ = w.Close() _ = w.Close()
os.Stdout = old os.Stdout = old
}()
fn()
var buf bytes.Buffer var buf bytes.Buffer
_, _ = io.Copy(&buf, r) _, _ = io.Copy(&buf, r)
_ = r.Close()
return buf.String() return buf.String()
} }

View File

@@ -21,6 +21,8 @@ var (
seedRandOnce sync.Once seedRandOnce sync.Once
) )
const testPasswordHash = "test_password_hash"
func initSeedRand() { func initSeedRand() {
seedRandOnce.Do(func() { seedRandOnce.Do(func() {
seed := time.Now().UnixNano() seed := time.Now().UnixNano()
@@ -61,11 +63,11 @@ func TestSeedCommand(t *testing.T) {
seedUserCount := 0 seedUserCount := 0
var seedUser *database.User var seedUser *database.User
regularUserCount := 0 regularUserCount := 0
for i := range users { for idx := range users {
if users[i].Username == "seed_admin" { if users[idx].Username == seedUsername {
seedUserCount++ seedUserCount++
seedUser = &users[i] seedUser = &users[idx]
} else if strings.HasPrefix(users[i].Username, "user_") { } else if strings.HasPrefix(users[idx].Username, "user_") {
regularUserCount++ regularUserCount++
} }
} }
@@ -78,12 +80,12 @@ func TestSeedCommand(t *testing.T) {
t.Fatal("Expected seed user to be created") t.Fatal("Expected seed user to be created")
} }
if seedUser.Username != "seed_admin" { if seedUser.Username != seedUsername {
t.Errorf("Expected username to be 'seed_admin', got '%s'", seedUser.Username) t.Errorf("Expected username to be %q, got '%s'", seedUsername, seedUser.Username)
} }
if seedUser.Email != "seed_admin@goyco.local" { if seedUser.Email != seedEmail {
t.Errorf("Expected email to be 'seed_admin@goyco.local', got '%s'", seedUser.Email) t.Errorf("Expected email to be %q, got '%s'", seedEmail, seedUser.Email)
} }
if !seedUser.EmailVerified { if !seedUser.EmailVerified {
@@ -103,20 +105,20 @@ func TestSeedCommand(t *testing.T) {
t.Errorf("Expected 5 posts, got %d", len(posts)) t.Errorf("Expected 5 posts, got %d", len(posts))
} }
for i, post := range posts { for idx, post := range posts {
if post.Title == "" { if post.Title == "" {
t.Errorf("Post %d has empty title", i) t.Errorf("Post %d has empty title", idx)
} }
if post.URL == "" { 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 { 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 expectedScore := post.UpVotes - post.DownVotes
if post.Score != expectedScore { 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)
} }
} }
@@ -148,11 +150,12 @@ func TestSeedCommand(t *testing.T) {
} }
func TestGenerateRandomPath(t *testing.T) { func TestGenerateRandomPath(t *testing.T) {
const articlePathPrefix = "/article/"
initSeedRand() initSeedRand()
pathLength := seedRandSource.Intn(20) 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) randomChar := seedRandSource.Intn(26)
path += string(rune('a' + randomChar)) path += string(rune('a' + randomChar))
} }
@@ -167,13 +170,14 @@ func TestGenerateRandomPath(t *testing.T) {
initSeedRand() initSeedRand()
secondPathLength := seedRandSource.Intn(20) secondPathLength := seedRandSource.Intn(20)
secondPath := "/article/" var secondPath strings.Builder
for i := 0; i < secondPathLength+5; i++ { secondPath.WriteString(articlePathPrefix)
for idx := 0; idx < secondPathLength+5; idx++ {
randomChar := seedRandSource.Intn(26) 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") t.Error("Generated paths should be different")
} }
} }
@@ -333,7 +337,7 @@ func TestSeedCommandIdempotency(t *testing.T) {
seedUserCount := 0 seedUserCount := 0
for _, user := range users { for _, user := range users {
if user.Username == "seed_admin" { if user.Username == seedUsername {
seedUserCount++ seedUserCount++
} }
} }
@@ -369,10 +373,10 @@ func TestSeedCommandIdempotency(t *testing.T) {
}) })
t.Run("database remains consistent after multiple runs", func(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"}) err := seedDatabase(userRepo, postRepo, voteRepo, []string{"--users", "0", "--posts", "1"})
if err != nil { if err != nil {
t.Fatalf("Seed run %d failed: %v", i+1, err) t.Fatalf("Seed run %d failed: %v", idx+1, err)
} }
} }
@@ -417,9 +421,9 @@ func TestSeedCommandIdempotency(t *testing.T) {
} }
func findSeedUser(users []database.User) *database.User { func findSeedUser(users []database.User) *database.User {
for i := range users { for idx := range users {
if users[i].Username == "seed_admin" { if users[idx].Username == seedUsername {
return &users[i] return &users[idx]
} }
} }
return nil return nil
@@ -519,14 +523,14 @@ func TestEnsureSeedUser(t *testing.T) {
} }
userRepo := repositories.NewUserRepository(db) userRepo := repositories.NewUserRepository(db)
passwordHash := "test_password_hash" passwordHash := testPasswordHash
firstUser, err := ensureSeedUser(userRepo, passwordHash) firstUser, err := ensureSeedUser(userRepo, passwordHash)
if err != nil { if err != nil {
t.Fatalf("Failed to create seed user: %v", err) 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", t.Errorf("Invalid seed user: username=%s, email=%s, password matches=%v, emailVerified=%v",
firstUser.Username, firstUser.Email, firstUser.Password == passwordHash, firstUser.EmailVerified) firstUser.Username, firstUser.Email, firstUser.Password == passwordHash, firstUser.EmailVerified)
} }
@@ -540,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) 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 { 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)
} }
} }
@@ -553,7 +557,7 @@ func TestEnsureSeedUser(t *testing.T) {
seedUserCount := 0 seedUserCount := 0
for _, user := range users { for _, user := range users {
if user.Username == "seed_admin" { if user.Username == seedUsername {
seedUserCount++ seedUserCount++
} }
} }
@@ -565,7 +569,7 @@ func TestEnsureSeedUser(t *testing.T) {
func TestEnsureSeedUser_HandlesDatabaseErrors(t *testing.T) { func TestEnsureSeedUser_HandlesDatabaseErrors(t *testing.T) {
userRepo := testutils.NewMockUserRepository() userRepo := testutils.NewMockUserRepository()
passwordHash := "test_password_hash" passwordHash := testPasswordHash
dbError := fmt.Errorf("database connection failed") dbError := fmt.Errorf("database connection failed")

View File

@@ -1,5 +1,5 @@
// @title Goyco API // @title Goyco API
// @version 0.1.0 // @version 0.1.1
// @description Goyco is a Y Combinator-style news aggregation platform API. // @description Goyco is a Y Combinator-style news aggregation platform API.
// @contact.name Goyco Team // @contact.name Goyco Team
// @contact.email sandro@cazzaniga.fr // @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.Title = fmt.Sprintf("%s API", cfg.App.Title)
docs.SwaggerInfo.Description = "Y Combinator-style news board API." 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.BasePath = "/api"
docs.SwaggerInfo.Host = fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) docs.SwaggerInfo.Host = fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
docs.SwaggerInfo.Schemes = []string{"http"} docs.SwaggerInfo.Schemes = []string{"http"}

View File

@@ -2129,7 +2129,7 @@ const docTemplate = `{
// SwaggerInfo holds exported Swagger Info so clients can modify it // SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{ var SwaggerInfo = &swag.Spec{
Version: "0.1.0", Version: "0.1.1",
Host: "localhost:8080", Host: "localhost:8080",
BasePath: "/api", BasePath: "/api",
Schemes: []string{"http"}, Schemes: []string{"http"},

View File

@@ -14,7 +14,7 @@
"name": "GPLv3", "name": "GPLv3",
"url": "https://www.gnu.org/licenses/gpl-3.0.html" "url": "https://www.gnu.org/licenses/gpl-3.0.html"
}, },
"version": "0.1.0" "version": "0.1.1"
}, },
"host": "localhost:8080", "host": "localhost:8080",
"basePath": "/api", "basePath": "/api",

View File

@@ -221,7 +221,7 @@ info:
name: GPLv3 name: GPLv3
url: https://www.gnu.org/licenses/gpl-3.0.html url: https://www.gnu.org/licenses/gpl-3.0.html
title: Goyco API title: Goyco API
version: 0.1.0 version: 0.1.1
paths: paths:
/api: /api:
get: get:

View File

@@ -75,7 +75,7 @@ func (h *APIHandler) GetAPIInfo(w http.ResponseWriter, r *http.Request) {
apiInfo := map[string]any{ apiInfo := map[string]any{
"name": fmt.Sprintf("%s API", h.config.App.Title), "name": fmt.Sprintf("%s API", h.config.App.Title),
"version": version.Version, "version": version.GetVersion(),
"description": "Y Combinator-style news board API", "description": "Y Combinator-style news board API",
"endpoints": map[string]any{ "endpoints": map[string]any{
"authentication": 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 { if h.healthChecker != nil {
health := h.healthChecker.CheckHealth() health := h.healthChecker.CheckHealth()
health["version"] = version.Version health["version"] = version.GetVersion()
SendSuccessResponse(w, "Health check successful", health) SendSuccessResponse(w, "Health check successful", health)
return return
} }
@@ -155,7 +155,7 @@ func (h *APIHandler) GetHealth(w http.ResponseWriter, r *http.Request) {
health := map[string]any{ health := map[string]any{
"status": "healthy", "status": "healthy",
"timestamp": currentTimestamp, "timestamp": currentTimestamp,
"version": version.Version, "version": version.GetVersion(),
"services": map[string]any{ "services": map[string]any{
"database": "connected", "database": "connected",
"api": "running", "api": "running",
@@ -230,7 +230,7 @@ func (h *APIHandler) GetMetrics(w http.ResponseWriter, r *http.Request) {
}, },
"system": map[string]any{ "system": map[string]any{
"timestamp": time.Now().UTC().Format(time.RFC3339), "timestamp": time.Now().UTC().Format(time.RFC3339),
"version": version.Version, "version": version.GetVersion(),
}, },
} }

View File

@@ -1,3 +1,7 @@
package version package version
const Version = "0.1.0" const version = "0.1.1"
func GetVersion() string {
return version
}

View File

@@ -8,39 +8,39 @@ import (
func TestVersionSemver(t *testing.T) { 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-]+)*))?$`) 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) { if !semverRegex.MatchString(GetVersion()) {
t.Errorf("Version %q does not follow semantic versioning format (MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD])", Version) t.Errorf("Version %q does not follow semantic versioning format (MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD])", GetVersion())
} }
} }
func TestVersionSemverFlexible(t *testing.T) { 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-]+)*))?$`) 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) { if !flexibleSemverRegex.MatchString(GetVersion()) {
t.Errorf("Version %q does not follow semantic versioning format (MAJOR.MINOR[.PATCH][-PRERELEASE][+BUILD])", Version) t.Errorf("Version %q does not follow semantic versioning format (MAJOR.MINOR[.PATCH][-PRERELEASE][+BUILD])", GetVersion())
} }
} }
func TestVersionNotEmpty(t *testing.T) { func TestVersionNotEmpty(t *testing.T) {
if Version == "" { if GetVersion() == "" {
t.Error("Version should not be empty") t.Error("Version should not be empty")
} }
} }
func TestVersionFormat(t *testing.T) { func TestVersionFormat(t *testing.T) {
if !regexp.MustCompile(`\d+\.\d+`).MatchString(Version) { if !regexp.MustCompile(`\d+\.\d+`).MatchString(GetVersion()) {
t.Errorf("Version %q should contain at least MAJOR.MINOR format", Version) t.Errorf("Version %q should contain at least MAJOR.MINOR format", GetVersion())
} }
} }
func TestVersionStartsWithNumber(t *testing.T) { func TestVersionStartsWithNumber(t *testing.T) {
if !regexp.MustCompile(`^\d+`).MatchString(Version) { if !regexp.MustCompile(`^\d+`).MatchString(GetVersion()) {
t.Errorf("Version %q should start with a number", Version) t.Errorf("Version %q should start with a number", GetVersion())
} }
} }
func TestVersionNoLeadingZeros(t *testing.T) { func TestVersionNoLeadingZeros(t *testing.T) {
parts := regexp.MustCompile(`^(\d+)\.(\d+)`).FindStringSubmatch(Version) parts := regexp.MustCompile(`^(\d+)\.(\d+)`).FindStringSubmatch(GetVersion())
if len(parts) >= 3 { if len(parts) >= 3 {
major := parts[1] major := parts[1]
minor := parts[2] minor := parts[2]