Add backend middleware and frontend card tests
This commit is contained in:
parent
f8148c428e
commit
f913f6b3d4
5 changed files with 339 additions and 0 deletions
60
backend/internal/http/middleware/compress_test.go
Normal file
60
backend/internal/http/middleware/compress_test.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/internal/http/middleware/cors_test.go
Normal file
91
backend/internal/http/middleware/cors_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/internal/http/middleware/security_test.go
Normal file
54
backend/internal/http/middleware/security_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
marketplace/src/components/GroupedProductCard.test.tsx
Normal file
69
marketplace/src/components/GroupedProductCard.test.tsx
Normal file
|
|
@ -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(<GroupedProductCard group={buildGroup(offers, 500)} onClick={vi.fn()} />)
|
||||||
|
|
||||||
|
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(<GroupedProductCard group={buildGroup(offers, 700)} onClick={onClick} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Vence em breve')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Ver ofertas'))
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
65
marketplace/src/components/ProductCard.test.tsx
Normal file
65
marketplace/src/components/ProductCard.test.tsx
Normal file
|
|
@ -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(<ProductCard product={product} onAddToCart={vi.fn()} />)
|
||||||
|
|
||||||
|
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(<ProductCard product={baseProduct} onAddToCart={onAddToCart} />)
|
||||||
|
|
||||||
|
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(<ProductCard product={product} onAddToCart={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.queryByText('Vence em breve')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue