From 9997aed18af7dede6128f530756708bc24385795 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Sun, 21 Dec 2025 23:11:33 -0300 Subject: [PATCH] fix(backend): fix build errors, update tests, and improve documentation - Add GetUserByEmail to Repository interface for password reset flow - Add username to UpdateUser query - Fix config_test.go: remove references to deleted DB pool fields - Fix handler_test.go: add GetUserByUsername to MockRepository - Fix usecase_test.go: add GetUserByUsername and update auth tests - Update backend README with auth and admin seeding info - Create seeder-api README with usage and warnings --- backend/README.md | 35 +++++- backend/internal/config/config_test.go | 73 ++++++------ backend/internal/http/handler/handler_test.go | 11 +- .../internal/repository/postgres/postgres.go | 11 +- backend/internal/usecase/usecase.go | 1 + backend/internal/usecase/usecase_test.go | 15 ++- seeder-api/README.md | 112 ++++++++++++++++++ 7 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 seeder-api/README.md diff --git a/backend/README.md b/backend/README.md index 48bbfee..2610dbb 100644 --- a/backend/README.md +++ b/backend/README.md @@ -83,8 +83,41 @@ backend/ ### Variáveis de Ambiente ```bash +# Banco de Dados DATABASE_URL=postgres://user:password@localhost:5432/saveinmed?sslmode=disable -PORT=8080 + +# Servidor +BACKEND_PORT=8214 + +# Autenticação +JWT_SECRET=your-secret-key +JWT_EXPIRES_IN=24h +PASSWORD_PEPPER=your-pepper + +# Admin Seeding (criado automaticamente na inicialização) +ADMIN_NAME=Administrator +ADMIN_USERNAME=admin +ADMIN_EMAIL=admin@saveinmed.com +ADMIN_PASSWORD=admin123 + +# CORS +CORS_ORIGINS=* + +# Mercado Pago +MERCADOPAGO_BASE_URL=https://api.mercadopago.com +MARKETPLACE_COMMISSION=2.5 +``` + +### Autenticação + +A autenticação utiliza **username** (não email) para login: + +```json +POST /api/v1/auth/login +{ + "username": "admin", + "password": "admin123" +} ``` ### Pré-requisitos diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 92384e4..6f1b455 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -9,10 +9,9 @@ import ( func TestLoadDefaults(t *testing.T) { // Clear any environment variables that might interfere envVars := []string{ - "APP_NAME", "BACKEND_PORT", "DATABASE_URL", "DB_MAX_OPEN_CONNS", - "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_IDLE", "MERCADOPAGO_BASE_URL", - "MARKETPLACE_COMMISSION", "JWT_SECRET", "JWT_EXPIRES_IN", - "PASSWORD_PEPPER", "CORS_ORIGINS", + "APP_NAME", "BACKEND_PORT", "DATABASE_URL", + "MERCADOPAGO_BASE_URL", "MARKETPLACE_COMMISSION", "JWT_SECRET", "JWT_EXPIRES_IN", + "PASSWORD_PEPPER", "CORS_ORIGINS", "ADMIN_NAME", "ADMIN_USERNAME", "ADMIN_EMAIL", "ADMIN_PASSWORD", } origEnvs := make(map[string]string) for _, key := range envVars { @@ -35,15 +34,6 @@ func TestLoadDefaults(t *testing.T) { if cfg.Port != "8214" { t.Errorf("expected Port '8214', got '%s'", cfg.Port) } - if cfg.MaxOpenConns != 15 { - t.Errorf("expected MaxOpenConns 15, got %d", cfg.MaxOpenConns) - } - if cfg.MaxIdleConns != 5 { - t.Errorf("expected MaxIdleConns 5, got %d", cfg.MaxIdleConns) - } - if cfg.ConnMaxIdle != 5*time.Minute { - t.Errorf("expected ConnMaxIdle 5m, got %v", cfg.ConnMaxIdle) - } if cfg.JWTSecret != "dev-secret" { t.Errorf("expected JWTSecret 'dev-secret', got '%s'", cfg.JWTSecret) } @@ -56,33 +46,47 @@ func TestLoadDefaults(t *testing.T) { if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "*" { t.Errorf("expected CORSOrigins ['*'], got %v", cfg.CORSOrigins) } + if cfg.AdminName != "Administrator" { + t.Errorf("expected AdminName 'Administrator', got '%s'", cfg.AdminName) + } + if cfg.AdminUsername != "admin" { + t.Errorf("expected AdminUsername 'admin', got '%s'", cfg.AdminUsername) + } + if cfg.AdminEmail != "admin@saveinmed.com" { + t.Errorf("expected AdminEmail 'admin@saveinmed.com', got '%s'", cfg.AdminEmail) + } + if cfg.AdminPassword != "admin123" { + t.Errorf("expected AdminPassword 'admin123', got '%s'", cfg.AdminPassword) + } } func TestLoadFromEnv(t *testing.T) { os.Setenv("APP_NAME", "test-app") os.Setenv("BACKEND_PORT", "9999") os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test") - os.Setenv("DB_MAX_OPEN_CONNS", "100") - os.Setenv("DB_MAX_IDLE_CONNS", "50") - os.Setenv("DB_CONN_MAX_IDLE", "10m") os.Setenv("MARKETPLACE_COMMISSION", "5.0") os.Setenv("JWT_SECRET", "super-secret") os.Setenv("JWT_EXPIRES_IN", "12h") os.Setenv("PASSWORD_PEPPER", "pepper123") os.Setenv("CORS_ORIGINS", "https://example.com,https://app.example.com") + os.Setenv("ADMIN_NAME", "CustomAdmin") + os.Setenv("ADMIN_USERNAME", "customadmin") + os.Setenv("ADMIN_EMAIL", "custom@example.com") + os.Setenv("ADMIN_PASSWORD", "securepass") defer func() { os.Unsetenv("APP_NAME") os.Unsetenv("BACKEND_PORT") os.Unsetenv("DATABASE_URL") - os.Unsetenv("DB_MAX_OPEN_CONNS") - os.Unsetenv("DB_MAX_IDLE_CONNS") - os.Unsetenv("DB_CONN_MAX_IDLE") os.Unsetenv("MARKETPLACE_COMMISSION") os.Unsetenv("JWT_SECRET") os.Unsetenv("JWT_EXPIRES_IN") os.Unsetenv("PASSWORD_PEPPER") os.Unsetenv("CORS_ORIGINS") + os.Unsetenv("ADMIN_NAME") + os.Unsetenv("ADMIN_USERNAME") + os.Unsetenv("ADMIN_EMAIL") + os.Unsetenv("ADMIN_PASSWORD") }() cfg := Load() @@ -96,15 +100,6 @@ func TestLoadFromEnv(t *testing.T) { if cfg.DatabaseURL != "postgres://test:test@localhost:5432/test" { t.Errorf("expected custom DatabaseURL, got '%s'", cfg.DatabaseURL) } - if cfg.MaxOpenConns != 100 { - t.Errorf("expected MaxOpenConns 100, got %d", cfg.MaxOpenConns) - } - if cfg.MaxIdleConns != 50 { - t.Errorf("expected MaxIdleConns 50, got %d", cfg.MaxIdleConns) - } - if cfg.ConnMaxIdle != 10*time.Minute { - t.Errorf("expected ConnMaxIdle 10m, got %v", cfg.ConnMaxIdle) - } if cfg.MarketplaceCommission != 5.0 { t.Errorf("expected MarketplaceCommission 5.0, got %f", cfg.MarketplaceCommission) } @@ -120,6 +115,18 @@ func TestLoadFromEnv(t *testing.T) { if len(cfg.CORSOrigins) != 2 { t.Errorf("expected 2 CORS origins, got %d", len(cfg.CORSOrigins)) } + if cfg.AdminName != "CustomAdmin" { + t.Errorf("expected AdminName 'CustomAdmin', got '%s'", cfg.AdminName) + } + if cfg.AdminUsername != "customadmin" { + t.Errorf("expected AdminUsername 'customadmin', got '%s'", cfg.AdminUsername) + } + if cfg.AdminEmail != "custom@example.com" { + t.Errorf("expected AdminEmail 'custom@example.com', got '%s'", cfg.AdminEmail) + } + if cfg.AdminPassword != "securepass" { + t.Errorf("expected AdminPassword 'securepass', got '%s'", cfg.AdminPassword) + } } func TestAddr(t *testing.T) { @@ -131,28 +138,18 @@ func TestAddr(t *testing.T) { } func TestInvalidEnvValues(t *testing.T) { - os.Setenv("DB_MAX_OPEN_CONNS", "not-a-number") os.Setenv("MARKETPLACE_COMMISSION", "invalid") - os.Setenv("DB_CONN_MAX_IDLE", "bad-duration") defer func() { - os.Unsetenv("DB_MAX_OPEN_CONNS") os.Unsetenv("MARKETPLACE_COMMISSION") - os.Unsetenv("DB_CONN_MAX_IDLE") }() cfg := Load() // Should use defaults when values are invalid - if cfg.MaxOpenConns != 15 { - t.Errorf("expected fallback MaxOpenConns 15, got %d", cfg.MaxOpenConns) - } if cfg.MarketplaceCommission != 2.5 { t.Errorf("expected fallback MarketplaceCommission 2.5, got %f", cfg.MarketplaceCommission) } - if cfg.ConnMaxIdle != 5*time.Minute { - t.Errorf("expected fallback ConnMaxIdle 5m, got %v", cfg.ConnMaxIdle) - } } func TestEmptyCORSOrigins(t *testing.T) { diff --git a/backend/internal/http/handler/handler_test.go b/backend/internal/http/handler/handler_test.go index d4a088e..00a4333 100644 --- a/backend/internal/http/handler/handler_test.go +++ b/backend/internal/http/handler/handler_test.go @@ -193,6 +193,15 @@ func (m *MockRepository) GetUser(ctx context.Context, id uuid.UUID) (*domain.Use return nil, errors.New("user not found") } +func (m *MockRepository) GetUserByUsername(ctx context.Context, username string) (*domain.User, error) { + for _, u := range m.users { + if u.Username == username { + return &u, nil + } + } + return nil, errors.New("user not found") +} + func (m *MockRepository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { for _, u := range m.users { if u.Email == email { @@ -350,7 +359,7 @@ func TestCreateProduct(t *testing.T) { func TestLoginInvalidCredentials(t *testing.T) { h := newTestHandler() - payload := `{"email":"nonexistent@test.com","password":"wrongpassword"}` + payload := `{"username":"nonexistent","password":"wrongpassword"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader([]byte(payload))) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 804a6e4..3200ce2 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -842,11 +842,20 @@ func (r *Repository) GetUserByUsername(ctx context.Context, username string) (*d return &user, nil } +func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { + var user domain.User + query := `SELECT id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at FROM users WHERE email = $1` + if err := r.db.GetContext(ctx, &user, query, email); err != nil { + return nil, err + } + return &user, nil +} + func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error { user.UpdatedAt = time.Now().UTC() query := `UPDATE users -SET company_id = :company_id, role = :role, name = :name, email = :email, email_verified = :email_verified, password_hash = :password_hash, updated_at = :updated_at +SET company_id = :company_id, role = :role, name = :name, username = :username, email = :email, email_verified = :email_verified, password_hash = :password_hash, updated_at = :updated_at WHERE id = :id` res, err := r.db.NamedExecContext(ctx, query, user) diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index 2fd6ac8..1943b43 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -46,6 +46,7 @@ type Repository interface { ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) GetUserByUsername(ctx context.Context, username string) (*domain.User, error) + GetUserByEmail(ctx context.Context, email string) (*domain.User, error) UpdateUser(ctx context.Context, user *domain.User) error DeleteUser(ctx context.Context, id uuid.UUID) error diff --git a/backend/internal/usecase/usecase_test.go b/backend/internal/usecase/usecase_test.go index 25f0231..c04bd48 100644 --- a/backend/internal/usecase/usecase_test.go +++ b/backend/internal/usecase/usecase_test.go @@ -196,6 +196,15 @@ func (m *MockRepository) GetUser(ctx context.Context, id uuid.UUID) (*domain.Use return nil, nil } +func (m *MockRepository) GetUserByUsername(ctx context.Context, username string) (*domain.User, error) { + for _, u := range m.users { + if u.Username == username { + return &u, nil + } + } + return nil, nil +} + func (m *MockRepository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { for _, u := range m.users { if u.Email == email { @@ -694,6 +703,7 @@ func TestAuthenticate(t *testing.T) { CompanyID: uuid.Must(uuid.NewV4()), Role: "admin", Name: "Test User", + Username: "authuser", Email: "auth@example.com", } err := svc.CreateUser(ctx, user, "testpass123") @@ -705,7 +715,7 @@ func TestAuthenticate(t *testing.T) { repo.users[0] = *user // Test authentication - token, expiresAt, err := svc.Authenticate(ctx, "auth@example.com", "testpass123") + token, expiresAt, err := svc.Authenticate(ctx, "authuser", "testpass123") if err != nil { t.Fatalf("failed to authenticate: %v", err) } @@ -726,12 +736,13 @@ func TestAuthenticateInvalidPassword(t *testing.T) { CompanyID: uuid.Must(uuid.NewV4()), Role: "admin", Name: "Test User", + Username: "failuser", Email: "fail@example.com", } svc.CreateUser(ctx, user, "correctpass") repo.users[0] = *user - _, _, err := svc.Authenticate(ctx, "fail@example.com", "wrongpass") + _, _, err := svc.Authenticate(ctx, "failuser", "wrongpass") if err == nil { t.Error("expected authentication to fail") } diff --git a/seeder-api/README.md b/seeder-api/README.md new file mode 100644 index 0000000..c71bbfa --- /dev/null +++ b/seeder-api/README.md @@ -0,0 +1,112 @@ +# SaveInMed Seeder API + +Microserviço utilitário para popular o banco de dados com dados de teste para desenvolvimento e demonstração. + +## ⚠️ AVISO IMPORTANTE + +**Este serviço é DESTRUTIVO!** Ele: +1. **REMOVE** todas as tabelas existentes (`companies`, `products`, `users`, etc.) +2. **RECRIA** apenas as tabelas `companies` e `products` +3. **NÃO RECRIA** a tabela `users` - você precisa reiniciar o backend após usar o seeder + +## 🎯 Propósito + +Gerar dados de teste para o marketplace SaveInMed, criando: +- **400 farmácias** na região de Anápolis/GO +- **20-500 produtos** por farmácia +- Dados variados de medicamentos com preços e validades realistas + +## 🏗️ Arquitetura + +``` +seeder-api/ +├── main.go # Entry point HTTP (POST /seed) +├── pkg/ +│ └── seeder/ +│ └── seeder.go # Lógica de geração de dados +├── go.mod +└── go.sum +``` + +## 📍 Dados Gerados + +### Localização +- Centro em **Anápolis, GO** (Lat: -16.3281, Lng: -48.9530) +- Variação de ~5km para cada farmácia + +### Farmácias +- **Quantidade**: 400 empresas +- **Categoria**: `farmacia` +- **70%** verificadas (`is_verified = true`) +- CNPJs gerados automaticamente + +### Produtos (Medicamentos) +- **20-500 produtos** por farmácia +- **Categorias**: Analgésicos, Antibióticos, Anti-inflamatórios, Cardiovasculares, Dermatológicos, Vitaminas, Oftálmicos, Respiratórios, etc. +- **Validade**: 30 dias a 2 anos +- **Variação de preço**: -20% a +30% do preço base + +## 🔧 Variáveis de Ambiente + +```bash +DATABASE_URL=postgres://user:password@host:port/dbname?sslmode=disable +PORT=8216 # Porta padrão do seeder +``` + +## 🚀 Uso + +### Executar Localmente + +```bash +# Configurar DATABASE_URL +export DATABASE_URL=postgres://postgres:postgres@localhost:5432/saveinmed?sslmode=disable + +# Executar +go run main.go +``` + +### Endpoint + +```bash +# Iniciar seeding +POST http://localhost:8216/seed + +# Resposta de sucesso +{ + "tenants": 400, + "products": 85432, + "location": "Anápolis, GO" +} +``` + +## ⚡ Fluxo de Uso Recomendado + +1. **Parar o backend principal** (para evitar conflitos de conexão) +2. **Executar o seeder**: `curl -X POST http://localhost:8216/seed` +3. **Reiniciar o backend** (para aplicar migrations e recriar tabela `users`) +4. A API estará pronta com dados de teste + +## 🐳 Docker + +```bash +# Build +docker build -t saveinmed-seeder:latest . + +# Run +docker run -p 8216:8216 \ + -e DATABASE_URL=postgres://user:password@host:5432/saveinmed \ + saveinmed-seeder:latest + +# Seed +curl -X POST http://localhost:8216/seed +``` + +## 📝 Notas + +- Os dados são **regeneráveis** - execute novamente para limpar e recriar +- Ideal para ambientes de **desenvolvimento** e **staging** +- **NÃO USE EM PRODUÇÃO** - vai apagar todos os dados reais! + +## 📝 Licença + +MIT