diff --git a/backend/internal/http/middleware/compress_test.go b/backend/internal/http/middleware/compress_test.go new file mode 100644 index 0000000..948e9ff --- /dev/null +++ b/backend/internal/http/middleware/compress_test.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGzip_SkipsWhenNotAccepted(t *testing.T) { + handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("plain")) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Errorf("expected no content encoding, got %q", got) + } + if rec.Body.String() != "plain" { + t.Errorf("expected plain response, got %q", rec.Body.String()) + } +} + +func TestGzip_CompressesWhenAccepted(t *testing.T) { + handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("compressed")) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Encoding", "gzip") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("expected gzip encoding, got %q", got) + } + if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") { + t.Fatalf("expected Vary to include Accept-Encoding, got %q", got) + } + + reader, err := gzip.NewReader(rec.Body) + if err != nil { + t.Fatalf("failed to create gzip reader: %v", err) + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("failed to read gzip body: %v", err) + } + + if string(data) != "compressed" { + t.Errorf("expected decompressed body 'compressed', got %q", string(data)) + } +} diff --git a/backend/internal/http/middleware/cors_test.go b/backend/internal/http/middleware/cors_test.go new file mode 100644 index 0000000..3bfc510 --- /dev/null +++ b/backend/internal/http/middleware/cors_test.go @@ -0,0 +1,91 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestCORSWithConfig_AllowsAllOrigins(t *testing.T) { + middleware := CORSWithConfig(CORSConfig{AllowedOrigins: []string{"*"}}) + handler := middleware(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 got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("expected allow origin '*', got %q", got) + } +} + +func TestCORSWithConfig_AllowsMatchingOrigin(t *testing.T) { + middleware := CORSWithConfig(CORSConfig{AllowedOrigins: []string{"https://example.com"}}) + handler := middleware(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 got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" { + t.Errorf("expected allow origin to match request, got %q", got) + } + if got := rec.Header().Get("Vary"); got != "Origin" { + t.Errorf("expected Vary header Origin, got %q", got) + } +} + +func TestCORSWithConfig_BlocksUnknownOrigin(t *testing.T) { + middleware := CORSWithConfig(CORSConfig{AllowedOrigins: []string{"https://example.com"}}) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "https://unknown.com") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("expected no allow origin header, got %q", got) + } +} + +func TestCORSWithConfig_OptionsPreflight(t *testing.T) { + called := false + middleware := CORSWithConfig(CORSConfig{AllowedOrigins: []string{"*"}}) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodOptions, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200 for preflight, got %d", rec.Code) + } + if called { + t.Error("expected handler not to be called for preflight") + } +} + +func TestCORSWrapperAllowsAll(t *testing.T) { + handler := CORS(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 got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("expected allow origin '*', got %q", got) + } +} diff --git a/backend/internal/http/middleware/security_test.go b/backend/internal/http/middleware/security_test.go new file mode 100644 index 0000000..ac132d0 --- /dev/null +++ b/backend/internal/http/middleware/security_test.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSecurityHeaders_DefaultPolicy(t *testing.T) { + handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("expected nosniff, got %q", got) + } + if got := rec.Header().Get("X-Frame-Options"); got != "DENY" { + t.Errorf("expected DENY, got %q", got) + } + if got := rec.Header().Get("X-XSS-Protection"); got != "1; mode=block" { + t.Errorf("expected X-XSS-Protection, got %q", got) + } + if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" { + t.Errorf("expected Referrer-Policy, got %q", got) + } + if got := rec.Header().Get("Content-Security-Policy"); got != "default-src 'none'" { + t.Errorf("expected CSP default-src 'none', got %q", got) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store, max-age=0" { + t.Errorf("expected Cache-Control no-store, got %q", got) + } +} + +func TestSecurityHeaders_DocsPolicy(t *testing.T) { + handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/docs/index.html", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + csp := rec.Header().Get("Content-Security-Policy") + if csp == "" { + t.Fatal("expected CSP header for docs") + } + if csp == "default-src 'none'" { + t.Errorf("expected docs CSP to be more permissive, got %q", csp) + } +} diff --git a/marketplace/src/components/GroupedProductCard.test.tsx b/marketplace/src/components/GroupedProductCard.test.tsx new file mode 100644 index 0000000..6f9f1fe --- /dev/null +++ b/marketplace/src/components/GroupedProductCard.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { GroupedProductCard } from './GroupedProductCard' +import type { GroupedProduct, ProductWithDistance } from '../types/product' + +const offerBase: ProductWithDistance = { + id: 'offer-1', + seller_id: 'seller-1', + name: 'Dipirona 500mg', + description: 'Caixa com 10 comprimidos', + batch: 'B123', + expires_at: new Date().toISOString(), + price_cents: 500, + stock: 5, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + distance_km: 5, + tenant_city: 'Rio de Janeiro', + tenant_state: 'RJ' +} + +const buildGroup = (offers: ProductWithDistance[], minPriceCents: number): GroupedProduct => ({ + name: 'Dipirona', + description: 'Analgésico', + offers, + minPriceCents, + offerCount: offers.length +}) + +describe('GroupedProductCard', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('shows nearest offer and pluralized count', () => { + const offers = [ + { ...offerBase, id: 'offer-1', distance_km: 8, expires_at: new Date(Date.now() + 40 * 24 * 60 * 60 * 1000).toISOString() }, + { ...offerBase, id: 'offer-2', distance_km: 2, expires_at: new Date(Date.now() + 50 * 24 * 60 * 60 * 1000).toISOString() } + ] + + render() + + expect(screen.getByText('2 ofertas')).toBeInTheDocument() + expect(screen.getByText('Mais próximo: 2 km')).toBeInTheDocument() + expect(screen.getByText('R$ 5,00')).toBeInTheDocument() + }) + + it('shows expiring soon badge based on soonest expiry and triggers click', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-02-01T00:00:00.000Z')) + + const offers = [ + { ...offerBase, id: 'offer-3', distance_km: 3, expires_at: new Date('2024-02-10T00:00:00.000Z').toISOString() }, + { ...offerBase, id: 'offer-4', distance_km: 6, expires_at: new Date('2024-04-10T00:00:00.000Z').toISOString() } + ] + + const onClick = vi.fn() + const user = userEvent.setup() + + render() + + expect(screen.getByText('Vence em breve')).toBeInTheDocument() + + await user.click(screen.getByText('Ver ofertas')) + + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/marketplace/src/components/ProductCard.test.tsx b/marketplace/src/components/ProductCard.test.tsx new file mode 100644 index 0000000..5d73ff3 --- /dev/null +++ b/marketplace/src/components/ProductCard.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ProductCard } from './ProductCard' +import type { ProductWithDistance } from '../types/product' + +const baseProduct: ProductWithDistance = { + id: 'prod-1', + seller_id: 'seller-1', + name: 'Amoxicilina 500mg', + description: 'Caixa com 20 cápsulas', + batch: 'L123', + expires_at: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(), + price_cents: 1299, + stock: 10, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + distance_km: 4.2, + tenant_city: 'São Paulo', + tenant_state: 'SP' +} + +describe('ProductCard', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('renders expiring soon badge and formats pricing', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')) + + const product = { + ...baseProduct, + expires_at: new Date('2024-01-15T00:00:00.000Z').toISOString() + } + + render() + + expect(screen.getByText('Vence em breve')).toBeInTheDocument() + expect(screen.getByText('R$ 12,99')).toBeInTheDocument() + expect(screen.getByText(/4.2 km/)).toBeInTheDocument() + }) + + it('invokes add to cart handler when clicking the button', async () => { + const onAddToCart = vi.fn() + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: 'Adicionar' })) + + expect(onAddToCart).toHaveBeenCalledWith(baseProduct) + }) + + it('does not show expiring badge for long expiry', () => { + const product = { + ...baseProduct, + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString() + } + + render() + + expect(screen.queryByText('Vence em breve')).not.toBeInTheDocument() + }) +})