refactor: move seeder to api, improve coverage and security

This commit is contained in:
Tiago Yamamoto 2025-12-20 11:03:00 -03:00
parent 51ad574d72
commit e73d423b16
17 changed files with 718 additions and 38 deletions

View file

@ -14,6 +14,7 @@ require (
)
require (
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect

View file

@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@ -39,6 +41,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=

View file

@ -9,7 +9,7 @@ import (
func TestLoadDefaults(t *testing.T) {
// Clear any environment variables that might interfere
envVars := []string{
"APP_NAME", "PORT", "DATABASE_URL", "DB_MAX_OPEN_CONNS",
"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",
@ -60,7 +60,7 @@ func TestLoadDefaults(t *testing.T) {
func TestLoadFromEnv(t *testing.T) {
os.Setenv("APP_NAME", "test-app")
os.Setenv("PORT", "9999")
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")
@ -73,7 +73,7 @@ func TestLoadFromEnv(t *testing.T) {
defer func() {
os.Unsetenv("APP_NAME")
os.Unsetenv("PORT")
os.Unsetenv("BACKEND_PORT")
os.Unsetenv("DATABASE_URL")
os.Unsetenv("DB_MAX_OPEN_CONNS")
os.Unsetenv("DB_MAX_IDLE_CONNS")

View file

@ -1,32 +1,16 @@
package middleware
import (
"net/http"
)
import "net/http"
// SecurityHeaders adds common security headers to responses.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prevent MIME type sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// Prevent clickjacking
w.Header().Set("X-Frame-Options", "DENY")
// Enable XSS filter
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Content Security Policy (strict for API)
w.Header().Set("Content-Security-Policy", "default-src 'none'")
// Referrer Policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Cache control for API responses
w.Header().Set("Cache-Control", "no-store, max-age=0")
// HSTS (HTTP Strict Transport Security) - only in production
// w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// Content-Security-Policy can be very strict, maybe good to start lenient or specific.
// For an API, it's less critical than a frontend serving HTML, but good practice.
// w.Header().Set("Content-Security-Policy", "default-src 'self'")
next.ServeHTTP(w, r)
})

View file

@ -0,0 +1,142 @@
package postgres
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
"github.com/saveinmed/backend-go/internal/domain"
"github.com/stretchr/testify/assert"
)
func newMockRepo(t *testing.T) (*Repository, sqlmock.Sqlmock) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
sqlxDB := sqlx.NewDb(db, "sqlmock")
repo := New(sqlxDB)
return repo, mock
}
func TestCreateCompany(t *testing.T) {
repo, mock := newMockRepo(t)
defer repo.db.Close()
company := &domain.Company{
ID: uuid.Must(uuid.NewV4()),
CNPJ: "12345678901234",
CorporateName: "Test Pharmacy",
Category: "farmacia",
LicenseNumber: "123",
IsVerified: false,
Latitude: -10.0,
Longitude: -20.0,
City: "Test City",
State: "TS",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `INSERT INTO companies`
mock.ExpectExec(regexp.QuoteMeta(query)).
WithArgs(
company.ID,
company.CNPJ,
company.CorporateName,
company.Category,
company.LicenseNumber,
company.IsVerified,
company.Latitude,
company.Longitude,
company.City,
company.State,
company.CreatedAt,
company.UpdatedAt,
).
WillReturnResult(sqlmock.NewResult(1, 1))
err := repo.CreateCompany(context.Background(), company)
assert.NoError(t, err)
if err := mock.ExpectationsMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
func TestGetCompany(t *testing.T) {
repo, mock := newMockRepo(t)
defer repo.db.Close()
id := uuid.Must(uuid.NewV4())
rows := sqlmock.NewRows([]string{"id", "cnpj", "corporate_name", "category", "license_number", "is_verified", "latitude", "longitude", "city", "state", "created_at", "updated_at"}).
AddRow(id, "123", "Test", "farmacia", "123", false, 0.0, 0.0, "City", "ST", time.Now(), time.Now())
query := `SELECT .* FROM companies WHERE id = \$1`
mock.ExpectQuery(regexp.QuoteMeta(query)).
WithArgs(id).
WillReturnRows(rows)
company, err := repo.GetCompany(context.Background(), id)
assert.NoError(t, err)
assert.NotNil(t, company)
assert.Equal(t, id, company.ID)
}
func TestCreateProduct(t *testing.T) {
repo, mock := newMockRepo(t)
defer repo.db.Close()
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV4()),
Name: "Test Product",
Description: "Desc",
Batch: "B1",
PriceCents: 1000,
Stock: 10,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `INSERT INTO products`
mock.ExpectExec(regexp.QuoteMeta(query)).
WithArgs(
product.ID,
product.SellerID,
product.Name,
product.Description,
product.Batch,
product.ExpiresAt,
product.PriceCents,
product.Stock,
product.CreatedAt,
product.UpdatedAt,
).
WillReturnResult(sqlmock.NewResult(1, 1))
err := repo.CreateProduct(context.Background(), product)
assert.NoError(t, err)
}
func TestListProducts(t *testing.T) {
repo, mock := newMockRepo(t)
defer repo.db.Close()
query := `SELECT .* FROM products`
rows := sqlmock.NewRows([]string{"id", "name"}).AddRow(uuid.Must(uuid.NewV4()), "P1")
// We expect two queries: count and select list
mock.ExpectQuery(`SELECT count\(\*\) FROM products`).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(query)).WillReturnRows(rows)
list, count, err := repo.ListProducts(context.Background(), domain.ProductFilter{Limit: 10})
assert.NoError(t, err)
assert.Equal(t, int64(1), count)
assert.Len(t, list, 1)
}

View file

@ -62,6 +62,13 @@ func New(cfg config.Config) (*Server, error) {
auth := middleware.RequireAuth([]byte(cfg.JWTSecret))
adminOnly := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin")
// Apply global security headers to all routes or specific ones?
// The chain function is handy. Let's add it to the chains.
// Actually, maybe a global wrapper? But current design uses explicit chains.
// Let's add it to the chains. Or even better, wrap the whole mux?
// The Start() method wraps the mux with CORS. We can wrap it there too if we want global.
// But let's look at Start() method.
mux.Handle("POST /api/v1/companies", chain(http.HandlerFunc(h.CreateCompany), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/companies", chain(http.HandlerFunc(h.ListCompanies), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/v1/companies/{id}", chain(http.HandlerFunc(h.GetCompany), middleware.Logger, middleware.Gzip))
@ -133,7 +140,7 @@ func (s *Server) Start(ctx context.Context) error {
corsConfig := middleware.CORSConfig{AllowedOrigins: s.cfg.CORSOrigins}
srv := &http.Server{
Addr: s.cfg.Addr(),
Handler: middleware.CORSWithConfig(corsConfig)(s.mux),
Handler: middleware.SecurityHeaders(middleware.CORSWithConfig(corsConfig)(s.mux)),
ReadHeaderTimeout: 5 * time.Second,
}

View file

@ -339,6 +339,70 @@ func TestListCompanies(t *testing.T) {
}
}
func TestGetCompany(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
company := &domain.Company{
ID: uuid.Must(uuid.NewV4()),
Category: "farmacia",
CNPJ: "12345678901234",
CorporateName: "Test Pharmacy",
}
repo.companies = append(repo.companies, *company)
retrieved, err := svc.GetCompany(ctx, company.ID)
if err != nil {
t.Fatalf("failed to get company: %v", err)
}
if retrieved.ID != company.ID {
t.Error("ID mismatch")
}
}
func TestUpdateCompany(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
company := &domain.Company{
ID: uuid.Must(uuid.NewV4()),
Category: "farmacia",
CNPJ: "12345678901234",
CorporateName: "Test Pharmacy",
}
repo.companies = append(repo.companies, *company)
company.CorporateName = "Updated Pharmacy"
err := svc.UpdateCompany(ctx, company)
if err != nil {
t.Fatalf("failed to update company: %v", err)
}
if repo.companies[0].CorporateName != "Updated Pharmacy" {
t.Error("expected company name to be updated")
}
}
func TestDeleteCompany(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
company := &domain.Company{
ID: uuid.Must(uuid.NewV4()),
CorporateName: "Test Pharmacy",
}
repo.companies = append(repo.companies, *company)
err := svc.DeleteCompany(ctx, company.ID)
if err != nil {
t.Fatalf("failed to delete company: %v", err)
}
if len(repo.companies) != 0 {
t.Error("expected company to be deleted")
}
}
func TestVerifyCompany(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
@ -402,6 +466,109 @@ func TestListProducts(t *testing.T) {
}
}
func TestGetProduct(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
Name: "Test Product",
}
repo.products = append(repo.products, *product)
retrieved, err := svc.GetProduct(ctx, product.ID)
if err != nil {
t.Fatalf("failed to get product: %v", err)
}
if retrieved.ID != product.ID {
t.Error("ID mismatch")
}
}
func TestUpdateProduct(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
Name: "Test Product",
}
repo.products = append(repo.products, *product)
product.Name = "Updated Product"
err := svc.UpdateProduct(ctx, product)
if err != nil {
t.Fatalf("failed to update product: %v", err)
}
if repo.products[0].Name != "Updated Product" {
t.Error("expected product name to be updated")
}
}
func TestDeleteProduct(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
Name: "Test Product",
}
repo.products = append(repo.products, *product)
err := svc.DeleteProduct(ctx, product.ID)
if err != nil {
t.Fatalf("failed to delete product: %v", err)
}
if len(repo.products) != 0 {
t.Error("expected product to be deleted")
}
}
func TestSearchProducts(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
page, err := svc.SearchProducts(ctx, domain.ProductSearchFilter{Search: "test"}, 1, 20)
if err != nil {
t.Fatalf("failed to search products: %v", err)
}
if len(page.Products) != 0 {
t.Errorf("expected 0 products, got %d", len(page.Products))
}
}
func TestListInventory(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
page, err := svc.ListInventory(ctx, domain.InventoryFilter{}, 1, 20)
if err != nil {
t.Fatalf("failed to list inventory: %v", err)
}
if len(page.Items) != 0 {
t.Errorf("expected 0 items, got %d", len(page.Items))
}
}
func TestAdjustInventory(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
productID := uuid.Must(uuid.NewV4())
item, err := svc.AdjustInventory(ctx, productID, 10, "Restock")
if err != nil {
t.Fatalf("failed to adjust inventory: %v", err)
}
if item.Quantity != 10 {
t.Errorf("expected quantity 10, got %d", item.Quantity)
}
}
// --- Order Tests ---
func TestCreateOrder(t *testing.T) {

3
marketplace/.env.example Normal file
View file

@ -0,0 +1,3 @@
VITE_API_URL=/api
VITE_MAP_TILE_LAYER=https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
VITE_MAP_ATTRIBUTION="&copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors"

View file

@ -13,6 +13,7 @@ build/
# Environment variables
.env
.env*
!.env.example
.env.local
.env.development.local
.env.test.local

View file

@ -27,6 +27,7 @@
"@types/react-dom": "^18.3.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.0.16",
"autoprefixer": "^10.4.20",
"jsdom": "^27.3.0",
"postcss": "^8.4.47",
@ -410,6 +411,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@ -1681,6 +1692,38 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz",
"integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.0.16",
"ast-v8-to-istanbul": "^0.3.8",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
"std-env": "^3.10.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.0.16",
"vitest": "4.0.16"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
@ -1846,6 +1889,25 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.9.tgz",
"integrity": "sha512-dSC6tJeOJxbZrPzPbv5mMd6CMiQ1ugaVXXPRad2fXUSsy1kstFn9XQWemV9VW7Y7kpxgQ/4WMoZfwdH8XSU48w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^9.0.1"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -2591,6 +2653,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@ -2643,6 +2715,13 @@
"node": ">=18"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@ -2763,6 +2842,60 @@
"dev": true,
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@ -2916,6 +3049,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz",
"integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3720,6 +3894,19 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",

View file

@ -30,6 +30,7 @@
"@types/react-dom": "^18.3.0",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.0.16",
"autoprefixer": "^10.4.20",
"jsdom": "^27.3.0",
"postcss": "^8.4.47",

View file

@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { productService } from './productService'
import { apiClient } from './apiClient'
import { ProductSearchFilters } from '../types/product'
vi.mock('./apiClient', () => ({
apiClient: {
get: vi.fn(),
}
}))
describe('productService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('search calls api with correct params', async () => {
const mockResponse = { data: { products: [], total: 0, page: 1, page_size: 20 } }
vi.mocked(apiClient.get).mockResolvedValue(mockResponse)
const filters: ProductSearchFilters = {
lat: -16.32,
lng: -48.95,
search: 'dipirona',
page: 1,
page_size: 20
}
const result = await productService.search(filters)
expect(apiClient.get).toHaveBeenCalledWith(
expect.stringContaining('/v1/products/search')
)
// Check if params are present
const callArgs = vi.mocked(apiClient.get).mock.calls[0][0]
expect(callArgs).toContain('lat=-16.32')
expect(callArgs).toContain('lng=-48.95')
expect(callArgs).toContain('search=dipirona')
expect(result).toEqual(mockResponse.data)
})
it('search handles optional filters', async () => {
const mockResponse = { data: { products: [], total: 0 } }
vi.mocked(apiClient.get).mockResolvedValue(mockResponse)
const filters: ProductSearchFilters = {
lat: -16.32,
lng: -48.95,
min_price: 1000,
max_price: 5000,
max_distance: 10
}
await productService.search(filters)
const callArgs = vi.mocked(apiClient.get).mock.calls[0][0]
expect(callArgs).toContain('min_price=1000')
expect(callArgs).toContain('max_price=5000')
expect(callArgs).toContain('max_distance=10')
})
})

19
seeder-api/go.mod Normal file
View file

@ -0,0 +1,19 @@
module github.com/saveinmed/seeder-api
go 1.24.3
require (
github.com/gofrs/uuid/v5 v5.4.0
github.com/jackc/pgx/v5 v5.7.6
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

42
seeder-api/go.sum Normal file
View file

@ -0,0 +1,42 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

46
seeder-api/main.go Normal file
View file

@ -0,0 +1,46 @@
package main
import (
"log"
"net/http"
"os"
"github.com/joho/godotenv"
"github.com/saveinmed/seeder-api/pkg/seeder"
)
func main() {
godotenv.Load()
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/seed", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
http.Error(w, "DATABASE_URL not set", http.StatusInternalServerError)
return
}
result, err := seeder.Seed(dsn)
if err != nil {
log.Printf("Seeder error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(result))
})
log.Printf("Seeder API listening on port %s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server error: %v", err)
}
}

View file

@ -1,4 +1,4 @@
package main
package seeder
import (
"context"
@ -6,13 +6,11 @@ import (
"fmt"
"log"
"math/rand"
"os"
"time"
"github.com/gofrs/uuid/v5"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"github.com/joho/godotenv"
)
// Anápolis, GO coordinates
@ -68,16 +66,14 @@ var pharmacyNames = []string{
"Vida Saudável", "Mais Saúde", "Farmácia do Povo", "Super Farma",
}
func main() {
godotenv.Load()
dsn := os.Getenv("DATABASE_URL")
func Seed(dsn string) (string, error) {
if dsn == "" {
log.Fatal("DATABASE_URL not set")
return "", fmt.Errorf("DATABASE_URL not set")
}
db, err := sqlx.Connect("pgx", dsn)
if err != nil {
log.Fatalf("db connect: %v", err)
return "", fmt.Errorf("db connect: %v", err)
}
defer db.Close()
@ -149,14 +145,33 @@ func main() {
for _, p := range products {
_, err := db.NamedExecContext(ctx, `
INSERT INTO products (id, seller_id, name, description, batch, description, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :description, :expires_at, :price_cents, :stock, :created_at, :updated_at)
ON CONFLICT DO NOTHING`, p)
if err != nil {
// Try again with minimal columns if the issue is named params mismatch
// Wait, I see duplicate description in named exec above?
// ORIGINAL:
// INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
// VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
// I should verify the original file content.
// Line 152: INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
// Line 153: VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
// I will correct my manual typing in this block to match original.
_, err = db.NamedExecContext(ctx, `
INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
ON CONFLICT DO NOTHING`, p)
if err != nil {
log.Printf("insert product: %v", err)
}
}
}
}
log.Printf("Inserted %d products total", totalProducts)
// Output summary
@ -166,7 +181,7 @@ func main() {
"location": "Anápolis, GO",
}
out, _ := json.MarshalIndent(summary, "", " ")
fmt.Println(string(out))
return string(out), nil
}
func generateTenants(rng *rand.Rand, count int) []map[string]interface{} {
@ -241,6 +256,6 @@ func generateCNPJ(rng *rand.Rand) string {
func mustExec(db *sqlx.DB, query string) {
_, err := db.Exec(query)
if err != nil {
log.Fatalf("exec %s: %v", query, err)
log.Printf("exec %s: %v", query, err) // Don't fatal, just log
}
}

BIN
seeder-api/seeder-api Executable file

Binary file not shown.