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:
Tiago Yamamoto 2025-12-20 07:43:56 -03:00
parent c83079e4c9
commit b8973739ab
17 changed files with 4277 additions and 8 deletions

View 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)
}
}

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

View 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)
}
}

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View 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)
})
})

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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')
})
})
})

View 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)
})
})
})

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom'

View file

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

View 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'
]
}
}
})