feat(backend): add comprehensive test suite for 80% coverage
- Add config_test.go (5 tests for env parsing) - Add middleware_test.go (16 tests for CORS, Auth, Gzip, Logger) - Add usecase_test.go (30+ tests for business logic) - Add payments_test.go (6 tests for MercadoPago gateway) Coverage: config 100%, middleware 95.9%, payments 100%, usecase 64.7% feat(marketplace): add test framework and new pages - Setup Vitest with jsdom environment - Add cartStore.test.ts (15 tests for Zustand store) - Add usePersistentFilters.test.ts (5 tests for hook) - Add apiClient.test.ts (7 tests for axios client) - Add Orders page with status transitions - Add Inventory page with stock adjustments - Add Company page with edit functionality - Add SellerDashboard page with KPIs Total marketplace tests: 27 passing
This commit is contained in:
parent
c83079e4c9
commit
b8973739ab
17 changed files with 4277 additions and 8 deletions
167
backend/internal/config/config_test.go
Normal file
167
backend/internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadDefaults(t *testing.T) {
|
||||||
|
// Clear any environment variables that might interfere
|
||||||
|
envVars := []string{
|
||||||
|
"APP_NAME", "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",
|
||||||
|
}
|
||||||
|
origEnvs := make(map[string]string)
|
||||||
|
for _, key := range envVars {
|
||||||
|
origEnvs[key] = os.Getenv(key)
|
||||||
|
os.Unsetenv(key)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for key, val := range origEnvs {
|
||||||
|
if val != "" {
|
||||||
|
os.Setenv(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
if cfg.AppName != "saveinmed-performance-core" {
|
||||||
|
t.Errorf("expected AppName 'saveinmed-performance-core', got '%s'", cfg.AppName)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if cfg.JWTExpiresIn != 24*time.Hour {
|
||||||
|
t.Errorf("expected JWTExpiresIn 24h, got %v", cfg.JWTExpiresIn)
|
||||||
|
}
|
||||||
|
if cfg.MarketplaceCommission != 2.5 {
|
||||||
|
t.Errorf("expected MarketplaceCommission 2.5, got %f", cfg.MarketplaceCommission)
|
||||||
|
}
|
||||||
|
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "*" {
|
||||||
|
t.Errorf("expected CORSOrigins ['*'], got %v", cfg.CORSOrigins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFromEnv(t *testing.T) {
|
||||||
|
os.Setenv("APP_NAME", "test-app")
|
||||||
|
os.Setenv("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")
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("APP_NAME")
|
||||||
|
os.Unsetenv("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")
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
if cfg.AppName != "test-app" {
|
||||||
|
t.Errorf("expected AppName 'test-app', got '%s'", cfg.AppName)
|
||||||
|
}
|
||||||
|
if cfg.Port != "9999" {
|
||||||
|
t.Errorf("expected Port '9999', got '%s'", cfg.Port)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if cfg.JWTSecret != "super-secret" {
|
||||||
|
t.Errorf("expected JWTSecret 'super-secret', got '%s'", cfg.JWTSecret)
|
||||||
|
}
|
||||||
|
if cfg.JWTExpiresIn != 12*time.Hour {
|
||||||
|
t.Errorf("expected JWTExpiresIn 12h, got %v", cfg.JWTExpiresIn)
|
||||||
|
}
|
||||||
|
if cfg.PasswordPepper != "pepper123" {
|
||||||
|
t.Errorf("expected PasswordPepper 'pepper123', got '%s'", cfg.PasswordPepper)
|
||||||
|
}
|
||||||
|
if len(cfg.CORSOrigins) != 2 {
|
||||||
|
t.Errorf("expected 2 CORS origins, got %d", len(cfg.CORSOrigins))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddr(t *testing.T) {
|
||||||
|
cfg := Config{Port: "3000"}
|
||||||
|
expected := ":3000"
|
||||||
|
if cfg.Addr() != expected {
|
||||||
|
t.Errorf("expected Addr '%s', got '%s'", expected, cfg.Addr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
os.Setenv("CORS_ORIGINS", "")
|
||||||
|
defer os.Unsetenv("CORS_ORIGINS")
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "*" {
|
||||||
|
t.Errorf("expected fallback CORSOrigins ['*'], got %v", cfg.CORSOrigins)
|
||||||
|
}
|
||||||
|
}
|
||||||
342
backend/internal/http/middleware/middleware_test.go
Normal file
342
backend/internal/http/middleware/middleware_test.go
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- CORS Tests ---
|
||||||
|
|
||||||
|
func TestCORSWithConfigAllowAll(t *testing.T) {
|
||||||
|
cfg := CORSConfig{AllowedOrigins: []string{"*"}}
|
||||||
|
handler := CORSWithConfig(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Origin", "https://example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||||
|
t.Errorf("expected Access-Control-Allow-Origin '*', got '%s'", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSWithConfigSpecificOrigins(t *testing.T) {
|
||||||
|
cfg := CORSConfig{AllowedOrigins: []string{"https://allowed.com", "https://another.com"}}
|
||||||
|
handler := CORSWithConfig(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Test allowed origin
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Origin", "https://allowed.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Header().Get("Access-Control-Allow-Origin") != "https://allowed.com" {
|
||||||
|
t.Errorf("expected origin 'https://allowed.com', got '%s'", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test blocked origin
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req2.Header.Set("Origin", "https://blocked.com")
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec2, req2)
|
||||||
|
|
||||||
|
if rec2.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||||
|
t.Errorf("expected empty Access-Control-Allow-Origin, got '%s'", rec2.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSPreflight(t *testing.T) {
|
||||||
|
cfg := CORSConfig{AllowedOrigins: []string{"*"}}
|
||||||
|
handler := CORSWithConfig(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot) // Should not reach here
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200 for preflight, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSHeaders(t *testing.T) {
|
||||||
|
cfg := CORSConfig{AllowedOrigins: []string{"*"}}
|
||||||
|
handler := CORSWithConfig(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Header().Get("Access-Control-Allow-Methods"), "GET") {
|
||||||
|
t.Error("expected Access-Control-Allow-Methods to include GET")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Header().Get("Access-Control-Allow-Headers"), "Authorization") {
|
||||||
|
t.Error("expected Access-Control-Allow-Headers to include Authorization")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth Tests ---
|
||||||
|
|
||||||
|
func createTestToken(secret string, userID uuid.UUID, role string, companyID *uuid.UUID) string {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": userID.String(),
|
||||||
|
"role": role,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
if companyID != nil {
|
||||||
|
claims["company_id"] = companyID.String()
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenStr, _ := token.SignedString([]byte(secret))
|
||||||
|
return tokenStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthValidToken(t *testing.T) {
|
||||||
|
secret := "test-secret"
|
||||||
|
userID, _ := uuid.NewV4()
|
||||||
|
companyID, _ := uuid.NewV4()
|
||||||
|
tokenStr := createTestToken(secret, userID, "Admin", &companyID)
|
||||||
|
|
||||||
|
var receivedClaims Claims
|
||||||
|
handler := RequireAuth([]byte(secret))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedClaims, _ = GetClaims(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenStr)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if receivedClaims.UserID != userID {
|
||||||
|
t.Errorf("expected userID %s, got %s", userID, receivedClaims.UserID)
|
||||||
|
}
|
||||||
|
if receivedClaims.Role != "Admin" {
|
||||||
|
t.Errorf("expected role 'Admin', got '%s'", receivedClaims.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthMissingToken(t *testing.T) {
|
||||||
|
handler := RequireAuth([]byte("secret"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected status 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthInvalidToken(t *testing.T) {
|
||||||
|
handler := RequireAuth([]byte("secret"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected status 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthWrongSecret(t *testing.T) {
|
||||||
|
userID, _ := uuid.NewV4()
|
||||||
|
tokenStr := createTestToken("correct-secret", userID, "User", nil)
|
||||||
|
|
||||||
|
handler := RequireAuth([]byte("wrong-secret"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenStr)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected status 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthRoleRestriction(t *testing.T) {
|
||||||
|
secret := "secret"
|
||||||
|
userID, _ := uuid.NewV4()
|
||||||
|
tokenStr := createTestToken(secret, userID, "User", nil)
|
||||||
|
|
||||||
|
handler := RequireAuth([]byte(secret), "Admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenStr)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected status 403, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAuthRoleAllowed(t *testing.T) {
|
||||||
|
secret := "secret"
|
||||||
|
userID, _ := uuid.NewV4()
|
||||||
|
tokenStr := createTestToken(secret, userID, "Admin", nil)
|
||||||
|
|
||||||
|
handler := RequireAuth([]byte(secret), "Admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenStr)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClaimsFromContext(t *testing.T) {
|
||||||
|
claims := Claims{
|
||||||
|
UserID: uuid.Must(uuid.NewV4()),
|
||||||
|
Role: "Admin",
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(context.Background(), claimsKey, claims)
|
||||||
|
|
||||||
|
retrieved, ok := GetClaims(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Error("expected to retrieve claims from context")
|
||||||
|
}
|
||||||
|
if retrieved.UserID != claims.UserID {
|
||||||
|
t.Errorf("expected userID %s, got %s", claims.UserID, retrieved.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClaimsNotInContext(t *testing.T) {
|
||||||
|
_, ok := GetClaims(context.Background())
|
||||||
|
if ok {
|
||||||
|
t.Error("expected claims to not be in context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gzip Tests ---
|
||||||
|
|
||||||
|
func TestGzipCompression(t *testing.T) {
|
||||||
|
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("Hello, World!"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Header().Get("Content-Encoding") != "gzip" {
|
||||||
|
t.Error("expected Content-Encoding 'gzip'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decompress and verify
|
||||||
|
reader, err := gzip.NewReader(rec.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create gzip reader: %v", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read gzip body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(body) != "Hello, World!" {
|
||||||
|
t.Errorf("expected 'Hello, World!', got '%s'", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGzipNoCompression(t *testing.T) {
|
||||||
|
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("Hello, World!"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
// No Accept-Encoding header
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Header().Get("Content-Encoding") == "gzip" {
|
||||||
|
t.Error("should not use gzip when not requested")
|
||||||
|
}
|
||||||
|
if rec.Body.String() != "Hello, World!" {
|
||||||
|
t.Errorf("expected 'Hello, World!', got '%s'", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logger Tests ---
|
||||||
|
|
||||||
|
func TestLoggerMiddleware(t *testing.T) {
|
||||||
|
handler := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test-path", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CORS Legacy Wrapper Test ---
|
||||||
|
|
||||||
|
func TestCORSLegacyWrapper(t *testing.T) {
|
||||||
|
handler := CORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Origin", "https://example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||||
|
t.Errorf("expected '*', got '%s'", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
|
}
|
||||||
162
backend/internal/payments/payments_test.go
Normal file
162
backend/internal/payments/payments_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
"github.com/saveinmed/backend-go/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewMercadoPagoGateway(t *testing.T) {
|
||||||
|
gateway := NewMercadoPagoGateway("https://api.mercadopago.com", 2.5)
|
||||||
|
|
||||||
|
if gateway.BaseURL != "https://api.mercadopago.com" {
|
||||||
|
t.Errorf("expected BaseURL 'https://api.mercadopago.com', got '%s'", gateway.BaseURL)
|
||||||
|
}
|
||||||
|
if gateway.MarketplaceCommission != 2.5 {
|
||||||
|
t.Errorf("expected commission 2.5, got %f", gateway.MarketplaceCommission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePreference(t *testing.T) {
|
||||||
|
gateway := NewMercadoPagoGateway("https://api.mercadopago.com", 2.5)
|
||||||
|
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
BuyerID: uuid.Must(uuid.NewV4()),
|
||||||
|
SellerID: uuid.Must(uuid.NewV4()),
|
||||||
|
TotalCents: 10000, // R$100
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pref, err := gateway.CreatePreference(ctx, order)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create preference: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pref.OrderID != order.ID {
|
||||||
|
t.Errorf("expected order ID %s, got %s", order.ID, pref.OrderID)
|
||||||
|
}
|
||||||
|
if pref.Gateway != "mercadopago" {
|
||||||
|
t.Errorf("expected gateway 'mercadopago', got '%s'", pref.Gateway)
|
||||||
|
}
|
||||||
|
if pref.CommissionPct != 2.5 {
|
||||||
|
t.Errorf("expected commission 2.5, got %f", pref.CommissionPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test marketplace fee calculation (2.5% of 10000 = 250)
|
||||||
|
expectedFee := int64(250)
|
||||||
|
if pref.MarketplaceFee != expectedFee {
|
||||||
|
t.Errorf("expected marketplace fee %d, got %d", expectedFee, pref.MarketplaceFee)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test seller receivable calculation (10000 - 250 = 9750)
|
||||||
|
expectedReceivable := int64(9750)
|
||||||
|
if pref.SellerReceivable != expectedReceivable {
|
||||||
|
t.Errorf("expected seller receivable %d, got %d", expectedReceivable, pref.SellerReceivable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test payment URL format
|
||||||
|
expectedURL := "https://api.mercadopago.com/checkout/v1/redirect?order_id=" + order.ID.String()
|
||||||
|
if pref.PaymentURL != expectedURL {
|
||||||
|
t.Errorf("expected URL '%s', got '%s'", expectedURL, pref.PaymentURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePreferenceWithDifferentCommissions(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
commission float64
|
||||||
|
totalCents int64
|
||||||
|
expectedFee int64
|
||||||
|
expectedSeller int64
|
||||||
|
}{
|
||||||
|
{"5% commission", 5.0, 10000, 500, 9500},
|
||||||
|
{"10% commission", 10.0, 10000, 1000, 9000},
|
||||||
|
{"0% commission", 0.0, 10000, 0, 10000},
|
||||||
|
{"2.5% on large order", 2.5, 100000, 2500, 97500},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
gateway := NewMercadoPagoGateway("https://test.com", tc.commission)
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
TotalCents: tc.totalCents,
|
||||||
|
}
|
||||||
|
|
||||||
|
pref, err := gateway.CreatePreference(context.Background(), order)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create preference: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pref.MarketplaceFee != tc.expectedFee {
|
||||||
|
t.Errorf("expected fee %d, got %d", tc.expectedFee, pref.MarketplaceFee)
|
||||||
|
}
|
||||||
|
if pref.SellerReceivable != tc.expectedSeller {
|
||||||
|
t.Errorf("expected seller receivable %d, got %d", tc.expectedSeller, pref.SellerReceivable)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePreferenceWithCancelledContext(t *testing.T) {
|
||||||
|
gateway := NewMercadoPagoGateway("https://api.mercadopago.com", 2.5)
|
||||||
|
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
TotalCents: 10000,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
_, err := gateway.CreatePreference(ctx, order)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for cancelled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePreferenceWithTimeout(t *testing.T) {
|
||||||
|
gateway := NewMercadoPagoGateway("https://api.mercadopago.com", 2.5)
|
||||||
|
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
TotalCents: 10000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a context that will timeout after a very short duration
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Wait for context to expire
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
_, err := gateway.CreatePreference(ctx, order)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for timed out context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePreferenceWithZeroTotal(t *testing.T) {
|
||||||
|
gateway := NewMercadoPagoGateway("https://api.mercadopago.com", 2.5)
|
||||||
|
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
TotalCents: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
pref, err := gateway.CreatePreference(context.Background(), order)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create preference: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pref.MarketplaceFee != 0 {
|
||||||
|
t.Errorf("expected fee 0, got %d", pref.MarketplaceFee)
|
||||||
|
}
|
||||||
|
if pref.SellerReceivable != 0 {
|
||||||
|
t.Errorf("expected seller receivable 0, got %d", pref.SellerReceivable)
|
||||||
|
}
|
||||||
|
}
|
||||||
784
backend/internal/usecase/usecase_test.go
Normal file
784
backend/internal/usecase/usecase_test.go
Normal file
|
|
@ -0,0 +1,784 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
"github.com/saveinmed/backend-go/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockRepository implements Repository interface for testing
|
||||||
|
type MockRepository struct {
|
||||||
|
companies []domain.Company
|
||||||
|
products []domain.Product
|
||||||
|
users []domain.User
|
||||||
|
orders []domain.Order
|
||||||
|
cartItems []domain.CartItem
|
||||||
|
reviews []domain.Review
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockRepository() *MockRepository {
|
||||||
|
return &MockRepository{
|
||||||
|
companies: make([]domain.Company, 0),
|
||||||
|
products: make([]domain.Product, 0),
|
||||||
|
users: make([]domain.User, 0),
|
||||||
|
orders: make([]domain.Order, 0),
|
||||||
|
cartItems: make([]domain.CartItem, 0),
|
||||||
|
reviews: make([]domain.Review, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company methods
|
||||||
|
func (m *MockRepository) CreateCompany(ctx context.Context, company *domain.Company) error {
|
||||||
|
company.CreatedAt = time.Now()
|
||||||
|
company.UpdatedAt = time.Now()
|
||||||
|
m.companies = append(m.companies, *company)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) ListCompanies(ctx context.Context) ([]domain.Company, error) {
|
||||||
|
return m.companies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
|
||||||
|
for _, c := range m.companies {
|
||||||
|
if c.ID == id {
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) UpdateCompany(ctx context.Context, company *domain.Company) error {
|
||||||
|
for i, c := range m.companies {
|
||||||
|
if c.ID == company.ID {
|
||||||
|
m.companies[i] = *company
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) DeleteCompany(ctx context.Context, id uuid.UUID) error {
|
||||||
|
for i, c := range m.companies {
|
||||||
|
if c.ID == id {
|
||||||
|
m.companies = append(m.companies[:i], m.companies[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product methods
|
||||||
|
func (m *MockRepository) CreateProduct(ctx context.Context, product *domain.Product) error {
|
||||||
|
product.CreatedAt = time.Now()
|
||||||
|
product.UpdatedAt = time.Now()
|
||||||
|
m.products = append(m.products, *product)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) ListProducts(ctx context.Context) ([]domain.Product, error) {
|
||||||
|
return m.products, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
|
||||||
|
for _, p := range m.products {
|
||||||
|
if p.ID == id {
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) UpdateProduct(ctx context.Context, product *domain.Product) error {
|
||||||
|
for i, p := range m.products {
|
||||||
|
if p.ID == product.ID {
|
||||||
|
m.products[i] = *product
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||||
|
for i, p := range m.products {
|
||||||
|
if p.ID == id {
|
||||||
|
m.products = append(m.products[:i], m.products[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) {
|
||||||
|
return &domain.InventoryItem{ProductID: productID, Quantity: delta}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, error) {
|
||||||
|
return []domain.InventoryItem{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order methods
|
||||||
|
func (m *MockRepository) CreateOrder(ctx context.Context, order *domain.Order) error {
|
||||||
|
m.orders = append(m.orders, *order)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) ListOrders(ctx context.Context) ([]domain.Order, error) {
|
||||||
|
return m.orders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
|
||||||
|
for _, o := range m.orders {
|
||||||
|
if o.ID == id {
|
||||||
|
return &o, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error {
|
||||||
|
for i, o := range m.orders {
|
||||||
|
if o.ID == id {
|
||||||
|
m.orders[i].Status = status
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) DeleteOrder(ctx context.Context, id uuid.UUID) error {
|
||||||
|
for i, o := range m.orders {
|
||||||
|
if o.ID == id {
|
||||||
|
m.orders = append(m.orders[:i], m.orders[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) CreateShipment(ctx context.Context, shipment *domain.Shipment) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// User methods
|
||||||
|
func (m *MockRepository) CreateUser(ctx context.Context, user *domain.User) error {
|
||||||
|
m.users = append(m.users, *user)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) {
|
||||||
|
return m.users, int64(len(m.users)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
||||||
|
for _, u := range m.users {
|
||||||
|
if u.ID == id {
|
||||||
|
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 {
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) UpdateUser(ctx context.Context, user *domain.User) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cart methods
|
||||||
|
func (m *MockRepository) AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error) {
|
||||||
|
m.cartItems = append(m.cartItems, *item)
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error) {
|
||||||
|
var items []domain.CartItem
|
||||||
|
for _, c := range m.cartItems {
|
||||||
|
if c.BuyerID == buyerID {
|
||||||
|
items = append(items, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error {
|
||||||
|
for i, c := range m.cartItems {
|
||||||
|
if c.ID == id && c.BuyerID == buyerID {
|
||||||
|
m.cartItems = append(m.cartItems[:i], m.cartItems[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review methods
|
||||||
|
func (m *MockRepository) CreateReview(ctx context.Context, review *domain.Review) error {
|
||||||
|
m.reviews = append(m.reviews, *review)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error) {
|
||||||
|
return &domain.CompanyRating{CompanyID: companyID, AverageScore: 4.5, TotalReviews: 10}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error) {
|
||||||
|
return &domain.SellerDashboard{SellerID: sellerID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRepository) AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error) {
|
||||||
|
return &domain.AdminDashboard{GMVCents: 1000000}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockPaymentGateway for testing
|
||||||
|
type MockPaymentGateway struct{}
|
||||||
|
|
||||||
|
func (m *MockPaymentGateway) CreatePreference(ctx context.Context, order *domain.Order) (*domain.PaymentPreference, error) {
|
||||||
|
return &domain.PaymentPreference{
|
||||||
|
OrderID: order.ID,
|
||||||
|
Gateway: "mock",
|
||||||
|
CommissionPct: 2.5,
|
||||||
|
MarketplaceFee: int64(float64(order.TotalCents) * 0.025),
|
||||||
|
SellerReceivable: order.TotalCents - int64(float64(order.TotalCents)*0.025),
|
||||||
|
PaymentURL: "https://mock.payment.url",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a test service
|
||||||
|
func newTestService() (*Service, *MockRepository) {
|
||||||
|
repo := NewMockRepository()
|
||||||
|
gateway := &MockPaymentGateway{}
|
||||||
|
svc := NewService(repo, gateway, 2.5, "test-secret", time.Hour, "test-pepper")
|
||||||
|
return svc, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Company Tests ---
|
||||||
|
|
||||||
|
func TestRegisterCompany(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
company := &domain.Company{
|
||||||
|
Role: "pharmacy",
|
||||||
|
CNPJ: "12345678901234",
|
||||||
|
CorporateName: "Test Pharmacy",
|
||||||
|
LicenseNumber: "LIC-001",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.RegisterCompany(ctx, company)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to register company: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if company.ID == uuid.Nil {
|
||||||
|
t.Error("expected company ID to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCompanies(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
companies, err := svc.ListCompanies(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list companies: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(companies) != 0 {
|
||||||
|
t.Errorf("expected 0 companies, got %d", len(companies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCompany(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
company := &domain.Company{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
Role: "pharmacy",
|
||||||
|
CNPJ: "12345678901234",
|
||||||
|
CorporateName: "Test Pharmacy",
|
||||||
|
IsVerified: false,
|
||||||
|
}
|
||||||
|
repo.companies = append(repo.companies, *company)
|
||||||
|
|
||||||
|
verified, err := svc.VerifyCompany(ctx, company.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to verify company: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verified.IsVerified {
|
||||||
|
t.Error("expected company to be verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Product Tests ---
|
||||||
|
|
||||||
|
func TestRegisterProduct(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
product := &domain.Product{
|
||||||
|
SellerID: uuid.Must(uuid.NewV4()),
|
||||||
|
Name: "Test Product",
|
||||||
|
Description: "A test product",
|
||||||
|
Batch: "BATCH-001",
|
||||||
|
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
||||||
|
PriceCents: 1000,
|
||||||
|
Stock: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.RegisterProduct(ctx, product)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to register product: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if product.ID == uuid.Nil {
|
||||||
|
t.Error("expected product ID to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListProducts(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
products, err := svc.ListProducts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list products: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(products) != 0 {
|
||||||
|
t.Errorf("expected 0 products, got %d", len(products))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Order Tests ---
|
||||||
|
|
||||||
|
func TestCreateOrder(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order := &domain.Order{
|
||||||
|
BuyerID: uuid.Must(uuid.NewV4()),
|
||||||
|
SellerID: uuid.Must(uuid.NewV4()),
|
||||||
|
TotalCents: 10000,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.CreateOrder(ctx, order)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.ID == uuid.Nil {
|
||||||
|
t.Error("expected order ID to be set")
|
||||||
|
}
|
||||||
|
if order.Status != domain.OrderStatusPending {
|
||||||
|
t.Errorf("expected status 'Pendente', got '%s'", order.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateOrderStatus(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
BuyerID: uuid.Must(uuid.NewV4()),
|
||||||
|
SellerID: uuid.Must(uuid.NewV4()),
|
||||||
|
Status: domain.OrderStatusPending,
|
||||||
|
TotalCents: 10000,
|
||||||
|
}
|
||||||
|
repo.orders = append(repo.orders, *order)
|
||||||
|
|
||||||
|
err := svc.UpdateOrderStatus(ctx, order.ID, domain.OrderStatusPaid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to update order status: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User Tests ---
|
||||||
|
|
||||||
|
func TestCreateUser(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := &domain.User{
|
||||||
|
CompanyID: uuid.Must(uuid.NewV4()),
|
||||||
|
Role: "admin",
|
||||||
|
Name: "Test User",
|
||||||
|
Email: "test@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.CreateUser(ctx, user, "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.ID == uuid.Nil {
|
||||||
|
t.Error("expected user ID to be set")
|
||||||
|
}
|
||||||
|
if user.PasswordHash == "" {
|
||||||
|
t.Error("expected password to be hashed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListUsers(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
page, err := svc.ListUsers(ctx, domain.UserFilter{}, 1, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list users: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if page.Page != 1 {
|
||||||
|
t.Errorf("expected page 1, got %d", page.Page)
|
||||||
|
}
|
||||||
|
if page.PageSize != 20 {
|
||||||
|
t.Errorf("expected pageSize 20, got %d", page.PageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListUsersPagination(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test with page < 1 (should default to 1)
|
||||||
|
page, err := svc.ListUsers(ctx, domain.UserFilter{}, 0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list users: %v", err)
|
||||||
|
}
|
||||||
|
if page.Page != 1 {
|
||||||
|
t.Errorf("expected page 1, got %d", page.Page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with pageSize <= 0 (should default to 20)
|
||||||
|
page2, err := svc.ListUsers(ctx, domain.UserFilter{}, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list users: %v", err)
|
||||||
|
}
|
||||||
|
if page2.PageSize != 20 {
|
||||||
|
t.Errorf("expected pageSize 20, got %d", page2.PageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Authentication Tests ---
|
||||||
|
|
||||||
|
func TestAuthenticate(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First create a user
|
||||||
|
user := &domain.User{
|
||||||
|
CompanyID: uuid.Must(uuid.NewV4()),
|
||||||
|
Role: "admin",
|
||||||
|
Name: "Test User",
|
||||||
|
Email: "auth@example.com",
|
||||||
|
}
|
||||||
|
err := svc.CreateUser(ctx, user, "testpass123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the mock with the hashed password
|
||||||
|
repo.users[0] = *user
|
||||||
|
|
||||||
|
// Test authentication
|
||||||
|
token, expiresAt, err := svc.Authenticate(ctx, "auth@example.com", "testpass123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to authenticate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
t.Error("expected token to be returned")
|
||||||
|
}
|
||||||
|
if expiresAt.Before(time.Now()) {
|
||||||
|
t.Error("expected expiration to be in the future")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateInvalidPassword(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := &domain.User{
|
||||||
|
CompanyID: uuid.Must(uuid.NewV4()),
|
||||||
|
Role: "admin",
|
||||||
|
Name: "Test User",
|
||||||
|
Email: "fail@example.com",
|
||||||
|
}
|
||||||
|
svc.CreateUser(ctx, user, "correctpass")
|
||||||
|
repo.users[0] = *user
|
||||||
|
|
||||||
|
_, _, err := svc.Authenticate(ctx, "fail@example.com", "wrongpass")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected authentication to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cart Tests ---
|
||||||
|
|
||||||
|
func TestAddItemToCart(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
buyerID := uuid.Must(uuid.NewV4())
|
||||||
|
product := &domain.Product{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
SellerID: uuid.Must(uuid.NewV4()),
|
||||||
|
Name: "Test Product",
|
||||||
|
PriceCents: 1000,
|
||||||
|
Stock: 100,
|
||||||
|
Batch: "BATCH-001",
|
||||||
|
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
||||||
|
}
|
||||||
|
repo.products = append(repo.products, *product)
|
||||||
|
|
||||||
|
summary, err := svc.AddItemToCart(ctx, buyerID, product.ID, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add item to cart: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary.SubtotalCents != 5000 {
|
||||||
|
t.Errorf("expected subtotal 5000, got %d", summary.SubtotalCents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddItemToCartInvalidQuantity(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
buyerID := uuid.Must(uuid.NewV4())
|
||||||
|
productID := uuid.Must(uuid.NewV4())
|
||||||
|
|
||||||
|
_, err := svc.AddItemToCart(ctx, buyerID, productID, 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for zero quantity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartB2BDiscount(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
buyerID := uuid.Must(uuid.NewV4())
|
||||||
|
product := &domain.Product{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
SellerID: uuid.Must(uuid.NewV4()),
|
||||||
|
Name: "Expensive Product",
|
||||||
|
PriceCents: 50000, // R$500 per unit
|
||||||
|
Stock: 1000,
|
||||||
|
Batch: "BATCH-001",
|
||||||
|
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
||||||
|
}
|
||||||
|
repo.products = append(repo.products, *product)
|
||||||
|
|
||||||
|
// Add enough to trigger B2B discount (>R$1000)
|
||||||
|
summary, err := svc.AddItemToCart(ctx, buyerID, product.ID, 3) // R$1500
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add item to cart: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary.DiscountCents == 0 {
|
||||||
|
t.Error("expected B2B discount to be applied")
|
||||||
|
}
|
||||||
|
if summary.DiscountReason == "" {
|
||||||
|
t.Error("expected discount reason to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Review Tests ---
|
||||||
|
|
||||||
|
func TestCreateReview(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
buyerID := uuid.Must(uuid.NewV4())
|
||||||
|
sellerID := uuid.Must(uuid.NewV4())
|
||||||
|
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
BuyerID: buyerID,
|
||||||
|
SellerID: sellerID,
|
||||||
|
Status: domain.OrderStatusDelivered,
|
||||||
|
TotalCents: 10000,
|
||||||
|
}
|
||||||
|
repo.orders = append(repo.orders, *order)
|
||||||
|
|
||||||
|
review, err := svc.CreateReview(ctx, buyerID, order.ID, 5, "Great service!")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create review: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if review.Rating != 5 {
|
||||||
|
t.Errorf("expected rating 5, got %d", review.Rating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateReviewInvalidRating(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := svc.CreateReview(ctx, uuid.Must(uuid.NewV4()), uuid.Must(uuid.NewV4()), 6, "Invalid")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid rating")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateReviewNotDelivered(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
buyerID := uuid.Must(uuid.NewV4())
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
BuyerID: buyerID,
|
||||||
|
Status: domain.OrderStatusPending, // Not delivered
|
||||||
|
TotalCents: 10000,
|
||||||
|
}
|
||||||
|
repo.orders = append(repo.orders, *order)
|
||||||
|
|
||||||
|
_, err := svc.CreateReview(ctx, buyerID, order.ID, 5, "Great!")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-delivered order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dashboard Tests ---
|
||||||
|
|
||||||
|
func TestGetSellerDashboard(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sellerID := uuid.Must(uuid.NewV4())
|
||||||
|
dashboard, err := svc.GetSellerDashboard(ctx, sellerID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get seller dashboard: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dashboard.SellerID != sellerID {
|
||||||
|
t.Errorf("expected seller ID %s, got %s", sellerID, dashboard.SellerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAdminDashboard(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dashboard, err := svc.GetAdminDashboard(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get admin dashboard: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dashboard.GMVCents != 1000000 {
|
||||||
|
t.Errorf("expected GMV 1000000, got %d", dashboard.GMVCents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payment Tests ---
|
||||||
|
|
||||||
|
func TestCreatePaymentPreference(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
BuyerID: uuid.Must(uuid.NewV4()),
|
||||||
|
SellerID: uuid.Must(uuid.NewV4()),
|
||||||
|
TotalCents: 10000,
|
||||||
|
}
|
||||||
|
repo.orders = append(repo.orders, *order)
|
||||||
|
|
||||||
|
pref, err := svc.CreatePaymentPreference(ctx, order.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create payment preference: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pref.PaymentURL == "" {
|
||||||
|
t.Error("expected payment URL to be set")
|
||||||
|
}
|
||||||
|
if pref.MarketplaceFee == 0 {
|
||||||
|
t.Error("expected marketplace fee to be calculated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePaymentWebhook(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order := &domain.Order{
|
||||||
|
ID: uuid.Must(uuid.NewV4()),
|
||||||
|
BuyerID: uuid.Must(uuid.NewV4()),
|
||||||
|
SellerID: uuid.Must(uuid.NewV4()),
|
||||||
|
Status: domain.OrderStatusPending,
|
||||||
|
TotalCents: 10000,
|
||||||
|
}
|
||||||
|
repo.orders = append(repo.orders, *order)
|
||||||
|
|
||||||
|
event := domain.PaymentWebhookEvent{
|
||||||
|
PaymentID: "PAY-123",
|
||||||
|
OrderID: order.ID,
|
||||||
|
Status: "approved",
|
||||||
|
TotalPaidAmount: 10000,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.HandlePaymentWebhook(ctx, event)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to handle webhook: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Status != "approved" {
|
||||||
|
t.Errorf("expected status 'approved', got '%s'", result.Status)
|
||||||
|
}
|
||||||
|
if result.MarketplaceFee == 0 {
|
||||||
|
t.Error("expected marketplace fee to be calculated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Register Account Tests ---
|
||||||
|
|
||||||
|
func TestRegisterAccount(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
company := &domain.Company{
|
||||||
|
Role: "pharmacy",
|
||||||
|
CNPJ: "12345678901234",
|
||||||
|
CorporateName: "Test Pharmacy",
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &domain.User{
|
||||||
|
Role: "admin",
|
||||||
|
Name: "Admin User",
|
||||||
|
Email: "admin@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.RegisterAccount(ctx, company, user, "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to register account: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if company.ID == uuid.Nil {
|
||||||
|
t.Error("expected company ID to be set")
|
||||||
|
}
|
||||||
|
if user.CompanyID != company.ID {
|
||||||
|
t.Error("expected user to be linked to company")
|
||||||
|
}
|
||||||
|
}
|
||||||
1792
marketplace/package-lock.json
generated
1792
marketplace/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mercadopago/sdk-react": "^1.0.6",
|
"@mercadopago/sdk-react": "^1.0.6",
|
||||||
|
|
@ -18,14 +20,19 @@
|
||||||
"zustand": "^4.5.5"
|
"zustand": "^4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.1",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^18.3.7",
|
"@types/react": "^18.3.7",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"jsdom": "^27.3.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.10",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^5.4.3"
|
"vite": "^5.4.3",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import { DashboardPage } from './pages/Dashboard'
|
||||||
import { CartPage } from './pages/Cart'
|
import { CartPage } from './pages/Cart'
|
||||||
import { CheckoutPage } from './pages/Checkout'
|
import { CheckoutPage } from './pages/Checkout'
|
||||||
import { ProfilePage } from './pages/Profile'
|
import { ProfilePage } from './pages/Profile'
|
||||||
|
import { OrdersPage } from './pages/Orders'
|
||||||
|
import { InventoryPage } from './pages/Inventory'
|
||||||
|
import { CompanyPage } from './pages/Company'
|
||||||
|
import { SellerDashboardPage } from './pages/SellerDashboard'
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -34,6 +38,38 @@ function App() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/orders"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<OrdersPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/inventory"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<InventoryPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/company"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CompanyPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/seller-dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SellerDashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
path="/profile"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
95
marketplace/src/hooks/usePersistentFilters.test.ts
Normal file
95
marketplace/src/hooks/usePersistentFilters.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { usePersistentFilters, defaultFilters, FilterState } from './usePersistentFilters'
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {}
|
||||||
|
return {
|
||||||
|
getItem: vi.fn((key: string) => store[key] || null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
store[key] = value
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
delete store[key]
|
||||||
|
}),
|
||||||
|
clear: vi.fn(() => {
|
||||||
|
store = {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||||
|
|
||||||
|
describe('usePersistentFilters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return default filters when localStorage is empty', () => {
|
||||||
|
const { result } = renderHook(() => usePersistentFilters())
|
||||||
|
|
||||||
|
expect(result.current.filters).toEqual(defaultFilters)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should load persisted filters from localStorage', () => {
|
||||||
|
const persistedFilters: FilterState = {
|
||||||
|
activeIngredient: 'Amoxicilina',
|
||||||
|
category: 'Antibiótico',
|
||||||
|
lab: 'EMS'
|
||||||
|
}
|
||||||
|
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(persistedFilters))
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePersistentFilters())
|
||||||
|
|
||||||
|
expect(result.current.filters).toEqual(persistedFilters)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update filters and persist to localStorage', () => {
|
||||||
|
const { result } = renderHook(() => usePersistentFilters())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFilters({
|
||||||
|
...defaultFilters,
|
||||||
|
activeIngredient: 'Dipirona'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.filters.activeIngredient).toBe('Dipirona')
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
|
'mp-quick-filters',
|
||||||
|
expect.stringContaining('Dipirona')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update individual filter fields', () => {
|
||||||
|
const { result } = renderHook(() => usePersistentFilters())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFilters((prev) => ({ ...prev, lab: 'Eurofarma' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.filters.lab).toBe('Eurofarma')
|
||||||
|
expect(result.current.filters.activeIngredient).toBe('')
|
||||||
|
expect(result.current.filters.category).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset filters to defaults', () => {
|
||||||
|
const { result } = renderHook(() => usePersistentFilters())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFilters({
|
||||||
|
activeIngredient: 'Test',
|
||||||
|
category: 'Test',
|
||||||
|
lab: 'Test'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFilters(defaultFilters)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.filters).toEqual(defaultFilters)
|
||||||
|
})
|
||||||
|
})
|
||||||
173
marketplace/src/pages/Company.tsx
Normal file
173
marketplace/src/pages/Company.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Shell } from '../layouts/Shell'
|
||||||
|
import { apiClient } from '../services/apiClient'
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
id: string
|
||||||
|
role: string
|
||||||
|
cnpj: string
|
||||||
|
corporate_name: string
|
||||||
|
license_number: string
|
||||||
|
is_verified: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyPage() {
|
||||||
|
const [company, setCompany] = useState<Company | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
corporate_name: '',
|
||||||
|
license_number: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCompany()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadCompany = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await apiClient.get('/v1/companies/me')
|
||||||
|
setCompany(response.data)
|
||||||
|
setForm({
|
||||||
|
corporate_name: response.data.corporate_name,
|
||||||
|
license_number: response.data.license_number
|
||||||
|
})
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Erro ao carregar dados da empresa')
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveChanges = async () => {
|
||||||
|
if (!company) return
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/v1/companies/${company.id}`, form)
|
||||||
|
await loadCompany()
|
||||||
|
setEditing(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao salvar:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
pharmacy: 'Farmácia',
|
||||||
|
distributor: 'Distribuidora',
|
||||||
|
admin: 'Administrador'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold text-medicalBlue">Minha Empresa</h1>
|
||||||
|
{company && !editing && (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="rounded bg-medicalBlue px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-medicalBlue"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-100 p-4 text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{company && !editing && (
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow-sm space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500">Status</span>
|
||||||
|
{company.is_verified ? (
|
||||||
|
<span className="rounded bg-green-100 px-3 py-1 text-sm font-semibold text-green-800">
|
||||||
|
✓ Verificada
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-yellow-100 px-3 py-1 text-sm font-semibold text-yellow-800">
|
||||||
|
Pendente verificação
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t pt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Razão Social</p>
|
||||||
|
<p className="font-semibold text-gray-800">{company.corporate_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">CNPJ</p>
|
||||||
|
<p className="font-semibold text-gray-800">{company.cnpj}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Tipo</p>
|
||||||
|
<p className="font-semibold text-gray-800">{roleLabels[company.role] || company.role}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Licença Sanitária</p>
|
||||||
|
<p className="font-semibold text-gray-800">{company.license_number}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Cadastro</p>
|
||||||
|
<p className="font-semibold text-gray-800">
|
||||||
|
{new Date(company.created_at).toLocaleDateString('pt-BR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{company && editing && (
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow-sm space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Razão Social
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.corporate_name}
|
||||||
|
onChange={(e) => setForm({ ...form, corporate_name: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Licença Sanitária
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.license_number}
|
||||||
|
onChange={(e) => setForm({ ...form, license_number: e.target.value })}
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={saveChanges}
|
||||||
|
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Salvar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
marketplace/src/pages/Inventory.tsx
Normal file
153
marketplace/src/pages/Inventory.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Shell } from '../layouts/Shell'
|
||||||
|
import { apiClient } from '../services/apiClient'
|
||||||
|
|
||||||
|
interface InventoryItem {
|
||||||
|
product_id: string
|
||||||
|
seller_id: string
|
||||||
|
name: string
|
||||||
|
batch: string
|
||||||
|
expires_at: string
|
||||||
|
quantity: number
|
||||||
|
price_cents: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryPage() {
|
||||||
|
const [inventory, setInventory] = useState<InventoryItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [expiringDays, setExpiringDays] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInventory()
|
||||||
|
}, [expiringDays])
|
||||||
|
|
||||||
|
const loadInventory = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const params = expiringDays ? `?expires_in_days=${expiringDays}` : ''
|
||||||
|
const response = await apiClient.get(`/v1/inventory${params}`)
|
||||||
|
setInventory(response.data || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Erro ao carregar estoque')
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustStock = async (productId: string, delta: number, reason: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.post('/v1/inventory/adjust', {
|
||||||
|
product_id: productId,
|
||||||
|
delta,
|
||||||
|
reason
|
||||||
|
})
|
||||||
|
await loadInventory()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao ajustar estoque:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpiringSoon = (date: string) => {
|
||||||
|
const expires = new Date(date)
|
||||||
|
const now = new Date()
|
||||||
|
const diffDays = Math.ceil((expires.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
return diffDays <= 30
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-medicalBlue">Estoque</h1>
|
||||||
|
<p className="text-sm text-gray-600">Controle de inventário e validades</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={expiringDays}
|
||||||
|
onChange={(e) => setExpiringDays(e.target.value)}
|
||||||
|
className="rounded border border-gray-200 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todos os produtos</option>
|
||||||
|
<option value="7">Vencendo em 7 dias</option>
|
||||||
|
<option value="30">Vencendo em 30 dias</option>
|
||||||
|
<option value="90">Vencendo em 90 dias</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={loadInventory}
|
||||||
|
className="rounded bg-medicalBlue px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Atualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-medicalBlue"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-100 p-4 text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && inventory.length === 0 && (
|
||||||
|
<div className="rounded bg-gray-100 p-8 text-center text-gray-600">
|
||||||
|
Nenhum item no estoque
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-gray-600">Produto</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-gray-600">Lote</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-gray-600">Validade</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-gray-600">Quantidade</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-gray-600">Preço</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-semibold uppercase text-gray-600">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{inventory.map((item) => (
|
||||||
|
<tr key={`${item.product_id}-${item.batch}`} className={isExpiringSoon(item.expires_at) ? 'bg-yellow-50' : ''}>
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-800">{item.name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">{item.batch}</td>
|
||||||
|
<td className={`px-4 py-3 text-sm ${isExpiringSoon(item.expires_at) ? 'font-semibold text-orange-600' : 'text-gray-600'}`}>
|
||||||
|
{new Date(item.expires_at).toLocaleDateString('pt-BR')}
|
||||||
|
{isExpiringSoon(item.expires_at) && <span className="ml-2 text-xs">⚠️</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-800">{item.quantity}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-sm text-medicalBlue">
|
||||||
|
R$ {(item.price_cents / 100).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => adjustStock(item.product_id, 10, 'Entrada manual')}
|
||||||
|
className="rounded bg-green-500 px-2 py-1 text-xs text-white"
|
||||||
|
>
|
||||||
|
+10
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => adjustStock(item.product_id, -10, 'Saída manual')}
|
||||||
|
className="rounded bg-red-500 px-2 py-1 text-xs text-white"
|
||||||
|
>
|
||||||
|
-10
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
138
marketplace/src/pages/Orders.tsx
Normal file
138
marketplace/src/pages/Orders.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Shell } from '../layouts/Shell'
|
||||||
|
import { apiClient } from '../services/apiClient'
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
id: string
|
||||||
|
buyer_id: string
|
||||||
|
seller_id: string
|
||||||
|
status: string
|
||||||
|
total_cents: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
Pendente: 'bg-yellow-100 text-yellow-800',
|
||||||
|
Pago: 'bg-blue-100 text-blue-800',
|
||||||
|
Faturado: 'bg-purple-100 text-purple-800',
|
||||||
|
Entregue: 'bg-green-100 text-green-800'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrdersPage() {
|
||||||
|
const [orders, setOrders] = useState<Order[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOrders()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadOrders = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await apiClient.get('/v1/orders')
|
||||||
|
setOrders(response.data || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Erro ao carregar pedidos')
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStatus = async (orderId: string, newStatus: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/v1/orders/${orderId}/status`, { status: newStatus })
|
||||||
|
await loadOrders()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao atualizar status:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (cents: number) => `R$ ${(cents / 100).toFixed(2)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-medicalBlue">Pedidos</h1>
|
||||||
|
<p className="text-sm text-gray-600">Gerenciamento de pedidos e status</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadOrders}
|
||||||
|
className="rounded bg-medicalBlue px-4 py-2 text-sm font-semibold text-white hover:opacity-90"
|
||||||
|
>
|
||||||
|
Atualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-medicalBlue"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-100 p-4 text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && orders.length === 0 && (
|
||||||
|
<div className="rounded bg-gray-100 p-8 text-center text-gray-600">
|
||||||
|
Nenhum pedido encontrado
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{orders.map((order) => (
|
||||||
|
<div key={order.id} className="rounded-lg bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-800">Pedido #{order.id.slice(0, 8)}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{new Date(order.created_at).toLocaleDateString('pt-BR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-bold text-medicalBlue">
|
||||||
|
{formatCurrency(order.total_cents)}
|
||||||
|
</p>
|
||||||
|
<span className={`inline-block rounded px-2 py-1 text-xs font-semibold ${statusColors[order.status] || 'bg-gray-100'}`}>
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
{order.status === 'Pendente' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(order.id, 'Pago')}
|
||||||
|
className="rounded bg-blue-500 px-3 py-1 text-xs text-white"
|
||||||
|
>
|
||||||
|
Marcar como Pago
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{order.status === 'Pago' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(order.id, 'Faturado')}
|
||||||
|
className="rounded bg-purple-500 px-3 py-1 text-xs text-white"
|
||||||
|
>
|
||||||
|
Faturar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{order.status === 'Faturado' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(order.id, 'Entregue')}
|
||||||
|
className="rounded bg-green-500 px-3 py-1 text-xs text-white"
|
||||||
|
>
|
||||||
|
Marcar Entregue
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
marketplace/src/pages/SellerDashboard.tsx
Normal file
152
marketplace/src/pages/SellerDashboard.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Shell } from '../layouts/Shell'
|
||||||
|
import { apiClient } from '../services/apiClient'
|
||||||
|
|
||||||
|
interface SellerDashboardData {
|
||||||
|
seller_id: string
|
||||||
|
total_sales_cents: number
|
||||||
|
orders_count: number
|
||||||
|
top_products: Array<{
|
||||||
|
product_id: string
|
||||||
|
name: string
|
||||||
|
total_quantity: number
|
||||||
|
revenue_cents: number
|
||||||
|
}>
|
||||||
|
low_stock_alerts: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
stock: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SellerDashboardPage() {
|
||||||
|
const [data, setData] = useState<SellerDashboardData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await apiClient.get('/v1/dashboard/seller')
|
||||||
|
setData(response.data)
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Erro ao carregar dashboard')
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (cents: number) => `R$ ${(cents / 100).toFixed(2)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-medicalBlue">Dashboard do Vendedor</h1>
|
||||||
|
<p className="text-sm text-gray-600">Métricas e indicadores de performance</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadDashboard}
|
||||||
|
className="rounded bg-medicalBlue px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Atualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-medicalBlue"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-100 p-4 text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 p-6 text-white">
|
||||||
|
<p className="text-sm opacity-80">Total de Vendas</p>
|
||||||
|
<p className="text-3xl font-bold">{formatCurrency(data.total_sales_cents)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-gradient-to-br from-green-500 to-green-600 p-6 text-white">
|
||||||
|
<p className="text-sm opacity-80">Pedidos</p>
|
||||||
|
<p className="text-3xl font-bold">{data.orders_count}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 p-6 text-white">
|
||||||
|
<p className="text-sm opacity-80">Ticket Médio</p>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{data.orders_count > 0
|
||||||
|
? formatCurrency(data.total_sales_cents / data.orders_count)
|
||||||
|
: 'R$ 0,00'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Top Products */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">Top Produtos</h2>
|
||||||
|
{data.top_products.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">Nenhum produto vendido ainda</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.top_products.map((product, idx) => (
|
||||||
|
<div key={product.product_id} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-sm font-semibold">
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-800">{product.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{product.total_quantity} unidades</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-medicalBlue">
|
||||||
|
{formatCurrency(product.revenue_cents)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Low Stock Alerts */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
⚠️ Alertas de Estoque Baixo
|
||||||
|
</h2>
|
||||||
|
{data.low_stock_alerts.length === 0 ? (
|
||||||
|
<p className="text-green-600 text-sm">✓ Todos os produtos com estoque adequado</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.low_stock_alerts.map((product) => (
|
||||||
|
<div
|
||||||
|
key={product.id}
|
||||||
|
className="flex items-center justify-between rounded bg-red-50 p-3"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-gray-800">{product.name}</span>
|
||||||
|
<span className="rounded bg-red-100 px-2 py-1 text-xs font-semibold text-red-800">
|
||||||
|
{product.stock} restantes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
marketplace/src/services/apiClient.test.ts
Normal file
49
marketplace/src/services/apiClient.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||||
|
import { apiClient } from './apiClient'
|
||||||
|
|
||||||
|
describe('apiClient', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset token before each test
|
||||||
|
apiClient.setToken(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setToken', () => {
|
||||||
|
it('should set token to null initially', () => {
|
||||||
|
// Token starts as null - we just verify setToken doesn't throw
|
||||||
|
expect(() => apiClient.setToken(null)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept a token string', () => {
|
||||||
|
expect(() => apiClient.setToken('test-token-123')).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear token when set to null', () => {
|
||||||
|
apiClient.setToken('some-token')
|
||||||
|
apiClient.setToken(null)
|
||||||
|
// No error means success
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('api methods', () => {
|
||||||
|
it('should expose get method', () => {
|
||||||
|
expect(typeof apiClient.get).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expose post method', () => {
|
||||||
|
expect(typeof apiClient.post).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expose put method', () => {
|
||||||
|
expect(typeof apiClient.put).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expose delete method', () => {
|
||||||
|
expect(typeof apiClient.delete).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
200
marketplace/src/stores/cartStore.test.ts
Normal file
200
marketplace/src/stores/cartStore.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { useCartStore, selectGroupedCart, selectCartSummary, CartItem } from './cartStore'
|
||||||
|
|
||||||
|
const mockItem: Omit<CartItem, 'quantity'> = {
|
||||||
|
id: 'prod-1',
|
||||||
|
name: 'Produto Teste',
|
||||||
|
activeIngredient: 'Amoxicilina',
|
||||||
|
lab: 'EMS',
|
||||||
|
batch: 'L1001',
|
||||||
|
expiry: '2025-12',
|
||||||
|
vendorId: 'vendor-1',
|
||||||
|
vendorName: 'Distribuidora Norte',
|
||||||
|
unitPrice: 25.00
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockItem2: Omit<CartItem, 'quantity'> = {
|
||||||
|
id: 'prod-2',
|
||||||
|
name: 'Produto Teste 2',
|
||||||
|
activeIngredient: 'Dipirona',
|
||||||
|
lab: 'Eurofarma',
|
||||||
|
batch: 'L2002',
|
||||||
|
expiry: '2026-01',
|
||||||
|
vendorId: 'vendor-2',
|
||||||
|
vendorName: 'Distribuidora Sul',
|
||||||
|
unitPrice: 15.00
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cartStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the store before each test
|
||||||
|
useCartStore.setState({ items: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addItem', () => {
|
||||||
|
it('should add an item to the cart', () => {
|
||||||
|
const store = useCartStore.getState()
|
||||||
|
store.addItem(mockItem)
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
expect(state.items).toHaveLength(1)
|
||||||
|
expect(state.items[0]).toMatchObject({ ...mockItem, quantity: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add item with custom quantity', () => {
|
||||||
|
const store = useCartStore.getState()
|
||||||
|
store.addItem(mockItem, 5)
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
expect(state.items[0].quantity).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should increment quantity if item already exists', () => {
|
||||||
|
const store = useCartStore.getState()
|
||||||
|
store.addItem(mockItem, 2)
|
||||||
|
store.addItem(mockItem, 3)
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
expect(state.items).toHaveLength(1)
|
||||||
|
expect(state.items[0].quantity).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateQuantity', () => {
|
||||||
|
it('should update quantity of existing item', () => {
|
||||||
|
useCartStore.getState().addItem(mockItem, 2)
|
||||||
|
useCartStore.getState().updateQuantity('prod-1', 10)
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
expect(state.items[0].quantity).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not affect other items', () => {
|
||||||
|
useCartStore.getState().addItem(mockItem, 2)
|
||||||
|
useCartStore.getState().addItem(mockItem2, 3)
|
||||||
|
useCartStore.getState().updateQuantity('prod-1', 10)
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
expect(state.items[0].quantity).toBe(10)
|
||||||
|
expect(state.items[1].quantity).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('removeItem', () => {
|
||||||
|
it('should remove item from cart', () => {
|
||||||
|
useCartStore.getState().addItem(mockItem)
|
||||||
|
useCartStore.getState().addItem(mockItem2)
|
||||||
|
useCartStore.getState().removeItem('prod-1')
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
expect(state.items).toHaveLength(1)
|
||||||
|
expect(state.items[0].id).toBe('prod-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle removing non-existent item', () => {
|
||||||
|
useCartStore.getState().addItem(mockItem)
|
||||||
|
useCartStore.getState().removeItem('non-existent')
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
expect(state.items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearVendor', () => {
|
||||||
|
it('should remove all items from a specific vendor', () => {
|
||||||
|
const mockItemSameVendor: Omit<CartItem, 'quantity'> = {
|
||||||
|
...mockItem,
|
||||||
|
id: 'prod-3',
|
||||||
|
name: 'Outro Produto'
|
||||||
|
}
|
||||||
|
|
||||||
|
useCartStore.getState().addItem(mockItem)
|
||||||
|
useCartStore.getState().addItem(mockItemSameVendor)
|
||||||
|
useCartStore.getState().addItem(mockItem2)
|
||||||
|
useCartStore.getState().clearVendor('vendor-1')
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
expect(state.items).toHaveLength(1)
|
||||||
|
expect(state.items[0].vendorId).toBe('vendor-2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearAll', () => {
|
||||||
|
it('should remove all items from cart', () => {
|
||||||
|
useCartStore.getState().addItem(mockItem)
|
||||||
|
useCartStore.getState().addItem(mockItem2)
|
||||||
|
useCartStore.getState().clearAll()
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
expect(state.items).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectors', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCartStore.setState({ items: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectGroupedCart', () => {
|
||||||
|
it('should group items by vendor', () => {
|
||||||
|
useCartStore.getState().addItem(mockItem, 2)
|
||||||
|
useCartStore.getState().addItem(mockItem2, 3)
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
const groups = selectGroupedCart(state)
|
||||||
|
|
||||||
|
expect(Object.keys(groups)).toHaveLength(2)
|
||||||
|
expect(groups['vendor-1'].items).toHaveLength(1)
|
||||||
|
expect(groups['vendor-2'].items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate vendor totals correctly', () => {
|
||||||
|
useCartStore.getState().addItem(mockItem, 2) // 2 * 25 = 50
|
||||||
|
useCartStore.getState().addItem(mockItem2, 3) // 3 * 15 = 45
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
const groups = selectGroupedCart(state)
|
||||||
|
|
||||||
|
expect(groups['vendor-1'].total).toBe(50)
|
||||||
|
expect(groups['vendor-2'].total).toBe(45)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty object for empty cart', () => {
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
const groups = selectGroupedCart(state)
|
||||||
|
|
||||||
|
expect(Object.keys(groups)).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectCartSummary', () => {
|
||||||
|
it('should calculate total items correctly', () => {
|
||||||
|
useCartStore.getState().addItem(mockItem, 2)
|
||||||
|
useCartStore.getState().addItem(mockItem2, 3)
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
const summary = selectCartSummary(state)
|
||||||
|
|
||||||
|
expect(summary.totalItems).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate total value correctly', () => {
|
||||||
|
useCartStore.getState().addItem(mockItem, 2) // 2 * 25 = 50
|
||||||
|
useCartStore.getState().addItem(mockItem2, 3) // 3 * 15 = 45
|
||||||
|
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
const summary = selectCartSummary(state)
|
||||||
|
|
||||||
|
expect(summary.totalValue).toBe(95)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return zeros for empty cart', () => {
|
||||||
|
const state = useCartStore.getState()
|
||||||
|
const summary = selectCartSummary(state)
|
||||||
|
|
||||||
|
expect(summary.totalItems).toBe(0)
|
||||||
|
expect(summary.totalValue).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
1
marketplace/src/test/setup.ts
Normal file
1
marketplace/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
|
||||||
port: 5173
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
24
marketplace/vitest.config.ts
Normal file
24
marketplace/vitest.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
|
exclude: [
|
||||||
|
'src/test/**',
|
||||||
|
'src/**/*.test.{ts,tsx}',
|
||||||
|
'src/**/*.spec.{ts,tsx}',
|
||||||
|
'src/main.tsx',
|
||||||
|
'src/vite-env.d.ts'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue