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
This commit is contained in:
Tiago Yamamoto 2025-12-21 23:11:33 -03:00
parent 4612172b3c
commit 9997aed18a
7 changed files with 215 additions and 43 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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")
}

112
seeder-api/README.md Normal file
View file

@ -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