Cambios de la lección 14
Todo lo que cambia en tienda/pasos/paso-14 respecto al paso anterior. Vuelve a la lección: 14. Tests en Go: del dominio a Keycloak real.
6 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.
| Archivo | Estado | Líneas |
|---|---|---|
go.mod | modificado | +56 −2 |
internal/adaptadores/keycloak/keycloak_test.go | nuevo | +33 −0 |
internal/adaptadores/rest/rest_test.go | nuevo | +97 −0 |
internal/apiauth/apiauth_test.go | nuevo | +148 −0 |
internal/integracion/keycloak_test.go | nuevo | +111 −0 |
internal/pedidos/servicio_test.go | nuevo | +121 −0 |
go.mod
@@ -5,16 +5,70 @@
require (
github.com/Nerzal/gocloak/v14 v14.0.4
github.com/coreos/go-oidc/v3 v3.21.0
+ github.com/testcontainers/testcontainers-go v0.44.0
golang.org/x/oauth2 v0.37.0
)
require (
- github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+ dario.cat/mergo v1.0.2 // indirect
+ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
+ github.com/containerd/log v0.1.0 // indirect
+ github.com/containerd/platforms v0.2.1 // indirect
+ github.com/cpuguy83/dockercfg v0.3.2 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/distribution/reference v0.6.0 // indirect
+ github.com/docker/go-connections v0.7.0 // indirect
+ github.com/docker/go-units v0.5.0 // indirect
+ github.com/ebitengine/purego v0.10.1 // indirect
+ github.com/felixge/httpsnoop v1.1.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/klauspost/compress v1.18.6 // indirect
+ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
+ github.com/magiconair/properties v1.8.10 // indirect
+ github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/go-archive v0.2.0 // indirect
+ github.com/moby/moby/api v1.55.0 // indirect
+ github.com/moby/moby/client v0.5.0 // indirect
+ github.com/moby/patternmatcher v0.6.1 // indirect
+ github.com/moby/sys/sequential v0.7.0 // indirect
+ github.com/moby/sys/user v0.4.0 // indirect
+ github.com/moby/sys/userns v0.1.0 // indirect
+ github.com/moby/term v0.5.2 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
+ github.com/shirou/gopsutil/v4 v4.26.6 // indirect
+ github.com/sirupsen/logrus v1.9.4 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
+ github.com/tklauser/go-sysconf v0.4.0 // indirect
+ github.com/tklauser/numcpus v0.12.0 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
+ golang.org/x/crypto v0.54.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+require (
+ github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
golang.org/x/mod v0.35.0 // indirect
- golang.org/x/net v0.55.0 // indirect
+ golang.org/x/net v0.56.0 // indirect
)
internal/adaptadores/keycloak/keycloak_test.go
@@ -0,0 +1,33 @@
+package keycloak_test
+
+import (
+ "slices"
+ "testing"
+
+ "tienda/internal/adaptadores/keycloak"
+ "tienda/internal/apiauth"
+ "tienda/internal/pedidos"
+)
+
+// La traducción roles de Keycloak → permisos del dominio es una tabla: se
+// prueba como tal, sin tokens.
+func TestActorFrom(t *testing.T) {
+ tests := []struct {
+ name string
+ p apiauth.Principal
+ want []pedidos.Permission
+ }{
+ {"cliente", apiauth.Principal{Roles: []string{"cliente"}}, []pedidos.Permission{pedidos.PermBuy}},
+ {"admin", apiauth.Principal{Roles: []string{"admin"}}, []pedidos.Permission{pedidos.PermBuy, pedidos.PermManage}},
+ {"servicio de facturación", apiauth.Principal{APIRoles: []string{"facturar"}}, []pedidos.Permission{pedidos.PermInvoice}},
+ {"sin roles de la tienda", apiauth.Principal{Roles: []string{"offline_access"}}, nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := keycloak.ActorFrom(&tt.p).Permissions
+ if !slices.Equal(got, tt.want) {
+ t.Errorf("permisos = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
internal/adaptadores/rest/rest_test.go
@@ -0,0 +1,97 @@
+package rest_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "testing"
+
+ "tienda/internal/adaptadores/memoria"
+ "tienda/internal/adaptadores/rest"
+ "tienda/internal/pedidos"
+)
+
+// fakeIdentity sustituye a Keycloak: el test decide quién llama y con qué
+// scopes. Si no hay actor, responde 401 como haría el adaptador real.
+type fakeIdentity struct {
+ actor *pedidos.Actor
+ scopes []string
+}
+
+func (f fakeIdentity) Middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if f.actor == nil {
+ http.Error(w, "sin token", http.StatusUnauthorized)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (f fakeIdentity) RequireScope(scope string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !slices.Contains(f.scopes, scope) {
+ http.Error(w, "falta "+scope, http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+func (f fakeIdentity) Actor(*http.Request) pedidos.Actor { return *f.actor }
+
+var ana = &pedidos.Actor{ID: memoria.AnaID, Permissions: []pedidos.Permission{pedidos.PermBuy}}
+
+// do monta la API con esa identidad y hace una petición.
+func do(id fakeIdentity, method, path, body string) *httptest.ResponseRecorder {
+ mux := http.NewServeMux()
+ rest.Register(mux, pedidos.NewService(memoria.NewWithSamples()), id)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body)))
+ return rec
+}
+
+func TestCodigosDeEstado(t *testing.T) {
+ tests := []struct {
+ name string
+ id fakeIdentity
+ method, path string
+ body string
+ want int
+ }{
+ {"sin identidad", fakeIdentity{}, "GET", "/pedidos", "", 401},
+ {"mis pedidos", fakeIdentity{actor: ana}, "GET", "/pedidos", "", 200},
+ {"pedido de otro → 404", fakeIdentity{actor: ana}, "GET", "/pedidos/1003", "", 404},
+ {"id que no es número", fakeIdentity{actor: ana}, "GET", "/pedidos/abc", "", 400},
+ {"crear sin scope", fakeIdentity{actor: ana}, "POST", "/pedidos", `{"producto":"taza","cantidad":1}`, 403},
+ {"crear con scope", fakeIdentity{actor: ana, scopes: []string{"pedidos:escribir"}}, "POST", "/pedidos", `{"producto":"taza","cantidad":1}`, 201},
+ {"crear con datos inválidos", fakeIdentity{actor: ana, scopes: []string{"pedidos:escribir"}}, "POST", "/pedidos", `{"producto":"yate","cantidad":1}`, 400},
+ {"admin sin permiso de dominio", fakeIdentity{actor: ana}, "GET", "/admin/pedidos", "", 403},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if rec := do(tt.id, tt.method, tt.path, tt.body); rec.Code != tt.want {
+ t.Errorf("%s %s = %d, want %d (%s)", tt.method, tt.path, rec.Code, tt.want, rec.Body)
+ }
+ })
+ }
+}
+
+// El formato JSON es un contrato con tienda-web y facturacion: si cambia un
+// nombre de campo, este test avisa.
+func TestFormatoJSON(t *testing.T) {
+ rec := do(fakeIdentity{actor: ana}, "GET", "/pedidos/1001", "")
+ var got map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range []string{"id", "owner", "items", "total", "status", "created_at"} {
+ if _, ok := got[k]; !ok {
+ t.Errorf("falta el campo %q en %s", k, rec.Body)
+ }
+ }
+}
internal/apiauth/apiauth_test.go
@@ -0,0 +1,148 @@
+package apiauth_test
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/go-jose/go-jose/v4"
+ "github.com/go-jose/go-jose/v4/jwt"
+
+ "tienda/internal/apiauth"
+)
+
+// fakeIssuer es un «Keycloak» mínimo para tests: publica el documento de
+// descubrimiento y el JWKS, y firma tokens con su clave. Así se prueba la
+// validación de verdad (firma, iss, aud, exp, typ) sin levantar Keycloak.
+type fakeIssuer struct {
+ srv *httptest.Server
+ key *rsa.PrivateKey
+}
+
+func newFakeIssuer(t *testing.T) *fakeIssuer {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f := &fakeIssuer{key: key}
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "issuer": f.srv.URL,
+ "jwks_uri": f.srv.URL + "/certs",
+ "authorization_endpoint": f.srv.URL + "/auth",
+ "token_endpoint": f.srv.URL + "/token",
+ "id_token_signing_alg_values_supported": []string{"RS256"},
+ })
+ })
+ mux.HandleFunc("GET /certs", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
+ {Key: &key.PublicKey, KeyID: "clave-1", Algorithm: "RS256", Use: "sig"},
+ }})
+ })
+ f.srv = httptest.NewServer(mux)
+ t.Cleanup(f.srv.Close)
+ return f
+}
+
+// claims de un access token típico de Keycloak para ana en api-pedidos.
+func (f *fakeIssuer) claims() map[string]any {
+ return map[string]any{
+ "iss": f.srv.URL, "sub": "id-de-ana", "aud": []string{"api-pedidos", "account"},
+ "exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(),
+ "typ": "Bearer", "azp": "tienda-web", "preferred_username": "ana",
+ "scope": "openid profile pedidos:escribir",
+ "realm_access": map[string]any{"roles": []string{"cliente"}},
+ "resource_access": map[string]any{"api-pedidos": map[string]any{"roles": []string{"facturar"}}},
+ }
+}
+
+// sign firma los claims con key (la del issuer, o otra para simular un ataque).
+func sign(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string {
+ t.Helper()
+ signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key},
+ (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "clave-1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := jwt.Signed(signer).Claims(claims).Serialize()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+}
+
+func TestMiddleware(t *testing.T) {
+ f := newFakeIssuer(t)
+ v, err := apiauth.NewVerifier(context.Background(), f.srv.URL, "api-pedidos")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got *apiauth.Principal
+ h := v.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got = apiauth.FromContext(r.Context())
+ }))
+
+ otherKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+ with := func(k string, val any) map[string]any { c := f.claims(); c[k] = val; return c }
+
+ tests := []struct {
+ name string
+ token string // "" = sin cabecera Authorization
+ want int
+ wantDesc string
+ }{
+ {"token válido", sign(t, f.key, f.claims()), 200, ""},
+ {"sin token", "", 401, "falta la cabecera"},
+ {"caducado", sign(t, f.key, with("exp", time.Now().Add(-time.Minute).Unix())), 401, "caducado"},
+ {"para otra API", sign(t, f.key, with("aud", "otra-api")), 401, "inválido"},
+ {"de otro issuer", sign(t, f.key, with("iss", "http://otro")), 401, "inválido"},
+ {"un ID token, no un access token", sign(t, f.key, with("typ", "ID")), 401, "inválido"},
+ {"firmado con otra clave", sign(t, otherKey, f.claims()), 401, "inválido"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got = nil
+ req := httptest.NewRequest("GET", "/pedidos", nil)
+ if tt.token != "" {
+ req.Header.Set("Authorization", "Bearer "+tt.token)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != tt.want {
+ t.Fatalf("código = %d, want %d (%s)", rec.Code, tt.want, rec.Body)
+ }
+ if tt.want == 401 {
+ if !strings.HasPrefix(rec.Header().Get("WWW-Authenticate"), "Bearer") {
+ t.Errorf("falta WWW-Authenticate: Bearer (RFC 6750)")
+ }
+ if !strings.Contains(rec.Body.String(), tt.wantDesc) {
+ t.Errorf("cuerpo %s no menciona %q", rec.Body, tt.wantDesc)
+ }
+ }
+ })
+ }
+
+ // Con el token válido, el Principal refleja los claims.
+ if got == nil {
+ h.ServeHTTP(httptest.NewRecorder(), withBearer(sign(t, f.key, f.claims())))
+ }
+ if got.Subject != "id-de-ana" || got.Username != "ana" || !got.HasRole("cliente") ||
+ !got.HasScope("pedidos:escribir") || !slices.Contains(got.APIRoles, "facturar") {
+ t.Errorf("Principal inesperado: %+v", got)
+ }
+}
+
+func withBearer(tok string) *http.Request {
+ r := httptest.NewRequest("GET", "/", nil)
+ r.Header.Set("Authorization", "Bearer "+tok)
+ return r
+}
internal/integracion/keycloak_test.go
@@ -0,0 +1,111 @@
+//go:build integracion
+
+// Package integracion prueba api-pedidos contra un Keycloak de verdad, en un
+// contenedor que el propio test arranca (testcontainers-go) con el realm del
+// paso. Necesita Docker y tarda un minuto, por eso va aparte:
+//
+// go test -tags integracion ./internal/integracion
+package integracion
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/testcontainers/testcontainers-go"
+ "github.com/testcontainers/testcontainers-go/wait"
+
+ "tienda/internal/adaptadores/keycloak"
+ "tienda/internal/adaptadores/memoria"
+ "tienda/internal/adaptadores/rest"
+ "tienda/internal/apiauth"
+ "tienda/internal/pedidos"
+)
+
+func TestAPIConKeycloakReal(t *testing.T) {
+ ctx := context.Background()
+ kc, err := testcontainers.Run(ctx, "quay.io/keycloak/keycloak:26.8.0",
+ testcontainers.WithCmd("start-dev", "--import-realm"),
+ testcontainers.WithEnv(map[string]string{
+ "KC_BOOTSTRAP_ADMIN_USERNAME": "admin",
+ "KC_BOOTSTRAP_ADMIN_PASSWORD": "admin",
+ }),
+ testcontainers.WithFiles(testcontainers.ContainerFile{
+ HostFilePath: "../../infra/realm/tienda-realm.json",
+ ContainerFilePath: "/opt/keycloak/data/import/tienda-realm.json",
+ FileMode: 0o644,
+ }),
+ testcontainers.WithExposedPorts("8080/tcp"),
+ testcontainers.WithWaitStrategyAndDeadline(3*time.Minute,
+ wait.ForHTTP("/realms/tienda").WithPort("8080/tcp")),
+ )
+ testcontainers.CleanupContainer(t, kc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ endpoint, err := kc.PortEndpoint(ctx, "8080/tcp", "http")
+ if err != nil {
+ t.Fatal(err)
+ }
+ issuer := endpoint + "/realms/tienda"
+
+ // api-pedidos completa, con sus adaptadores reales, en un servidor de test.
+ v, err := apiauth.NewVerifier(ctx, issuer, "api-pedidos")
+ if err != nil {
+ t.Fatal(err)
+ }
+ mux := http.NewServeMux()
+ rest.Register(mux, pedidos.NewService(memoria.NewWithSamples()), keycloak.Identity{Verifier: v})
+ api := httptest.NewServer(mux)
+ defer api.Close()
+
+ // facturacion pide un token (Client Credentials) y lista lo entregado.
+ tok := clientCredentials(t, issuer, "facturacion", "facturacion-secret")
+ if code, body := get(t, api.URL+"/facturacion/pedidos", tok); code != 200 || !strings.Contains(body, `"id":1003`) {
+ t.Errorf("facturacion: %d %s", code, body)
+ }
+ // El token de admin-tool es válido, pero no es para api-pedidos (aud).
+ other := clientCredentials(t, issuer, "admin-tool", "admin-tool-secret")
+ if code, _ := get(t, api.URL+"/facturacion/pedidos", other); code != 401 {
+ t.Errorf("token de otra audiencia: %d, want 401", code)
+ }
+}
+
+func clientCredentials(t *testing.T, issuer, id, secret string) string {
+ t.Helper()
+ req, _ := http.NewRequest("POST", issuer+"/protocol/openid-connect/token",
+ strings.NewReader(url.Values{"grant_type": {"client_credentials"}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetBasicAuth(id, secret)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ AccessToken string `json:"access_token"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.AccessToken == "" {
+ t.Fatalf("token de %s: %v (%s)", id, err, resp.Status)
+ }
+ return out.AccessToken
+}
+
+func get(t *testing.T, u, tok string) (int, string) {
+ t.Helper()
+ req, _ := http.NewRequest("GET", u, nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(body)
+}
internal/pedidos/servicio_test.go
@@ -0,0 +1,121 @@
+package pedidos_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "tienda/internal/adaptadores/memoria"
+ "tienda/internal/pedidos"
+)
+
+// Actores de prueba: el dominio no sabe de tokens, así que basta con crearlos.
+var (
+ ana = pedidos.Actor{ID: memoria.AnaID, Name: "ana", Permissions: []pedidos.Permission{pedidos.PermBuy}}
+ carlos = pedidos.Actor{ID: memoria.CarlosID, Name: "carlos", Permissions: []pedidos.Permission{pedidos.PermBuy, pedidos.PermManage}}
+ factura = pedidos.Actor{ID: "service-account-facturacion", Permissions: []pedidos.Permission{pedidos.PermInvoice}}
+ anonimo = pedidos.Actor{ID: "x"}
+ pedidoDe = map[string]int{"ana": 1001, "carlos": 1003} // de memoria.NewWithSamples
+)
+
+func newService() *pedidos.Service { return pedidos.NewService(memoria.NewWithSamples()) }
+
+func TestMineSoloDevuelveLosPropios(t *testing.T) {
+ got, err := newService().Mine(context.Background(), ana)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("ana tiene 2 pedidos de ejemplo, got %d", len(got))
+ }
+ for _, o := range got {
+ if o.Owner != ana.ID {
+ t.Errorf("pedido #%d es de %s, no de ana", o.ID, o.Owner)
+ }
+ }
+}
+
+func TestGet(t *testing.T) {
+ tests := []struct {
+ name string
+ actor pedidos.Actor
+ id int
+ wantErr error
+ }{
+ {"su propio pedido", ana, pedidoDe["ana"], nil},
+ {"el de otro cliente parece no existir", ana, pedidoDe["carlos"], pedidos.ErrNotFound},
+ {"quien gestiona ve cualquiera", carlos, pedidoDe["ana"], nil},
+ {"un número que no existe", carlos, 9999, pedidos.ErrNotFound},
+ {"sin permisos", anonimo, pedidoDe["ana"], pedidos.ErrForbidden},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := newService().Get(context.Background(), tt.actor, tt.id)
+ if !errors.Is(err, tt.wantErr) {
+ t.Errorf("err = %v, want %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestCreateTomaPrecioDelCatalogoYDuenoDelActor(t *testing.T) {
+ o, err := newService().Create(context.Background(), ana, "taza", 3)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Owner != ana.ID || o.Total != 28.5 || o.Status != "Pendiente" || o.ID == 0 {
+ t.Errorf("pedido inesperado: %+v", o)
+ }
+}
+
+func TestCreateRechazaDatosInvalidos(t *testing.T) {
+ for _, tc := range []struct {
+ producto string
+ cantidad int
+ }{{"taza", 0}, {"taza", 11}, {"yate", 1}} {
+ _, err := newService().Create(context.Background(), ana, tc.producto, tc.cantidad)
+ if !errors.Is(err, pedidos.ErrInvalid) {
+ t.Errorf("Create(%q, %d): err = %v, want ErrInvalid", tc.producto, tc.cantidad, err)
+ }
+ }
+}
+
+func TestPermisos(t *testing.T) {
+ ctx := context.Background()
+ s := newService()
+ checks := []struct {
+ name string
+ err error
+ }{
+ {"un cliente no ve todos los pedidos", second(s.All(ctx, ana))},
+ {"un cliente no cambia estados", second(s.SetStatus(ctx, ana, 1001, "Enviado"))},
+ {"un cliente no lista para facturar", second(s.ToInvoice(ctx, ana, "Entregado"))},
+ {"facturacion no compra", second(s.Create(ctx, factura, "taza", 1))},
+ }
+ for _, c := range checks {
+ if !errors.Is(c.err, pedidos.ErrForbidden) {
+ t.Errorf("%s: err = %v, want ErrForbidden", c.name, c.err)
+ }
+ }
+}
+
+func TestSetStatus(t *testing.T) {
+ ctx := context.Background()
+ s := newService()
+ if _, err := s.SetStatus(ctx, carlos, 1002, "Volando"); !errors.Is(err, pedidos.ErrInvalid) {
+ t.Errorf("estado inventado: err = %v, want ErrInvalid", err)
+ }
+ if _, err := s.SetStatus(ctx, carlos, 1002, "Enviado"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := s.ToInvoice(ctx, factura, "Enviado")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 { // 1001 ya estaba Enviado
+ t.Errorf("pedidos enviados = %d, want 2", len(got))
+ }
+}
+
+// second devuelve el error de una llamada que devuelve (valor, error).
+func second[T any](_ T, err error) error { return err }